Page cover image

Exercise #2: Transfer Tokens & Data

Mint an NFT on one chain and pay for minting on another

Getting started

In this exercise, we are going to reuse and expand the smart contract from the previous exercise. We will send more than just a simple text message. To mint an NFT on the destination chain, we will send the minting price amount of tokens & ABI encoded NFT contract's mint function signature as a data parameter of the CCIP Message.

Adjust CCIPTokenSender.sol from Exercise #1

Three adjustments that need to be made are:

  • Renaming CCIPTokenSender.sol to CCIPTokenAndDataSender.sol

  • Provide abi.encodeWithSignature("mint(address)", msg.sender) as data parameter of the CCIP Message instead of an empty string

  • Increase gasLimit from 0 to 200_000.

Create a new file inside the contracts folder and name it CCIPTokenAndDataSender.sol , and after that, apply the above adjustments.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {IRouterClient} from "@chainlink/contracts-ccip/src/v0.8/ccip/interfaces/IRouterClient.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.0/token/ERC20/IERC20.sol";
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.sol";

contract CCIPTokenAndDataSender is OwnerIsCreator {
    IRouterClient router;
    LinkTokenInterface linkToken;
    
    mapping(uint64 => bool) public whitelistedChains;
    
    error NotEnoughBalance(uint256 currentBalance, uint256 calculatedFees); 
    error DestinationChainNotWhitelisted(uint64 destinationChainSelector);
    error NothingToWithdraw();
    
    event TokensTransferred(
        bytes32 indexed messageId, // The unique ID of the message.
        uint64 indexed destinationChainSelector, // The chain selector of the destination chain.
        address receiver, // The address of the receiver 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.
    );
    
    modifier onlyWhitelistedChain(uint64 _destinationChainSelector) {
        if (!whitelistedChains[_destinationChainSelector])
            revert DestinationChainNotWhitelisted(_destinationChainSelector);
        _;
    }

    constructor(address _router, address _link) {
        router = IRouterClient(_router);
        linkToken = LinkTokenInterface(_link);
    }
   
    function whitelistChain(
        uint64 _destinationChainSelector
    ) external onlyOwner {
        whitelistedChains[_destinationChainSelector] = true;
    }

    function denylistChain(
        uint64 _destinationChainSelector
    ) external onlyOwner {
        whitelistedChains[_destinationChainSelector] = false;
    }
    
    function transferTokens(
        uint64 _destinationChainSelector,
        address _receiver,
        address _token,
        uint256 _amount
    ) 
        external
        onlyOwner
        onlyWhitelistedChain(_destinationChainSelector)
        returns (bytes32 messageId) 
    {
        Client.EVMTokenAmount[]
            memory tokenAmounts = new Client.EVMTokenAmount[](1);
        Client.EVMTokenAmount memory tokenAmount = Client.EVMTokenAmount({
            token: _token,
            amount: _amount
        });
        tokenAmounts[0] = tokenAmount;
        
        // Build the CCIP Message
        Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
            receiver: abi.encode(_receiver),
            data: abi.encodeWithSignature("mint(address)", msg.sender),
            tokenAmounts: tokenAmounts,
            extraArgs: Client._argsToBytes(
                Client.EVMExtraArgsV1({gasLimit: 200_000, strict: false})
            ),
            feeToken: address(linkToken)
        });
        
        // CCIP Fees Management
        uint256 fees = router.getFee(_destinationChainSelector, message);

        if (fees > linkToken.balanceOf(address(this)))
            revert NotEnoughBalance(linkToken.balanceOf(address(this)), fees);

        linkToken.approve(address(router), fees);
        
        // Approve Router to spend CCIP-BnM tokens we send
        IERC20(_token).approve(address(router), _amount);
        
        // Send CCIP Message
        messageId = router.ccipSend(_destinationChainSelector, message); 
        
        emit TokensTransferred(
            messageId,
            _destinationChainSelector,
            _receiver,
            _token,
            _amount,
            address(linkToken),
            fees
        );   
    }
    
    function withdrawToken(
        address _beneficiary,
        address _token
    ) public onlyOwner {
        uint256 amount = IERC20(_token).balanceOf(address(this));
        
        if (amount == 0) revert NothingToWithdraw();
        
        IERC20(_token).transfer(_beneficiary, amount);
    }
}

