Transfer Tokens with Data - Defensive Example

This tutorial extends the Transfer Tokens with Data example. It uses CCIP to send tokens + arbitrary data from one chain to another in a single transaction. In addition though, it incorporates defensive receiver logic that stores failed messages and locks received tokens instead of reverting the whole delivery.

You will send CCIP-BnM tokens and a string payload from Ethereum Sepolia to Arbitrum Sepolia. The destination contract is configured to fail intentionally, then you will recover the locked tokens.

Before you begin

  1. You should understand how to write, compile, deploy, and fund a smart contract. Go through this tutorial to get started.
  2. Your account must have some ETH on Ethereum Sepolia and ETH on Arbitrum Sepolia for gas. If you plan to pay CCIP fees in LINK, you also need testnet LINK on Ethereum Sepolia.
    Learn how to Acquire testnet LINK.
  3. Check the CCIP Directory if you want to configure a different set of source and destination chains/tokens.
  4. Acquire CCIP test tokens. You should have CCIP-BnM tokens, and CCIP-BnM should appear in the list of your tokens in MetaMask.

Examine the code

1 Initializing the contract

When deploying the contract, we define the router address of the blockchain we deploy the contract on. Defining the router address is useful for the following:

  • Sender part:

    • Calls the router's getFee function to estimate the CCIP fees.
    • Calls the router's ccipSend function to send CCIP messages.
  • Receiver part:

    • The contract inherits from CCIPReceiver, which serves as a base contract for receiver contracts. This contract requires that child contracts implement the _ccipReceive function.
    • This defensive contract overrides ccipReceive directly so it can wrap processing in try/catch. The inherited onlyRouter modifier still ensures that only the router can deliver CCIP messages to the receiver contract.

Some key things to note:

  • OwnerIsCreator sets the deployer as the owner of the contract.
  • The constructor passes the router address into CCIPReceiver at deployment time.
  • sendMessage is payable and open to any caller (not restricted to the owner). It handles both LINK and native fee payments:
    • pass the LINK token address as _feeTokenAddress to pay in LINK, or,
    • address(0) to pay in native gas.
  • The function accepts pre-encoded _extraArgs bytes built off-chain, making the contract forward-compatible with any extraArgs version.
  • Access control is enforced through allowlisting:
    • outbound messages are restricted by destination chain selector (onlyAllowlistedDestinationChain), not by destination receiver address
    • inbound messages are restricted by source chain selector + source sender contract pair (onlyAllowlisted)
  • The contract overrides getCCVsAndFinalityConfig from CCIPReceiver to advertise per-source-chain receiver finality policy to the OffRamp. See Configure receiver finality policy below.
  • An EnumerableMap (s_failedMessages) tracks failed message IDs and their error codes (FAILED or RESOLVED). The s_messageContents mapping stores the full CCIP message payload for failed messages, enabling token recovery.
  • The s_simRevert flag allows the owner to simulate processing failures for testing.
ProgrammableDefensiveTokenTransfers.sol
// Imports
import {EnumerableMap} from "@openzeppelin/contracts/utils/structs/EnumerableMap.sol";

contract ProgrammableDefensiveTokenTransfers is CCIPReceiver, OwnerIsCreator {
    using EnumerableMap for EnumerableMap.Bytes32ToUintMap;
    using SafeERC20 for IERC20;

    enum ErrorCode { RESOLVED, FAILED }

    mapping(uint64 => bool) public allowlistedDestinationChains;
    mapping(uint64 => mapping(address => bool)) public allowlistedChainSenders;
    mapping(uint64 => bytes4) private s_allowedFinalityConfig;
    mapping(bytes32 messageId => Client.Any2EVMMessage contents) public s_messageContents;
    EnumerableMap.Bytes32ToUintMap internal s_failedMessages;
    bool internal s_simRevert = false;

    constructor(address _router) CCIPReceiver(_router) {}

    function sendMessage(
        uint64 _destinationChainSelector,
        address _receiver,
        string calldata _text,
        address _token,
        uint256 _amount,
        address _feeTokenAddress,
        bytes calldata _extraArgs
    )
        external
        payable
        onlyAllowlistedDestinationChain(_destinationChainSelector)
        validateReceiver(_receiver)
        returns (bytes32 messageId)
    {
        messageId = _sendCCIPMessage(/* ... */);
    }

    // ... defensive receive pipeline, recovery functions, and finality config ...
}
2 Build transaction payload

_sendCCIPMessage calls the _buildCCIPMessage helper to build a CCIP message payload using EVM2AnyMessage struct.
This payload is then passed to the router's getFee and ccipSend functions.

The payload includes:

  • receiver: ABI-encoded destination address (abi.encode(_receiver)).
  • data: ABI-encoded text payload (abi.encode(_text)).
  • tokenAmounts: A 1-element array containing the token address and amount to transfer.
  • extraArgs: Pre-encoded message execution parameters built off-chain by a helper script. For default finality, the scripts encode V3 extraArgs with the finalized/default finality config. For non-default finality requests, the scripts detect lane support and use V3 extraArgs with requestedFinalityConfig on FTF-capable lanes, or V2 extraArgs on pre-v2.0 lanes.
  • feeToken: The token used to pay CCIP fees (_feeTokenAddress). Pass the LINK token address to pay in LINK, or address(0) to pay in native gas.
_buildCCIPMessage
  function _buildCCIPMessage(
      address _receiver,
      string calldata _text,
      address _token,
      uint256 _amount,
      address _feeTokenAddress,
      bytes calldata _extraArgs
  ) private pure returns (Client.EVM2AnyMessage memory) {
      Client.EVMTokenAmount[] memory tokenAmounts = new Client.EVMTokenAmount[](1);
      tokenAmounts[0] = Client.EVMTokenAmount({token: _token, amount: _amount});

      return Client.EVM2AnyMessage({
          receiver: abi.encode(_receiver),
          data: abi.encode(_text),
          tokenAmounts: tokenAmounts,
          extraArgs: _extraArgs,
          feeToken: _feeTokenAddress
      });
  }
3 Sending messages

The public sendMessage function delegates to _sendCCIPMessage, which performs four operations:

  1. Builds the message payload by calling _buildCCIPMessage. See Build transaction payload for details.
  2. Computes the fees by invoking the router's getFee function.
  3. Pulls tokens from the caller and grants the router the required approvals by calling _handleFeeAndTokenApprovals. See Handling fees and token approvals for details.
  4. Dispatches the CCIP message by executing the router's ccipSend function. If paying in native gas (_feeTokenAddress == address(0)), the fee is forwarded via {value: ccipFee}.

