CCIP Bootcamp
CCIP Bootcamp Aug 2024
CCIP Bootcamp Aug 2024
  • 💻Setup Instructions
  • 1️⃣Day 1
    • Welcome to CCIP Bootcamp!
    • SmartCon
    • Tokenizated RWA Bootcamp
    • Meet Our Instructors
    • Transporter, powered by CCIP
    • Introduction to Interoperability Problem and Chainlink CCIP
      • How CCIP Programmable Token Transfers Unlock Cross-Chain Innovation
      • CCIP Programmable Token Transfers in TradFi
      • CCIP Programmable Token Transfers in DeFi
    • How to Use Chainlink CCIP
    • Exercise 1: Programmable Token Transfers using the Defensive Example Pattern
      • Defensive Example Pattern
    • Day 1 Homework
    • A talk with the Interport team
  • 2️⃣Day 2
    • CCIP Architecture and Message Processing
    • Building Cross-Chain NFTs
    • Exercise 2: Build Your First Cross-Chain NFT
    • Exercise 3: Testing Cross-Chain contracts using Chainlink Local
    • Debugging Tips and Tricks
    • Day 2 Homework
    • A talk with the Celo team
  • 3️⃣Day 3
    • 5 Levels of Cross-Chain Security with Chainlink CCIP
    • Exercise 4: Sending USDC Cross-Chain
    • Day 3 Homework
  • 🛣️Next Steps
Powered by GitBook
On this page
  • What are Cross-Chain NFTs?
  • How Do Cross-Chain NFTs Work?
  • How to use Chainlink CCIP to create Cross-Chain NFTs?
  • Implementing Burn-and-Mint model
  • NFT Metadata
  • Development Best Practices
  • Verify source chain, destination chain, sender and receiver addresses
Export as PDF
  1. Day 2

Building Cross-Chain NFTs

How to design a cross-chain NFT smart contract

PreviousCCIP Architecture and Message ProcessingNextExercise 2: Build Your First Cross-Chain NFT

Last updated 9 months ago

What are Cross-Chain NFTs?

A cross-chain NFT is a smart contract that can exist on any blockchain, abstracting away the need for users to understand which blockchain they’re using.

Typically, NFT movements from one chain to another are eye-catching events, but this simple fact brings up a larger question of the reliability and security of Web3’s middleware infrastructure. In a seamless cross-chain world, moving digital assets from one chain to another should be as normal as submitting a transaction on the same blockchain.

When an NFT moves from one blockchain to another, it becomes a cross-chain NFT.

How Do Cross-Chain NFTs Work?

At a high level, an NFT is a digital token on a blockchain with a unique identifier different from any other token on the chain.

Any NFT is implemented by a smart contract that is intrinsically connected to a single blockchain. The smart contract is arguably the most important part of this equation because it controls the NFT implementation: How many are minted, when, what conditions need to be met to distribute them, and more. This means that any cross-chain NFT implementation requires at least two smart contracts on two blockchains and interconnection between them.

This is what a cross-chain NFT looks like - equivalent NFTs that exist across multiple blockchains.

With this in mind, cross-chain NFTs can be implemented in three ways:

  • Burn-and-mint: An NFT owner puts their NFT into a smart contract on the source chain and burns it, in effect removing it from that blockchain. Once this is done, an equivalent NFT is created on the destination blockchain from its corresponding smart contract. This process can occur in both directions.

  • Lock-and-mint: An NFT owner locks their NFT into a smart contract on the source chain, and an equivalent NFT is created on the destination blockchain. When the owner wants to move their NFT back, they burn the NFT and it unlocks the NFT on the original blockchain.

  • Lock and unlock: The same NFT collection is minted on multiple blockchains. An NFT owner can lock their NFT on a source blockchain to unlock the equivalent NFT on a destination blockchain. This means only a single NFT can actively be used at any point in time, even if there are multiple instances of that NFT across blockchains.

At this Masterclass, we are going to implement the Burn-and-Mint mechanism

In each scenario, a cross-chain messaging protocol in the middle is necessary to send data instructions from one blockchain to another.

How to use Chainlink CCIP to create Cross-Chain NFTs?

To build Cross-Chain NFTs with Chainlink CCIP, let's first understand what one can do with Chainlink CCIP. With Chainlink CCIP, one can:

  • Transfer (supported) tokens

  • Send any kind of data

  • Send both tokens and data

CCIP sender can be:

  • EOA

  • Any smart contract

CCIP receiver can be:

  • EOA

  • Any smart contract that implements CCIPReceiver.sol

Implementing Burn-and-Mint model

To implement Burn-and-Mint model using Chainlink CCIP, we will on cross-chain transfer function burn an NFT on the source blockchain (Arbitrum Sepolia) and send the cross-chain message using Chainlink CCIP. We will need to encode to and from addresses, NFT's tokenId and tokenURI so we can mint exactly the same NFT on the destination blockchain once it receives a cross-chain message.

The Arbitrum Sepolia side:

