Transfer USDC with Data
USDC is a digital dollar backed 100% and is always redeemable 1:1 for US dollars. The stablecoin is issued by Circle on multiple blockchain platforms.
This guide will first explain how Chainlink CCIP enables native USDC transfers under the hood by leveraging Circle's Cross-Chain Transfer Protocol (CCTP). Then, you will learn how to use Chainlink CCIP to transfer USDC and arbitrary data from a smart contract on Avalanche Fuji to a smart contract on Ethereum Sepolia. Note: In addition to programmable token transfers, you can also use CCIP to transfer USDC tokens without data. Check the Mainnets and Testnets configuration pages to learn on which blockchains CCIP supports USDC transfers.
Architecture
Fundamentally the architecture of CCIP and API are unchanged:
- The sender has to interact with the CCIP router to initiate a cross-chain transaction, similar to the process for any other token transfers. See the Transfer Tokens guide to learn more.
- The process uses the same onchain components including the Router, OnRamp, Commit Store, OffRamp, and Token Pool.
- The process uses the same offchain components including the Committing DON, Executing DON, and the Risk Management Network.
- USDC transfers also benefit from CCIP additional security provided by the Risk Management Network.
The diagram below shows that the USDC token pools and Executing DON handle the integration with Circle’s contracts and offchain CCTP Attestation API. As with any other supported ERC-20 token, USDC has a linked token pool on each supported blockchain to facilitate OnRamp and OffRamp operations. To learn more about these components, read the architecture page.
The following describes the operational process:
- On the source blockchain:
- When the sender initiates a transfer of USDC, the USDC token pool interacts with CCTP’s contract to burn USDC tokens and specifies the USDC token pool address on the destination blockchain as the authorized caller to mint them.
- CCTP burns the specified USDC tokens and emits an associated CCTP event.
- Offchain:
- Circle attestation service listens to CCTP events on the source blockchain.
- CCIP Executing DON listens to relevant CCTP events on the source blockchain. When it captures such an event, it calls the Circle Attestation service API to request an attestation. An attestation is a signed authorization to mint the specified amount of USDC on the destination blockchain.
- On the destination blockchain:
- The Executing DON provides the attestation to the OffRamp contract.
- The OffRamp contract calls the USDC token pool with the USDC amount to be minted, the Receiver address, and the Circle attestation.
- The USDC token pool calls the CCTP contract. The CCTP contract verifies the attestation signature before minting the specified USDC amount into the Receiver.
- If there is data in the CCIP message and the Receiver is not an EOA, then the OffRamp contract transmits the CCIP message via the Router contract to the Receiver.
Example
In this tutorial, you will learn how to send USDC tokens from a smart contract on Avalanche Fuji to a smart contract on Ethereum Sepolia using Chainlink CCIP and pay CCIP fees in LINK tokens. The process uses the following steps:
- Transfer USDC and Data: Initiate a transfer of USDC tokens and associated data from the Sender contract on Avalanche Fuji. The data includes the required arguments and the signature of the
stake
function from the Staker contract. - Receive and Stake: The Receiver contract on Ethereum Sepolia receives the tokens and data. Then, it uses this data to make a low-level call to the Staker contract, executing the
stake
function to stake USDC on behalf of a beneficiary. - Redeem Staked Tokens: The beneficiary can redeem the staked tokens for USDC later.
The purpose of including the function signature and arguments in the data is to demonstrate how arbitrary data can support a variety of scenarios and use cases. By sending specific instructions within the data, you can define various interactions between smart contracts across different blockchain networks and make your decentralized application more flexible and powerful.
Before you begin
- You should understand how to write, compile, deploy, and fund a smart contract. If you need to brush up on the basics, read this tutorial, which will guide you through using the Solidity programming language, interacting with the MetaMask wallet and working within the Remix Development Environment.
- Your account must have some AVAX and LINK tokens on Avalanche Fuji and ETH tokens on Ethereum Sepolia. You can use the Chainlink faucet to acquire testnet tokens.
- Check the Supported Networks page to confirm that USDC are supported for your lane. In this example, you will transfer tokens from Avalanche Fuji to Ethereum Sepolia so check the list of supported tokens here.
- Use the Circle faucet to acquire USDC tokens on Avalanche Fuji.
- Learn how to fund your contract. This guide shows how to fund your contract in LINK, but you can use the same guide for funding your contract with any ERC-20 tokens as long as they appear in the list of tokens in MetaMask.
Tutorial
Deploy your contracts
Deploy the Sender contract on Avalanche Fuji:
-
Compile your contract.
-
Deploy, fund your sender contract on Avalanche Fuji and enable sending messages to Ethereum Sepolia:
-
Open MetaMask and select the network Avalanche Fuji.
-
In Remix IDE, click on Deploy & Run Transactions and select Injected Provider - MetaMask from the environment list. Remix will then interact with your MetaMask wallet to communicate with Avalanche Fuji.
-
Fill in your blockchain's router, LINK, and USDC contract addresses. The router and USDC addresses can be found on the supported networks page and the LINK contract address on the LINK token contracts page. For Avalanche Fuji, the addresses are:
- Router address:
0xf694e193200268f9a4868e4aa017a0118c9a8177
- LINK contract address:
0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846
- USDC contract address:
0x5425890298aed601595a70AB815c96711a31Bc65
- Router address:
-
Click the transact button. After you confirm the transaction, the contract address appears on the Deployed Contracts list. Note your contract address.
-
Open MetaMask and fund your contract with USDC tokens. You can transfer
1
USDC to your contract. -
Fund your contract with LINK tokens. You can transfer
1.5
LINK to your contract. In this example, LINK is used to pay the CCIP fees.
-
Deploy the Staker and Receiver contracts on Ethereum Sepolia. Configure the Receiver contract to receive CCIP messages from the Sender contract:
-
Deploy the Staker contract:
-
Open MetaMask and select the network Ethereum Sepolia.
-
Compile your contract.
-
In Remix IDE, under Deploy & Run Transactions, make sure the environment is still Injected Provider - MetaMask.
-
Fill in the usdc contract address. The usdc contract address can be found on the supported networks page. For Ethereum Sepolia, the usdc contract address is:
0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238
.
-
Click the transact button. After you confirm the transaction, the contract address appears on the Deployed Contracts list.
Note your contract address.
-
-
Deploy the Receiver contract:
-
Compile your contract.
-
In Remix IDE, under Deploy & Run Transactions, make sure the environment is still Injected Provider - MetaMask and that you are still connected to Ethereum Sepolia.
-
Fill in your blockchain's router, LINK, and Staker contract addresses. The router and usdc addresses can be found on the supported networks page and the Staker contract address from the previous step. For Ethereum Sepolia, the addresses are:
- Router address:
0x0BF3dE8c5D3e8A2B34D2BEeB17ABfCeBaf363A59
- USDC contract address:
0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238
- Staker address: Copied from the previous step
- Router address:
-
Configure the Receiver contract to receive CCIP messages from the Sender contract:
-
In Remix IDE, under Deploy & Run Transactions, open the list of transactions of your Receiver contract deployed on Ethereum Sepolia.
-
Fill in the arguments of the setSenderForSourceChain function:
Argument Value and Description _sourceChainSelector 14767482510784806043
The chain selector of Avalanche Fuji. You can find it on the supported networks page._sender Your sender contract address at Avalanche Fuji.
The sender contract address. -
Click on
transact
and confirm the transaction on MetaMask.
-
Configure the Sender contract on Avalanche Fuji:
-
Open MetaMask and select the network Avalanche Fuji.
-
In Remix IDE, under Deploy & Run Transactions, open the list of transactions of your Sender contract deployed on Avalanche Fuji.
-
Fill in the arguments of the setReceiverForDestinationChain function:
Argument Value and Description _destinationChainSelector 16015286601757825753
The chain selector of Ethereum Sepolia. You can find it on the supported networks page._receiver Your receiver contract address at Ethereum Sepolia.
The receiver contract address. -
Fill in the arguments of the setGasLimitForDestinationChain: function:
Argument Value and Description _destinationChainSelector 16015286601757825753
The chain selector of Ethereum Sepolia. You can find it on the supported networks page._gasLimit 200000
The gas limit for the execution of the CCIP message on the destination chain.
At this point:
- You have one sender contract on Avalanche Fuji, one staker contract and one receiver contract on Ethereum Sepolia.
- You enabled the sender contract to send messages to the receiver contract on Ethereum Sepolia.
- You set the gas limit for the execution of the CCIP message on Ethereum Sepolia.
- You enabled the receiver contract to receive messages from the sender contract on Avalanche Fuji.
- You funded the sender contract with USDC and LINK tokens on Avalanche Fuji.
Transfer and Receive tokens and data and pay in LINK
You will transfer 1 USDC and arbitrary data, which contains the encoded stake function name and parameters for calling Staker's stake function on the destination chain. The parameters contain the amount of staked tokens and the beneficiary address. The CCIP fees for using CCIP will be paid in LINK.
-
Transfer tokens and data from Avalanche Fuji:
-
Open MetaMask and select the network Avalanche Fuji.
-
In Remix IDE, under Deploy & Run Transactions, open the list of transactions of your smart contract deployed on Avalanche Fuji.
-
Fill in the arguments of the sendMessagePayLINK function:
-
Argument | Value and Description |
---|---|
_destinationChainSelector | 16015286601757825753 CCIP Chain identifier of the destination blockchain (Ethereum Sepolia in this example). You can find each chain selector on the supported networks page. |
_beneficiary | The beneficiary of the Staker tokens on Ethereum Sepolia. You can set your own EOA (Externally Owned Account) so you can redeem the Staker tokens in exchange for USDC tokens. |
_amount | 1000000 The token amount (1 USDC). |
- Click on
transact
and confirm the transaction on MetaMask. - After the transaction is successful, record the transaction hash. Here is an example of a transaction on Avalanche Fuji.
-
Open the CCIP explorer and search your cross-chain transaction using the transaction hash.
-
The CCIP transaction is completed once the status is marked as "Success". In this example, the CCIP message ID is 0xcb0fad9eec6664ad959f145cc4eb023924faded08baefc29952205ee37da7f13.
-
Check the balance of the beneficiary on the destination chain:
-
Open MetaMask and select the network Ethereum Sepolia.
-
In Remix IDE, under Deploy & Run Transactions, open the list of transactions of your Staker contract deployed on Ethereum Sepolia.
-
Call the
balanceOf
function with the beneficiary address.
-
Notice that the balance of the beneficiary is 1,000,000 Staker tokens. The Staker contract has the same number of decimals as the USDC token, which is 6. This means the beneficiary has 1 USDC staked and can redeem it by providing the same amount of Staker tokens.
-
-
Redeem the staked tokens:
-
Open MetaMask and make sure the network is Ethereum Sepolia.
-
Make sure you are connected with the beneficiary account.
-
In Remix IDE, under Deploy & Run Transactions, open the list of transactions of your Staker contract deployed on Ethereum Sepolia.
-
Call the
redeem
function with the amount of Staker tokens to redeem. In this example, the beneficiary will redeem 1,000,000 Staker tokens. When confirming, MetaMask will confirm that you will transfer the Staker tokens in exchange for USDC tokens.
-
Confirm the transaction on MetaMask. After the transaction is successful, the beneficiary will receive 1 USDC tokens.
-
Explanation
The smart contracts featured in this tutorial are designed to interact with CCIP to send and receive USDC tokens and data across different blockchains. The contract code contains supporting comments clarifying the functions, events, and underlying logic. We will explain the Sender, Staker, and Receiver contracts further.
Sender Contract
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol";
import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
import {IERC20} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/utils/SafeERC20.sol";
/**
* THIS IS AN EXAMPLE CONTRACT THAT USES HARDCODED VALUES FOR CLARITY.
* THIS IS AN EXAMPLE CONTRACT THAT USES UN-AUDITED CODE.
* DO NOT USE THIS CODE IN PRODUCTION.
*/
interface IStaker {
function stake(address beneficiary, uint256 amount) external;
function redeem() external;
}
/// @title - A simple messenger contract for transferring tokens to a receiver that calls a staker contract.
contract Sender is OwnerIsCreator {
using SafeERC20 for IERC20;
// Custom errors to provide more descriptive revert messages.
error InvalidRouter(); // Used when the router address is 0
error InvalidLinkToken(); // Used when the link token address is 0
error InvalidUsdcToken(); // Used when the usdc token address is 0
error NotEnoughBalance(uint256 currentBalance, uint256 calculatedFees); // Used to make sure contract has enough balance to cover the fees.
error NothingToWithdraw(); // Used when trying to withdraw Ether but there's nothing to withdraw.
error InvalidDestinationChain(); // Used when the destination chain selector is 0.
error InvalidReceiverAddress(); // Used when the receiver address is 0.
error NoReceiverOnDestinationChain(uint64 destinationChainSelector); // Used when the receiver address is 0 for a given destination chain.
error AmountIsZero(); // Used if the amount to transfer is 0.
error InvalidGasLimit(); // Used if the gas limit is 0.
error NoGasLimitOnDestinationChain(uint64 destinationChainSelector); // Used when the gas limit is 0.
// Event emitted when a message is sent to another chain.
event MessageSent(
bytes32 indexed messageId, // The unique ID of the CCIP message.
uint64 indexed destinationChainSelector, // The chain selector of the destination chain.
address indexed receiver, // The address of the receiver contract on the destination chain.
address beneficiary, // The beneficiary of the staked tokens on the destination chain.
address token, // The token address that was transferred.
uint256 tokenAmount, // The token amount that was transferred.
address feeToken, // the token address used to pay CCIP fees.
uint256 fees // The fees paid for sending the message.
);
IRouterClient private immutable i_router;
IERC20 private immutable i_linkToken;
IERC20 private immutable i_usdcToken;
// Mapping to keep track of the receiver contract per destination chain.
mapping(uint64 => address) public s_receivers;
// Mapping to store the gas limit per destination chain.
mapping(uint64 => uint256) public s_gasLimits;
modifier validateDestinationChain(uint64 _destinationChainSelector) {
if (_destinationChainSelector == 0) revert InvalidDestinationChain();
_;
}
/// @notice Constructor initializes the contract with the router address.
/// @param _router The address of the router contract.
/// @param _link The address of the link contract.
/// @param _usdcToken The address of the usdc contract.
constructor(address _router, address _link, address _usdcToken) {
if (_router == address(0)) revert InvalidRouter();
if (_link == address(0)) revert InvalidLinkToken();
if (_usdcToken == address(0)) revert InvalidUsdcToken();
i_router = IRouterClient(_router);
i_linkToken = IERC20(_link);
i_usdcToken = IERC20(_usdcToken);
}
/// @dev Set the receiver contract for a given destination chain.
/// @notice This function can only be called by the owner.
/// @param _destinationChainSelector The selector of the destination chain.
/// @param _receiver The receiver contract on the destination chain .
function setReceiverForDestinationChain(
uint64 _destinationChainSelector,
address _receiver
) external onlyOwner validateDestinationChain(_destinationChainSelector) {
if (_receiver == address(0)) revert InvalidReceiverAddress();
s_receivers[_destinationChainSelector] = _receiver;
}
/// @dev Set the gas limit for a given destination chain.
/// @notice This function can only be called by the owner.
/// @param _destinationChainSelector The selector of the destination chain.
/// @param _gasLimit The gas limit on the destination chain .
function setGasLimitForDestinationChain(
uint64 _destinationChainSelector,
uint256 _gasLimit
) external onlyOwner validateDestinationChain(_destinationChainSelector) {
if (_gasLimit == 0) revert InvalidGasLimit();
s_gasLimits[_destinationChainSelector] = _gasLimit;
}
/// @dev Delete the receiver contract for a given destination chain.
/// @notice This function can only be called by the owner.
/// @param _destinationChainSelector The selector of the destination chain.
function deleteReceiverForDestinationChain(
uint64 _destinationChainSelector
) external onlyOwner validateDestinationChain(_destinationChainSelector) {
if (s_receivers[_destinationChainSelector] == address(0))
revert NoReceiverOnDestinationChain(_destinationChainSelector);
delete s_receivers[_destinationChainSelector];
}
/// @notice Sends data and transfer tokens to receiver on the destination chain.
/// @notice Pay for fees in LINK.
/// @dev Assumes your contract has sufficient LINK to pay for CCIP fees.
/// @param _destinationChainSelector The identifier (aka selector) for the destination blockchain.
/// @param _beneficiary The address of the beneficiary of the staked tokens on the destination blockchain.
/// @param _amount token amount.
/// @return messageId The ID of the CCIP message that was sent.
function sendMessagePayLINK(
uint64 _destinationChainSelector,
address _beneficiary,
uint256 _amount
)
external
onlyOwner
validateDestinationChain(_destinationChainSelector)
returns (bytes32 messageId)
{
address receiver = s_receivers[_destinationChainSelector];
if (receiver == address(0))
revert NoReceiverOnDestinationChain(_destinationChainSelector);
if (_amount == 0) revert AmountIsZero();
uint256 gasLimit = s_gasLimits[_destinationChainSelector];
if (gasLimit == 0)
revert NoGasLimitOnDestinationChain(_destinationChainSelector);
// Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message
// address(linkToken) means fees are paid in LINK
Client.EVMTokenAmount[]
memory tokenAmounts = new Client.EVMTokenAmount[](1);
tokenAmounts[0] = Client.EVMTokenAmount({
token: address(i_usdcToken),
amount: _amount
});
// Create an EVM2AnyMessage struct in memory with necessary information for sending a cross-chain message
Client.EVM2AnyMessage memory evm2AnyMessage = Client.EVM2AnyMessage({
receiver: abi.encode(receiver), // ABI-encoded receiver address
data: abi.encodeWithSelector(
IStaker.stake.selector,
_beneficiary,
_amount
), // Encode the function selector and the arguments of the stake function
tokenAmounts: tokenAmounts, // The amount and type of token being transferred
extraArgs: Client._argsToBytes(
// Additional arguments, setting gas limit
Client.EVMExtraArgsV1({gasLimit: gasLimit})
),
// Set the feeToken to a feeTokenAddress, indicating specific asset will be used for fees
feeToken: address(i_linkToken)
});
// Get the fee required to send the CCIP message
uint256 fees = i_router.getFee(
_destinationChainSelector,
evm2AnyMessage
);
if (fees > i_linkToken.balanceOf(address(this)))
revert NotEnoughBalance(i_linkToken.balanceOf(address(this)), fees);
// approve the Router to transfer LINK tokens on contract's behalf. It will spend the fees in LINK
i_linkToken.approve(address(i_router), fees);
// approve the Router to spend usdc tokens on contract's behalf. It will spend the amount of the given token
i_usdcToken.approve(address(i_router), _amount);
// Send the message through the router and store the returned message ID
messageId = i_router.ccipSend(
_destinationChainSelector,
evm2AnyMessage
);
// Emit an event with message details
emit MessageSent(
messageId,
_destinationChainSelector,
receiver,
_beneficiary,
address(i_usdcToken),
_amount,
address(i_linkToken),
fees
);
// Return the message ID
return messageId;
}
/// @notice Allows the owner of the contract to withdraw all LINK tokens in the contract and transfer them to a beneficiary.
/// @dev This function reverts with a 'NothingToWithdraw' error if there are no tokens to withdraw.
/// @param _beneficiary The address to which the tokens will be sent.
function withdrawLinkToken(address _beneficiary) public onlyOwner {
// Retrieve the balance of this contract
uint256 amount = i_linkToken.balanceOf(address(this));
// Revert if there is nothing to withdraw
if (amount == 0) revert NothingToWithdraw();
i_linkToken.safeTransfer(_beneficiary, amount);
}
/// @notice Allows the owner of the contract to withdraw all usdc tokens in the contract and transfer them to a beneficiary.
/// @dev This function reverts with a 'NothingToWithdraw' error if there are no tokens to withdraw.
/// @param _beneficiary The address to which the tokens will be sent.
function withdrawUsdcToken(address _beneficiary) public onlyOwner {
// Retrieve the balance of this contract
uint256 amount = i_usdcToken.balanceOf(address(this));
// Revert if there is nothing to withdraw
if (amount == 0) revert NothingToWithdraw();
i_usdcToken.safeTransfer(_beneficiary, amount);
}
}
The Sender contract is responsible for initiating the transfer of USDC tokens and data. Here’s how it works:
-
Initializing the contract:
- When deploying the contract, you define the router address, LINK contract address, and USDC contract address.
- These addresses are essential for interacting with the CCIP router and handling token transfers.
-
sendMessagePayLINK
function:- This function sends USDC tokens, the encoded function signature of the
stake
function, and arguments (beneficiary address and amount) to the Receiver contract on the destination chain. - Constructs a CCIP message using the
EVM2AnyMessage
struct. - Computes the necessary fees using the router’s
getFee
function. - Ensures the contract has enough LINK to cover the fees and approves the router transfer of LINK on its behalf.
- Dispatches the CCIP message to the destination chain by executing the router’s
ccipSend
function. - Emits a
MessageSent
event.
- This function sends USDC tokens, the encoded function signature of the
Staker Contract
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
import {ERC20} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/ERC20.sol";
import {SafeERC20} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/utils/SafeERC20.sol";
/**
* THIS IS AN EXAMPLE CONTRACT THAT USES HARDCODED VALUES FOR CLARITY.
* THIS IS AN EXAMPLE CONTRACT THAT USES UN-AUDITED CODE.
* DO NOT USE THIS CODE IN PRODUCTION.
*/
interface IStaker {
function stake(address beneficiary, uint256 amount) external;
function redeem() external;
}
/// @title - A simple Staker contract for staking usc tokens and redeeming the staker contracts
contract Staker is IStaker, ERC20 {
using SafeERC20 for ERC20;
error InvalidUsdcToken(); // Used when the usdc token address is 0
error InvalidNumberOfDecimals(); // Used when the number of decimals is 0
error InvalidBeneficiary(); // Used when the beneficiary address is 0
error InvalidAmount(); // Used when the amount is 0
error NothingToRedeem(); // Used when the balance of Staker tokens is 0
event UsdcStaked(address indexed beneficiary, uint256 amount);
event UsdcRedeemed(address indexed beneficiary, uint256 amount);
ERC20 private immutable i_usdcToken;
uint8 private immutable i_decimals;
/// @notice Constructor initializes the contract with the usdc token address.
/// @param _usdcToken The address of the usdc contract.
constructor(address _usdcToken) ERC20("Simple Staker", "STK") {
if (_usdcToken == address(0)) revert InvalidUsdcToken();
i_usdcToken = ERC20(_usdcToken);
i_decimals = i_usdcToken.decimals();
if (i_decimals == 0) revert InvalidNumberOfDecimals();
}
function stake(address _beneficiary, uint256 _amount) external {
if (_beneficiary == address(0)) revert InvalidBeneficiary();
if (_amount == 0) revert InvalidAmount();
i_usdcToken.safeTransferFrom(msg.sender, address(this), _amount);
_mint(_beneficiary, _amount);
emit UsdcStaked(_beneficiary, _amount);
}
function redeem() external {
uint256 balance = balanceOf(msg.sender);
if (balance == 0) revert NothingToRedeem();
_burn(msg.sender, balance);
i_usdcToken.safeTransfer(msg.sender, balance);
emit UsdcRedeemed(msg.sender, balance);
}
function decimals() public view override returns (uint8) {
return i_decimals;
}
}
The Staker contract manages the staking and redemption of USDC tokens. Here’s how it works:
-
Initializing the contract:
- When deploying the contract, you define the USDC token address.
- This address is essential for interacting with the USDC token contract.
-
stake
function:- Allows staking of USDC tokens on behalf of a beneficiary.
- Transfers USDC from the caller (
msg.sender
) to the contract, then mints an equivalent amount of staking tokens to the beneficiary.
-
redeem
function:- Allows beneficiaries to redeem their staked tokens for USDC.
- Burns the staked tokens and transfers the equivalent USDC to the beneficiary.
Receiver Contract
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
import {CCIPReceiver} from "@chainlink/contracts-ccip/src/v0.8/ccip/applications/CCIPReceiver.sol";
import {IERC20} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.3/contracts/token/ERC20/utils/SafeERC20.sol";
import {EnumerableMap} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.3/contracts/utils/structs/EnumerableMap.sol";
/**
* THIS IS AN EXAMPLE CONTRACT THAT USES HARDCODED VALUES FOR CLARITY.
* THIS IS AN EXAMPLE CONTRACT THAT USES UN-AUDITED CODE.
* DO NOT USE THIS CODE IN PRODUCTION.
*/
/// @title - A simple receiver contract for receiving usdc tokens then calling a staking contract.
contract Receiver is CCIPReceiver, OwnerIsCreator {
using SafeERC20 for IERC20;
using EnumerableMap for EnumerableMap.Bytes32ToUintMap;
error InvalidUsdcToken(); // Used when the usdc token address is 0
error InvalidStaker(); // Used when the staker address is 0
error InvalidSourceChain(); // Used when the source chain is 0
error InvalidSenderAddress(); // Used when the sender address is 0
error NoSenderOnSourceChain(uint64 sourceChainSelector); // Used when there is no sender for a given source chain
error WrongSenderForSourceChain(uint64 sourceChainSelector); // Used when the sender contract is not the correct one
error OnlySelf(); // Used when a function is called outside of the contract itself
error WrongReceivedToken(address usdcToken, address receivedToken); // Used if the received token is different than usdc token
error CallToStakerFailed(); // Used when the call to the stake function of the staker contract is not succesful
error NoReturnDataExpected(); // Used if the call to the stake function of the staker contract returns data. This is not expected
error MessageNotFailed(bytes32 messageId); // Used if you try to retry a message that has no failed
// Event emitted when a message is received from another chain.
event MessageReceived(
bytes32 indexed messageId, // The unique ID of the CCIP message.
uint64 indexed sourceChainSelector, // The chain selector of the source chain.
address indexed sender, // The address of the sender from the source chain.
bytes data, // The data that was received.
address token, // The token address that was transferred.
uint256 tokenAmount // The token amount that was transferred.
);
event MessageFailed(bytes32 indexed messageId, bytes reason);
event MessageRecovered(bytes32 indexed messageId);
// Example error code, could have many different error codes.
enum ErrorCode {
// RESOLVED is first so that the default value is resolved.
RESOLVED,
// Could have any number of error codes here.
FAILED
}
struct FailedMessage {
bytes32 messageId;
ErrorCode errorCode;
}
IERC20 private immutable i_usdcToken;
address private immutable i_staker;
// Mapping to keep track of the sender contract per source chain.
mapping(uint64 => address) public s_senders;
// The message contents of failed messages are stored here.
mapping(bytes32 => Client.Any2EVMMessage) public s_messageContents;
// Contains failed messages and their state.
EnumerableMap.Bytes32ToUintMap internal s_failedMessages;
modifier validateSourceChain(uint64 _sourceChainSelector) {
if (_sourceChainSelector == 0) revert InvalidSourceChain();
_;
}
/// @dev Modifier to allow only the contract itself to execute a function.
/// Throws an exception if called by any account other than the contract itself.
modifier onlySelf() {
if (msg.sender != address(this)) revert OnlySelf();
_;
}
/// @notice Constructor initializes the contract with the router address.
/// @param _router The address of the router contract.
/// @param _usdcToken The address of the usdc contract.
/// @param _staker The address of the staker contract.
constructor(
address _router,
address _usdcToken,
address _staker
) CCIPReceiver(_router) {
if (_usdcToken == address(0)) revert InvalidUsdcToken();
if (_staker == address(0)) revert InvalidStaker();
i_usdcToken = IERC20(_usdcToken);
i_staker = _staker;
i_usdcToken.safeApprove(_staker, type(uint256).max);
}
/// @dev Set the sender contract for a given source chain.
/// @notice This function can only be called by the owner.
/// @param _sourceChainSelector The selector of the source chain.
/// @param _sender The sender contract on the source chain .
function setSenderForSourceChain(
uint64 _sourceChainSelector,
address _sender
) external onlyOwner validateSourceChain(_sourceChainSelector) {
if (_sender == address(0)) revert InvalidSenderAddress();
s_senders[_sourceChainSelector] = _sender;
}
/// @dev Delete the sender contract for a given source chain.
/// @notice This function can only be called by the owner.
/// @param _sourceChainSelector The selector of the source chain.
function deleteSenderForSourceChain(
uint64 _sourceChainSelector
) external onlyOwner validateSourceChain(_sourceChainSelector) {
if (s_senders[_sourceChainSelector] == address(0))
revert NoSenderOnSourceChain(_sourceChainSelector);
delete s_senders[_sourceChainSelector];
}
/// @notice The entrypoint for the CCIP router to call. This function should
/// never revert, all errors should be handled internally in this contract.
/// @param any2EvmMessage The message to process.
/// @dev Extremely important to ensure only router calls this.
function ccipReceive(
Client.Any2EVMMessage calldata any2EvmMessage
) external override onlyRouter {
// validate the sender contract
if (
abi.decode(any2EvmMessage.sender, (address)) !=
s_senders[any2EvmMessage.sourceChainSelector]
) revert WrongSenderForSourceChain(any2EvmMessage.sourceChainSelector);
/* solhint-disable no-empty-blocks */
try this.processMessage(any2EvmMessage) {
// Intentionally empty in this example; no action needed if processMessage succeeds
} catch (bytes memory err) {
// Could set different error codes based on the caught error. Each could be
// handled differently.
s_failedMessages.set(
any2EvmMessage.messageId,
uint256(ErrorCode.FAILED)
);
s_messageContents[any2EvmMessage.messageId] = any2EvmMessage;
// Don't revert so CCIP doesn't revert. Emit event instead.
// The message can be retried later without having to do manual execution of CCIP.
emit MessageFailed(any2EvmMessage.messageId, err);
return;
}
}
/// @notice Serves as the entry point for this contract to process incoming messages.
/// @param any2EvmMessage Received CCIP message.
/// @dev Transfers specified token amounts to the owner of this contract. This function
/// must be external because of the try/catch for error handling.
/// It uses the `onlySelf`: can only be called from the contract.
function processMessage(
Client.Any2EVMMessage calldata any2EvmMessage
) external onlySelf {
_ccipReceive(any2EvmMessage); // process the message - may revert
}
function _ccipReceive(
Client.Any2EVMMessage memory any2EvmMessage
) internal override {
if (any2EvmMessage.destTokenAmounts[0].token != address(i_usdcToken))
revert WrongReceivedToken(
address(i_usdcToken),
any2EvmMessage.destTokenAmounts[0].token
);
(bool success, bytes memory returnData) = i_staker.call(
any2EvmMessage.data
); // low level call to the staker contract using the encoded function selector and arguments
if (!success) revert CallToStakerFailed();
if (returnData.length > 0) revert NoReturnDataExpected();
emit MessageReceived(
any2EvmMessage.messageId,
any2EvmMessage.sourceChainSelector, // fetch the source chain identifier (aka selector)
abi.decode(any2EvmMessage.sender, (address)), // abi-decoding of the sender address,
any2EvmMessage.data, // received data
any2EvmMessage.destTokenAmounts[0].token,
any2EvmMessage.destTokenAmounts[0].amount
);
}
/// @notice Allows the owner to retry a failed message in order to unblock the associated tokens.
/// @param messageId The unique identifier of the failed message.
/// @param beneficiary The address to which the tokens will be sent.
/// @dev This function is only callable by the contract owner. It changes the status of the message
/// from 'failed' to 'resolved' to prevent reentry and multiple retries of the same message.
function retryFailedMessage(
bytes32 messageId,
address beneficiary
) external onlyOwner {
// Check if the message has failed; if not, revert the transaction.
if (s_failedMessages.get(messageId) != uint256(ErrorCode.FAILED))
revert MessageNotFailed(messageId);
// Set the error code to RESOLVED to disallow reentry and multiple retries of the same failed message.
s_failedMessages.set(messageId, uint256(ErrorCode.RESOLVED));
// Retrieve the content of the failed message.
Client.Any2EVMMessage memory message = s_messageContents[messageId];
// This example expects one token to have been sent.
// Transfer the associated tokens to the specified receiver as an escape hatch.
IERC20(message.destTokenAmounts[0].token).safeTransfer(
beneficiary,
message.destTokenAmounts[0].amount
);
// Emit an event indicating that the message has been recovered.
emit MessageRecovered(messageId);
}
/// @notice Retrieves a paginated list of failed messages.
/// @dev This function returns a subset of failed messages defined by `offset` and `limit` parameters. It ensures that the pagination parameters are within the bounds of the available data set.
/// @param offset The index of the first failed message to return, enabling pagination by skipping a specified number of messages from the start of the dataset.
/// @param limit The maximum number of failed messages to return, restricting the size of the returned array.
/// @return failedMessages An array of `FailedMessage` struct, each containing a `messageId` and an `errorCode` (RESOLVED or FAILED), representing the requested subset of failed messages. The length of the returned array is determined by the `limit` and the total number of failed messages.
function getFailedMessages(
uint256 offset,
uint256 limit
) external view returns (FailedMessage[] memory) {
uint256 length = s_failedMessages.length();
// Calculate the actual number of items to return (can't exceed total length or requested limit)
uint256 returnLength = (offset + limit > length)
? length - offset
: limit;
FailedMessage[] memory failedMessages = new FailedMessage[](
returnLength
);
// Adjust loop to respect pagination (start at offset, end at offset + limit or total length)
for (uint256 i = 0; i < returnLength; i++) {
(bytes32 messageId, uint256 errorCode) = s_failedMessages.at(
offset + i
);
failedMessages[i] = FailedMessage(messageId, ErrorCode(errorCode));
}
return failedMessages;
}
}
The Receiver contract handles incoming cross-chain messages, processes them, and interacts with the Staker contract to stake USDC on behalf of the beneficiary. Here’s how it works:
-
Initializing the Contract:
- When deploying the contract, you define the router address, USDC token address, and staker contract address.
- These addresses are essential for interacting with the CCIP router, USDC token, and Staker contracts.
-
ccipReceive
function:- The entry point for the CCIP router to deliver messages to the contract.
- Validates the sender and processes the message, ensuring it comes from the correct sender contract on the source chain.
-
Processing Message:
- Calls the
processMessage
function, which is external to leverage Solidity’s try/catch error handling mechanism. - Inside
processMessage
, it calls the_ccipReceive
function for further message processing.
- Calls the
-
_ccipReceive
function:- Checks if the received token is USDC. If not, it reverts.
- Makes a low-level call to the
stake
function of the Staker contract using the encoded function signature and arguments from the received data. - Emits a
MessageReceived
event upon successful processing.
-
Error Handling:
- If an error occurs during processing, the catch block within ccipReceive is executed.
- The
messageId
of the failed message is added tos_failedMessages
, and the message content is stored ins_messageContents
. - A
MessageFailed
event is emitted, allowing for later identification and reprocessing of failed messages.
-
retryFailedMessage
function:- Allows the contract owner to retry a failed message and recover the associated tokens.
- Updates the error code for the message to
RESOLVED
to prevent multiple retries. - Transfers the locked tokens associated with the failed message to the specified beneficiary as an escape hatch.
-
getFailedMessages
function:- Retrieves a paginated list of failed messages for inspection.