Note: As a security measure, sendMessage is protected by the onlyAllowlistedDestinationChain and validateReceiver modifiers. Any caller can invoke it -- access is governed by the destination chain allowlist, not ownership.

_sendCCIPMessage
  function _sendCCIPMessage(
      uint64 _destinationChainSelector,
      address _receiver,
      string calldata _text,
      address _token,
      uint256 _amount,
      address _feeTokenAddress,
      bytes calldata _extraArgs
  ) private returns (bytes32 messageId) {
      Client.EVM2AnyMessage memory evm2AnyMessage = _buildCCIPMessage(
          _receiver, _text, _token, _amount, _feeTokenAddress, _extraArgs
      );

      IRouterClient router = IRouterClient(this.getRouter());
      uint256 ccipFee = router.getFee(_destinationChainSelector, evm2AnyMessage);

      _handleFeeAndTokenApprovals(router, _token, _amount, _feeTokenAddress, ccipFee);

      if (_feeTokenAddress == address(0)) {
          messageId = router.ccipSend{value: ccipFee}(_destinationChainSelector, evm2AnyMessage);
      } else {
          messageId = router.ccipSend(_destinationChainSelector, evm2AnyMessage);
      }

      emit MessageSent(
          messageId, _destinationChainSelector, _receiver, _text, _token, _amount, _feeTokenAddress, ccipFee
      );

      return messageId;
  }
4 Handling fees and token approvals

The contract uses a pull-from-caller model: when a user calls sendMessage, the contract pulls the required tokens from msg.sender via safeTransferFrom, then approves the Router to spend them via forceApprove. The caller (EOA or upstream contract) must approve this contract before calling sendMessage.

_handleFeeAndTokenApprovals handles three scenarios:

  1. Native fee (_feeTokenAddress == address(0)): Validates that msg.value covers the CCIP fee. Pulls the transfer token from the caller via safeTransferFrom and approves the Router.
  2. Same token for fee and transfer (_token == _feeTokenAddress): Pulls the combined total (ccipFee + amount) from the caller in one safeTransferFrom. Approves the Router for the combined total.
  3. Different ERC-20 tokens: Pulls each token separately from the caller. Approves the Router for each.
_handleFeeAndTokenApprovals
  function _handleFeeAndTokenApprovals(
      IRouterClient _router,
      address _token,
      uint256 _amount,
      address _feeTokenAddress,
      uint256 _ccipFee
  ) private {
      if (_feeTokenAddress == address(0)) {
          if (msg.value < _ccipFee) {
              revert InsufficientNativeForFees(msg.value, _ccipFee);
          }

          IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount);
          IERC20(_token).forceApprove(address(_router), _amount);
      } else if (_token == _feeTokenAddress) {
          uint256 totalAmount = _ccipFee + _amount;
          IERC20(_token).safeTransferFrom(msg.sender, address(this), totalAmount);
          IERC20(_token).forceApprove(address(_router), totalAmount);
      } else {
          IERC20(_feeTokenAddress).safeTransferFrom(msg.sender, address(this), _ccipFee);
          IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount);
          IERC20(_feeTokenAddress).forceApprove(address(_router), _ccipFee);
          IERC20(_token).forceApprove(address(_router), _amount);
      }
  }
5 Defensive message reception

This is the core defensive pattern. Unlike the standard CCIPReceiver, this contract overrides ccipReceive directly and wraps message processing in a try/catch so application-level failures are captured and stored instead of reverting the delivery.

Step-by-step flow:

  1. ccipReceive is called by the CCIP router. The onlyRouter modifier ensures no other caller can invoke it. The onlyAllowlisted modifier checks the (sourceChainSelector, sender) pair.

  2. Inside ccipReceive, the contract calls this.processMessage(any2EvmMessage) as an external call to itself. This external call is wrapped in a try/catch block.

  3. processMessage is protected by the onlySelf modifier (only the contract itself can call it). It first checks s_simRevert -- if true, it reverts with ErrorCase() to simulate a failure. Otherwise it forwards to _ccipReceive.

  4. On success: processing completes normally and _ccipReceive stores the message details and emits MessageReceived.

  5. On failure: the catch block stores the message ID in s_failedMessages with ErrorCode.FAILED, saves the full message payload in s_messageContents, and emits MessageFailed. The tokens are locked in the contract.

ccipReceive
  function ccipReceive(Client.Any2EVMMessage calldata any2EvmMessage)
      external
      override
      onlyRouter
      onlyAllowlisted(any2EvmMessage.sourceChainSelector, abi.decode(any2EvmMessage.sender, (address)))
  {
      try this.processMessage(any2EvmMessage) {
      } catch (bytes memory err) {
          s_failedMessages.set(any2EvmMessage.messageId, uint256(ErrorCode.FAILED));
          s_messageContents[any2EvmMessage.messageId] = any2EvmMessage;
          emit MessageFailed(any2EvmMessage.messageId, err);
          return;
      }
  }
processMessage
  function processMessage(Client.Any2EVMMessage calldata any2EvmMessage)
      external
      onlySelf
      onlyAllowlisted(any2EvmMessage.sourceChainSelector, abi.decode(any2EvmMessage.sender, (address)))
  {
      if (s_simRevert) revert ErrorCase();

      _ccipReceive(any2EvmMessage);
  }

The internal _ccipReceive is tokenless-safe -- it checks destTokenAmounts.length > 0 before accessing the array:

_ccipReceive
  function _ccipReceive(Client.Any2EVMMessage memory any2EvmMessage) internal override {
      s_lastReceivedMessageId = any2EvmMessage.messageId;
      s_lastReceivedSender = abi.decode(any2EvmMessage.sender, (address));
      s_lastReceivedText = abi.decode(any2EvmMessage.data, (string));

      bool hasToken = any2EvmMessage.destTokenAmounts.length > 0;
      s_lastReceivedTokenAddress = hasToken ? any2EvmMessage.destTokenAmounts[0].token : address(0);
      s_lastReceivedTokenAmount = hasToken ? any2EvmMessage.destTokenAmounts[0].amount : 0;

      emit MessageReceived(
          any2EvmMessage.messageId,
          any2EvmMessage.sourceChainSelector,
          s_lastReceivedSender,
          s_lastReceivedText,
          s_lastReceivedTokenAddress,
          s_lastReceivedTokenAmount
      );
  }
