Manage Token Transfer Fees Using Hardhat

Guide Versions

This guide is available in multiple versions. Choose the one that matches your needs.

CCIP v2 token pools let pool owners opt into pool-level token transfer fee overrides per destination lane. When an override is disabled, the OnRamp falls back to FeeQuoter defaults for that lane. In this tutorial you will:

  1. Review the current fee configuration for both configured lanes.
  2. Optionally set a fee admin who can withdraw accrued fees.
  3. Configure lane-specific flat fees, transfer fees, destination gas overhead, and destination bytes overhead.
  4. Send a LINK-paid transfer and inspect accrued fees in the source token pool.
  5. Withdraw accrued fee tokens.
  6. Disable the fee configuration on both lanes to return to FeeQuoter defaults.

Before You Begin

1 Set Up Your Development Environment
  1. Install Node.js and npm:

    • Make sure you have Node.js v22.10.0 or above installed. If not, install Node.js v22.10.0 using the Node.js documentation.
    • npm is bundled with Node.js. If you can't run npm, reinstall or update Node.js from the official installer.
  2. Install/Update Chainlink ccip-cli:

Terminal
npm install -g @chainlink/ccip-cli
ccip-cli --version
  1. Clone the repository and navigate to the project directory:
CCIP 2.0 template

Clone docs-cct-hardhat for the CCT Hardhat scripts used in this tutorial.

Terminal
git clone https://github.com/smartcontractkit/docs-cct-hardhat.git
cd docs-cct-hardhat
  1. Create a .env file by copying the .env.example file, and fill in the required values:
Terminal
cp .env.example .env
.env
# Keystore name (created via `npx hardhat keystore set`)
KEYSTORE_NAME=<your_private_key_name>

# Optional: keystore name for a configured fee admin
FEE_ADMIN_KEYSTORE_NAME=your_fee_admin_keystore_name

# RPC URLs
ETHEREUM_SEPOLIA_RPC_URL=your_eth_sepolia_rpc
ARBITRUM_SEPOLIA_RPC_URL=your_arbitrum_sepolia_rpc

# Etherscan API key (required only if you pass --verify to deployment tasks)
ETHERSCAN_API_KEY=your_etherscan_api_key

Use the CCIP Directory or helper-config.ts to confirm the router and LINK addresses for your network.

Complete List of Supported Chains

View the complete list of supported chains in the helper-config.ts file.

  1. Load your environment variables:
Terminal
source .env
  1. Build the project:
Terminal
npm install && npx hardhat compile
  1. Create an encrypted Hardhat keystore, if you haven't already:
Terminal
npx hardhat keystore set PRIVATE_KEY

Optionally, create an encrypted Hardhat keystore for the fee admin, if you haven't already:

Terminal
npx hardhat keystore set FEE_ADMIN_PRIVATE_KEY

Tutorial

1 Verify and Set Fee Admin

The fee admin is part of the token pool dynamic configuration. Use getDynamicConfig to inspect the current router, rate limit admin, and fee admin before making any changes.

getDynamicConfig.ts

View the dynamic config query task on GitHub.

Terminal
npx hardhat getDynamicConfig \
  --network sepolia

Your output should look something like this:

Terminal
========================================
โš™๏ธ  Get Dynamic Config
========================================
Chain:        Ethereum Sepolia
Token Pool:   0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
Action:       View dynamic config
========================================

Dynamic Configuration:
  Router:           0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59
  Rate Limit Admin: 0xYourAddressForRateLimitAdmin
  Fee Admin:        0xYourAddressForFeeAdmin

========================================
Token Pool:   https://sepolia.etherscan.io/address/0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
========================================
Terminal
npx hardhat getDynamicConfig \
  --network arbitrumSepolia

Your output should look something like this:

Terminal
========================================
โš™๏ธ  Get Dynamic Config
========================================
Chain:        Arbitrum Sepolia
Token Pool:   0x7D2f4A6c8E1b3D5a9F0c2E4b6A8d1C3e5F7a9B2d
Action:       View dynamic config
========================================

Dynamic Configuration:
  Router:           0x2a9C5afB0d0e4BAb2BCdaE109EC4b0c4Be15a165
  Rate Limit Admin: 0xYourAddressForRateLimitAdmin
  Fee Admin:        0xYourAddressForFeeAdmin

========================================
Token Pool:   https://sepolia.arbiscan.io/address/0x7D2f4A6c8E1b3D5a9F0c2E4b6A8d1C3e5F7a9B2d
========================================

