Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions ccip-sdk/src/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,33 @@ export type TokenInfo = {
readonly name?: string
}

/**
* Token transfer fee configuration returned by TokenPool v2.0 contracts.
*/
export type TokenTransferFeeConfig = {
destGasOverhead: number
destBytesOverhead: number
defaultBlockConfirmationsFeeUSDCents: number
customBlockConfirmationsFeeUSDCents: number
defaultBlockConfirmationsTransferFeeBps: number
customBlockConfirmationsTransferFeeBps: number
isEnabled: boolean
}

/**
* Options for {@link Chain.getTokenPoolFee}.
* Provide either `tokenPool` directly or `token` + `router` to auto-resolve it.
*/
export type TokenPoolFeeOpts = {
destChainSelector: bigint
blockConfirmationsRequested: number
/** Hex-encoded bytes passed as tokenArgs to the pool contract. */
tokenArgs: string
} & (
| { tokenPool: string; token?: undefined; router?: undefined }
| { token: string; router: string; tokenPool?: undefined }
)

/**
* Available lane feature keys.
* These represent features or thresholds that can be configured per-lane.
Expand Down Expand Up @@ -1104,6 +1131,18 @@ export abstract class Chain<F extends ChainFamily = ChainFamily> {
return Promise.reject(new CCIPNotImplementedError('getLaneFeatures'))
}

/**
* Retrieve the token transfer fee configuration from a TokenPool v2.0 contract.
*
* Returns `null` when the pool does not support fee config (pre-2.0 pools).
* Throws on RPC or other unexpected errors.
*
* @param _opts - Either `{ tokenPool }` or `{ token, router }` plus shared fields.
*/
getTokenPoolFee(_opts: TokenPoolFeeOpts): Promise<TokenTransferFeeConfig | null> {
return Promise.reject(new CCIPNotImplementedError('getTokenPoolFee'))
}

