Building Cross-Chain NFT using Router Protocol's CrossTalk Library
gm gm gm!!!
Today we will write an NFT (ERC721) smart contract, allowing sending and receiving an NFT from different chains. To transfer our NFTs between chains, we will use Router Cross-Talk Library.
We will transfer an NFT from Avalanche Fuji Testnet to Polygon Mumbai Testnet.
If you are more of a video tutorial fan, then this is for you ππ»
Understanding the Fundamentals
Before we start, let's look at the basics, so we're all on the same page.
What is Chain-Interoperability?
Chain interoperability is the capability of different blockchain networks or systems to interact and exchange information and resources. In simpler terms, blockchain systems can work together as a unified network instead of operating in isolation.
For example, we could use one blockchain to store financial transactions and another blockchain to store information about a supply chain. If these two blockchains are interoperable, they can create a new application combining both benefits.
Chain interoperability is a complex aspect of blockchain technology, requiring a deep understanding of cryptography, consensus algorithms, and network protocols.
Implementing interoperability protocols like the Router Protocol and Cosmos Network provides a common language and infrastructure for transferring information and assets between networks, creating a more interoperable and connected blockchain ecosystem.
The continued advancement of interoperability standards and protocols will play a vital role in shaping the future of blockchain technology and its impact on various industries and society as a whole.
What is the CrossTalk Library?
Router's CrossTalk library is an extensible cross-chain framework that enables seamless state transitions across multiple chains. In simple terms, this library leverages Router's infrastructure to allow contracts on one chain to communicate with contracts deployed on some other chain.
It consists of 3 essential functions, which help build a cross-chain application together.
requestToDest
sends cross-chain requests with a message from the source contract to the gateway contract on the source chain. The message will then be forwarded to the Gateway Contract on the destination chain.handleRequestFromSource
is to send the request (with some message) from the source contract (on the source chain) to the destination contract (on the destination chain), where it executes all the functions defined in it and then will return the data(if any).handleCrossTalkAck
is to receive the acknowledgment on the source chain from the gateway contract on the destination chain, which is in the boolean value stating whether the functions on the destination chain got executed.
Learn more about Cross Talk Library from here.
The following figure shows how we will implement CrossTalk Librabry in our contract.
Implementing the Contract π¨π»βπ»
Now that we have learned the fundamentals let's start developing our contract.
Setting up the Environment
Before we can start programming, we need to set up our development environment.
Creating a Project
Create a project folder and initialize NPM.
$ mkdir crossERC721 && cd crossERC721
$ npm init -y
Installing Dependencies
Install dependencies and initialize a Hardhat project.
Drink some water while all the dependencies to install!
$ npm install --save-dev hardhat ts-node typescript chai @types/node @types/mocha @types/chai dotenv
$ npx hardhat
Select "TypeScript Project" and then press "Enter" three times. The result should look like the following figure; it will start installing dependencies.
Configuring Hardhat
Open the folder with an IDE (I am using VSCode) to configure Hardhat.
File hardhat.config.ts
:
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config({ path: ".env" });
require("@nomiclabs/hardhat-waffle");
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const ALCHEMY_POLYGON_URL = process.env.ALCHEMY_POLYGON_URL;
const POLYGON_SCAN_KEY = process.env.POLYGON_SCAN_KEY;
const AVALANCHE_URL = process.env.AVALANCHE_URL;
const AVALANCHE_SNOWTRACE_KEY = process.env.AVALANCHE_SNOWTRACE_KEY;
module.exports = {
solidity: "0.8.18",
networks: {
mumbai: {
url: ALCHEMY_POLYGON_URL,
accounts: [PRIVATE_KEY],
},
fuji: {
url: AVALANCHE_URL,
accounts: [PRIVATE_KEY],
chainId: 43113,
},
},
etherscan: {
apiKey: {
polygonMumbai: POLYGON_SCAN_KEY,
avalancheFujiTestnet: AVALANCHE_SNOWTRACE_KEY,
},
},
};
Setting up the Environment Variables
To set up the env variables in development, add them into the .env
file.
File .env
:
ALCHEMY_POLYGON_URL= "abcabc"
PRIVATE_KEY=abcbcabc
POLYGON_SCAN_KEY= abcabcabc
AVALANCHE_SNOWTRACE_KEY = abcabcabc
AVALANCHE_URL= "https://api.avax-test.network/ext/bc/C/rpc"
The missing pieces and where to find them:
ALCHEMY_POLYGON_URL
- https://dashboard.alchemy.com/PRIVATE_KEY
- your development account in MetaMask.POLYGON_SCAN_KEY
- https://polygonscan.com/myapikeyAVALANCHE_SNOWTRACE_KEY
-https://snowtrace.io/myapikey
Congrats π€©π Your set up your environment for development.
Installing the Contract Dependencies
Install OpenZeppelin contracts and dependencies for cross-chain functionalities:
$ npm install @openzeppelin/contracts evm-gateway-contract @routerprotocol/router-crosstalk-utils
Finallyyy... done with DEPENDENCIESsss...
Writing the Contract Code πͺπ»
Create a new Solidity file at contracts/CrossERC721.sol
.
Implementing the Contract Definition
First, we need to set up our contract definition. The contract will use different dependencies to facilitate the cross-chain interaction.
File CrossERC721.sol
:
// SPDX-License-Identifier: Unlicensed
pragma solidity 0.8.18;
import "evm-gateway-contract/contracts/ICrossTalkApplication.sol"; import "evm-gateway-contract/contracts/Utils.sol"; import "@routerprotocol/router-crosstalk-utils/contracts/CrossTalkUtils.sol"; import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract CrossERC721 is ERC721, ICrossTalkApplication {}
Declaring the State Variables
File CrossERC721.sol
:
// ...
contract CrossERC721 is ERC721, ICrossTalkApplication {
// Address of the Owner of the contract.
address public admin;
// Address of the gateway contract on the chain will contract deployed.
address public gatewayContract;
// Gas limit required to handle cross-chain request on the destination chain
uint64 public destGasLimit;
// chain type + chain id => address of our contract in bytes
mapping(uint64 => mapping(string => bytes)) public ourContractOnChains;
// Transfer parameter which include tokenId and the address(in bytes) of the receiver on destination chain.
struct TransferParams {
uint256 nftId;
bytes recipient;
}
}
Implementing the Constructor
When we implement the constructor, we set the state variables in the constructor and mint an NFT (ERC721) to the msg.sender
.
File CrossERC721.sol
:
// ...
contract CrossERC721 is ERC721, ICrossTalkApplication {
// ...
/// @notice Constructor to initialize the contract.
/// @param gatewayAddress - Address of the gateway contract on the chain on which we will deploy the contract
/// @param _destGasLimit - Gas limit required to handle cross-chain request on the destination chain.
/// @param tokenId - Token Id of the NFT to be minted for testing.
constructor(
address payable gatewayAddress,
uint64 _destGasLimit,
uint256 tokenId
) ERC721("CrossERC721", "cerc721") {
gatewayContract = gatewayAddress;
destGasLimit = _destGasLimit;
admin = msg.sender;
_mint(msg.sender, tokenId);
}
}
Implementing the setContractOnChain Method
Implement setContractOnChain
to map all the contract addresses of the contract on different chains. The mapping is used to verify the contract with which we will interact.
File CrossERC721.sol
:
// ...
contract CrossERC721 is ERC721, ICrossTalkApplication {
// ...
/// @notice Function to map all the contract addresses on different chains.
/// @param chainType - Type of the chain specified by the Router Protocol on which we will deploy the contract
/// @param chainId - Chain Id of the chain on which the contract is deployed.
/// @param contractAddress - Address of the contract on the chain
function setContractOnChain(
uint64 chainType,
string memory chainId,
address contractAddress
) external {
require(msg.sender == admin, "only admin");
// CrossTalkUtils.toBytes() is a function which converts the address to bytes.
ourContractOnChains[chainType][chainId] = CrossTalkUtils.toBytes(
contractAddress
);
}
}
Implementing the transferCrossChain Method
Implement transferCrossChain
, the core function of our contract. It burns the NFT owned by msg.sender
on the source chain and then sends the request of transferring using CrossTalkUtils.singleRequestWithoutAcknowledgement
, which sends the request to the gateway contract on the destination chain.
File CrossERC721.sol
:
// ...
contract CrossERC721 is ERC721, ICrossTalkApplication {
// ...
/// @notice Function to transfer the NFT from the source chain to the destination chain.
/// @param chainType - Type of the chain specified by the Router Protocol on which the nft needs to transferred.
/// @param chainId - Chain Id of the destination chain.
/// @param expiryDurationInSeconds - Expiry duration in seconds of the request.
/// @param destGasPrice - Gas price required to handle the cross-chain request on the destination chain.
/// @param _nftId - Token Id of the NFT to be transferred.
/// @param _recepient - Address of the recipient on the destination chain.
function transferCrossChain(
uint64 chainType,
string memory chainId,
uint64 expiryDurationInSeconds,
uint64 destGasPrice,
uint256 _nftId,
address _recepient
) public payable {
require(
keccak256(ourContractOnChains[chainType][chainId]) !=
keccak256(CrossTalkUtils.toBytes(address(0))),
"ERR:CROSS_CHAIN_CONTRACT_NOT_SET"
);
TransferParams memory transferParams = TransferParams(
_nftId,
CrossTalkUtils.toBytes(_recepient)
);
require(_ownerOf(transferParams.nftId) == msg.sender, "ERR:NOT_OWNER");
// Burn the NFT of the user on the source chain.
_burn(transferParams.nftId);
// Encode the transfer parameters to bytes for sending it as payload to the gateway contract.
bytes memory payload = abi.encode(transferParams);
uint64 expiryTimestamp = uint64(block.timestamp) +
expiryDurationInSeconds;
Utils.DestinationChainParams memory destChainParams = Utils
.DestinationChainParams(
destGasLimit,
destGasPrice,
chainType,
chainId
);
// Call the singleRequestWithoutAcknowledgement() function
// to transfer the NFT from the source chain to the
// destination chain without any acknowledgment.
CrossTalkUtils.singleRequestWithoutAcknowledgement(
gatewayContract,
expiryTimestamp,
destChainParams,
ourContractOnChains[chainType][chainId],
payload
);
}
}
Implementing the handleRequestFromSource Method
Implement handleRequestFromSource
for the contract on the destination chain. Since we must deploy the contract on every chain we interact with, we must include the receiving function in the same contract. We mint the NFT for the receiver's address on the destination chain in this function.
File CrossERC721.sol
:
// ...
contract CrossERC721 is ERC721, ICrossTalkApplication {
// ...
/// @notice Function to handle the request from the gateway contract on the destination chain. It manages data received and calls the function(s).
/// @param srcContractAddress is the contract address on the source chain.
/// @param payload is the data received from the source chain in bytes.
/// @param srcChainId is the chain id of the source chain.
/// @param srcChainType is the chain type of the source contrac specified by the Router Protocol.
function handleRequestFromSource(
bytes memory srcContractAddress,
bytes memory payload,
string memory srcChainId,
uint64 srcChainType
) external override returns (bytes memory) {
require(msg.sender == gatewayContract, "ERR:NOT_GATEWAY_CONTRACT");
require(
keccak256(srcContractAddress) ==
keccak256(ourContractOnChains[srcChainType][srcChainId]),
"ERR:CONTRACT_NOT_FOUND"
);
TransferParams memory transferParams = abi.decode(
payload,
(TransferParams)
);
// Mint the NFT for the recipient address on the destination chain.
_mint(
CrossTalkUtils.toAddress(transferParams.recipient),
transferParams.nftId
);
// Since we don't want to return any data, we will just return empty string
return "";
}
}
Implementing the handleCrossTalkAck Method
Implement handleCrossTalkAck
, which handles the acknowledgment by the gateway contract after the transaction on the destination chain executes. Since we are using singleRequestWithoutAcknowledgement
type of request, we will leave this function empty.
File CrossERC721.sol
:
// ...
contract CrossERC721 is ERC721, ICrossTalkApplication {
// ...
/// @notice Function to handle the acknowledgement received by the gateway contract for the functions executed on the destination chain.
/// Since we are not expecting any acknowledgement, we will just keep this function empty.
/// @param eventIdentifier is the event identifier of the request.
/// @param execFlags is the array of boolean values which specifies whether the function executed successfully or not on destination chain.
/// @param execData is the array of bytes which contains the data returned by the function executed on the destination chain.
function handleCrossTalkAck(
uint64 eventIdentifier,
bool[] memory execFlags,
bytes[] memory execData
) external view override {}
}
CONGRATSSS, you wrote the cross-chain contract π
You can find the complete contract on GitHub.
Deploying the Contract
We will deploy our contract on Polygon Mumbai Testnet and Avalanche Fuji Testnet.
For BONUS!!! we will verify it on their respective block explorers, PolygonScan and Snowtrace
Creating a Deployment Script
Create a file at scripts/deploy.ts
with the following content.
File deploy.ts
:
import { ethers } from "hardhat";
async function main() {
const network = await hre.network;
const gatewayContract =
network.config.chainId == 43113
? "0x517f256cc48145c25c27cf453f6f5006e5266543"
: "0x8EA05371Eb360Eb79c295375CB2cCE9191EFdaD0";
const tokenId = network.config.chainId == 43113 ? 1 : 2;
const CrossERC721 = await ethers.getContractFactory("CrossERC721");
const crossERC721 = await CrossERC721.deploy(
gatewayContract,
1000000,
tokenId
);
await crossERC721.deployed();
console.log("CrossERC721 deployed to:", crossERC721.address);
console.log("Sleeping.....");
await sleep(40000);
await hre.run("verify:verify", {
address: crossERC721.address,
constructorArguments: [gatewayContract, 1000000, tokenId],
});
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
Executing the Deployment Script
Run the following commands in the terminal in the root directory:
$ npx hardhat run scripts/deploy.ts --network mumbai
SAVE the aboveπ address and URL
0xB905345D930707C992ec768Cf748AaBc0D3207Da
https://mumbai.polygonscan.com/address/0xB905345D930707C992ec768Cf748AaBc0D3207Da#code
$ npx hardhat run scripts/deploy.ts --network fuji
SAVE the aboveπ URL
0xC0438dF8A5008Af185B36F4f2C38be410C9ce95d
https://testnet.snowtrace.io/address/0xC0438dF8A5008Af185B36F4f2C38be410C9ce95d#code
You deployed and verified your contract on both chains. Awesome!!! ππ
Interacting with the Contract
To interact with our freshly deployed contracts, open the two URLs you saved in different tabs in your browser.
On the MumbaiScan tab
click "Write Contract".
Click on "Connect to Web3" and connect your MetaMmask wallet with it.
Scroll down to "setContractOnChain".
Enter the values. Here the
contractAddress
value is the address on the Fuji Network.Click "Write" and then "Confirm" the transaction.
On the SnowTrace tab
Click "Write Contract".
Click "Connect to Web3".
Scroll down to
setContractOnChain
Enter the values.
Click "Write" and "Confirm" the transaction.
> We have set the contracts on both the source and destination chains.
Scroll to the
transferCrossChain
Enter the values.
Click "Write" and "Confirm" the transaction.
Copy
recepient
and paste it into the destination chain (POLYGON MUMBAI) block explorer.
Hoorrayyy!!! ππ
You just transferred an NFT from Avalanche Fuji Testnet to Polygon Mumbai Testnet. You can do the same with NFTs on Polygon Mumbai Testnet.
PS: You can check out the whole project hereππ»
So, we have created an ERC721 with the implementation of Router Protocol's Cross Talk Library which helped us to transfer our NFT from Avalanche to Polygon.
You can do the same with ERC20 and others, and make your application more SCALABLE, SECURE, and DENCTRALISED.
Connect with me on LensπΏ**[@megabyte0x.lens]** or Twitter**[@megabyte0x].**
Also, feel free to share your learnings and reach out to me if you've any doubts or questions for me.
Happy building! π οΈ
WAGMIπ
Subscribe to my newsletter
Read articles from megabyte directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
megabyte
megabyte
A guy with curiosity about new tech and aims to help the generation save time.