Use setDynamicConfig to set a fee admin if you want to. This task sets the router, rate limit admin, and fee admin together, so pass the current router and rate limit admin from the previous output unless you intend to change them.

setDynamicConfig.ts

View the dynamic config update task on GitHub.

InputRequiredDescription
--routerNoRouter to keep or set. Use the current router from getDynamicConfig unless intentionally changing it.
--ratelimitadminNoRate limit admin to keep or set. Use the current value unless intentionally changing it.
--feeadminNoAddress allowed to withdraw accrued fee tokens. Set to address(0) to restrict withdrawal to the pool owner.
KEYSTORE_NAMEYesHardhat keystore name for the pool owner (set in .env).
  1. Set the fee admin on the Ethereum Sepolia pool:
Terminal
npx hardhat setDynamicConfig \
  --router 0xCurrentRouterFromOutput \
  --ratelimitadmin 0xYourAddressForRateLimitAdmin \
  --feeadmin 0xYourFeeAdminAddress \
  --network sepolia

Your output should look something like this:

Terminal
========================================
โš™๏ธ  Set Dynamic Config
========================================
Chain:        Ethereum Sepolia
Token Pool:   0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
Action:       Set dynamic config
========================================

Current Configuration:
  Router:           0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59
  Rate Limit Admin: 0xYourAddressForRateLimitAdmin
  Fee Admin:        0xYourAddressForFeeAdmin

New Configuration:
  Router:           0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59
  Rate Limit Admin: 0xYourAddressForRateLimitAdmin
  Fee Admin:        0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994

[Step 1] Setting dynamic config on Ethereum Sepolia
โณ Tx: https://sepolia.etherscan.io/tx/0x0f4fda7e9e39f35d2bb6d6b7967db7f1b8d2d99fc1f7a64dfe8a3f0d8c7a2b31
โœ… Dynamic config updated successfully!

========================================
โœ… Configuration Complete on Ethereum Sepolia!
========================================
Token Pool:       https://sepolia.etherscan.io/address/0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
Router:           0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59
Rate Limit Admin: 0xYourAddressForRateLimitAdmin
Fee Admin:        0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994
Transaction:      https://sepolia.etherscan.io/tx/0x0f4fda7e9e39f35d2bb6d6b7967db7f1b8d2d99fc1f7a64dfe8a3f0d8c7a2b31
========================================
  1. Set the fee admin on the Arbitrum Sepolia pool:
Terminal
npx hardhat setDynamicConfig \
  --router 0xCurrentRouterFromOutput \
  --ratelimitadmin 0xYourAddressForRateLimitAdmin \
  --feeadmin 0xYourFeeAdminAddress \
  --network arbitrumSepolia

Your output should look something like this:

Terminal
========================================
โš™๏ธ  Set Dynamic Config
========================================
Chain:        Arbitrum Sepolia
Token Pool:   0x7D2f4A6c8E1b3D5a9F0c2E4b6A8d1C3e5F7a9B2d
Action:       Set dynamic config
========================================

Current Configuration:
  Router:           0x2a9C5afB0d0e4BAb2BCdaE109EC4b0c4Be15a165
  Rate Limit Admin: 0xYourAddressForRateLimitAdmin
  Fee Admin:        0xYourAddressForFeeAdmin

New Configuration:
  Router:           0x2a9C5afB0d0e4BAb2BCdaE109EC4b0c4Be15a165
  Rate Limit Admin: 0xYourAddressForRateLimitAdmin
  Fee Admin:        0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994

[Step 1] Setting dynamic config on Arbitrum Sepolia
โณ Tx: https://sepolia.arbiscan.io/tx/0xe5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4
โœ… Dynamic config updated successfully!

========================================
โœ… Configuration Complete on Arbitrum Sepolia!
========================================
Token Pool:       https://sepolia.arbiscan.io/address/0x7D2f4A6c8E1b3D5a9F0c2E4b6A8d1C3e5F7a9B2d
Router:           0x2a9C5afB0d0e4BAb2BCdaE109EC4b0c4Be15a165
Rate Limit Admin: 0xYourAddressForRateLimitAdmin
Fee Admin:        0x3A34637a41aB08519d30Fdb65344aBa8E9b2e994
Transaction:      https://sepolia.arbiscan.io/tx/0xe5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4
========================================
2 Review Current Fee Configuration

Use getTokenTransferFeeConfig to read the current fee configuration for a destination lane from the source-chain token pool.