Develop the CCIPTokenAndDataReceiver.sol smart contract

Let's now develop the CCIP Receiver smart contract alongside the simple NFT smart contract.

Create a new file inside the contracts folder and name it CCIPTokenAndDataReceiver.sol

To compile the following contract, we must install the @openzeppelin/contracts package first.

Run:

npm i --save-dev @openzeppelin/contracts

Start with the development by setting the Solidity compiler version and importing necessary contracts from the @chainlink/contracts-ccip NPM package. Then implement a basic NFT smart contract using the OpenZeppelin library.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {CCIPReceiver} from "@chainlink/contracts-ccip/src/v0.8/ccip/applications/CCIPReceiver.sol";
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";

contract MyNFT is ERC721URIStorage, OwnerIsCreator {
    string constant TOKEN_URI =
        "https://ipfs.io/ipfs/QmYuKY45Aq87LeL1R5dhb1hqHLp6ZFbJaCP8jxqKM1MX6y/babe_ruth_1.json";
    uint256 internal tokenId;

    constructor() ERC721("MyNFT", "MNFT") {}

    function mint(address to) public onlyOwner {
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, TOKEN_URI);
        unchecked {
            tokenId++;
        }
    }
}

Now we can add the CCIP Receiver contract logic

// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {CCIPReceiver} from "@chainlink/contracts-ccip/src/v0.8/ccip/applications/CCIPReceiver.sol";
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";

contract MyNFT is ERC721URIStorage, OwnerIsCreator {
    string constant TOKEN_URI =
        "https://ipfs.io/ipfs/QmYuKY45Aq87LeL1R5dhb1hqHLp6ZFbJaCP8jxqKM1MX6y/babe_ruth_1.json";
    uint256 internal tokenId;

    constructor() ERC721("MyNFT", "MNFT") {}

    function mint(address to) public onlyOwner {
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, TOKEN_URI);
        unchecked {
            tokenId++;
        }
    }
}

contract CCIPTokenAndDataReceiver is CCIPReceiver, OwnerIsCreator {
    MyNFT public nft;
    uint256 price;
    
    event MintCallSuccessfull();
    
    constructor(address router, uint256 _price) CCIPReceiver(router) {
        nft = new MyNFT();
        price = _price;
    }
    
    function _ccipReceive(
        Client.Any2EVMMessage memory message
    ) 
        internal 
        override 
    {
        require(message.destTokenAmounts[0].amount >= price, "Not enough CCIP-BnM for mint");
        (bool success, ) = address(nft).call(message.data);
        require(success);
        emit MintCallSuccessfull();
    }
}

CCIP Best Practice: Verify both sender & source chain

When implementing the ccipReceive method in a contract residing on the destination chain, ensure to verify the source chain of the incoming CCIP message. It is also important to validate the sender of the incoming CCIP message. These verifications ensure that CCIP messages can only be received from trusted source chains and only from trusted sender addresses

// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {CCIPReceiver} from "@chainlink/contracts-ccip/src/v0.8/ccip/applications/CCIPReceiver.sol";
import {Client} from "@chainlink/contracts-ccip/src/v0.8/ccip/libraries/Client.sol";

contract MyNFT is ERC721URIStorage, OwnerIsCreator {
    string constant TOKEN_URI =
        "https://ipfs.io/ipfs/QmYuKY45Aq87LeL1R5dhb1hqHLp6ZFbJaCP8jxqKM1MX6y/babe_ruth_1.json";
    uint256 internal tokenId;

    constructor() ERC721("MyNFT", "MNFT") {}

    function mint(address to) public onlyOwner {
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, TOKEN_URI);
        unchecked {
            tokenId++;
        }
    }
}

