From ec6574aacd7fd1217623cc87764914cfdc03d6c3 Mon Sep 17 00:00:00 2001 From: PabloMansanet Date: Thu, 5 Mar 2026 14:11:39 +0100 Subject: [PATCH 1/2] Implement method to retrieve token transfer fees --- ccip-sdk/src/chain.ts | 39 +++++++++++++++++++++++ ccip-sdk/src/evm/index.ts | 66 +++++++++++++++++++++++++++++++++++++++ ccip-sdk/src/index.ts | 2 ++ 3 files changed, 107 insertions(+) diff --git a/ccip-sdk/src/chain.ts b/ccip-sdk/src/chain.ts index 25eba802..13a6a734 100644 --- a/ccip-sdk/src/chain.ts +++ b/ccip-sdk/src/chain.ts @@ -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. @@ -1104,6 +1131,18 @@ export abstract class Chain { 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 { + return Promise.reject(new CCIPNotImplementedError('getTokenPoolFee')) + } + /** * Default/generic implementation of getExecutionReceipts. * Yields execution receipts for a given offRamp. diff --git a/ccip-sdk/src/evm/index.ts b/ccip-sdk/src/evm/index.ts index 5067f060..7bd6e292 100644 --- a/ccip-sdk/src/evm/index.ts +++ b/ccip-sdk/src/evm/index.ts @@ -30,7 +30,9 @@ import { type GetBalanceOpts, type LaneFeatures, type LogFilter, + type TokenPoolFeeOpts, type TokenPoolRemote, + type TokenTransferFeeConfig, Chain, LaneFeature, } from '../chain.ts' @@ -688,6 +690,70 @@ export class EVMChain extends Chain { } } + /** + * {@inheritDoc Chain.getTokenPoolFee} + */ + override async getTokenPoolFee(opts: TokenPoolFeeOpts): Promise { + 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 + 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 + 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 diff --git a/ccip-sdk/src/index.ts b/ccip-sdk/src/index.ts index 700ef70d..eb6c6aa7 100644 --- a/ccip-sdk/src/index.ts +++ b/ccip-sdk/src/index.ts @@ -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' From ecf2bb0775afb9c3179f7c801f3e83f80f47e808 Mon Sep 17 00:00:00 2001 From: PabloMansanet Date: Thu, 5 Mar 2026 14:22:30 +0100 Subject: [PATCH 2/2] Add token transfer fork tests --- ccip-sdk/src/evm/fork.test.ts | 101 ++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/ccip-sdk/src/evm/fork.test.ts b/ccip-sdk/src/evm/fork.test.ts index f9d42452..8fc1616b 100644 --- a/ccip-sdk/src/evm/fork.test.ts +++ b/ccip-sdk/src/evm/fork.test.ts @@ -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', () => {