getTokenTransferFeeConfig.ts

View the fee config query task on GitHub.

InputRequiredDescription
--destchainYesRemote chain whose lane is being queried.
Source pool env varYesETHEREUM_SEPOLIA_TOKEN_POOL, ARBITRUM_SEPOLIA_TOKEN_POOL, or the --tokenpool override for the source chain selected by --network.
  1. Check the Ethereum Sepolia pool for the lane to Arbitrum Sepolia:
Terminal
npx hardhat getTokenTransferFeeConfig \
  --destchain arbitrumSepolia \
  --network sepolia

Your output should look something like this:

Terminal
========================================
๐Ÿ’ฐ Get Token Transfer Fee Config
========================================
Chain:        Ethereum Sepolia
Remote Chain: Arbitrum Sepolia
Token Pool:   0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
Action:       View fee config
========================================

Dest Chain Selector: 3478487238524512106

Fee Configuration:
  isEnabled:                   false
  destGasOverhead:             0
  destBytesOverhead:           0
  finalityFeeUSDCents:         0
  fastFinalityFeeUSDCents:     0
  finalityTransferFeeBps:      0
  fastFinalityTransferFeeBps:  0

โš ๏ธ  Fee config is disabled for this lane.
   The OnRamp will fall back to FeeQuoter defaults for this destination.

========================================
Token Pool:   https://sepolia.etherscan.io/address/0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
========================================
  1. Check the Arbitrum Sepolia pool for the lane to Ethereum Sepolia:
Terminal
npx hardhat getTokenTransferFeeConfig \
  --destchain sepolia \
  --network arbitrumSepolia

Your output should look something like this:

Terminal
========================================
๐Ÿ’ฐ Get Token Transfer Fee Config
========================================
Chain:        Arbitrum Sepolia
Remote Chain: Ethereum Sepolia
Token Pool:   0x7D2f4A6c8E1b3D5a9F0c2E4b6A8d1C3e5F7a9B2d
Action:       View fee config
========================================

Dest Chain Selector: 16015286601757825753

Fee Configuration:
  isEnabled:                   false
  destGasOverhead:             0
  destBytesOverhead:           0
  finalityFeeUSDCents:         0
  fastFinalityFeeUSDCents:     0
  finalityTransferFeeBps:      0
  fastFinalityTransferFeeBps:  0

โš ๏ธ  Fee config is disabled for this lane.
   The OnRamp will fall back to FeeQuoter defaults for this destination.

========================================
Token Pool:   https://sepolia.arbiscan.io/address/0x7D2f4A6c8E1b3D5a9F0c2E4b6A8d1C3e5F7a9B2d
========================================
3 Set Token Transfer Fee Configuration

Use updateTokenTransferFeeConfig to configure fee parameters for a destination lane on the source-chain token pool.

updateTokenTransferFeeConfig.ts

View the fee config update task on GitHub.

InputRequiredDescription
--destchainYesRemote chain to configure fees for.
Source pool env varYesETHEREUM_SEPOLIA_TOKEN_POOL, ARBITRUM_SEPOLIA_TOKEN_POOL, or the --tokenpool override for the source chain selected by --network.
KEYSTORE_NAMEYesHardhat keystore name for the pool owner (set in .env).
--destgasoverheadNoGas overhead charged in the fee model. Must be greater than 0 when enabled.
--destbytesoverheadNoData availability bytes overhead. Use at least 32 for Burn & Mint and Lock & Release pools.
--finalityfeeusdcentsNoFlat fee in 0.01 USD units for default-finality transfers.
--fastfinalityfeeusdcentsNoFlat fee in 0.01 USD units for fast-finality transfers.
--finalitytransferfeebpsNoBasis-point fee deducted from the transferred amount for default-finality transfers. Must be below 10000.
--fastfinalitytransferfeebpsNoBasis-point fee deducted from the transferred amount for fast-finality transfers. Must be below 10000.
--disableNoSet to disable the fee config for this lane.
--tokenpoolNoToken pool address. Overrides the {CHAIN}_TOKEN_POOL env var.

This section uses a two-step update demo for the Ethereum Sepolia โ†’ Arbitrum Sepolia lane so you can see that omitting fields does not reset them.

  • Step 1 sets a basis-point fee for default-finality transfers.
  • Step 2 adds a flat fee for fast-finality transfers, while intentionally omitting the basis points flag so it carries forward.