contract CCIPTokenAndDataReceiver is CCIPReceiver, OwnerIsCreator {
    MyNFT public nft;
    uint256 price;

    mapping(uint64 => bool) public whitelistedSourceChains;
    mapping(address => bool) public whitelistedSenders;

    event MintCallSuccessfull();

    error SourceChainNotWhitelisted(uint64 sourceChainSelector);
    error SenderNotWhitelisted(address sender);

    modifier onlyWhitelistedSourceChain(uint64 _sourceChainSelector) {
        if (!whitelistedSourceChains[_sourceChainSelector])
            revert SourceChainNotWhitelisted(_sourceChainSelector);
        _;
    }

    modifier onlyWhitelistedSenders(address _sender) {
        if (!whitelistedSenders[_sender]) revert SenderNotWhitelisted(_sender);
        _;
    }

    constructor(address router, uint256 _price) CCIPReceiver(router) {
        nft = new MyNFT();
        price = _price;
    }

    function whitelistSourceChain(
        uint64 _sourceChainSelector
    ) external onlyOwner {
        whitelistedSourceChains[_sourceChainSelector] = true;
    }

    function denylistSourceChain(
        uint64 _sourceChainSelector
    ) external onlyOwner {
        whitelistedSourceChains[_sourceChainSelector] = false;
    }

    function whitelistSender(address _sender) external onlyOwner {
        whitelistedSenders[_sender] = true;
    }

    function denySender(address _sender) external onlyOwner {
        whitelistedSenders[_sender] = false;
    }

    function _ccipReceive(
        Client.Any2EVMMessage memory message
    ) 
        internal
        onlyWhitelistedSourceChain(message.sourceChainSelector)
        onlyWhitelistedSenders(abi.decode(message.sender, (address))) 
        override 
    {
        require(message.destTokenAmounts[0].amount >= price, "Not enough CCIP-BnM for mint");
        (bool success, ) = address(nft).call(message.data);
        require(success);
        emit MintCallSuccessfull();
    }
}

Deployment and Usage

Now that we know the basics of working with CCIP, how to deploy contracts, how to fund them, and how to monitor for CCIP requests, go through the following checklist to deploy & use these contracts.

  1. Make sure you have at least 100 units of CCIP-BnM tokens on the Avalanche Fuji network

  2. Make sure you have at least 1 LINK on the Avalanche Fuji network

  3. Deploy the CCIPTokenAndDataSender.sol smart contract to the Avalanche Fuji network by providing the 0x554472a2720E5E7D5D3C817529aBA05EEd5F82D8 address (Router.sol on Avalanche Fuji) as the _router parameter and the 0x0b9d5D9136855f6FEc3c0993feE6E9CE8a297846 address as the _link parameter. Save the contract address.

  4. On Avalanche Fuji, call the whitelistChain function of the CCIPTokenAndDataSender.sol smart contract and pass 16015286601757825753 (Ethereum Sepolia CCIP chain selector) as the _destinationChainSelector parameter.

  5. On Avalanche Fuji, fund the CCIPTokenAndDataSender.sol smart contract with at least 100 units of CCIP-BnM tokens by sending them from your wallet to CCIPTokenAndDataSender.sol smart contract's address you previously saved.

  6. On Avalanche Fuji, fund the CCIPTokenAndDataSender.sol smart contract with at least 1 LINK by sending tokens from your wallet to CCIPTokenAndDataSender.sol smart contract's address you previously saved.

  7. Deploy the CCIPTokenAndDataReceiver.sol smart contract to the Ethereum Sepolia network by providing the 0xD0daae2231E9CB96b94C8512223533293C3693Bf address (Router.sol on Ethereum Spolia) as the _router parameter and 100 as the _price parameter. Save the contract address.

  8. On Ethereum Sepolia, call the whitelistSourceChain function of the CCIPTokenAndDataReceiver.sol smart contract and provide the 14767482510784806043 (Avalanche Fuji CCIP chain selector) as the _sourceChainSelector parameter.

  9. On Ethereum Sepolia, call the whitelistSender function of the CCIPTokenAndDataReceiver.sol smart contract and provide the CCIPTokenAndDataSender.sol smart contract's address you previously saved as the _sender parameter.

  10. Finally, go back to Avalanche Fuji and call the transferTokens function of the CCIPTokenAndDataSender.sol smart contract by providing the CCIPTokenAndDataReceiver.sol smart contract's address you previously saved as the _receiver parameter, 16015286601757825753 (Ethereum Sepolia CCIP chain selector) as the _destinationChainSelector parameter, 0xD21341536c5cF5EB1bcb58f6723cE26e8D8E90e4 (CCIP-BnM token address on Avalanche Fuji) as the _token parameter and 100 as the _price parameter.

You can now monitor the status of your CCIP Message using CCIP Explorer.

After the status of the message becomes 'Successful', you should be able to see your freshly minted NFT on OpenSea.

Last updated