function crossChainTransferFrom(
    address from,
    address to,
    uint256 tokenId,
    uint64 destinationChainSelector,
    PayFeesIn payFeesIn
)
    external
    nonReentrant
    onlyEnabledChain(destinationChainSelector)
    returns (bytes32 messageId)
{
    string memory tokenUri = tokenURI(tokenId);
    // Burning token on source blockchain
    _burn(tokenId);

    Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
        receiver: abi.encode(
            s_chains[destinationChainSelector].xNftAddress
        ),
        // Encoding details for minting on the destination blockchain
        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)
    });
}

The Ethereum Sepolia side:

function ccipReceive(
    Client.Any2EVMMessage calldata message
)
    external
    virtual
    override
    onlyRouter
    nonReentrant
    onlyEnabledChain(message.sourceChainSelector)
    onlyEnabledSender(
        message.sourceChainSelector,
        abi.decode(message.sender, (address))
    )
{
    (
        address from,
        address to,
        uint256 tokenId,
        string memory tokenUri
    ) = abi.decode(message.data, (address, address, uint256, string));

    _safeMint(to, tokenId);
    _setTokenURI(tokenId, tokenUri);
}

This design allows the cross-chain transfer and vice-versa, from Ethereum Sepolia back to Arbitrum Sepolia using the exact same codebase and Burn-and-Mint mechanism.

NFT Metadata

For this Cross-Chain NFT we will use Four Chainlink Warriors hosted on IPFS, as a Metadata.

Development Best Practices

For this exercise we will try to follow some of the CCIP Best Practices. For the full list you should always refer to the Chainlink Official Documentation.

Verify source chain, destination chain, sender and receiver addresses

It's crucial for this exercise that sending cross-chain messages is between Cross-Chain NFT smart contracts. To accomplish that, we need to track a record of these addresses on different blockchains using their CCIP chain selectors.

// struct XNftDetails {
        address xNftAddress;
        bytes ccipExtraArgsBytes;
}

mapping(uint64 destChainSelector => XNftDetails xNftDetailsPerChain)
        public s_chains;

modifier onlyEnabledChain(uint64 _chainSelector) {
    if (s_chains[_chainSelector].xNftAddress == address(0))
        revert ChainNotEnabled(_chainSelector);
    _;
}

modifier onlyEnabledSender(uint64 _chainSelector, address _sender) {
    if (s_chains[_chainSelector].xNftAddress != _sender)
        revert SenderNotEnabled(_sender);
    _;
}

function enableChain(
    uint64 chainSelector,
    address xNftAddress,
    bytes memory ccipExtraArgs
) external onlyOwner onlyOtherChains(chainSelector) {
    s_chains[chainSelector] = XNftDetails({
        xNftAddress: xNftAddress,
        ccipExtraArgsBytes: ccipExtraArgs
    });

    emit ChainEnabled(chainSelector, xNftAddress, ccipExtraArgs);
}

function disableChain(
    uint64 chainSelector
) external onlyOwner onlyOtherChains(chainSelector) {
    delete s_chains[chainSelector];

    emit ChainDisabled(chainSelector);
}

Verify Router addresses

When you implement the ccipReceive method in the contract residing on the destination chain, validate that the msg.sender is the correct Router address. This verification ensures that only the Router contract can call the ccipReceive function on the receiver contract and is for developers that want to restrict which accounts are allowed to call ccipReceive.

Setting gasLimit

The gasLimit specifies the maximum amount of gas CCIP can consume to execute ccipReceive() on the contract located on the destination blockchain. It is the main factor in determining the fee to send a message. Unspent gas is not refunded.

Using extraArgs

The purpose of extraArgs is to allow compatibility with future CCIP upgrades. To get this benefit, make sure that extraArgs is mutable in production deployments. This allows you to build it off-chain and pass it in a call to a function or store it in a variable that you can update on-demand.

If extraArgs are left empty, a default of 200000 gasLimit will be set.

To make extraArgs mutable, set them as described previously in the enableChain function. To calculate which bytes value to pass, you can create a helper script like this:

// EncodeExtraArgs.s.sol

// 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);
    }
}

Chainlink Elf, available at:

Chainlink Knight, available at:

Chainlink Orc, available at:

Chainlink Witch, available at:

2️⃣
https://ipfs.io/ipfs/QmTgqnhFBMkfT9s8PHKcdXBn1f5bG3Q5hmBaR4U6hoTvb1
https://ipfs.io/ipfs/QmZGQA92ri1jfzSu61JRaNQXYg1bLuM7p8YT83DzFA2KLH
https://ipfs.io/ipfs/QmW1toapYs7M29rzLXTENn3pbvwe8ioikX1PwzACzjfdHP
https://ipfs.io/ipfs/QmPMwQtFpEdKrUjpQJfoTeZS1aVSeuJT6Mof7uV29AcUpF
CCIP Token Transfer Mechanisms
CCIP Transfer options
Chainlink Elf
Chainlink Knight
Chainlink Orc
Chainlink Witch