38 min read
Overview
Prefer a video walkthrough? Follow along with Sahil and learn how to mint a cross-chain NFT.
In the rapidly evolving web3 ecosystem, blockchain interoperability has become essential to bridge the gap between isolated blockchain networks. This guide will walk you through the process of minting an NFT on one blockchain by using your funds on another blockchain by sending a transaction on that blockchain using the Chainlink Cross-Chain Interoperability Protocol (CCIP). Imagine attending an event with NFT ticket sales on Polygon while your funds are on Avalanche; CCIP allows you to buy the NFT that lives on Polygon by sending a transaction on Avalanche with your funds that are on Avalanche. By the end, you'll have a solid understanding of how to leverage CCIP for cross-chain NFT minting.
What You Will Do
In this guide, you will learn:
- What cross-chain interoperability is, and why it matters
- What is Chainlink CCIP, and what are its core capabilities
- How to mint an NFT on one blockchain from another using CCIP
- Real-world use cases for cross-chain dApps
What You Will Need
Before you begin, make sure you have the following:
- Basic knowledge of blockchain concepts
- Experience with ERC 721 NFTs and NFTs.
- An EVM-compatible wallet (e.g., MetaMask)
- Node.js installed
- Hardhat installed (we will cover it as well during the guide)
| Dependency | Version |
|---|---|
| node.js | >8.9.4 |
What is Cross-chain
The adoption of smart contracts initially centered on Ethereum due to its pioneering role in supporting programmable smart contracts. However, Ethereum's rising transaction fees prompted users to explore more cost-effective alternatives, leading to the rapid growth of a multi-chain ecosystem.
This ecosystem comprises various layer-1 blockchains, sidechains, subnets, and layer-2 rollups, each offering unique strengths and use cases. Some blockchains excel in gaming applications, while others are better suited for DeFi or NFT projects. Cross-chain interoperability plays a crucial role in this landscape, facilitating seamless communication and data sharing between these diverse blockchain networks. This connectivity enhances the flexibility and functionality of decentralized applications by bridging the gap between them, opening up exciting possibilities for developers and users alike.
What is CCIP
Chainlink CCIP, or Chainlink Cross-Chain Interoperability Protocol, is one such protocol that provides a secure and efficient way for decentralized applications (dApps) and web3 entrepreneurs to interact seamlessly across different blockchains. CCIP facilitates token transfers and arbitrary messaging, enabling developers to trigger actions on receiving smart contracts, such as minting NFTs, rebalancing indexes, or executing custom functions.
Chainlink CCIP is in the “Early Access” stage of development, which means that Chainlink CCIP currently has functionality which is under development and may be changed in later versions.
Use Cases of Cross-chain dApps
Cross-chain interoperability opens up a world of possibilities for developers and users. Some common use cases of cross-chain dApps include:
- Cross-chain lending: Users can lend and borrow various tokens across multiple decentralized finance (DeFi) platforms running on different chains.
- Low-cost transaction computation: CCIP can offload the computation of transaction data to cost-optimized chains, reducing transaction fees.
- Optimizing cross-chain yield: Users can leverage CCIP to move collateral to new DeFi protocols, maximizing yield opportunities across chains.
- Creating new dApps: CCIP allows users to harness the network effects of specific chains while utilizing the computational and storage capabilities of others.
Development
Now, let's dive into the technical aspect of cross-chain NFT minting using CCIP. In this section, we'll provide step-by-step instructions and code examples to guide you through the development process. You'll learn how to set up the required infrastructure, write smart contracts, and execute cross-chain NFT minting transactions.
All smart contracts and related files, including tasks, utilized in this guide, are openly available on GitHub, and credit goes to their respective creators.
NFT Details
Before jumping into the coding, let’s decide on the specifications of the NFT that we mint, such as its name, description, and how it looks. Then, store everything in a JSON metadata file.
We will use the following JSON file to determine the metadata. Our NFT image and metadata are stored on IPFS. Thus, you do not need to create any JSON file. However, if you want to upload your own image, you'd need to upload the image and metadata on IPFS. To learn more about using IPFS, check our How to Create and Host a Blog with IPFS using Quicknode and How to Create and Deploy an ERC-721 (NFT) guides.
{
"name": "QN CCIP NFT",
"description": "Cross Chain NFT",
"image": "https://ipfs.io/ipfs/Qme68BnvU3Y3fYymZRXgqjYsVLe7MogeM11mXYzsD8gAmo/qn_pixel.png",
"attributes": [
{
"trait_type": "Year",
"value": "2023"
},
{
"trait_type": "Quality",
"value": "98"
},
{
"trait_type": "Type",
"value": "Pixel"
},
]
}
Our NFT's metadata is stored on IPFS. We will use this URL in the following sections.
Faucet
In this guide, we will use Polygon Amoy Testnet and Avalanche Fuji Testnet. Thus, you need to get some POL (on Amoy) and AVAX (on Fuji) test tokens.
To get your test tokens,
- Go to the Quicknode's Faucet
- Select chain and network
- Type your wallet address
- Click Continue and follow the instructions
Configuration
Hardhat is a development environment used to compile, deploy, and test smart contracts for EVM-based blockchains. We will use Hardhat to compile, deploy, and test smart contracts for this guide.
Open your terminal in any directory you want and run the following code.
This code creates a folder named ccip-nft-project, initializes a project, installs Hardhat, and then runs Hardhat, respectively.
mkdir ccip-nft-project && cd ccip-nft-project
npm init --yes
npm install --save-dev hardhat
npx hardhat init
Select Create a TypeScript project and follow the instructions.
👷 Welcome to Hardhat v2.17.4 👷
✔ What do you want to do? · Create a TypeScript project
✔ Hardhat project root: ·
✔ Do you want to add a .gitignore? (Y/n) · y
✔ Do you want to install this sample project's dependencies with npm (@nomicfoundation/hardhat-toolbox)? (Y/n) · y
Then, install the following packages.
npm install --save-dev @chainlink/contracts@^0.6.1 @chainlink/contracts-ccip@^0.7.3 @chainlink/env-enc@^1.0.5 @nomicfoundation/hardhat-toolbox@^2.0.1 ethers@^5.7.2
Polygon Amoy and Avalanche Fuji Endpoints
To deploy smart contracts on Polygon Amoy and Avalanche Fuji blockchains, you'll need API endpoints to communicate with the network. You're welcome to use public nodes or deploy and manage your own infrastructure; however, if you'd like faster response times, you can leave the heavy lifting to us. Sign up for an account here.
Once logged in, click the Create an endpoint button, then select the Polygon chain and Amoy network.
After creating your endpoint, copy the HTTP Provider link and keep it handy, as you'll need it next.
If your account supports more than one endpoint, repeat the same process for Avalanche Fuji. If not, you can use the following public endpoint.
https://api.avax-test.network/ext/bc/C/rpc