6 Recovering locked tokens

When a message fails, the tokens are locked in the contract. The owner can recover them using retryFailedMessage:

  1. Validation: checks that the message has ErrorCode.FAILED in s_failedMessages. Reverts with MessageNotFailed otherwise.
  2. Status update: sets the error code to RESOLVED to prevent re-entry and duplicate retries.
  3. Token recovery: retrieves the stored message from s_messageContents and transfers the locked tokens to the specified tokenReceiver via safeTransfer.
  4. Event: emits MessageRecovered.
retryFailedMessage
  function retryFailedMessage(bytes32 messageId, address tokenReceiver) external onlyOwner {
      if (s_failedMessages.get(messageId) != uint256(ErrorCode.FAILED)) {
          revert MessageNotFailed(messageId);
      }

      s_failedMessages.set(messageId, uint256(ErrorCode.RESOLVED));

      Client.Any2EVMMessage memory message = s_messageContents[messageId];

      IERC20(message.destTokenAmounts[0].token).safeTransfer(tokenReceiver, message.destTokenAmounts[0].amount);

      emit MessageRecovered(messageId);
  }

Supporting functions:

  • getFailedMessages(uint256 offset, uint256 limit): returns a paginated list of FailedMessage structs (each containing messageId and errorCode). Use this to find failed message IDs before calling retryFailedMessage.
  • setSimRevert(bool simRevert): owner-only toggle that controls the s_simRevert flag. Set to true to simulate failures for testing, false to resume normal processing.
7 Configure receiver finality policy

The receiver exposes an allowedFinalityConfig for each source chain. This value tells CCIP which finality modes the receiver accepts for messages from that chain. The sender scripts encode the requested mode into V3 extraArgs as requestedFinalityConfig, then validate the request against the receiver policy (and the token pool policy, when applicable) before sending.

CCIP 2.0 supports two finality request styles:

  • Default finality (finalized): Omit BLOCK_DEPTH (or set BLOCK_DEPTH=DEFAULT).
  • Numeric block depth (faster than finality): Set BLOCK_DEPTH=32 (or higher). This tutorial standardizes on 32.

This contract uses two functions to manage receiver-side finality policy:

  1. setAllowedFinalityConfig: An owner-only setter that stores the FinalityCodec-encoded policy for a source chain.
  2. getCCVsAndFinalityConfig: The OffRamp and scripts can call this hook to read the receiver's CCV and finality policy. This tutorial does not configure custom CCVs, so it returns empty CCV arrays and optionalThreshold = 0.
Receiver finality policy
  function setAllowedFinalityConfig(
      uint64 _sourceChainSelector,
      bytes4 _allowedFinalityConfig
  ) external onlyOwner {
      s_allowedFinalityConfig[_sourceChainSelector] = _allowedFinalityConfig;
      emit AllowedFinalityConfigSet(_sourceChainSelector, _allowedFinalityConfig);
  }

  function getCCVsAndFinalityConfig(
      uint64 sourceChainSelector,
      bytes calldata sender
  )
      external
      view
      override
      returns (
          address[] memory requiredCCVs,
          address[] memory optionalCCVs,
          uint8 optionalThreshold,
          bytes4 allowedFinalityConfig
      )
  {
      address decodedSender = abi.decode(sender, (address));
      if (!allowlistedChainSenders[sourceChainSelector][decodedSender]) {
          revert SenderNotAllowedForChain(sourceChainSelector, decodedSender);
      }

      requiredCCVs = new address[](0);
      optionalCCVs = new address[](0);
      optionalThreshold = 0;
      allowedFinalityConfig = s_allowedFinalityConfig[sourceChainSelector];
  }
ProgrammableDefensiveTokenTransfers.sol

Check out the complete contract code on Github.

Tutorial

Let's get started! Choose your preferred development environment below.

Foundry

Best for Solidity-native workflows that prefer a modular, powerful scripting framework.

1 Bootstrap a new Foundry project
Foundry Starter Kit

Clone the Foundry Starter Kit for a smoother setup.

  1. Clone the CCIP 2.0 template repository, and open a terminal inside the project directory:
Terminal
git clone https://github.com/smartcontractkit/docs-ccip.git && cd docs-ccip
  1. If you don't already have a Foundry keystore, use the cast command to create a new one. Here, your_keystore_name is the alias you assign to this keystore entry -- Foundry will prompt you to enter the actual private key and a password to encrypt it:
Terminal
cast wallet import your_keystore_name --interactive
  1. Install dependencies:
Terminal
npm install
  1. Create a .env file by copying the example file, and fill in your values:
Terminal
cp .env.example .env

Set KEYSTORE_NAME to the name of the keystore entry you created above, and provide RPC endpoints for the chains you will use:

.env
# Keystore name
KEYSTORE_NAME=your_keystore_name

# RPC URLs (add the ones you need)
ETHEREUM_SEPOLIA_RPC_URL=
ARBITRUM_SEPOLIA_RPC_URL=

# Etherscan API key (required only if you pass --verify to deployment scripts)
ETHERSCAN_API_KEY=
  1. Load the environment variables:
Terminal
source .env
  1. Run the following command to compile all the contracts:
Terminal
forge build
2 Deploy your contracts

In this section, you will deploy the ProgrammableDefensiveTokenTransfers contracts on the source and destination chains.

  1. The Deploy.s.sol script does the following:
  • Deploy ProgrammableDefensiveTokenTransfers on Ethereum Sepolia (source).
  • Deploy ProgrammableDefensiveTokenTransfers on Arbitrum Sepolia (destination).
  • Returns the contract addresses in the terminal

To run the script, use the following command:

Terminal
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
forge script foundry/scripts/tutorials/programmable-defensive-token-transfers/deploy/Deploy.s.sol \
--account $KEYSTORE_NAME \
--broadcast -vv \
--verify

Your terminal should look something like this:

Terminal
========================================
๐Ÿš€ Deploy CCIP Defensive Contracts on Both Chains
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
========================================


[Step 1] Deploying ProgrammableDefensiveTokenTransfers on Ethereum Sepolia
Contract deployed at: 0x8A1b2C3d4E5f60718293A4b5c6D7e8F9a0B1c2D3
https://sepolia.etherscan.io/address/0x8A1b2C3d4E5f60718293A4b5c6D7e8F9a0B1c2D3