Fee behavior in this tutorial:

  • bps fields are deducted from the transferred token amount and retained by the source pool as the pool token.
  • Flat fee fields are charged in the selected fee token (LINK in this tutorial) and credited to the source pool.

Step 1: Configure a basis points fee for default-finality transfers (ETH Sepolia โ†’ Arbitrum Sepolia)

Terminal
npx hardhat updateTokenTransferFeeConfig \
  --destchain arbitrumSepolia \
  --destgasoverhead 50000 \
  --destbytesoverhead 32 \
  --finalityfeeusdcents 0 \
  --finalitytransferfeebps 25 \
  --fastfinalityfeeusdcents 0 \
  --fastfinalitytransferfeebps 0 \
  --network sepolia

Your output should look something like this:

Terminal
========================================
๐Ÿ’ฐ Update Token Transfer Fee Config
========================================
Chain:        Ethereum Sepolia
Remote Chain: Arbitrum Sepolia
Token Pool:   0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
Action:       Set fee config
========================================

Dest Chain Selector: 3478487238524512106

Current On-Chain Fee Configuration:
  isEnabled:                   false
  destGasOverhead:             0
  destBytesOverhead:           0
  finalityFeeUSDCents:         0
  fastFinalityFeeUSDCents:     0
  finalityTransferFeeBps:      0
  fastFinalityTransferFeeBps:  0

Fee Configuration to Apply:
  destGasOverhead:             50000
  destBytesOverhead:           32
  finalityFeeUSDCents:         0
  fastFinalityFeeUSDCents:     0
  finalityTransferFeeBps:      25
  fastFinalityTransferFeeBps:  0

[Step 1] Applying fee config for lane to Arbitrum Sepolia
โณ Tx: https://sepolia.etherscan.io/tx/0x...
โœ… Fee config applied successfully!

========================================
โœ… Operation Complete!
========================================
Token Pool:   https://sepolia.etherscan.io/address/0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
========================================

Step 2: Add a flat fee for fast-finality transfers (ETH Sepolia โ†’ Arbitrum Sepolia)

This command intentionally omits --finalitytransferfeebps. The task defaults omitted fields to the current on-chain values, so the 25 basis points default-finality fee carries forward.

Terminal
npx hardhat updateTokenTransferFeeConfig \
  --destchain arbitrumSepolia \
  --fastfinalityfeeusdcents 150 \
  --network sepolia

Your output should look something like this:

Terminal
========================================
๐Ÿ’ฐ Update Token Transfer Fee Config
========================================
Chain:        Ethereum Sepolia
Remote Chain: Arbitrum Sepolia
Token Pool:   0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
Action:       Set fee config
========================================

Dest Chain Selector: 3478487238524512106

Current On-Chain Fee Configuration:
  isEnabled:                   true
  destGasOverhead:             50000
  destBytesOverhead:           32
  finalityFeeUSDCents:         0
  fastFinalityFeeUSDCents:     0
  finalityTransferFeeBps:      25
  fastFinalityTransferFeeBps:  0

Fee Configuration to Apply:
  destGasOverhead:             50000
  destBytesOverhead:           32
  finalityFeeUSDCents:         0
  fastFinalityFeeUSDCents:     150
  finalityTransferFeeBps:      25
  fastFinalityTransferFeeBps:  0

[Step 1] Applying fee config for lane to Arbitrum Sepolia
โณ Tx: https://sepolia.etherscan.io/tx/0x...
โœ… Fee config applied successfully!

========================================
โœ… Operation Complete!
========================================
Token Pool:   https://sepolia.etherscan.io/address/0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
========================================

Repeat for the reverse lane (Arbitrum Sepolia โ†’ Ethereum Sepolia)

Repeat the same two steps on the Arbitrum Sepolia token pool by switching the network and destination chain.

Step 1:

Terminal
npx hardhat updateTokenTransferFeeConfig \
  --destchain sepolia \
  --destgasoverhead 50000 \
  --destbytesoverhead 32 \
  --finalityfeeusdcents 0 \
  --finalitytransferfeebps 25 \
  --fastfinalityfeeusdcents 0 \
  --fastfinalitytransferfeebps 0 \
  --network arbitrumSepolia

Step 2:

Terminal
npx hardhat updateTokenTransferFeeConfig \
  --destchain sepolia \
  --fastfinalityfeeusdcents 150 \
  --network arbitrumSepolia

Both steps should complete successfully and print the token pool address and transaction hash for the update.

4 Verify the Updated Fee Configuration

