You can use Chainlink CCIP with any blockchain development framework. For this Masterclass, we prepared the steps for Hardhat, Foundry, and Remix IDE.
Let's create a new project
Make sure you have Node.js and NPM installed. To check, run the following command:
node-v
npm-v
Create a new folder and name it ccip-masterclass-3
mkdirccip-masterclass-3
Navigate to it
cdccip-masterclass-3
Create a hew Hardhat project by running:
npxhardhatinit
And then select "Create a TypeScript project".
Make sure you have Foundry installed. To check, run the following command:
forge--version
Create a new folder and name it ccip-masterclass-3
mkdirccip-masterclass-3
Navigate to it
cdccip-masterclass-3
Create a hew Foundry project by running:
forgeinit
Navigate to https://remix.ethereum.org/ and click the "Create new Workspace" button. Select "Blank" template and name the workspace as "CCIP Masterclass 3".
To use Chainlink CCIP, you need to interact with Chainlink CCIP-specific contracts from the @chainlink/contracts-ccip NPM package.
To install it, follow steps specific to the development environment you will use for this Masterclass.
npmi@chainlink/contracts-ccip--save-dev
We will need a standard @chainlink/contracts NPM package for this Module as well, so let's install it too while we are here by running the following command:
npmi@chainlink/contracts--save-dev
Finally, for this exercise we will need to install and the @openzeppelin/contracts NPM package, as well. To do so, run:
npmi@openzeppelin/contracts--save-dev
Since Foundry is designed to run with Solidity, NPM packages, though usable, ,can be replaced with directly installying Solidity contract packages from Github source repositories. We will install Chainlink CCIP contracts and then the contracts for other Chainlink Services.
First the CCIP Contracts.
forgeinstallsmartcontractkit/ccip@ccip-develop
And after that set remappings in your foundry.toml or remappings.txt files to
Create a new Solidity file, and paste the following content. It is an empty contract that just imports one of the contracts from the @chainlink/contracts-ccip package.
Compile it. If compiled successfully and new .deps/npm/@chainlink/contracts-ccip , .deps/npm/@chainlink/contracts and .deps/npm/@openzeppelin/contracts folders are generated, that means we imported all of the necessary packages into the Remix IDE Workspace.
Faucet
To pay for CCIP Fees you can use either LINK token or native/wrapped native asset on a given blockchain. For this exercise we will need at least 1 LINK or Arbitrum Sepolia testnet. To get it, navigate to the https://faucets.chain.link/arbitrum-sepolia
Develop xNFT smart contract
Create a new file inside the contracts folder and name it XNFT.sol
// SPDX-License-Identifier: MITpragmasolidity ^0.8.20;import {ERC721} from"@openzeppelin/contracts/token/ERC721/ERC721.sol";import {ERC721URIStorage} from"@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";import {ERC721Burnable} from"@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";import {IERC20} from"@openzeppelin/contracts/interfaces/IERC20.sol";import {SafeERC20} from"@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";import {ReentrancyGuard} from"@openzeppelin/contracts/security/ReentrancyGuard.sol";import {Client} from"@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";import {IRouterClient} from"@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol";import {IAny2EVMMessageReceiver} from"@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IAny2EVMMessageReceiver.sol";import {OwnerIsCreator} from"@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";import {LinkTokenInterface} from"@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.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. */contractXNFTisERC721,ERC721URIStorage,ERC721Burnable,IAny2EVMMessageReceiver,ReentrancyGuard,OwnerIsCreator{usingSafeERC20forIERC20;enumPayFeesIn { Native, LINK }errorInvalidRouter(address router);errorOnlyOnArbitrumSepolia();errorNotEnoughBalanceForFees(uint256 currentBalance,uint256 calculatedFees );errorNothingToWithdraw();errorFailedToWithdrawEth(address owner,address target,uint256 value);errorChainNotEnabled(uint64 chainSelector);errorSenderNotEnabled(address sender);errorOperationNotAllowedOnCurrentChain(uint64 chainSelector);structXNftDetails {address xNftAddress;bytes ccipExtraArgsBytes; }uint256constant ARBITRUM_SEPOLIA_CHAIN_ID =421614;string[] characters = ["https://ipfs.io/ipfs/QmTgqnhFBMkfT9s8PHKcdXBn1f5bG3Q5hmBaR4U6hoTvb1?filename=Chainlink_Elf.png","https://ipfs.io/ipfs/QmZGQA92ri1jfzSu61JRaNQXYg1bLuM7p8YT83DzFA2KLH?filename=Chainlink_Knight.png","https://ipfs.io/ipfs/QmW1toapYs7M29rzLXTENn3pbvwe8ioikX1PwzACzjfdHP?filename=Chainlink_Orc.png","https://ipfs.io/ipfs/QmPMwQtFpEdKrUjpQJfoTeZS1aVSeuJT6Mof7uV29AcUpF?filename=Chainlink_Witch.png" ]; IRouterClient internalimmutable i_ccipRouter; LinkTokenInterface internalimmutable i_linkToken;uint64privateimmutable i_currentChainSelector;uint256private _nextTokenId;mapping(uint64 destChainSelector => XNftDetails xNftDetailsPerChain)public s_chains;eventChainEnabled(uint64 chainSelector,address xNftAddress,bytes ccipExtraArgs );eventChainDisabled(uint64 chainSelector);eventCrossChainSent(address from,address to,uint256 tokenId,uint64 sourceChainSelector,uint64 destinationChainSelector );eventCrossChainReceived(address from,address to,uint256 tokenId,uint64 sourceChainSelector,uint64 destinationChainSelector );modifieronlyRouter() {if (msg.sender !=address(i_ccipRouter))revertInvalidRouter(msg.sender); _; }modifieronlyOnArbitrumSepolia() {if (block.chainid != ARBITRUM_SEPOLIA_CHAIN_ID)revertOnlyOnArbitrumSepolia(); _; }modifieronlyEnabledChain(uint64_chainSelector) {if (s_chains[_chainSelector].xNftAddress ==address(0))revertChainNotEnabled(_chainSelector); _; }modifieronlyEnabledSender(uint64_chainSelector,address_sender) {if (s_chains[_chainSelector].xNftAddress != _sender)revertSenderNotEnabled(_sender); _; }modifieronlyOtherChains(uint64_chainSelector) {if (_chainSelector == i_currentChainSelector)revertOperationNotAllowedOnCurrentChain(_chainSelector); _; }constructor(address ccipRouterAddress,address linkTokenAddress,uint64 currentChainSelector ) ERC721("Cross Chain NFT", "XNFT") {if (ccipRouterAddress ==address(0)) revertInvalidRouter(address(0)); i_ccipRouter =IRouterClient(ccipRouterAddress); i_linkToken =LinkTokenInterface(linkTokenAddress); i_currentChainSelector = currentChainSelector; }functionmint() externalonlyOnArbitrumSepolia {uint256 tokenId = _nextTokenId++;stringmemory uri = characters[tokenId % characters.length];_safeMint(msg.sender, tokenId);_setTokenURI(tokenId, uri); }functionenableChain(uint64 chainSelector,address xNftAddress,bytesmemory ccipExtraArgs ) externalonlyOwneronlyOtherChains(chainSelector) { s_chains[chainSelector] =XNftDetails({ xNftAddress: xNftAddress, ccipExtraArgsBytes: ccipExtraArgs });emitChainEnabled(chainSelector, xNftAddress, ccipExtraArgs); }functiondisableChain(uint64 chainSelector ) externalonlyOwneronlyOtherChains(chainSelector) {delete s_chains[chainSelector];emitChainDisabled(chainSelector); }functioncrossChainTransferFrom(address from,address to,uint256 tokenId,uint64 destinationChainSelector,PayFeesIn payFeesIn )externalnonReentrantonlyEnabledChain(destinationChainSelector)returns (bytes32 messageId) {stringmemory tokenUri =tokenURI(tokenId);_burn(tokenId); Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ receiver: abi.encode( s_chains[destinationChainSelector].xNftAddress ), data: abi.encode(from, to, tokenId, tokenUri), tokenAmounts:new Client.EVMTokenAmount[](0), extraArgs: s_chains[destinationChainSelector].ccipExtraArgsBytes, feeToken: payFeesIn == PayFeesIn.LINK?address(i_linkToken):address(0) });// Get the fee required to send the CCIP messageuint256 fees = i_ccipRouter.getFee(destinationChainSelector, message);if (payFeesIn == PayFeesIn.LINK) {if (fees > i_linkToken.balanceOf(address(this)))revertNotEnoughBalanceForFees( 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_ccipRouter), fees);// Send the message through the router and store the returned message ID messageId = i_ccipRouter.ccipSend( destinationChainSelector, message ); } else {if (fees >address(this).balance)revertNotEnoughBalanceForFees(address(this).balance, fees);// Send the message through the router and store the returned message ID messageId = i_ccipRouter.ccipSend{value: fees}( destinationChainSelector, message ); }emitCrossChainSent( from, to, tokenId, i_currentChainSelector, destinationChainSelector ); }/// @inheritdoc IAny2EVMMessageReceiverfunctionccipReceive(Client.Any2EVMMessagecalldata message )externalvirtualoverrideonlyRouternonReentrantonlyEnabledChain(message.sourceChainSelector)onlyEnabledSender(message.sourceChainSelector, abi.decode(message.sender, (address)) ) {uint64 sourceChainSelector = message.sourceChainSelector; (address from,address to,uint256 tokenId,stringmemory tokenUri ) = abi.decode(message.data, (address,address,uint256,string));_safeMint(to, tokenId);_setTokenURI(tokenId, tokenUri);emitCrossChainReceived( from, to, tokenId, sourceChainSelector, i_currentChainSelector ); }functionwithdraw(address_beneficiary) publiconlyOwner {uint256 amount =address(this).balance;if (amount ==0) revertNothingToWithdraw(); (bool sent, ) = _beneficiary.call{value: amount}("");if (!sent) revertFailedToWithdrawEth(msg.sender, _beneficiary, amount); }functionwithdrawToken(address_beneficiary,address_token ) publiconlyOwner {uint256 amount =IERC20(_token).balanceOf(address(this));if (amount ==0) revertNothingToWithdraw();IERC20(_token).safeTransfer(_beneficiary, amount); }functiontokenURI(uint256 tokenId ) publicviewoverride(ERC721,ERC721URIStorage) returns (stringmemory) {return super.tokenURI(tokenId); }functiongetCCIPRouter() publicviewreturns (address) {returnaddress(i_ccipRouter); }functionsupportsInterface(bytes4 interfaceId ) publicviewoverride(ERC721,ERC721URIStorage) returns (bool) {return interfaceId == type(IAny2EVMMessageReceiver).interfaceId || super.supportsInterface(interfaceId); }}
Compile your contract by running:
npxhardhatcompile
Create a new file inside the src folder and name it XNFT.sol
// SPDX-License-Identifier: MITpragmasolidity ^0.8.20;import {ERC721} from"@openzeppelin/contracts/token/ERC721/ERC721.sol";import {ERC721URIStorage} from"@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";import {ERC721Burnable} from"@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";import {IERC20} from"@openzeppelin/contracts/interfaces/IERC20.sol";import {SafeERC20} from"@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";import {ReentrancyGuard} from"@openzeppelin/contracts/security/ReentrancyGuard.sol";import {Client} from"@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";import {IRouterClient} from"@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol";import {IAny2EVMMessageReceiver} from"@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IAny2EVMMessageReceiver.sol";import {OwnerIsCreator} from"@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";import {LinkTokenInterface} from"@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.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. */contractXNFTisERC721,ERC721URIStorage,ERC721Burnable,IAny2EVMMessageReceiver,ReentrancyGuard,OwnerIsCreator{usingSafeERC20forIERC20;enumPayFeesIn { Native, LINK }errorInvalidRouter(address router);errorOnlyOnArbitrumSepolia();errorNotEnoughBalanceForFees(uint256 currentBalance,uint256 calculatedFees );errorNothingToWithdraw();errorFailedToWithdrawEth(address owner,address target,uint256 value);errorChainNotEnabled(uint64 chainSelector);errorSenderNotEnabled(address sender);errorOperationNotAllowedOnCurrentChain(uint64 chainSelector);structXNftDetails {address xNftAddress;bytes ccipExtraArgsBytes; }uint256constant ARBITRUM_SEPOLIA_CHAIN_ID =421614;string[] characters = ["https://ipfs.io/ipfs/QmTgqnhFBMkfT9s8PHKcdXBn1f5bG3Q5hmBaR4U6hoTvb1?filename=Chainlink_Elf.png","https://ipfs.io/ipfs/QmZGQA92ri1jfzSu61JRaNQXYg1bLuM7p8YT83DzFA2KLH?filename=Chainlink_Knight.png","https://ipfs.io/ipfs/QmW1toapYs7M29rzLXTENn3pbvwe8ioikX1PwzACzjfdHP?filename=Chainlink_Orc.png","https://ipfs.io/ipfs/QmPMwQtFpEdKrUjpQJfoTeZS1aVSeuJT6Mof7uV29AcUpF?filename=Chainlink_Witch.png" ]; IRouterClient internalimmutable i_ccipRouter; LinkTokenInterface internalimmutable i_linkToken;uint64privateimmutable i_currentChainSelector;uint256private _nextTokenId;mapping(uint64 destChainSelector => XNftDetails xNftDetailsPerChain)public s_chains;eventChainEnabled(uint64 chainSelector,address xNftAddress,bytes ccipExtraArgs );eventChainDisabled(uint64 chainSelector);eventCrossChainSent(address from,address to,uint256 tokenId,uint64 sourceChainSelector,uint64 destinationChainSelector );eventCrossChainReceived(address from,address to,uint256 tokenId,uint64 sourceChainSelector,uint64 destinationChainSelector );modifieronlyRouter() {if (msg.sender !=address(i_ccipRouter))revertInvalidRouter(msg.sender); _; }modifieronlyOnArbitrumSepolia() {if (block.chainid != ARBITRUM_SEPOLIA_CHAIN_ID)revertOnlyOnArbitrumSepolia(); _; }modifieronlyEnabledChain(uint64_chainSelector) {if (s_chains[_chainSelector].xNftAddress ==address(0))revertChainNotEnabled(_chainSelector); _; }modifieronlyEnabledSender(uint64_chainSelector,address_sender) {if (s_chains[_chainSelector].xNftAddress != _sender)revertSenderNotEnabled(_sender); _; }modifieronlyOtherChains(uint64_chainSelector) {if (_chainSelector == i_currentChainSelector)revertOperationNotAllowedOnCurrentChain(_chainSelector); _; }constructor(address ccipRouterAddress,address linkTokenAddress,uint64 currentChainSelector ) ERC721("Cross Chain NFT", "XNFT") {if (ccipRouterAddress ==address(0)) revertInvalidRouter(address(0)); i_ccipRouter =IRouterClient(ccipRouterAddress); i_linkToken =LinkTokenInterface(linkTokenAddress); i_currentChainSelector = currentChainSelector; }functionmint() externalonlyOnArbitrumSepolia {uint256 tokenId = _nextTokenId++;stringmemory uri = characters[tokenId % characters.length];_safeMint(msg.sender, tokenId);_setTokenURI(tokenId, uri); }functionenableChain(uint64 chainSelector,address xNftAddress,bytesmemory ccipExtraArgs ) externalonlyOwneronlyOtherChains(chainSelector) { s_chains[chainSelector] =XNftDetails({ xNftAddress: xNftAddress, ccipExtraArgsBytes: ccipExtraArgs });emitChainEnabled(chainSelector, xNftAddress, ccipExtraArgs); }functiondisableChain(uint64 chainSelector ) externalonlyOwneronlyOtherChains(chainSelector) {delete s_chains[chainSelector];emitChainDisabled(chainSelector); }functioncrossChainTransferFrom(address from,address to,uint256 tokenId,uint64 destinationChainSelector,PayFeesIn payFeesIn )externalnonReentrantonlyEnabledChain(destinationChainSelector)returns (bytes32 messageId) {stringmemory tokenUri =tokenURI(tokenId);_burn(tokenId); Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ receiver: abi.encode( s_chains[destinationChainSelector].xNftAddress ), data: abi.encode(from, to, tokenId, tokenUri), tokenAmounts:new Client.EVMTokenAmount[](0), extraArgs: s_chains[destinationChainSelector].ccipExtraArgsBytes, feeToken: payFeesIn == PayFeesIn.LINK?address(i_linkToken):address(0) });// Get the fee required to send the CCIP messageuint256 fees = i_ccipRouter.getFee(destinationChainSelector, message);if (payFeesIn == PayFeesIn.LINK) {if (fees > i_linkToken.balanceOf(address(this)))revertNotEnoughBalanceForFees( 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_ccipRouter), fees);// Send the message through the router and store the returned message ID messageId = i_ccipRouter.ccipSend( destinationChainSelector, message ); } else {if (fees >address(this).balance)revertNotEnoughBalanceForFees(address(this).balance, fees);// Send the message through the router and store the returned message ID messageId = i_ccipRouter.ccipSend{value: fees}( destinationChainSelector, message ); }emitCrossChainSent( from, to, tokenId, i_currentChainSelector, destinationChainSelector ); }/// @inheritdoc IAny2EVMMessageReceiverfunctionccipReceive(Client.Any2EVMMessagecalldata message )externalvirtualoverrideonlyRouternonReentrantonlyEnabledChain(message.sourceChainSelector)onlyEnabledSender(message.sourceChainSelector, abi.decode(message.sender, (address)) ) {uint64 sourceChainSelector = message.sourceChainSelector; (address from,address to,uint256 tokenId,stringmemory tokenUri ) = abi.decode(message.data, (address,address,uint256,string));_safeMint(to, tokenId);_setTokenURI(tokenId, tokenUri);emitCrossChainReceived( from, to, tokenId, sourceChainSelector, i_currentChainSelector ); }functionwithdraw(address_beneficiary) publiconlyOwner {uint256 amount =address(this).balance;if (amount ==0) revertNothingToWithdraw(); (bool sent, ) = _beneficiary.call{value: amount}("");if (!sent) revertFailedToWithdrawEth(msg.sender, _beneficiary, amount); }functionwithdrawToken(address_beneficiary,address_token ) publiconlyOwner {uint256 amount =IERC20(_token).balanceOf(address(this));if (amount ==0) revertNothingToWithdraw();IERC20(_token).safeTransfer(_beneficiary, amount); }functiontokenURI(uint256 tokenId ) publicviewoverride(ERC721,ERC721URIStorage) returns (stringmemory) {return super.tokenURI(tokenId); }functiongetCCIPRouter() publicviewreturns (address) {returnaddress(i_ccipRouter); }functionsupportsInterface(bytes4 interfaceId ) publicviewoverride(ERC721,ERC721URIStorage) returns (bool) {return interfaceId == type(IAny2EVMMessageReceiver).interfaceId || super.supportsInterface(interfaceId); }}
Create a new Solidity file by clicking on the "Create new file" button and name it XNFT.sol
// SPDX-License-Identifier: MITpragmasolidity ^0.8.20;import {ERC721} from"@openzeppelin/contracts/token/ERC721/ERC721.sol";import {ERC721URIStorage} from"@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";import {ERC721Burnable} from"@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol";import {IERC20} from"@openzeppelin/contracts/interfaces/IERC20.sol";import {SafeERC20} from"@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";import {ReentrancyGuard} from"@openzeppelin/contracts/security/ReentrancyGuard.sol";import {Client} from"@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";import {IRouterClient} from"@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.sol";import {IAny2EVMMessageReceiver} from"@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IAny2EVMMessageReceiver.sol";import {OwnerIsCreator} from"@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";import {LinkTokenInterface} from"@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.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. */contractXNFTisERC721,ERC721URIStorage,ERC721Burnable,IAny2EVMMessageReceiver,ReentrancyGuard,OwnerIsCreator{usingSafeERC20forIERC20;enumPayFeesIn { Native, LINK }errorInvalidRouter(address router);errorOnlyOnArbitrumSepolia();errorNotEnoughBalanceForFees(uint256 currentBalance,uint256 calculatedFees );errorNothingToWithdraw();errorFailedToWithdrawEth(address owner,address target,uint256 value);errorChainNotEnabled(uint64 chainSelector);errorSenderNotEnabled(address sender);errorOperationNotAllowedOnCurrentChain(uint64 chainSelector);structXNftDetails {address xNftAddress;bytes ccipExtraArgsBytes; }uint256constant ARBITRUM_SEPOLIA_CHAIN_ID =421614;string[] characters = ["https://ipfs.io/ipfs/QmTgqnhFBMkfT9s8PHKcdXBn1f5bG3Q5hmBaR4U6hoTvb1?filename=Chainlink_Elf.png","https://ipfs.io/ipfs/QmZGQA92ri1jfzSu61JRaNQXYg1bLuM7p8YT83DzFA2KLH?filename=Chainlink_Knight.png","https://ipfs.io/ipfs/QmW1toapYs7M29rzLXTENn3pbvwe8ioikX1PwzACzjfdHP?filename=Chainlink_Orc.png","https://ipfs.io/ipfs/QmPMwQtFpEdKrUjpQJfoTeZS1aVSeuJT6Mof7uV29AcUpF?filename=Chainlink_Witch.png" ]; IRouterClient internalimmutable i_ccipRouter; LinkTokenInterface internalimmutable i_linkToken;uint64privateimmutable i_currentChainSelector;uint256private _nextTokenId;mapping(uint64 destChainSelector => XNftDetails xNftDetailsPerChain)public s_chains;eventChainEnabled(uint64 chainSelector,address xNftAddress,bytes ccipExtraArgs );eventChainDisabled(uint64 chainSelector);eventCrossChainSent(address from,address to,uint256 tokenId,uint64 sourceChainSelector,uint64 destinationChainSelector );eventCrossChainReceived(address from,address to,uint256 tokenId,uint64 sourceChainSelector,uint64 destinationChainSelector );modifieronlyRouter() {if (msg.sender !=address(i_ccipRouter))revertInvalidRouter(msg.sender); _; }modifieronlyOnArbitrumSepolia() {if (block.chainid != ARBITRUM_SEPOLIA_CHAIN_ID)revertOnlyOnArbitrumSepolia(); _; }modifieronlyEnabledChain(uint64_chainSelector) {if (s_chains[_chainSelector].xNftAddress ==address(0))revertChainNotEnabled(_chainSelector); _; }modifieronlyEnabledSender(uint64_chainSelector,address_sender) {if (s_chains[_chainSelector].xNftAddress != _sender)revertSenderNotEnabled(_sender); _; }modifieronlyOtherChains(uint64_chainSelector) {if (_chainSelector == i_currentChainSelector)revertOperationNotAllowedOnCurrentChain(_chainSelector); _; }constructor(address ccipRouterAddress,address linkTokenAddress,uint64 currentChainSelector ) ERC721("Cross Chain NFT", "XNFT") {if (ccipRouterAddress ==address(0)) revertInvalidRouter(address(0)); i_ccipRouter =IRouterClient(ccipRouterAddress); i_linkToken =LinkTokenInterface(linkTokenAddress); i_currentChainSelector = currentChainSelector; }functionmint() externalonlyOnArbitrumSepolia {uint256 tokenId = _nextTokenId++;stringmemory uri = characters[tokenId % characters.length];_safeMint(msg.sender, tokenId);_setTokenURI(tokenId, uri); }functionenableChain(uint64 chainSelector,address xNftAddress,bytesmemory ccipExtraArgs ) externalonlyOwneronlyOtherChains(chainSelector) { s_chains[chainSelector] =XNftDetails({ xNftAddress: xNftAddress, ccipExtraArgsBytes: ccipExtraArgs });emitChainEnabled(chainSelector, xNftAddress, ccipExtraArgs); }functiondisableChain(uint64 chainSelector ) externalonlyOwneronlyOtherChains(chainSelector) {delete s_chains[chainSelector];emitChainDisabled(chainSelector); }functioncrossChainTransferFrom(address from,address to,uint256 tokenId,uint64 destinationChainSelector,PayFeesIn payFeesIn )externalnonReentrantonlyEnabledChain(destinationChainSelector)returns (bytes32 messageId) {stringmemory tokenUri =tokenURI(tokenId);_burn(tokenId); Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({ receiver: abi.encode( s_chains[destinationChainSelector].xNftAddress ), data: abi.encode(from, to, tokenId, tokenUri), tokenAmounts:new Client.EVMTokenAmount[](0), extraArgs: s_chains[destinationChainSelector].ccipExtraArgsBytes, feeToken: payFeesIn == PayFeesIn.LINK?address(i_linkToken):address(0) });// Get the fee required to send the CCIP messageuint256 fees = i_ccipRouter.getFee(destinationChainSelector, message);if (payFeesIn == PayFeesIn.LINK) {if (fees > i_linkToken.balanceOf(address(this)))revertNotEnoughBalanceForFees( 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_ccipRouter), fees);// Send the message through the router and store the returned message ID messageId = i_ccipRouter.ccipSend( destinationChainSelector, message ); } else {if (fees >address(this).balance)revertNotEnoughBalanceForFees(address(this).balance, fees);// Send the message through the router and store the returned message ID messageId = i_ccipRouter.ccipSend{value: fees}( destinationChainSelector, message ); }emitCrossChainSent( from, to, tokenId, i_currentChainSelector, destinationChainSelector ); }/// @inheritdoc IAny2EVMMessageReceiverfunctionccipReceive(Client.Any2EVMMessagecalldata message )externalvirtualoverrideonlyRouternonReentrantonlyEnabledChain(message.sourceChainSelector)onlyEnabledSender(message.sourceChainSelector, abi.decode(message.sender, (address)) ) {uint64 sourceChainSelector = message.sourceChainSelector; (address from,address to,uint256 tokenId,stringmemory tokenUri ) = abi.decode(message.data, (address,address,uint256,string));_safeMint(to, tokenId);_setTokenURI(tokenId, tokenUri);emitCrossChainReceived( from, to, tokenId, sourceChainSelector, i_currentChainSelector ); }functionwithdraw(address_beneficiary) publiconlyOwner {uint256 amount =address(this).balance;if (amount ==0) revertNothingToWithdraw(); (bool sent, ) = _beneficiary.call{value: amount}("");if (!sent) revertFailedToWithdrawEth(msg.sender, _beneficiary, amount); }functionwithdrawToken(address_beneficiary,address_token ) publiconlyOwner {uint256 amount =IERC20(_token).balanceOf(address(this));if (amount ==0) revertNothingToWithdraw();IERC20(_token).safeTransfer(_beneficiary, amount); }functiontokenURI(uint256 tokenId ) publicviewoverride(ERC721,ERC721URIStorage) returns (stringmemory) {return super.tokenURI(tokenId); }functiongetCCIPRouter() publicviewreturns (address) {returnaddress(i_ccipRouter); }functionsupportsInterface(bytes4 interfaceId ) publicviewoverride(ERC721,ERC721URIStorage) returns (bool) {return interfaceId == type(IAny2EVMMessageReceiver).interfaceId || super.supportsInterface(interfaceId); }}
Prepare for deployment
Follow the steps to add the necessary environment variables for deploying these contracts and sending your first CCIP Message.
This contract expects at least 0.8.20 Solidity version. It is very important to understand that with Solc version 0.8.20, the default EVM version is set to "Shanghai". A new opcode, PUSH0, was added to the Ethereum Virtual Machine in the Shanghai upgrade.
However, besides Ethereum, the majority of blockchains haven't included PUSH0 opcode.
That means the PUSH0 opcode can now be part of the contract's bytecode and if the chain you are working on does not support it, it will error with the "Invalid opcode" error.
To understand more, we highly encourage you to check this StackOverflow answer:
We are going to use the @chainlink/env-enc package for extra security. It encrypts sensitive data instead of storing them as plain text in the .env file by creating a new .env.enc file. Although it's not recommended to push this file online, if that accidentally happens, your secrets will still be encrypted.
Install the package by running the following command:
npmi@chainlink/env-enc--save-dev
Set a password for encrypting and decrypting the environment variable file. You can change it later by typing the same command.
npxenv-encset-pw
Now set the following environment variables: PRIVATE_KEY, Source Blockchain RPC URL, Destination Blockchain RPC URL. For this example, we are going to use Arbitrum Sepolia and Ethereum Sepolia.
Create a new file and name it .env. Fill in your wallet's PRIVATE_KEY and RPC URLs for at least two blockchains. For this example, we are going to use Arbitrum Sepolia and Ethereum Sepolia.
Once that is done, to load the variables in the .env file, run the following command:
source.env
Finally, expand the foundry.toml to support these two networks:
[profile.default]src ='src'out ='out'remappings = ['@chainlink/contracts/=lib/chainlink/contracts','@chainlink/contracts-ccip/=lib/ccip/contracts','@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/']solc ='0.8.20'evm_version ='paris'[rpc_endpoints]arbitrumSepolia ="${ARBITRUM_SEPOLIA_RPC_URL}"ethereumSepolia ="${ETHEREUM_SEPOLIA_RPC_URL}"# See more config options https://github.com/foundry-rs/foundry/tree/master/config
Navigate to the "Solidity compiler" tab
Toggle the "Advanced Configurations" dropdown
Toggle the "EVM VERSION" dropdown menu and select "paris" instead of "default"
Navigate to the "Deploy & run transactions" tab and select the "Injected Provider - Metamask" option from the "Environment" dropdown menu.
If you are using Metamask wallet, the Ethereum Sepolia network should already came preinstalled. Make sure you added the Arbitrum Sepolia network.
Go to Chainlist.org and search for "arbitrum sepolia". Once you see the network with Chain ID 421614, click the "Add to Metamask" button.
Step 1) Deploy XNFT.sol to Ethereum Sepolia
Prepare Chain Selector and CCIP Router & LINK token addresses on Ethereum Sepolia. You can get them if you scroll to the beginning of this page, at #ccip-config-details
Navigate to the scripts folder and create new file named deployXNFT.ts
// deployXNFT.tsimport { ethers, network } from"hardhat";asyncfunctionmain() {constccipRouterAddressEthereumSepolia=`0x0bf3de8c5d3e8a2b34d2beeb17abfcebaf363a59`;constlinkTokenAddressEthereumSepolia=`0x779877A7B0D9E8603169DdbD7836e478b4624789`;constchainIdEthereumSepolia=`16015286601757825753`;constxNft=awaitethers.deployContract("XNFT", [ ccipRouterAddressEthereumSepolia, linkTokenAddressEthereumSepolia, chainIdEthereumSepolia ]);awaitxNft.waitForDeployment();console.log(`XNFT deployed on ${network.name} with address ${xNft.target}`);}// We recommend this pattern to be able to use async/await everywhere// and properly handle errors.main().catch((error) => {console.error(error);process.exitCode =1;});
Prepare Chain Selector and CCIP Router & LINK token addresses on Ethereum Sepolia. You can get them if you scroll to begging of this page, at #ccip-config-details
Create a new smart contract under the script folder and name it XNFT.s.sol
Note that deployment of the XNFT smart contract is hard coded to Ethereum Sepolia for this example, but feel free to refactor the following deployment script to support other networks. You can check CCIP Starter Kit (Foundry version) for reference.
Prepare Chain Selector and CCIP Router & LINK token addresses on Ethereum Sepolia. You can get them if you scroll to begging of this page, at #ccip-config-details
Open your Metamask wallet and switch to the Ethereum Sepolia network.
Open the XNFT.sol file.
Navigate to the "Solidity Compiler" tab and click the "Compile XNFT.sol" button.
Navigate to the "Deploy & run transactions" tab and select the "Injected Provider - Metamask" option from the "Environment" dropdown menu. Make sure that chainId is switched to 11155111 (if not, you may need to refresh the Remix IDE page in your browser).
Under the "Contract" dropdown menu, make sure that the "XNFT - XNFT.sol" is selected.
Locate the orange "Deploy" button. Provide 0x0bf3de8c5d3e8a2b34d2beeb17abfcebaf363a59 as the ccipRouterAddress , 0x779877A7B0D9E8603169DdbD7836e478b4624789 as the linkTokenAddress and 16015286601757825753 as the currentChainSelector.
Click the orange "Deploy"/"Transact" button.
Metamask notification will pop up. Sign the transaction.
Step 2) Deploy XNFT.sol to Arbitrum Sepolia
Prepare Chain Selector and CCIP Router & LINK token addresses on Arbitrum Sepolia. You can get them if you scroll to beginning of this page, at #ccip-config-details
Navigate to the scripts folder and create new file named deployXNFTArbitrum.ts
// deployXNFTArbitrum.tsimport { ethers, network } from"hardhat";asyncfunctionmain() {constccipRouterAddressArbitrumSepolia=`0x2a9c5afb0d0e4bab2bcdae109ec4b0c4be15a165`;constlinkTokenAddressArbitrumSepolia=`0xb1D4538B4571d411F07960EF2838Ce337FE1E80E`;constchainIdArbitrumSepolia=`3478487238524512106`;constxNft=awaitethers.deployContract("XNFT", [ ccipRouterAddressArbitrumSepolia, linkTokenAddressArbitrumSepolia, chainIdArbitrumSepolia ]);awaitxNft.waitForDeployment();console.log(`XNFT deployed on ${network.name} with address ${xNft.target}`);}// We recommend this pattern to be able to use async/await everywhere// and properly handle errors.main().catch((error) => {console.error(error);process.exitCode =1;});
Prepare Chain Selector and CCIP Router & LINK token addresses on Arbitrum Sepolia. You can get them if you scroll to begging of this page, at #ccip-config-details
Create a new smart contract under the script folder and name it XNFTArbitrum.s.sol
Note that deployment of the XNFT smart contract is hard coded to Arbitrum Sepolia for this example, but feel free to refactor the following deployment script to support other networks. You can check CCIP Starter Kit (Foundry version) for reference.
Prepare Chain Selector and CCIP Router & LINK token addresses on Arbitrum Sepolia. You can get them if you scroll to begging of this page, at #ccip-config-details
Open your Metamask wallet and switch to the Arbitrum Sepolia network.
Open the XNFT.sol file.
Navigate to the "Solidity Compiler" tab and click the "Compile XNFT.sol" button.
Navigate to the "Deploy & run transactions" tab and select the "Injected Provider - Metamask" option from the "Environment" dropdown menu. Make sure that chainId is switched to 421614 (if not, you may need to refresh the Remix IDE page in your browser).
Under the "Contract" dropdown menu, make sure that the "XNFT - XNFT.sol" is selected.
Locate the orange "Deploy" button. Provide 0x2a9c5afb0d0e4bab2bcdae109ec4b0c4be15a165 as the ccipRouterAddress , 0xb1D4538B4571d411F07960EF2838Ce337FE1E80E as the linkTokenAddress and 3478487238524512106 as the currentChainSelector.
Click the orange "Deploy"/"Transact" button.
Metamask notification will pop up. Sign the transaction.
Step 3) On Ethereum Sepolia, call enableChain function
Prepare:
The address of the address of the XNFT.sol smart contract you previously deployed to Ethereum Sepolia;
The address of the address of the XNFT.sol smart contract you previously deployed to Arbitrum Sepolia;
3478487238524512106, which is the CCIP Chain Selector for the Arbitrum Sepolia network, as the chainSelector parameter;
0x97a657c90000000000000000000000000000000000000000000000000000000000030d40, which is the bytes version of CCIP extraArgs' default value with 200_000 gas set for gasLimit, as ccipExtraArgs parameter.
If you would like to calculate this value by yourself, you can reuse the following helper smart contract:
// SPDX-License-Identifier: MITpragmasolidity ^0.8.19;import {Client} from"@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";contract EncodeExtraArgs {// Below is a simplistic example (same params for all messages) of using storage to allow for new options without// upgrading the dapp. Note that extra args are chain family specific (e.g. gasLimit is EVM specific etc.).// and will always be backwards compatible i.e. upgrades are opt-in.// Offchain we can compute the V1 extraArgs:// Client.EVMExtraArgsV1 memory extraArgs = Client.EVMExtraArgsV1({gasLimit: 300_000});// bytes memory encodedV1ExtraArgs = Client._argsToBytes(extraArgs);// Then later compute V2 extraArgs, for example if a refund feature was added:// Client.EVMExtraArgsV2 memory extraArgs = Client.EVMExtraArgsV2({gasLimit: 300_000, destRefundAddress: 0x1234});// bytes memory encodedV2ExtraArgs = Client._argsToBytes(extraArgs);// and update storage with the new args.// If different options are required for different messages, for example different gas limits,// one can simply key based on (chainSelector, messageType) instead of only chainSelector.functionencode(uint256 gasLimit) externalpurereturns(bytesmemory extraArgsBytes) { Client.EVMExtraArgsV1 memory extraArgs = Client.EVMExtraArgsV1({gasLimit: gasLimit}); extraArgsBytes = Client._argsToBytes(extraArgs); }}
Create a new TypeScript file under the scripts folder and name it enableChain.ts
// scripts/enableChain.tsimport { ethers, network } from"hardhat";import { Wallet } from"ethers";import { XNFT, XNFT__factory } from"../typechain-types";asyncfunctionmain() {if (network.name !==`ethereumSepolia`) {console.error(`Must be called from Ethereum Sepolia`);return1; }constprivateKey=process.env.PRIVATE_KEY!;constrpcProviderUrl=process.env.AVALANCHE_FUJI_RPC_URL;constprovider=newethers.JsonRpcProvider(rpcProviderUrl);constwallet=newWallet(privateKey);constsigner=wallet.connect(provider);constxNftAddressEthereumSepolia=`PUT XNFT ADDRESS ON ETHEREUM SEPOLIA HERE`;constxNftAddressArbitrumSepolia=`PUT XNFT ADDRESS ON ARBITRUM SEPOLIA HERE`;constchainSelectorArbitrumSepolia=`3478487238524512106`;constccipExtraArgs=`0x97a657c90000000000000000000000000000000000000000000000000000000000030d40`;constxNft:XNFT=XNFT__factory.connect(xNftAddressEthereumSepolia, signer);consttx=awaitxNft.enableChain( chainSelectorArbitrumSepolia, xNftAddressArbitrumSepolia, ccipExtraArgs );console.log(`Transaction hash: ${tx.hash}`);}main().catch((error) => {console.error(error);process.exitCode =1;});
Call the function by running the following command:
The address of the address of the XNFT.sol smart contract you previously deployed to Ethereum Sepolia;
The address of the address of the XNFT.sol smart contract you previously deployed to Arbitrum Sepolia;
3478487238524512106, which is the CCIP Chain Selector for the Arbitrum Sepolia network, as the chainSelector parameter;
0x97a657c90000000000000000000000000000000000000000000000000000000000030d40, which is the bytes version of CCIP extraArgs' default value with 200_000 gas set for gasLimit, as ccipExtraArgs parameter.
If you would like to calculate this value by yourself, you can reuse the following helper smart contract. Inside the scripts folder, create EncodeExtraArgs.s.sol and paste the following code:
// SPDX-License-Identifier: MITpragmasolidity ^0.8.19;import {Client} from"@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";contract EncodeExtraArgs {// Below is a simplistic example (same params for all messages) of using storage to allow for new options without// upgrading the dapp. Note that extra args are chain family specific (e.g. gasLimit is EVM specific etc.).// and will always be backwards compatible i.e. upgrades are opt-in.// Offchain we can compute the V1 extraArgs:// Client.EVMExtraArgsV1 memory extraArgs = Client.EVMExtraArgsV1({gasLimit: 300_000});// bytes memory encodedV1ExtraArgs = Client._argsToBytes(extraArgs);// Then later compute V2 extraArgs, for example if a refund feature was added:// Client.EVMExtraArgsV2 memory extraArgs = Client.EVMExtraArgsV2({gasLimit: 300_000, destRefundAddress: 0x1234});// bytes memory encodedV2ExtraArgs = Client._argsToBytes(extraArgs);// and update storage with the new args.// If different options are required for different messages, for example different gas limits,// one can simply key based on (chainSelector, messageType) instead of only chainSelector.functionencode(uint256 gasLimit) externalpurereturns(bytesmemory extraArgsBytes) { Client.EVMExtraArgsV1 memory extraArgs = Client.EVMExtraArgsV1({gasLimit: gasLimit}); extraArgsBytes = Client._argsToBytes(extraArgs); }}
Under the "Deployed Contracts" section, you should find the XNFT.sol contract you previously deployed to Ethereum Sepolia. Find the enableChain function and provide:
3478487238524512106, which is the CCIP Chain Selector for the Arbitrum Sepolia network, as the chainSelector parameter;
The address of the address of the XNFT.sol smart contract you previously deployed to Arbitrum Sepolia, as xNftAddress parameter;
0x97a657c90000000000000000000000000000000000000000000000000000000000030d40, which is the bytes version of CCIP extraArgs' default value with 200_000 gas set for gasLimit, as ccipExtraArgs parameter.
Hit the "Transact" orange button.
If you would like to calculate this value by yourself, you can reuse the following helper smart contract. Create EncodeExtraArgs.sol file and paste the following code:
// SPDX-License-Identifier: MITpragmasolidity ^0.8.19;import {Client} from"@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";contract EncodeExtraArgs {// Below is a simplistic example (same params for all messages) of using storage to allow for new options without// upgrading the dapp. Note that extra args are chain family specific (e.g. gasLimit is EVM specific etc.).// and will always be backwards compatible i.e. upgrades are opt-in.// Offchain we can compute the V1 extraArgs:// Client.EVMExtraArgsV1 memory extraArgs = Client.EVMExtraArgsV1({gasLimit: 300_000});// bytes memory encodedV1ExtraArgs = Client._argsToBytes(extraArgs);// Then later compute V2 extraArgs, for example if a refund feature was added:// Client.EVMExtraArgsV2 memory extraArgs = Client.EVMExtraArgsV2({gasLimit: 300_000, destRefundAddress: 0x1234});// bytes memory encodedV2ExtraArgs = Client._argsToBytes(extraArgs);// and update storage with the new args.// If different options are required for different messages, for example different gas limits,// one can simply key based on (chainSelector, messageType) instead of only chainSelector.functionencode(uint256 gasLimit) externalpurereturns(bytesmemory extraArgsBytes) { Client.EVMExtraArgsV1 memory extraArgs = Client.EVMExtraArgsV1({gasLimit: gasLimit}); extraArgsBytes = Client._argsToBytes(extraArgs); }}
Step 4) On Arbitrum Sepolia, call enableChain function
Prepare:
The address of the XNFT.sol smart contract you previously deployed to Arbitrum Sepolia;
The address of the address of the XNFT.sol smart contract you previously deployed to Ethereum Sepolia, as xNftAddress parameter;
16015286601757825753, which is the CCIP Chain Selector for the Ethereum Sepolia network, as the chainSelector parameter;
0x97a657c90000000000000000000000000000000000000000000000000000000000030d40, which is the bytes version of CCIP extraArgs' default value with 200_000 gas set for gasLimit, as ccipExtraArgs parameter.
If you would like to calculate this value by yourself, you can reuse the following helper smart contract:
// SPDX-License-Identifier: MITpragmasolidity ^0.8.19;import {Client} from"@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";contract EncodeExtraArgs {// Below is a simplistic example (same params for all messages) of using storage to allow for new options without// upgrading the dapp. Note that extra args are chain family specific (e.g. gasLimit is EVM specific etc.).// and will always be backwards compatible i.e. upgrades are opt-in.// Offchain we can compute the V1 extraArgs:// Client.EVMExtraArgsV1 memory extraArgs = Client.EVMExtraArgsV1({gasLimit: 300_000});// bytes memory encodedV1ExtraArgs = Client._argsToBytes(extraArgs);// Then later compute V2 extraArgs, for example if a refund feature was added:// Client.EVMExtraArgsV2 memory extraArgs = Client.EVMExtraArgsV2({gasLimit: 300_000, destRefundAddress: 0x1234});// bytes memory encodedV2ExtraArgs = Client._argsToBytes(extraArgs);// and update storage with the new args.// If different options are required for different messages, for example different gas limits,// one can simply key based on (chainSelector, messageType) instead of only chainSelector.functionencode(uint256 gasLimit) externalpurereturns(bytesmemory extraArgsBytes) { Client.EVMExtraArgsV1 memory extraArgs = Client.EVMExtraArgsV1({gasLimit: gasLimit}); extraArgsBytes = Client._argsToBytes(extraArgs); }}
Create a new TypeScript file under the scripts folder and name it enableChainArbitrum.ts
// scripts/enableChainArbitrum.ts
import { ethers, network } from "hardhat";
import { Wallet } from "ethers";
import { XNFT, XNFT__factory } from "../typechain-types";
async function main() {
if (network.name !== `arbitrumSepolia`) {
console.error(`Must be called from Arbitrum Sepolia`);
return 1;
}
const privateKey = process.env.PRIVATE_KEY!;
const rpcProviderUrl = process.env.AVALANCHE_FUJI_RPC_URL;
const provider = new ethers.JsonRpcProvider(rpcProviderUrl);
const wallet = new Wallet(privateKey);
const signer = wallet.connect(provider);
const xNftAddressArbitrumSepolia = `PUT XNFT ADDRESS ON ARBITRUM SEPOLIA HERE`;
const xNftAddressEthereumSepolia = `PUT XNFT ADDRESS ON ETHEREUM SEPOLIA HERE`;
const chainSelectorEthereumSepolia = `16015286601757825753`;
const ccipExtraArgs = `0x97a657c90000000000000000000000000000000000000000000000000000000000030d40`;
const xNft: XNFT = XNFT__factory.connect(xNftAddressArbitrumSepolia, signer);
const tx = await xNft.enableChain(
chainSelectorEthereumSepolia,
xNftAddressEthereumSepolia,
ccipExtraArgs
);
console.log(`Transaction hash: ${tx.hash}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Call the function by running the following command:
npx hardhat run ./scripts/enableChainArbitrum.ts --network arbitrumSepolia
Prepare:
The address of the address of the XNFT.sol smart contract you previously deployed to Arbitrum Sepolia;
The address of the address of the XNFT.sol smart contract you previously deployed to Ethereum Sepolia, as xNftAddress parameter;
16015286601757825753, which is the CCIP Chain Selector for the Ethereum Sepolia network, as the chainSelector parameter;
0x97a657c90000000000000000000000000000000000000000000000000000000000030d40, which is the bytes version of CCIP extraArgs' default value with 200_000 gas set for gasLimit, as ccipExtraArgs parameter.
If you would like to calculate this value by yourself, you can reuse the following helper smart contract. Inside the scripts folder, create EncodeExtraArgs.s.sol and paste the following code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
contract EncodeExtraArgs {
// Below is a simplistic example (same params for all messages) of using storage to allow for new options without
// upgrading the dapp. Note that extra args are chain family specific (e.g. gasLimit is EVM specific etc.).
// and will always be backwards compatible i.e. upgrades are opt-in.
// Offchain we can compute the V1 extraArgs:
// Client.EVMExtraArgsV1 memory extraArgs = Client.EVMExtraArgsV1({gasLimit: 300_000});
// bytes memory encodedV1ExtraArgs = Client._argsToBytes(extraArgs);
// Then later compute V2 extraArgs, for example if a refund feature was added:
// Client.EVMExtraArgsV2 memory extraArgs = Client.EVMExtraArgsV2({gasLimit: 300_000, destRefundAddress: 0x1234});
// bytes memory encodedV2ExtraArgs = Client._argsToBytes(extraArgs);
// and update storage with the new args.
// If different options are required for different messages, for example different gas limits,
// one can simply key based on (chainSelector, messageType) instead of only chainSelector.
function encode(uint256 gasLimit) external pure returns(bytes memory extraArgsBytes) {
Client.EVMExtraArgsV1 memory extraArgs = Client.EVMExtraArgsV1({gasLimit: gasLimit});
extraArgsBytes = Client._argsToBytes(extraArgs);
}
}
Under the "Deployed Contracts" section, you should find the XNFT.sol contract you previously deployed to Arbitrum Sepolia. Find the enableChain function and provide:
16015286601757825753, which is the CCIP Chain Selector for the Ethereum Sepolia network, as the chainSelector parameter;
The address of the address of the XNFT.sol smart contract you previously deployed to Ethereum Sepolia, as xNftAddress parameter;
0x97a657c90000000000000000000000000000000000000000000000000000000000030d40, which is the bytes version of CCIP extraArgs' default value with 200_000 gas set for gasLimit, as ccipExtraArgs parameter.
Hit the "Transact" orange button.
If you would like to calculate this value by yourself, you can reuse the following helper smart contract. Create EncodeExtraArgs.sol file and paste the following code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";
contract EncodeExtraArgs {
// Below is a simplistic example (same params for all messages) of using storage to allow for new options without
// upgrading the dapp. Note that extra args are chain family specific (e.g. gasLimit is EVM specific etc.).
// and will always be backwards compatible i.e. upgrades are opt-in.
// Offchain we can compute the V1 extraArgs:
// Client.EVMExtraArgsV1 memory extraArgs = Client.EVMExtraArgsV1({gasLimit: 300_000});
// bytes memory encodedV1ExtraArgs = Client._argsToBytes(extraArgs);
// Then later compute V2 extraArgs, for example if a refund feature was added:
// Client.EVMExtraArgsV2 memory extraArgs = Client.EVMExtraArgsV2({gasLimit: 300_000, destRefundAddress: 0x1234});
// bytes memory encodedV2ExtraArgs = Client._argsToBytes(extraArgs);
// and update storage with the new args.
// If different options are required for different messages, for example different gas limits,
// one can simply key based on (chainSelector, messageType) instead of only chainSelector.
function encode(uint256 gasLimit) external pure returns(bytes memory extraArgsBytes) {
Client.EVMExtraArgsV1 memory extraArgs = Client.EVMExtraArgsV1({gasLimit: gasLimit});
extraArgsBytes = Client._argsToBytes(extraArgs);
}
}
Step 5) On Arbitrum Sepolia, fund XNFT.sol with 3 LINK
To cover for CCIP fees, fund XNFT.sol with some amount of LINK, 3 should be more than enough for this demo. Obviously, for the sake of full functionality, you should fund XNFT.sol smart contract on other blockchains as well, so you can perform cross-chain transfers between all of them.
Step 6) On Arbitrum Sepolia, mint new xNFT
Create a new TypeScript file under the scripts folder and name it mint.ts
// scripts/mint.ts
import { ethers, network } from "hardhat";
import { Wallet } from "ethers";
import { XNFT, XNFT__factory } from "../typechain-types";
async function main() {
if (network.name !== `arbitrumSepolia`) {
console.error(`Must be called from Arbitrum Sepolia`);
return 1;
}
const privateKey = process.env.PRIVATE_KEY!;
const rpcProviderUrl = process.env.AVALANCHE_FUJI_RPC_URL;
const provider = new ethers.JsonRpcProvider(rpcProviderUrl);
const wallet = new Wallet(privateKey);
const signer = wallet.connect(provider);
const xNftAddressArbitrumSepolia = `PUT XNFT ADDRESS ON ARBITRUM SEPOLIA HERE`;
const xNft: XNFT = XNFT__factory.connect(xNftAddressArbitrumSepolia, signer);
const tx = await xNft.mint();
console.log(`Transaction hash: ${tx.hash}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Call the function by running the following command:
npx hardhat run ./scripts/mint.ts --network arbitrumSepolia
Under the "Deployed Contracts" section, you should find the XNFT.sol contract you previously deployed to Arbitrum Sepolia. Find the mint function and hit the "Transact" orange button.
Step 7) On Arbitrum Sepolia, crossTransferFrom xNFT
Prepare:
Your EOA address, as the from parameter;
The address of an EOA on other chain where you want to cross-transfer your NFT, can be your EOA address, as to parameter;
The ID of a xNFT you want to cross-transfer, as tokenId parameter;
16015286601757825753 which is the CCIP Chain Selector of Ethereum Sepolia blockchain, as the destinationChainSelector parameter;
1 which stands that we are paying for CCIP fees in LINK, as the payFeesIn parameter.
Create a new TypeScript file under the scripts folder and name it crossChainTransferFrom.ts
// scripts/crossChainTransfer.ts
import { ethers, network } from "hardhat";
import { Wallet } from "ethers";
import { XNFT, XNFT__factory } from "../typechain-types";
async function main() {
if (network.name !== `arbitrumSepolia`) {
console.error(`Must be called from Arbitrum Sepolia`);
return 1;
}
const privateKey = process.env.PRIVATE_KEY!;
const rpcProviderUrl = process.env.AVALANCHE_FUJI_RPC_URL;
const provider = new ethers.JsonRpcProvider(rpcProviderUrl);
const wallet = new Wallet(privateKey);
const signer = wallet.connect(provider);
const xNftAddressArbitrumSepolia = `PUT XNFT ADDRESS ON ARBITRUM SEPOLIA HERE`;
const from = `PUT YOUR EOA ADDRESS HERE`;
const to = `PUT RECEIVER's ADDRESS HERE`;
const tokenId = 0; // put NFT token id here
const destinationChainSelector = `16015286601757825753`;
const payFeesIn = 1; // 0 - Native, 1 - LINK
const xNft: XNFT = XNFT__factory.connect(xNftAddressArbitrumSepolia, signer);
const tx = await xNft.crossChainTransferFrom(
from,
to,
tokenId,
destinationChainSelector,
payFeesIn
);
console.log(`Transaction hash: ${tx.hash}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Call the function by running the following command:
npx hardhat run ./scripts/crossChainTransfer.ts --network arbitrumSepolia
Prepare:
Your EOA address, as the from parameter;
The address of an EOA on other chain where you want to cross-transfer your NFT, can be your EOA address, as to parameter;
The ID of a xNFT you want to cross-transfer, as tokenId parameter;
16015286601757825753 which is the CCIP Chain Selector of Ethereum Sepolia blockchain, as the destinationChainSelector parameter;
1 which stands that we are paying for CCIP fees in LINK, as the payFeesIn parameter.
Under the "Deployed Contracts" section, you should find the XNFT.sol contract you previously deployed to Arbitrum Sepolia. Find the crossChainTransferFrom function and provide the following parameters:
Your EOA address, as the from parameter;
The address of an EOA on other chain where you want to cross-transfer your NFT, can be your EOA address, as to parameter;
The ID of a xNFT you want to cross-transfer, as tokenId parameter;
16015286601757825753 which is the CCIP Chain Selector of Ethereum Sepolia blockchain, as the destinationChainSelector parameter;
1 which stands that we are paying for CCIP fees in LINK, as the payFeesIn parameter.
Hit the "Transact" orange button.
You can now monitor this cross-chain transfer on CCIP Explorer page.
Once cross-chain NFT arrives to Ethereum Sepolia, you can manually display it inside your Metamask wallet. Navigate to the "NFT" tab and hit the "Import NFT" button.
Then, fill in XNFT.sol smart contract address on Ethereum Sepolia and token ID you received (0).
Finally, your NFT will appear inside Metamask wallet.