========================================
โœ… Deployment Complete on Ethereum Sepolia!
========================================
Source Contract Address: 0x8A1b2C3d4E5f60718293A4b5c6D7e8F9a0B1c2D3


[Step 2] Deploying ProgrammableDefensiveTokenTransfers on Arbitrum Sepolia
Contract deployed at: 0x6D2a8F4c1E9b3A7d5C0e6B8f2A4c9E1d3F7b5A0c
https://sepolia.arbiscan.io/address/0x6D2a8F4c1E9b3A7d5C0e6B8f2A4c9E1d3F7b5A0c

========================================
โœ… Deployment Complete on Arbitrum Sepolia!
========================================
Destination Contract Address: 0x6D2a8F4c1E9b3A7d5C0e6B8f2A4c9E1d3F7b5A0c

========================================
โœ… All Deployments Complete!
========================================
Source Chain: Ethereum Sepolia
Source Contract: 0x8A1b2C3d4E5f60718293A4b5c6D7e8F9a0B1c2D3
https://sepolia.etherscan.io/address/0x8A1b2C3d4E5f60718293A4b5c6D7e8F9a0B1c2D3

Destination Chain: Arbitrum Sepolia
Destination Contract: 0x6D2a8F4c1E9b3A7d5C0e6B8f2A4c9E1d3F7b5A0c
https://sepolia.arbiscan.io/address/0x6D2a8F4c1E9b3A7d5C0e6B8f2A4c9E1d3F7b5A0c

Run this command to set both environment variables:
export ETHEREUM_SEPOLIA_CONTRACT=0x8A1b2C3d4E5f60718293A4b5c6D7e8F9a0B1c2D3 && export ARBITRUM_SEPOLIA_CONTRACT=0x6D2a8F4c1E9b3A7d5C0e6B8f2A4c9E1d3F7b5A0c
========================================
  1. Before moving on, export the addresses of the previously deployed contracts so that they're available in the terminal:
Terminal
export ETHEREUM_SEPOLIA_CONTRACT=<sender-contract-address> && export ARBITRUM_SEPOLIA_CONTRACT=<receiver-contract-address>

Check out the complete file on Github here:

Deploy.s.sol

Check out the complete script code on Github.

3 Configure allowlists and finality

As a best practice, allowlist specific contracts to prevent unauthorized message delivery between your sender and receiver.

Configure.s.sol

Check out the complete script code on Github.

  1. The Configure.s.sol script handles all configuration in a single command:
    • Allowlists the destination chain on the sender contract.
    • Allowlists the chain-sender pair on the receiver contract via allowlistChainSender.
    • Sets the receiver's finality policy via setAllowedFinalityConfig.
    • Enables simulated revert on the receiver contract via setSimRevert(true).
Terminal
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH \
ALLOWED_BLOCK_DEPTH=32 \
forge script foundry/scripts/tutorials/programmable-defensive-token-transfers/configure/Configure.s.sol \
--account $KEYSTORE_NAME --broadcast -vv
Finality configuration

To allow numeric faster than finality requests, set ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH and choose a minimum ALLOWED_BLOCK_DEPTH. In this tutorial, ALLOWED_BLOCK_DEPTH=32 allows send-time requests of BLOCK_DEPTH=32 (or higher).

Your terminal should look something like this:

Terminal
========================================
โš™๏ธ Configure CCIP Defensive Contracts on Both Chains
========================================
Source Chain: Ethereum Sepolia
Source Contract: 0x8A1b2C3d4E5f60718293A4b5c6D7e8F9a0B1c2D3
Destination Chain: Arbitrum Sepolia
Destination Contract: 0x6D2a8F4c1E9b3A7d5C0e6B8f2A4c9E1d3F7b5A0c
========================================


[Step 1] Configuring sender on Ethereum Sepolia
Allowlisting Arbitrum Sepolia as destination chain...
โœ… Destination chain allowlisted: Arbitrum Sepolia

========================================
โœ… Configuration Complete on Ethereum Sepolia!
========================================


[Step 2] Configuring receiver on Arbitrum Sepolia
Allowlisting sender 0x8A1b2C3d4E5f60718293A4b5c6D7e8F9a0B1c2D3 from Ethereum Sepolia...
โœ… Chain-sender pair allowlisted: Ethereum Sepolia -> 0x8A1b2C3d4E5f60718293A4b5c6D7e8F9a0B1c2D3
Setting allowed finality config to 0x00000020 (BLOCK_DEPTH=32)...
โœ… Allowed finality config set to 0x00000020 (BLOCK_DEPTH=32) for Ethereum Sepolia
โœ… Sim revert set to true - messages will fail for testing recovery

========================================
โœ… Configuration Complete on Arbitrum Sepolia!
========================================

========================================
โœ… All Configurations Complete!
========================================
Ethereum Sepolia can send messages to Arbitrum Sepolia
Arbitrum Sepolia can receive messages from Ethereum Sepolia
โš ๏ธ  Messages will fail on destination (simRevert=true) to demonstrate defensive handling
4 Fund your wallet with test tokens
DripBnMToken.s.sol

Check out the faucet script on Github.

Before sending a CCIP message, you need CCIP-BnM test tokens in your wallet. The send scripts transfer CCIP-BnM from your EOA to the contract, so your wallet must hold a balance.

Use the faucet script included in the starter kit to drip CCIP-BnM tokens to your address:

Terminal
CHAIN=ETHEREUM_SEPOLIA RECIPIENT_ADDRESS=<your-wallet-address> forge script foundry/scripts/faucet/DripBnMToken.s.sol --account $KEYSTORE_NAME --broadcast -vv
5 Send a message

SendMessage.s.sol is a unified send script that handles both native and LINK fee payments. Set FEE_TOKEN=LINK to pay with LINK, or omit it (defaults to NATIVE) to pay with the native gas token. The script:

  • Builds off-chain extraArgs for the lane. Foundry uses ExtraArgsHelper.buildExtraArgs to detect the lane version and encode V2 or V3.
  • Approves the contract to spend the caller's tokens (the contract then pulls via safeTransferFrom).
  • Sends the CCIP message.
Faster Than Finality (block depth)

The BLOCK_DEPTH environment variable controls send-side finality behavior:

  • Omit BLOCK_DEPTH, or set BLOCK_DEPTH=DEFAULT/BLOCK_DEPTH=0 (default): Use finalized finality.
  • Set BLOCK_DEPTH=32: Request faster than finality using numeric block depth.