Re-run getTokenTransferFeeConfig to confirm the final (Step 2) configuration for the Ethereum Sepolia โ†’ Arbitrum Sepolia lane.

  1. Verify the Ethereum Sepolia pool for the lane to Arbitrum Sepolia:
Terminal
npx hardhat getTokenTransferFeeConfig \
  --destchain arbitrumSepolia \
  --network sepolia

Your output should look something like this:

Terminal
========================================
๐Ÿ’ฐ Get Token Transfer Fee Config
========================================
Chain:        Ethereum Sepolia
Remote Chain: Arbitrum Sepolia
Token Pool:   0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
Action:       View fee config
========================================

Dest Chain Selector: 3478487238524512106

Fee Configuration:
  isEnabled:                   true
  destGasOverhead:             50000
  destBytesOverhead:           32
  finalityFeeUSDCents:         0
  fastFinalityFeeUSDCents:     150
  finalityTransferFeeBps:      25
  fastFinalityTransferFeeBps:  0

========================================
Token Pool:   https://sepolia.etherscan.io/address/0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
========================================
  1. Repeat for the reverse lane (Arbitrum Sepolia โ†’ Ethereum Sepolia):
Terminal
npx hardhat getTokenTransferFeeConfig \
  --destchain sepolia \
  --network arbitrumSepolia

Success criteria:

  • Ethereum Sepolia โ†’ Arbitrum Sepolia shows isEnabled: true.
  • Ethereum Sepolia โ†’ Arbitrum Sepolia shows destGasOverhead: 50000.
  • Ethereum Sepolia โ†’ Arbitrum Sepolia shows destBytesOverhead: 32.
  • Ethereum Sepolia โ†’ Arbitrum Sepolia shows finalityFeeUSDCents: 0 and finalityTransferFeeBps: 25.
  • Ethereum Sepolia โ†’ Arbitrum Sepolia shows fastFinalityFeeUSDCents: 150 and fastFinalityTransferFeeBps: 0.
  • The reverse lane should show the same values after you repeat the two-step update.
5 Send Transfers That Use the Fee Config

This section demonstrates Ethereum Sepolia โ†’ Arbitrum Sepolia cross-chain transfers. Repeat the same pattern from Arbitrum Sepolia if you want the Arbitrum Sepolia source pool to accrue fees.

Before sending, inspect the source pool balances for LINK and the source token:

getFeeTokenBalances.ts

View the fee-token balance inspection task on GitHub.

Terminal
npx hardhat getFeeTokenBalances \
  --feetokens "$ETHEREUM_SEPOLIA_LINK,$ETHEREUM_SEPOLIA_TOKEN" \
  --network sepolia

Your output should look something like this:

Terminal
========================================
๐Ÿ” Get Fee Token Balances
========================================
Chain:        Ethereum Sepolia
Token Pool:   0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
Action:       Inspect fee token balances
========================================

Pool Token:   0xD73D06d36cEa36817186779fF877e418Ab759F09 (for reference)

Fee Token Balances:
  [0] 0x779877A7B0D9E8603169DdbD7836e478b4624789  โ†’  balance: 0 โš ๏ธ  (skipping)
  [1] 0xD73D06d36cEa36817186779fF877e418Ab759F09  โ†’  balance: 0 โš ๏ธ  (skipping)

โ„น๏ธ  No fee tokens have a non-zero balance in the pool. Nothing to withdraw.
========================================
  1. Send a default-finality transfer and pay CCIP fees in LINK:
Terminal
ccip-cli send \
  --source ethereum-testnet-sepolia \
  --router $ETHEREUM_SEPOLIA_ROUTER \
  --dest ethereum-testnet-sepolia-arbitrum-1 \
  --transfer-tokens $ETHEREUM_SEPOLIA_TOKEN=1.23 \
  --receiver 0xYourReceiverAddress \
  --fee-token LINK \
  --wallet hardhat:$KEYSTORE_NAME \
  --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \
  --rpc "$ARBITRUM_SEPOLIA_RPC_URL"

Your output should look something like this:

Terminal
Fee: 119317588427515265n = 0.119317588427515265 LINK
Request (source):
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ (index)                        โ”‚ Values                                                               โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ fee                            โ”‚ '0.119317588427515265 LINK'                                          โ”‚
โ”‚ tokens                         โ”‚ '1.226925 BnM-T'                                                     โ”‚
โ”‚ tokenAmountBeforeTokenPoolFees โ”‚ 1230000000000000000n                                                 โ”‚
โ”‚ receipts[1].issuer             โ”‚ '0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9'                         โ”‚
โ”‚ receipts[1].feeTokenAmount     โ”‚ 93786750729051300n                                                   โ”‚
โ”‚ finality                       โ”‚ 'finalized'                                                          โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