Environment Variables
First, create environment variables to determine variables such as private key and RPC endpoint URLs for Polygon Amoy and Avalanche Fuji, which are required to interact with blockchains.
If you do not know how to get your private key, click here.
To get your private key;
- Click MetaMask icon, a stylized fox head, on your browser. If it is not seen, check the Extensions page of your browser.
- Click ⋮ symbol and then Account details.
- Then, click Show private key and follow instructions.

For further protection, we'll utilize the @chainlink/env-enc package. By establishing a new .env.enc file, it encrypts sensitive data instead of keeping it as plain text in the .env file.
1. Set a password for encrypting and decrypting the environment variable file.
npx env-enc set-pw
2. Set the following environment variables: PRIVATE_KEY, POLYGON_AMOY_RPC_URL, and AVALANCHE_FUJI_RPC_URL. Run the command below and follow the instructions.
npx env-enc set
Your console output should be like the one below while setting the environment variables.
> npx env-enc set
Please enter the variable name (or press ENTER to finish):
PRIVATE_KEY
Please enter the variable value (input will be hidden):
****************************************************************
Would you like to set another variable? Please enter the variable name (or press ENTER to finish):
POLYGON_AMOY_RPC_URL
Please enter the variable value (input will be hidden):
****************************************************************************************************
Would you like to set another variable? Please enter the variable name (or press ENTER to finish):
AVALANCHE_FUJI_RPC_URL
Please enter the variable value (input will be hidden):
******************************************
- The
.env.encfile will be automatically generated.
Hardhat Configuration
In this section, we modify the configuration file for the Hardhat project. It sets up the project's configuration, including the Solidity version network settings for the Hardhat network, Polygon Amoy, and Avalanche Fuji networks. It loads environment variables using dotenvenc for sensitive information like private keys and RPC URLs.
Open hardhat.config.ts with your code editor, and modify as below.
import * as dotenvenc from '@chainlink/env-enc'
dotenvenc.config();
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
// import './tasks'
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const POLYGON_AMOY_RPC_URL = process.env.POLYGON_AMOY_RPC_URL;
const AVALANCHE_FUJI_RPC_URL = process.env.AVALANCHE_FUJI_RPC_URL;
const config: HardhatUserConfig = {
solidity: "0.8.19",
networks: {
hardhat: {
chainId: 31337,
},
polygonAmoy: {
url: POLYGON_AMOY_RPC_URL !== undefined ? POLYGON_AMOY_RPC_URL : "",
accounts: PRIVATE_KEY !== undefined ? [PRIVATE_KEY] : [],
chainId: 80002,
},
avalancheFuji: {
url: AVALANCHE_FUJI_RPC_URL !== undefined ? AVALANCHE_FUJI_RPC_URL : "",
accounts: PRIVATE_KEY !== undefined ? [PRIVATE_KEY] : [],
chainId: 43113,
allowUnlimitedContractSize: true,
},
},
};
export default config;
Smart Contracts
In this tutorial, we will send cross-chain minting transaction on Avalanche Fuji Testnet in order to mint the NFT on Polygon Amoy Testnet. So, our destination blockchain will be Polygon Amoy, while Avalanche Fuji is the source destination.
For the cross-chain NFT minting process, we need the following smart contracts.
- MyNFT.sol: This is the NFT contract representing the ERC-721 token. It will be deployed on the destination blockchain.
- DestionationMinter.sol: This contract's primary purpose is to receive and execute cross-chain minting requests for NFTs. It will be deployed on the destination blockchain.
- SourceMinter.sol: This contract allows users to initiate the minting process on a destination chain by sending a CCIP message, and it handles the fees either in the native tokens or Chainlink's LINK token. It will be deployed on the source blockchain.
- Withdraw.sol: This contract provides functions for withdrawing Ether and ERC-20 tokens from
SourceMinter.sol.
Each contract is provided as an example and should not be used in a production environment due to the use of hardcoded values and unaudited code. You can see the repository for cross-chain mint here.
Create the folder structure and files for smart contracts by running the code below.
mkdir contracts/utils
echo > contracts/DestinationMinter.sol
echo > contracts/MyNFT.sol
echo > contracts/SourceMinter.sol
echo > contracts/utils/Withdraw.sol
You can delete the Lock.sol smart contract, which is an automatically created sample smart contract.
MyNFT.sol
The MyNFT.sol contract inherits ERC721URIStorage for managing NFT metadata and Ownable for ownership control. It allows the contract owner to mint NFTs to specified addresses, each associated with a unique token URI.
Open the MyNFT.sol file and modify it as below.
As you may realize, the IPFS URL that defines NFT metadata is used in defining TOKEN_URI. If you would like to use your own NFT metadata, change it accordingly. For more information on how to upload files to IPFS using Quicknode, check out our guide, here.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.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.
*/
contract MyNFT is ERC721URIStorage, Ownable {
string constant TOKEN_URI =
"https://ipfs.io/ipfs/QmTKNJyrRpzbnB4XUyKKRDxtTaLhvc5sXbcdWz5tSg9sEM/qn_ccip_nft.json";
uint256 internal tokenId;
constructor() ERC721("MyNFT", "MNFT") {}
function mint(address to) public onlyOwner {
_safeMint(to, tokenId);
_setTokenURI(tokenId, TOKEN_URI);
unchecked {
tokenId++;
}
}
}
DestinationMinter.sol
The primary purpose of the DestinationMinter.sol contract to receive and execute cross-chain minting requests for NFTs. It initializes with a reference to the MyNFT contract and listens for incoming CCIP messages. When a message is received, it attempts to call the corresponding function on the MyNFT contract, emitting a MintCallSuccessfull event upon success.
Open the DestinationMinter.sol file with your code editor, and modify it like the below.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
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";
import {MyNFT} from "./MyNFT.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.
*/
contract DestinationMinter is CCIPReceiver {
MyNFT nft;
event MintCallSuccessfull();
constructor(address router, address nftAddress) CCIPReceiver(router) {
nft = MyNFT(nftAddress);
}
function _ccipReceive(
Client.Any2EVMMessage memory message
) internal override {
(bool success, ) = address(nft).call(message.data);
require(success);
emit MintCallSuccessfull();
}
}
Withdraw.sol
The Withdraw.sol contract includes functions for the contract owner to withdraw Ether and any ERC-20 tokens held by the contract.
Open Withdraw.sol in the contracts/utils folder and modify as below.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
import {OwnerIsCreator} from "@chainlink/contracts-ccip/src/v0.8/shared/access/OwnerIsCreator.sol";
import {IERC20} from "@chainlink/contracts-ccip/src/v0.8/vendor/openzeppelin-solidity/v4.8.0/token/ERC20/IERC20.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.
*/
contract Withdraw is OwnerIsCreator {
error FailedToWithdrawEth(address owner, address target, uint256 value);
function withdraw(address beneficiary) public onlyOwner {
uint256 amount = address(this).balance;
(bool sent, ) = beneficiary.call{value: amount}("");
if (!sent) revert FailedToWithdrawEth(msg.sender, beneficiary, amount);
}
function withdrawToken(
address beneficiary,
address token
) public onlyOwner {
uint256 amount = IERC20(token).balanceOf(address(this));
IERC20(token).transfer(beneficiary, amount);
}
}
SourceMinter.sol
The SourceMinter.sol contract is designed for cross-chain NFT minting and interacts with Chainlink's Cross-Chain Interoperability Protocol (CCIP). The contract allows users to initiate the minting process on a destination chain by sending a CCIP message, and it handles the fees either in the native token or Chainlink's LINK token.
Open SourceMinter.sol, and modify it just like the one below.
// SPDX-License-Identifier: MIT
pragma solidity 0.8.19;
import {LinkTokenInterface} from "@chainlink/contracts/src/v0.8/interfaces/LinkTokenInterface.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 {Withdraw} from "./utils/Withdraw.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.
*/
contract SourceMinter is Withdraw {
enum PayFeesIn {
Native,
LINK
}
address immutable i_router;
address immutable i_link;
event MessageSent(bytes32 messageId);
constructor(address router, address link) {
i_router = router;
i_link = link;
LinkTokenInterface(i_link).approve(i_router, type(uint256).max);
}
receive() external payable {}
function mint(
uint64 destinationChainSelector,
address receiver,
PayFeesIn payFeesIn
) external {
Client.EVM2AnyMessage memory message = Client.EVM2AnyMessage({
receiver: abi.encode(receiver),
data: abi.encodeWithSignature("mint(address)", msg.sender),
tokenAmounts: new Client.EVMTokenAmount[](0),
extraArgs: "",
feeToken: payFeesIn == PayFeesIn.LINK ? i_link : address(0)
});
uint256 fee = IRouterClient(i_router).getFee(
destinationChainSelector,
message
);
bytes32 messageId;
if (payFeesIn == PayFeesIn.LINK) {
// LinkTokenInterface(i_link).approve(i_router, fee);
messageId = IRouterClient(i_router).ccipSend(
destinationChainSelector,
message
);
} else {
messageId = IRouterClient(i_router).ccipSend{value: fee}(
destinationChainSelector,
message
);
}
emit MessageSent(messageId);
}
}
Before moving to the other sections, run the following command to create Typechain typings for smart contracts.
Typechain is a development tool used to generate TypeScript typings for Ethereum smart contracts, making it easier for developers to work with these contracts by providing auto-generated TypeScript interfaces and type checking.
npx hardhat typechain
The output should be similar to the one below.
Compiled 26 Solidity files successfully
Utils
Now, we will create a Spinner, a simple text-based spinning animation in the console. It starts and stops the animation, giving visual feedback to the user during some asynchronous tasks. Although it does not affect the minting process, it is helpful for developer experience.
Create a utils folder and spinner.ts file in it by running the code.
mkdir utils
echo > utils/spinner.ts
spinner.ts
Open spinner.ts, and modify it just like the one below.
export class Spinner {
private line = { interval: 130, frames: ['-', '\\', '|', '/'] }
private spin: any;
start() {
const start = 0;
const end = this.line.frames.length;
let i = start;
process.stdout.write('\x1B[?25l');
this.spin = setInterval(() => {
process.stdout.cursorTo(0);
process.stdout.write(this.line.frames[i]);
i == end - 1 ? i = start : i++;
}, this.line.interval);
}
stop() {
process.stdout.clearLine(0);
process.stdout.cursorTo(0);
clearInterval(this.spin);
process.stdout.write('\x1B[?25h');
}
}
Now, it is time to create Hardhat tasks, which are helpful for deploying smart contracts and minting NFT.
Tasks
A task is an async function with certain information attached to it. Hardhat uses this information to automate some tasks for you. Parsing arguments, validity, and help messages are all managed. Hardhat includes some tasks as predefined, such as compile, run, and test. You can run npx hardhat to see all available tasks.
We will create some additional tasks and helper files to make the cross-chain NFT minting process easier.
mkdir tasks
echo > tasks/constants.ts
echo > tasks/utils.ts
echo > tasks/helpers.ts
echo > tasks/balance-of.ts
echo > tasks/cross-chain-mint.ts
echo > tasks/deploy-destination-minter.ts
echo > tasks/deploy-source-minter.ts
echo > tasks/fill-sender.ts
echo > tasks/withdraw.ts
echo > tasks/index.ts