Example 1: Pay with native gas + faster than finality (BLOCK_DEPTH=32)
Terminal
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
BLOCK_DEPTH=32 \
TOKEN_AMOUNT=1000000000000000 GAS_LIMIT=400000 \
MESSAGE='Hello from Foundry!' \
forge script foundry/scripts/tutorials/programmable-defensive-token-transfers/interact/SendMessage.s.sol \
--account $KEYSTORE_NAME --broadcast -vv

Your terminal should look like this:

Terminal
========================================
๐Ÿ“ก CCIP Defensive Message Transfer - Pay with Native
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
Sender: 0x8A1b2C3d4E5f60718293A4b5c6D7e8F9a0B1c2D3
Receiver: 0x6D2a8F4c1E9b3A7d5C0e6B8f2A4c9E1d3F7b5A0c
Fee Token: Native (ETH)
========================================


[Pre-validation] Detecting lane version and building extraArgs...
Gas limit (override): 400000
Token pool ALLOWED_FINALITY_CONFIG: 0x00000020 (BLOCK_DEPTH: 32 block(s))
Receiver contract ALLOWED_FINALITY_CONFIG: 0x00000020 (BLOCK_DEPTH: 32 block(s))
โœ… Using V3 extraArgs with FTF (gasLimit=400000, finalityConfig=0x00000020 (BLOCK_DEPTH: 32 block(s))).

[Step 1] Approving contract to spend CCIP-BnM...
โœ… Contract approved to spend CCIP-BnM


[Step 2] Sending CCIP message with native token fee ( ETH )...
Required CCIP fee (in WEI): 330463107598943

========================================
โœ… Message sent successfully!
========================================
CCIP messageId: 0x1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708
CCIP Explorer:
https://ccip.chain.link/#/side-drawer/msg/0x1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708
Terminal
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
FEE_TOKEN=LINK \
TOKEN_AMOUNT=1000000000000000 GAS_LIMIT=400000 \
MESSAGE='Hello from Foundry!' \
forge script foundry/scripts/tutorials/programmable-defensive-token-transfers/interact/SendMessage.s.sol \
--account $KEYSTORE_NAME --broadcast -vv

Your terminal should look like this:

Terminal
========================================
๐Ÿ“ก CCIP Defensive Message Transfer - Pay with LINK
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
Sender: 0x8A1b2C3d4E5f60718293A4b5c6D7e8F9a0B1c2D3
Receiver: 0x6D2a8F4c1E9b3A7d5C0e6B8f2A4c9E1d3F7b5A0c
Fee Token: LINK
========================================


[Pre-validation] Detecting lane version and building extraArgs...
Gas limit (override): 400000
โœ… Using default finality (BLOCK_DEPTH=DEFAULT). V3 extraArgs (gasLimit=400000, finalityConfig=0x00000000).

[Step 1] Approving contract to spend fee token for CCIP fees...
Required CCIP fee (in token units): 38700123456789000
โœ… Contract approved to spend fee token


[Step 2] Approving contract to spend CCIP-BnM...
โœ… Contract approved to spend CCIP-BnM


[Step 3] Sending CCIP message...

========================================
โœ… Message sent successfully!
========================================
CCIP messageId: 0x2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a
CCIP Explorer:
https://ccip.chain.link/#/side-drawer/msg/0x2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a
Environment variables
VariableDescriptionDefault
KEYSTORE_NAMEFoundry encrypted keystore name used by --account $KEYSTORE_NAMEyour_keystore_name
SOURCE_CHAINSource chain name identifier (for example, ETHEREUM_SEPOLIA)Not set
DEST_CHAINDestination chain name identifier (for example, ARBITRUM_SEPOLIA)Not set
{CHAIN}_RPC_URLRPC URL for each chain you run scripts against (for example, ETHEREUM_SEPOLIA_RPC_URL, ARBITRUM_SEPOLIA_RPC_URL)Not set
{CHAIN}_CONTRACTDeployed tutorial contract address per chain (for example, ETHEREUM_SEPOLIA_CONTRACT, ARBITRUM_SEPOLIA_CONTRACT)Not set
FEE_TOKENLINK or NATIVENATIVE
FEE_TOKEN_ADDRESSERC-20 address of a CCIP-supported fee token on the lane. Takes priority over FEE_TOKENNot set
TOKEN_AMOUNTAmount of CCIP-BnM to transfer (in wei)1000000000000000 (0.001)
GAS_LIMITGas limit for the destination callback400000
BLOCK_DEPTHOmit or set DEFAULT for finalized finality (default), or set 32 for faster than finalityDEFAULT
ALLOWED_FINALITY_CONFIGReceiver allowed finality mode(s) used by the configure step. Set to BLOCK_DEPTH to allow numeric faster than finality requests.Not set
ALLOWED_BLOCK_DEPTHReceiver minimum block depth (used when ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH). Use 32 in this tutorial.Not set
SIM_REVERTEnables simulated failures on the receiver during configurationtrue
MESSAGEText payload to sendDefault String Text
CHAINChain name identifier used by the failed-message scripts (the chain where the receiver is deployed)Not set
MESSAGE_IDFailed message ID to retry (used by the retry script)Not set
TOKEN_RECEIVERAddress to receive recovered tokens when retrying a failed messageNot set
SendMessage.s.sol

Check out the complete script code on Github.

6 Check failed messages and recover tokens

After sending the message, wait for it to arrive on the destination chain (typically 10-20 minutes). Because s_simRevert is true, the message will fail and the tokens will be locked in the receiver contract.

Step 1: Check for failed messages

Run the GetFailedMessages.s.sol script to query the receiver contract for failed messages:

Terminal
CHAIN=ARBITRUM_SEPOLIA forge script foundry/scripts/tutorials/programmable-defensive-token-transfers/interact/GetFailedMessages.s.sol -vv

Your terminal should look like this:

Terminal
========================================
๐Ÿ” Check Failed Messages
========================================
Chain: Arbitrum Sepolia
Receiver Address: 0x6D2a8F4c1E9b3A7d5C0e6B8f2A4c9E1d3F7b5A0c
========================================

Found 1 unresolved failed message(s):

========================================
Failed Message #1
========================================
Message ID: 0x1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708
Error Code: 1 (FAILED)

Note the Message ID from the output -- you will need it for the next step.

GetFailedMessages.s.sol

Check out the complete script code on Github.

Step 2: Recover the locked tokens