This send uses finalityTransferFeeBps (25 bps). In this demo, finalityFeeUSDCents is 0.

  1. Send a fast-finality transfer with block-depth finality and pay CCIP fees in LINK:
Terminal
ccip-cli send \
  --source ethereum-testnet-sepolia \
  --router $ETHEREUM_SEPOLIA_ROUTER \
  --dest ethereum-testnet-sepolia-arbitrum-1 \
  --transfer-tokens $ETHEREUM_SEPOLIA_TOKEN=1.23 \
  --receiver 0xYourReceiverAddress \
  --fee-token LINK \
  --extra finality=32 \
  --wallet hardhat:$KEYSTORE_NAME \
  --rpc "$ETHEREUM_SEPOLIA_RPC_URL" \
  --rpc "$ARBITRUM_SEPOLIA_RPC_URL"

Your output should look something like this:

Terminal
Request (source):
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ (index)                        โ”‚ Values                                                               โ”‚
โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
โ”‚ tokenAmountBeforeTokenPoolFees โ”‚ 1230000000000000000n                                                 โ”‚
โ”‚ tokens                         โ”‚ '1.23 BnM-T'                                                         โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

This send uses fastFinalityFeeUSDCents (150). In this demo, fastFinalityTransferFeeBps is 0.

How to read fees from the ccip-cli output

  • The top-level fee: <X> LINK is the total fee paid by the sender. It is not necessarily the amount credited to the token pool.
  • For the fee-token amount credited to the pool, find the receipt where receipts[i].issuer equals the source token pool address. The corresponding receipts[i].feeTokenAmount is denominated in the fee token (LINK) in wei.
  • For basis points deductions, compare tokenAmountBeforeTokenPoolFees with the tokens field. Their difference is the token amount retained by the source pool.
6 Inspect and Withdraw Accrued Fees

After the transfers complete, inspect the source pool balances again:

Terminal
npx hardhat getFeeTokenBalances \
  --feetokens "$ETHEREUM_SEPOLIA_LINK,$ETHEREUM_SEPOLIA_TOKEN" \
  --network sepolia

Your output should look something like this:

Terminal
========================================
๐Ÿ” Get Fee Token Balances
========================================
Chain:        Ethereum Sepolia
Token Pool:   0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
Action:       Inspect fee token balances
========================================

Pool Token:   0xD73D06d36cEa36817186779fF877e418Ab759F09 (for reference)

Fee Token Balances:
  [0] 0x779877A7B0D9E8603169DdbD7836e478b4624789  โ†’  balance: <LINK_BALANCE_WEI>
  [1] 0xD73D06d36cEa36817186779fF877e418Ab759F09  โ†’  balance: 3075000000000000

โœ… 2 token(s) with non-zero balances are ready for withdrawal.

To withdraw, run:
  npx hardhat withdrawFeeTokens --feetokens "0x779877A7B0D9E8603169DdbD7836e478b4624789,0xD73D06d36cEa36817186779fF877e418Ab759F09" --network sepolia
  (Add --recipient 0x... to send to a different address; defaults to the deployer wallet.)
========================================

Expected balances:

  • Source token (bps fee): The default-finality send uses finalityTransferFeeBps = 25. With a 1.23 token transfer:
    • Retained by the pool: (1.23 \times 25 / 10000 = 0.003075) tokens
    • Verify in ccip-cli output: tokenAmountBeforeTokenPoolFees (1.23) - tokens received (1.226925) = 0.003075
  • LINK (fee token credited to the pool): The fee-token amount credited to the token pool is visible in the send output receipt where receipts[i].issuer equals the source token pool address. Sum receipts[i].feeTokenAmount across the default-finality and fast-finality sends, then convert from wei to LINK by dividing by (10^18). Confirm it matches getFeeTokenBalances for LINK.

Set a value for the FEE_ADMIN_KEYSTORE_NAME variable inside your .env, using the instructions covered inside Set Up Your Development Environment.

Use withdrawFeeTokens to withdraw accrued fee balances to a recipient. Withdraw as the configured fee admin:

withdrawFeeTokens.ts

View the fee-token withdrawal task on GitHub.