/**
* Default/generic implementation of getExecutionReceipts.
* Yields execution receipts for a given offRamp.
Expand Down
101 changes: 101 additions & 0 deletions ccip-sdk/src/evm/fork.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,107 @@ describe('EVM Fork Tests', { skip, timeout: 180_000 }, () => {
})
})

describe('getTokenPoolFee', () => {
it('should return disabled fee config for token with old pool', async () => {
assert.ok(sepoliaChain, 'sepolia chain should be initialized')

const result = await sepoliaChain.getTokenPoolFee({
token: OLD_POOL_TOKEN_SEPOLIA,
router: SEPOLIA_V2_0_ROUTER,
destChainSelector: FUJI_SELECTOR,
blockConfirmationsRequested: 0,
tokenArgs: '0x',
})

// Old pools may respond with all-zero config rather than reverting
assert.notEqual(result, null, 'old pool responds to the call')
assert.equal(result!.isEnabled, false, 'fee config should not be enabled on old pool')
})

it('should return fee config for v2.0 pool via token+router', async () => {
assert.ok(fujiChain, 'fuji chain should be initialized')

const result = await fujiChain.getTokenPoolFee({
token: FTF_TOKEN_FUJI,
router: FUJI_V2_0_ROUTER,
destChainSelector: SEPOLIA_SELECTOR,
blockConfirmationsRequested: 0,
tokenArgs: '0x',
})

assert.notEqual(result, null, 'v2.0 pool should return fee config')
assert.equal(typeof result!.destGasOverhead, 'number')
assert.equal(typeof result!.destBytesOverhead, 'number')
assert.equal(typeof result!.isEnabled, 'boolean')
console.log(' v2.0 pool fee config (blockConfirmationsRequested=0):')
console.log(
` defaultBlockConfirmationsFeeUSDCents = ${result!.defaultBlockConfirmationsFeeUSDCents}`,
)
console.log(
` customBlockConfirmationsFeeUSDCents = ${result!.customBlockConfirmationsFeeUSDCents}`,
)
console.log(
` defaultBlockConfirmationsTransferFeeBps = ${result!.defaultBlockConfirmationsTransferFeeBps}`,
)
console.log(
` customBlockConfirmationsTransferFeeBps = ${result!.customBlockConfirmationsTransferFeeBps}`,
)
})

it('should return fee config with blockConfirmationsRequested=1', async () => {
assert.ok(fujiChain, 'fuji chain should be initialized')

const result = await fujiChain.getTokenPoolFee({
token: FTF_TOKEN_FUJI,
router: FUJI_V2_0_ROUTER,
destChainSelector: SEPOLIA_SELECTOR,
blockConfirmationsRequested: 1,
tokenArgs: '0x',
})

assert.notEqual(result, null, 'v2.0 pool should return fee config')
assert.equal(typeof result!.destGasOverhead, 'number')
assert.equal(typeof result!.destBytesOverhead, 'number')
assert.equal(typeof result!.isEnabled, 'boolean')
console.log(' v2.0 pool fee config (blockConfirmationsRequested=1):')
console.log(
` defaultBlockConfirmationsFeeUSDCents = ${result!.defaultBlockConfirmationsFeeUSDCents}`,
)
console.log(
` customBlockConfirmationsFeeUSDCents = ${result!.customBlockConfirmationsFeeUSDCents}`,
)
console.log(
` defaultBlockConfirmationsTransferFeeBps = ${result!.defaultBlockConfirmationsTransferFeeBps}`,
)
console.log(
` customBlockConfirmationsTransferFeeBps = ${result!.customBlockConfirmationsTransferFeeBps}`,
)
})

it('should return fee config when given tokenPool directly', async () => {
assert.ok(fujiChain, 'fuji chain should be initialized')

// Resolve pool address to exercise the tokenPool input path
const onRamp = await fujiChain.getOnRampForRouter(FUJI_V2_0_ROUTER, SEPOLIA_SELECTOR)
const onRampContract = new Contract(onRamp, interfaces.OnRamp_v2_0, fujiChain.provider)
const poolAddress = (await onRampContract.getFunction('getPoolBySourceToken')(
SEPOLIA_SELECTOR,
FTF_TOKEN_FUJI,
)) as string

const result = await fujiChain.getTokenPoolFee({
tokenPool: poolAddress,
destChainSelector: SEPOLIA_SELECTOR,
blockConfirmationsRequested: 0,
tokenArgs: '0x',
})

assert.notEqual(result, null, 'v2.0 pool should return fee config via direct address')
assert.equal(typeof result!.destGasOverhead, 'number')
assert.equal(typeof result!.isEnabled, 'boolean')
})
})

// ── State-mutating tests below: keep these last so read-only tests see clean fork state ──

describe('sendMessage', () => {
Expand Down
66 changes: 66 additions & 0 deletions ccip-sdk/src/evm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ import {
type GetBalanceOpts,
type LaneFeatures,
type LogFilter,
type TokenPoolFeeOpts,
type TokenPoolRemote,
type TokenTransferFeeConfig,
Chain,
LaneFeature,
} from '../chain.ts'
Expand Down Expand Up @@ -688,6 +690,70 @@ export class EVMChain extends Chain<typeof ChainFamily.EVM> {
}
}

/**
* {@inheritDoc Chain.getTokenPoolFee}
*/
override async getTokenPoolFee(opts: TokenPoolFeeOpts): Promise<TokenTransferFeeConfig | null> {
let poolAddress: string
let token: string

if ('tokenPool' in opts && opts.tokenPool) {
poolAddress = opts.tokenPool
token = await this.getTokenForTokenPool(poolAddress)
} else if ('token' in opts && opts.token && 'router' in opts && opts.router) {
const onRamp = await this.getOnRampForRouter(opts.router, opts.destChainSelector)
const onRampContract = new Contract(
onRamp,
interfaces.OnRamp_v2_0,
this.provider,
) as unknown as TypedContract<typeof OnRamp_2_0_ABI>
const resolved = (await onRampContract.getPoolBySourceToken(
opts.destChainSelector,
opts.token,
)) as string

if (!resolved || resolved === ZeroAddress)
throw new CCIPTokenNotFoundError(opts.token, {
context: { router: opts.router, destChainSelector: String(opts.destChainSelector) },
recovery: 'Verify the token is supported on this lane',
})
poolAddress = resolved
token = opts.token
} else {
throw new CCIPError('UNKNOWN', 'Either tokenPool or both token and router must be provided')
}

try {
const poolContract = new Contract(
poolAddress,
interfaces.TokenPool_v2_0,
this.provider,
) as unknown as TypedContract<typeof TokenPool_2_0_ABI>
const result = await poolContract.getTokenTransferFeeConfig(
token,
opts.destChainSelector,
BigInt(opts.blockConfirmationsRequested),
opts.tokenArgs,
)
return {
destGasOverhead: Number(result.destGasOverhead),
destBytesOverhead: Number(result.destBytesOverhead),
defaultBlockConfirmationsFeeUSDCents: Number(result.defaultBlockConfirmationsFeeUSDCents),
customBlockConfirmationsFeeUSDCents: Number(result.customBlockConfirmationsFeeUSDCents),
defaultBlockConfirmationsTransferFeeBps: Number(
result.defaultBlockConfirmationsTransferFeeBps,
),
customBlockConfirmationsTransferFeeBps: Number(
result.customBlockConfirmationsTransferFeeBps,
),
isEnabled: result.isEnabled,
}
} catch (err) {
if (isError(err, 'CALL_EXCEPTION')) return null
throw CCIPError.from(err, 'UNKNOWN')
}
}

/**
* {@inheritDoc Chain.getRouterForOffRamp}
* @throws {@link CCIPVersionUnsupportedError} if OffRamp version is not supported
Expand Down
2 changes: 2 additions & 0 deletions ccip-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ export type {
RegistryTokenConfig,
TokenInfo,
TokenPoolConfig,
TokenPoolFeeOpts,
TokenPoolRemote,
TokenTransferFeeConfig,
} from './chain.ts'
export { DEFAULT_API_RETRY_CONFIG, LaneFeature } from './chain.ts'
export { calculateManualExecProof, discoverOffRamp } from './execution.ts'
Expand Down
Loading