Use the RetryFailedMessage.s.sol script to recover the locked tokens. Pass the failed message ID and the address where you want the tokens sent:

Terminal
MESSAGE_ID=<failed-message-id> TOKEN_RECEIVER=<your-wallet-address> CHAIN=ARBITRUM_SEPOLIA \
forge script foundry/scripts/tutorials/programmable-defensive-token-transfers/interact/RetryFailedMessage.s.sol \
--account $KEYSTORE_NAME --broadcast -vv

Your terminal should look like this:

Terminal
========================================
๐Ÿ”„ Retry Failed Message
========================================
Chain: Arbitrum Sepolia
Receiver Address: 0x6D2a8F4c1E9b3A7d5C0e6B8f2A4c9E1d3F7b5A0c
Message ID: 0x1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708
========================================

Retrying failed message...
Token receiver address: 0x90F79bf6EB2c4f870365E785982E1f101E93b906


========================================
โœ… Message Retry Complete!
========================================
Tokens have been recovered and sent to: 0x90F79bf6EB2c4f870365E785982E1f101E93b906

RetryFailedMessage.s.sol

Check out the complete script code on Github.

Step 3: Verify the recovery

Re-run the GetFailedMessages.s.sol script. The error code for the recovered message should now be 0 (RESOLVED):

Terminal
CHAIN=ARBITRUM_SEPOLIA forge script foundry/scripts/tutorials/programmable-defensive-token-transfers/interact/GetFailedMessages.s.sol -vv

Your terminal should look like this:

Terminal
========================================
๐Ÿ” Check Failed Messages
========================================
Chain: Arbitrum Sepolia
Receiver Address: 0x6D2a8F4c1E9b3A7d5C0e6B8f2A4c9E1d3F7b5A0c
========================================

โœ… No unresolved failed messages found.
Total messages: 1 (all resolved)

Hardhat

Best for developers who want a mature, TypeScript-based smart contract development framework.

1 Bootstrap a new Hardhat project
CCIP Starter Kit

Clone the starter kit (contains both Hardhat and Foundry code) for a smoother setup.

  1. Clone the CCIP 2.0 template repository, and open a terminal inside the project directory:
Terminal
git clone https://github.com/smartcontractkit/docs-ccip.git && cd docs-ccip
  1. Copy the example environment file and fill in your values:
Terminal
cp .env.example .env

Set KEYSTORE_NAME to the keystore alias you will create later in this section, and provide RPC endpoints for the chains you will use:

.env
# Keystore name
KEYSTORE_NAME=your_keystore_name

# RPC URLs (add the ones you need)
ETHEREUM_SEPOLIA_RPC_URL=
ARBITRUM_SEPOLIA_RPC_URL=

# Etherscan API key (required only if you pass --verify to deployment scripts)
ETHERSCAN_API_KEY=
  1. Install dependencies and compile:
Terminal
npm install && npx hardhat compile
  1. Load the environment variables:
Terminal
source .env
  1. Create a Hardhat keystore entry for your private key. Use the same name as KEYSTORE_NAME in your .env file. Hardhat will prompt you to enter the private key and a password to encrypt it:
Terminal
npx hardhat keystore set your_keystore_name
2 Deploy your contracts
deploy.ts

Check out the complete script code on Github.

In this section, you will deploy the ProgrammableDefensiveTokenTransfers contracts on the source and destination chains.

  1. The deploy.ts script does the following:
  • Deploy ProgrammableDefensiveTokenTransfers on Ethereum Sepolia (source).
  • Deploy ProgrammableDefensiveTokenTransfers on Arbitrum Sepolia (destination).
  • Returns the contract addresses in the terminal
  1. To run the script, use the following command:
Terminal
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
npx hardhat run hardhat/scripts/tutorials/programmable-defensive-token-transfers/deploy/deploy.ts

Your terminal should look something like this:

Terminal
========================================
๐Ÿš€ Deploy CCIP Defensive Contracts on Both Chains
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
========================================

[Step 1] Deploying ProgrammableDefensiveTokenTransfers on Ethereum Sepolia
Contract deployed at: 0xB0c1D2e3F4a5B6c7D8e9F00112233445566778899
https://sepolia.etherscan.io/address/0xB0c1D2e3F4a5B6c7D8e9F00112233445566778899

========================================
โœ… Deployment Complete on Ethereum Sepolia!
========================================
Source Contract Address: 0xB0c1D2e3F4a5B6c7D8e9F00112233445566778899

[Step 2] Deploying ProgrammableDefensiveTokenTransfers on Arbitrum Sepolia
Contract deployed at: 0xC1d2E3f4A5b6C7d8E9F00112233445566778899Aa
https://sepolia.arbiscan.io/address/0xC1d2E3f4A5b6C7d8E9F00112233445566778899Aa

========================================
โœ… Deployment Complete on Arbitrum Sepolia!
========================================
Destination Contract Address: 0xC1d2E3f4A5b6C7d8E9F00112233445566778899Aa

========================================
โœ… All Deployments Complete!
========================================
Source Chain: Ethereum Sepolia
Source Contract: 0xB0c1D2e3F4a5B6c7D8e9F00112233445566778899
https://sepolia.etherscan.io/address/0xB0c1D2e3F4a5B6c7D8e9F00112233445566778899

Destination Chain: Arbitrum Sepolia
Destination Contract: 0xC1d2E3f4A5b6C7d8E9F00112233445566778899Aa
https://sepolia.arbiscan.io/address/0xC1d2E3f4A5b6C7d8E9F00112233445566778899Aa

Run this command to set both environment variables:
export ETHEREUM_SEPOLIA_CONTRACT=0xB0c1D2e3F4a5B6c7D8e9F00112233445566778899 && export ARBITRUM_SEPOLIA_CONTRACT=0xC1d2E3f4A5b6C7d8E9F00112233445566778899Aa
========================================
  1. Before moving on, export the addresses of the previously deployed contracts so that they're available in the terminal:
Terminal
export ETHEREUM_SEPOLIA_CONTRACT=<sender-contract-address> && \
export ARBITRUM_SEPOLIA_CONTRACT=<receiver-contract-address>
3 Configure allowlists and finality

As a best practice, allowlist specific contracts to prevent unauthorized message delivery between your sender and receiver.

configure.ts