Terminal
KEYSTORE_NAME=$FEE_ADMIN_KEYSTORE_NAME \
  npx hardhat withdrawFeeTokens \
  --recipient 0xYourRecipientAddress \
  --feetokens "$ETHEREUM_SEPOLIA_LINK,$ETHEREUM_SEPOLIA_TOKEN" \
  --network sepolia

Your output should look something like this:

Terminal
========================================
๐Ÿ’ธ Withdraw Fee Tokens
========================================
Chain:        Ethereum Sepolia
Token Pool:   0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
Action:       Withdraw fee tokens
========================================

Pool Token:   0xD73D06d36cEa36817186779fF877e418Ab759F09 (for reference)
Recipient:    0x368b6c4218Cd5B8feF04eFDb207Bd52f0566c411

Tokens to Withdraw:
  [0] 0x779877A7B0D9E8603169DdbD7836e478b4624789  โ†’  balance: <LINK_BALANCE_WEI>
  [1] 0xD73D06d36cEa36817186779fF877e418Ab759F09  โ†’  balance: 3075000000000000

[Step 1] Withdrawing fee tokens on Ethereum Sepolia
โณ Tx: https://sepolia.etherscan.io/tx/0x5e1c0d9a8b7f6e5d4c3b2a190f1e2d3c4b5a69788796a5b4c3d2e1f0a9b8c7d6
โœ… Fee tokens withdrawn successfully!

========================================
โœ… Withdrawal complete on Ethereum Sepolia!
========================================
Token Pool:   https://sepolia.etherscan.io/address/0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
Recipient:    https://sepolia.etherscan.io/address/0x368b6c4218Cd5B8feF04eFDb207Bd52f0566c411
========================================

Or withdraw as the pool owner:

Terminal
npx hardhat withdrawFeeTokens \
  --recipient 0xYourRecipientAddress \
  --feetokens "$ETHEREUM_SEPOLIA_LINK,$ETHEREUM_SEPOLIA_TOKEN" \
  --network sepolia

Your output should look something like this:

Terminal
========================================
๐Ÿ’ธ Withdraw Fee Tokens
========================================
Chain:        Ethereum Sepolia
Token Pool:   0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
Action:       Withdraw fee tokens
========================================

Pool Token:   0xD73D06d36cEa36817186779fF877e418Ab759F09 (for reference)
Recipient:    0x368b6c4218Cd5B8feF04eFDb207Bd52f0566c411

Tokens to Withdraw:
  [0] 0x779877A7B0D9E8603169DdbD7836e478b4624789  โ†’  balance: <LINK_BALANCE_WEI>
  [1] 0xD73D06d36cEa36817186779fF877e418Ab759F09  โ†’  balance: 3075000000000000

[Step 1] Withdrawing fee tokens on Ethereum Sepolia
โณ Tx: https://sepolia.etherscan.io/tx/0x9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d
โœ… Fee tokens withdrawn successfully!

========================================
โœ… Withdrawal complete on Ethereum Sepolia!
========================================
Token Pool:   https://sepolia.etherscan.io/address/0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
Recipient:    https://sepolia.etherscan.io/address/0x368b6c4218Cd5B8feF04eFDb207Bd52f0566c411
========================================

Verify the balances after withdrawal:

Terminal
npx hardhat getFeeTokenBalances \
  --feetokens "$ETHEREUM_SEPOLIA_LINK,$ETHEREUM_SEPOLIA_TOKEN" \
  --network sepolia

Your output should look something like this:

Terminal
========================================
๐Ÿ” Get Fee Token Balances
========================================
Chain:        Ethereum Sepolia
Token Pool:   0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
Action:       Inspect fee token balances
========================================

Pool Token:   0xD73D06d36cEa36817186779fF877e418Ab759F09 (for reference)

Fee Token Balances:
  [0] 0x779877A7B0D9E8603169DdbD7836e478b4624789  โ†’  balance: 0 โš ๏ธ  (skipping)
  [1] 0xD73D06d36cEa36817186779fF877e418Ab759F09  โ†’  balance: 0 โš ๏ธ  (skipping)

โ„น๏ธ  No fee tokens have a non-zero balance in the pool. Nothing to withdraw.
========================================
7 Disable the Fee Configuration

Disabling fee configuration for a lane removes the lane-specific override and returns that lane to FeeQuoter defaults. Disable both lanes that you configured earlier.

  1. Disable the fee config on the Ethereum Sepolia pool for the lane to Arbitrum Sepolia:
Terminal
npx hardhat updateTokenTransferFeeConfig \
  --destchain arbitrumSepolia \
  --disable \
  --network sepolia