Check out the complete script code on Github.

  1. The configure.ts script handles all configuration in a single command:
    • Allowlists the destination chain on the sender contract.
    • Allowlists the chain-sender pair on the receiver contract via allowlistChainSender.
    • Sets the receiver's finality policy via setAllowedFinalityConfig.
    • Enables simulated revert on the receiver contract via setSimRevert(true).
Terminal
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH \
ALLOWED_BLOCK_DEPTH=32 \
npx hardhat run hardhat/scripts/tutorials/programmable-defensive-token-transfers/configure/configure.ts
Finality configuration

To allow numeric faster than finality requests, set ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH and choose a minimum ALLOWED_BLOCK_DEPTH. In this tutorial, ALLOWED_BLOCK_DEPTH=32 allows send-time requests of BLOCK_DEPTH=32 (or higher).

Your terminal should look something like this:

Terminal
========================================
โš™๏ธ Configure CCIP Defensive Contracts on Both Chains
========================================
Source Chain: Ethereum Sepolia
Source Contract: 0xB0c1D2e3F4a5B6c7D8e9F00112233445566778899
Destination Chain: Arbitrum Sepolia
Destination Contract: 0xC1d2E3f4A5b6C7d8E9F00112233445566778899Aa
========================================

[Step 1] Configuring sender on Ethereum Sepolia
Allowlisting Arbitrum Sepolia as destination chain...
โœ… Destination chain allowlisted: Arbitrum Sepolia

========================================
โœ… Configuration Complete on Ethereum Sepolia!
========================================

[Step 2] Configuring receiver on Arbitrum Sepolia
Allowlisting sender 0xB0c1D2e3F4a5B6c7D8e9F00112233445566778899 from Ethereum Sepolia...
โœ… Chain-sender pair allowlisted: Ethereum Sepolia -> 0xB0c1D2e3F4a5B6c7D8e9F00112233445566778899
Setting allowed finality config to 0x00000020 (BLOCK_DEPTH=32)...
โœ… Allowed finality config set to 0x00000020 (BLOCK_DEPTH=32) for Ethereum Sepolia
Setting simRevert to true...
โœ… Sim revert set to true - messages will fail for testing recovery

========================================
โœ… Configuration Complete on Arbitrum Sepolia!
========================================

========================================
โœ… All Configurations Complete!
========================================
Ethereum Sepolia can send messages to Arbitrum Sepolia
Arbitrum Sepolia can receive messages from Ethereum Sepolia
โš ๏ธ  Messages will fail on destination (simRevert=true) to demonstrate defensive handling
4 Fund your wallet with test tokens
drip-bnm-token.ts

Check out the faucet script on Github.

Before sending a CCIP message, you need CCIP-BnM test tokens in your wallet. The send scripts transfer CCIP-BnM from your EOA to the contract, so your wallet must hold a balance.

Use the faucet script included in the starter kit to drip CCIP-BnM tokens to your address:

Terminal
CHAIN=ETHEREUM_SEPOLIA \
RECIPIENT_ADDRESS=<your-wallet-address> \
npx hardhat run hardhat/scripts/faucet/drip-bnm-token.ts

If you plan to pay CCIP fees in LINK (instead of native gas), you also need LINK tokens. Get test LINK from the Chainlink faucet.

5 Send a message

send-message.ts is a unified send script that handles both native and LINK fee payments. Set FEE_TOKEN=LINK to pay with LINK, or omit it (defaults to NATIVE) to pay with the native gas token. The script:

  • Builds off-chain extraArgs for the lane. Hardhat uses @chainlink/ccip-sdk and the receiver policy check in buildExtraArgs to encode V2 or V3.
  • Approves the contract to spend the caller's tokens (the contract then pulls via safeTransferFrom).
  • Sends the CCIP message.
Dynamic gas estimation

When GAS_LIMIT is not set, the script uses estimateReceiveExecution from @chainlink/ccip-sdk to dynamically estimate the required gas limit for the destination callback. If estimation fails, it falls back to 400000.

Faster Than Finality (block depth)

The BLOCK_DEPTH environment variable controls send-side finality behavior:

  • Omit BLOCK_DEPTH, or set BLOCK_DEPTH=DEFAULT/BLOCK_DEPTH=0 (default): Use finalized finality.
  • Set BLOCK_DEPTH=32: Request faster than finality using numeric block depth.
Example 1: Pay with native gas + faster than finality (BLOCK_DEPTH=32) + explicit gas limit
Terminal
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
BLOCK_DEPTH=32 \
TOKEN_AMOUNT=1000000000000000 \
MESSAGE="Hello from Hardhat" \
GAS_LIMIT=400000 \
FEE_TOKEN=NATIVE \
npx hardhat run hardhat/scripts/tutorials/programmable-defensive-token-transfers/interact/send-message.ts

Your terminal should look like this:

Terminal
========================================
๐Ÿ“ก CCIP Defensive Message Transfer - Pay with ETH
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
Sender: 0xB0c1D2e3F4a5B6c7D8e9F00112233445566778899
Receiver: 0xC1d2E3f4A5b6C7d8E9F00112233445566778899Aa
Fee Token: Native (ETH)
========================================

[Pre-validation] Querying lane features and receiver contract...
Gas limit (override): 400000
Token pool ALLOWED_FINALITY_CONFIG: 0x00000020 (BLOCK_DEPTH: 32 block(s))
Receiver contract ALLOWED_FINALITY_CONFIG: 0x00000020 (BLOCK_DEPTH: 32 block(s))
โœ… Using V3 extraArgs with FTF (gasLimit=400000, finalityConfig=32 block(s)).
[Pre-validation] CCIP fee: 330463107598943

[Step 1] Approving contract to spend CCIP-BnM...
โœ… Contract approved to spend CCIP-BnM


[Step 2] Sending CCIP message with native token fee (ETH)...
Required CCIP fee (in WEI): 330463107598943

========================================
โœ… Message sent successfully!
========================================
CCIP messageId: 0x3e4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7082
CCIP Explorer:
https://ccip.chain.link/#/side-drawer/msg/0x3e4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7082
Terminal
SOURCE_CHAIN=ETHEREUM_SEPOLIA DEST_CHAIN=ARBITRUM_SEPOLIA \
TOKEN_AMOUNT=1000000000000000 \
MESSAGE="Hello from Hardhat" \
GAS_LIMIT=400000 \
FEE_TOKEN=LINK \
npx hardhat run hardhat/scripts/tutorials/programmable-defensive-token-transfers/interact/send-message.ts

Your terminal should look like this:

Terminal
========================================
๐Ÿ“ก CCIP Defensive Message Transfer - Pay with LINK
========================================
Source Chain: Ethereum Sepolia
Destination Chain: Arbitrum Sepolia
Sender: 0xB0c1D2e3F4a5B6c7D8e9F00112233445566778899
Receiver: 0xC1d2E3f4A5b6C7d8E9F00112233445566778899Aa
Fee Token: LINK
========================================

[Pre-validation] Querying lane features and receiver contract...
Gas limit (override): 400000
โœ… Using default finality (BLOCK_DEPTH=DEFAULT). V3 extraArgs (gasLimit=400000, finalityConfig=0x00000000).
[Pre-validation] CCIP fee: 38700123456789000

[Step 1] Approving contract to spend LINK for CCIP fees...
Required CCIP fee (in LINK units): 38700123456789000
โœ… Contract approved to spend LINK


[Step 2] Approving contract to spend CCIP-BnM...
โœ… Contract approved to spend CCIP-BnM


[Step 3] Sending CCIP message...

========================================
โœ… Message sent successfully!
========================================
CCIP messageId: 0x4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708193
CCIP Explorer:
https://ccip.chain.link/#/side-drawer/msg/0x4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708193
Environment variables
VariableDescriptionDefault
KEYSTORE_NAMEHardhat keystore entry name, set in .envyour_keystore_name
SOURCE_CHAINSource chain name identifier (for example, ETHEREUM_SEPOLIA)Not set
DEST_CHAINDestination chain name identifier (for example, ARBITRUM_SEPOLIA)Not set
{CHAIN}_RPC_URLRPC URL for each chain you run scripts against (for example, ETHEREUM_SEPOLIA_RPC_URL, ARBITRUM_SEPOLIA_RPC_URL)Not set
{CHAIN}_CONTRACTDeployed tutorial contract address per chain (for example, ETHEREUM_SEPOLIA_CONTRACT, ARBITRUM_SEPOLIA_CONTRACT)Not set
FEE_TOKENLINK or NATIVENATIVE
FEE_TOKEN_ADDRESSERC-20 address of a CCIP-supported fee token on the lane. Takes priority over FEE_TOKENNot set
TOKEN_AMOUNTAmount of CCIP-BnM to transfer (in wei)1000000000000000 (0.001)
GAS_LIMITGas limit for the destination callback (if unset, estimated dynamically via estimateReceiveExecution)400000 (fallback)
BLOCK_DEPTHOmit or set DEFAULT for finalized finality (default), or set 32 for faster than finalityDEFAULT
ALLOWED_FINALITY_CONFIGReceiver allowed finality mode(s) used by the configure step. Set to BLOCK_DEPTH to allow numeric faster than finality requests.Not set
ALLOWED_BLOCK_DEPTHReceiver minimum block depth (used when ALLOWED_FINALITY_CONFIG=BLOCK_DEPTH). Use 32 in this tutorial.Not set
SIM_REVERTEnables simulated failures on the receiver during configurationtrue
MESSAGEText payload to sendDefault String Text
CHAINChain name identifier used by the failed-message scripts (the chain where the receiver is deployed)Not set
MESSAGE_IDFailed message ID to retry (used by the retry script)Not set
TOKEN_RECEIVERAddress to receive recovered tokens when retrying a failed messageNot set
OFFSETOffset into the receiver's failed-message list (used by get-failed-messages.ts)0
LIMITMaximum number of failed messages to return (used by get-failed-messages.ts)10
send-message.ts

Check out the complete script code on Github.

6 Check failed messages and recover tokens

After sending the message, wait for it to arrive on the destination chain (typically 10-20 minutes). Because s_simRevert is true, the message will fail and the tokens will be locked in the receiver contract.

Step 1: Check for failed messages

Run the get-failed-messages.ts script to query the receiver contract for failed messages:

Terminal
CHAIN=ARBITRUM_SEPOLIA \
npx hardhat run hardhat/scripts/tutorials/programmable-defensive-token-transfers/interact/get-failed-messages.ts

Your terminal should look like this:

Terminal
========================================
๐Ÿ” Check Failed Messages
========================================
Chain: Arbitrum Sepolia
Receiver Address: 0xC1d2E3f4A5b6C7d8E9F00112233445566778899Aa
========================================

Found 1 unresolved failed message(s):

========================================
Failed Message #1
========================================
Message ID: 0x3e4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7082
Error Code: 1 (FAILED)

Note the Message ID from the output -- you will need it for the next step.

get-failed-messages.ts

Check out the complete script code on Github.

Step 2: Recover the locked tokens

Use the retry-failed-message.ts script to recover the locked tokens. Pass the failed message ID and the address where you want the tokens sent:

Terminal
MESSAGE_ID=<failed-message-id> TOKEN_RECEIVER=<your-wallet-address> CHAIN=ARBITRUM_SEPOLIA \
npx hardhat run hardhat/scripts/tutorials/programmable-defensive-token-transfers/interact/retry-failed-message.ts

Your terminal should look like this:

Terminal
========================================
๐Ÿ”„ Retry Failed Message
========================================
Chain: Arbitrum Sepolia
Receiver Address: 0xC1d2E3f4A5b6C7d8E9F00112233445566778899Aa
Message ID: 0x3e4f5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f7082
Token Receiver: 0x90F79bf6EB2c4f870365E785982E1f101E93b906
========================================

Retrying failed message...
Token receiver address: 0x90F79bf6EB2c4f870365E785982E1f101E93b906

========================================
โœ… Message Retry Complete!
========================================
Tokens have been recovered and sent to: 0x90F79bf6EB2c4f870365E785982E1f101E93b906
Transaction: 0x5a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a4b5c
retry-failed-message.ts

Check out the complete script code on Github.

Step 3: Verify the recovery

Re-run the get-failed-messages.ts script. The error code for the recovered message should now be 0 (RESOLVED):

Terminal
CHAIN=ARBITRUM_SEPOLIA \
npx hardhat run hardhat/scripts/tutorials/programmable-defensive-token-transfers/interact/get-failed-messages.ts

Your terminal should look like this:

Terminal
========================================
๐Ÿ” Check Failed Messages
========================================
Chain: Arbitrum Sepolia
Receiver Address: 0xC1d2E3f4A5b6C7d8E9F00112233445566778899Aa
========================================

โœ… No unresolved failed messages found.
Total messages: 1 (all resolved)

What's next

Get the latest Chainlink content straight to your inbox.