Your output should look something like this:

Terminal
========================================
๐Ÿ’ฐ Update Token Transfer Fee Config
========================================
Chain:        Ethereum Sepolia
Remote Chain: Arbitrum Sepolia
Token Pool:   0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
Action:       Disable fee config
========================================

Dest Chain Selector: 3478487238524512106

[Step 1] Disabling fee config for lane to Arbitrum Sepolia
โณ Tx: https://sepolia.etherscan.io/tx/0x6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b
โœ… Fee config disabled for this lane.
   The OnRamp will now use FeeQuoter defaults for this destination.

========================================
โœ… Operation Complete!
========================================
Token Pool:   https://sepolia.etherscan.io/address/0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
========================================
  1. Verify the Ethereum Sepolia pool for the lane to Arbitrum Sepolia:
Terminal
npx hardhat getTokenTransferFeeConfig \
  --destchain arbitrumSepolia \
  --network sepolia

Your output should look something like this:

Terminal
========================================
๐Ÿ’ฐ Get Token Transfer Fee Config
========================================
Chain:        Ethereum Sepolia
Remote Chain: Arbitrum Sepolia
Token Pool:   0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
Action:       View fee config
========================================

Dest Chain Selector: 3478487238524512106

Fee Configuration:
  isEnabled:                   false
  destGasOverhead:             0
  destBytesOverhead:           0
  finalityFeeUSDCents:         0
  fastFinalityFeeUSDCents:     0
  finalityTransferFeeBps:      0
  fastFinalityTransferFeeBps:  0

โš ๏ธ  Fee config is disabled for this lane.
   The OnRamp will fall back to FeeQuoter defaults for this destination.

========================================
Token Pool:   https://sepolia.etherscan.io/address/0x147D4625b71f58D7CFA54BB705DC7741B94e0CE9
========================================
  1. Disable the fee config on the Arbitrum Sepolia pool for the lane to Ethereum Sepolia:
Terminal
npx hardhat updateTokenTransferFeeConfig \
  --destchain sepolia \
  --disable \
  --network arbitrumSepolia

Your output should look something like this:

Terminal
========================================
๐Ÿ’ฐ Update Token Transfer Fee Config
========================================
Chain:        Arbitrum Sepolia
Remote Chain: Ethereum Sepolia
Token Pool:   0x7D2f4A6c8E1b3D5a9F0c2E4b6A8d1C3e5F7a9B2d
Action:       Disable fee config
========================================

Dest Chain Selector: 16015286601757825753

[Step 1] Disabling fee config for lane to Ethereum Sepolia
โณ Tx: https://sepolia.arbiscan.io/tx/0xf60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5
โœ… Fee config disabled for this lane.
   The OnRamp will now use FeeQuoter defaults for this destination.

========================================
โœ… Operation Complete!
========================================
Token Pool:   https://sepolia.arbiscan.io/address/0x7D2f4A6c8E1b3D5a9F0c2E4b6A8d1C3e5F7a9B2d
========================================
  1. Verify the Arbitrum Sepolia pool for the lane to Ethereum Sepolia:
Terminal
npx hardhat getTokenTransferFeeConfig \
  --destchain sepolia \
  --network arbitrumSepolia

Your output should look something like this:

Terminal
========================================
๐Ÿ’ฐ Get Token Transfer Fee Config
========================================
Chain:        Arbitrum Sepolia
Remote Chain: Ethereum Sepolia
Token Pool:   0x7D2f4A6c8E1b3D5a9F0c2E4b6A8d1C3e5F7a9B2d
Action:       View fee config
========================================

Dest Chain Selector: 16015286601757825753

Fee Configuration:
  isEnabled:                   false
  destGasOverhead:             0
  destBytesOverhead:           0
  finalityFeeUSDCents:         0
  fastFinalityFeeUSDCents:     0
  finalityTransferFeeBps:      0
  fastFinalityTransferFeeBps:  0

โš ๏ธ  Fee config is disabled for this lane.
   The OnRamp will fall back to FeeQuoter defaults for this destination.

========================================
Token Pool:   https://sepolia.arbiscan.io/address/0x7D2f4A6c8E1b3D5a9F0c2E4b6A8d1C3e5F7a9B2d
========================================

Both lanes should show isEnabled: false and zeroed fee fields. The OnRamp will use FeeQuoter defaults for both directions again.

What's next

Get the latest Chainlink content straight to your inbox.