Building a Cross-Chain NFT Bridge on Rootstock with LayerZero

Introduction

Blockchain technology is evolving rapidly, and interoperability is becoming a key requirement. Rootstock (RSK) is a Bitcoin sidechain that enables smart contracts, while LayerZero is a cross-chain messaging protocol designed to connect different blockchains. This article will guide you through building a Cross-Chain NFT Bridge on Rootstock, which will allow NFTs to move between Rootstock and other EVM-compatible chains.

What is a Cross-Chain NFT Bridge?

A Cross-Chain NFT Bridge allows NFTs to be transferred from one blockchain to another while maintaining ownership and uniqueness. This is done by locking the original NFT on the source chain and minting a wrapped version on the destination chain.

How Does It Work?

  1. Minting the NFT: The NFT is first created on Rootstock.

  2. Locking the NFT: The NFT is locked in the Rootstock NFT Bridge smart contract.

  3. Cross-Chain Messaging: A message is sent to the destination blockchain via LayerZero (once support is available).

  4. Minting Wrapped NFT: A wrapped version of the NFT is created on the destination chain.

  5. Redeeming NFT: When transferring back, the wrapped NFT is burned, and the original NFT is unlocked on Rootstock.

Smart Contracts Used

  • MyNFT: Mints NFTs on Rootstock.

  • LayerZeroNFTBridge: Handles cross-chain NFT transfers on both Rootstock and Sepolia, integrated with LayerZero.

  • WrappedNFT: Mints and burns wrapped NFTs on Sepolia.

Prerequisites

  • Node.js and npm: Install from https://nodejs.org/.

  • Hardhat: For contract development and deployment.

  • Rootstock Testnet Funds: Obtain RBTC from https://faucet.testnet.rsk.co/.

  • Sepolia Testnet Funds: Obtain ETH from https://sepoliafaucet.com/.

  • Wallet: A private key for deploying and interacting with contracts.

Step 1: Initialize the Project

Run these commands in your terminal to set up the project:

# Create project folder and initialize npm
mkdir cross-chain-nft-bridge && cd cross-chain-nft-bridge
npm init -y

# Install dependencies
npm install --save-dev hardhat ethers dotenv @openzeppelin/contracts

# Initialize Hardhat (choose "Create a JavaScript project")
npx hardhat init

Step 2: Configure Environment Variables

Create a .env file in the root directory:

touch .env

Add your keys (replace placeholders):

# Rootstock Testnet
RSK_TESTNET_RPC_URL=https://rpc.testnet.rootstock.io/<your-api-key>
WALLET_PRIVATE_KEY=your_private_key_here
RSK_TESTNET_API_KEY=your_blockscout_api_key

# Ethereum Sepolia Testnet
SEPOLIA_RPC=https://1rpc.io/sepolia

# Contract Addresses (update after deployment)
MY_NFT_ADDRESS=0x...
ROOTSTOCK_BRIDGE_ADDRESS=0x...
WRAPPED_NFT_ADDRESS=0x...

Replace placeholders with your private key and API keys. Obtain a Rootstock RPC API key from a provider or use https://public-node.testnet.rsk.co for testing (rate-limited).

Step 3: Update hardhat.config.js

Add support for Rootstock and Ethereum:

require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();

const SEPOLIA_RPC = process.env.SEPOLIA_RPC;
const RSK_TESTNET_RPC_URL = process.env.RSK_TESTNET_RPC_URL;
const WALLET_PRIVATE_KEY = process.env.WALLET_PRIVATE_KEY;
const RSK_TESTNET_API_KEY = process.env.RSK_TESTNET_API_KEY;

if (!RSK_TESTNET_RPC_URL) {
  throw new Error("The RPC URL for the testnet is not configured.");
}

if (!WALLET_PRIVATE_KEY) {
  throw new Error("Private key is not configured.");
}

module.exports = {
  solidity: "0.8.20",
  networks: {
    rootstock: {
      url: RSK_TESTNET_RPC_URL,
      chainId: 31,
      gasPrice: 90000000,
      accounts: [WALLET_PRIVATE_KEY],
    },
    sepolia: {
      url: SEPOLIA_RPC,
      accounts: [WALLET_PRIVATE_KEY],
    },
  },
  etherscan: {
    apiKey: {
      rskTestnet: RSK_TESTNET_API_KEY,
    },
    customChains: [
      {
        network: "rskTestnet",
        chainId: 31,
        urls: {
          apiURL: "https://rootstock-testnet.blockscout.com/api/",
          browserURL: "https://rootstock-testnet.blockscout.com/",
        },
      },
    ],
  },
};

Step 4: Implementing the Smart Contracts

Create the following contracts in the contracts/ directory.

Contract 1: MyNFT.sol

Mints NFTs on Rootstock.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract MyNFT is ERC721URIStorage, Ownable {
    uint256 private _nextTokenId = 1;

    constructor() ERC721("MyNFT", "MNFT") Ownable(msg.sender) {}

    function safeMint(address to, string memory uri) public onlyOwner {
        uint256 tokenId = _nextTokenId++;
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, uri);
    }
}

Key Features:

  • Mints NFTs with auto-incrementing IDs.

  • Stores metadata URIs (e.g., IPFS links).

  • Restricted to the owner for minting.

Contract 2: LayerZeroNFTBridge.sol

Handles cross-chain NFT transfers on Rootstock and Sepolia.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@layerzerolabs/lz-evm-oapp-v2/contracts/oapp/OApp.sol";
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "hardhat/console.sol";

contract LayerZeroNFTBridge is OApp {
    constructor(
        address _nftAddress,
        address _layerZeroEndpoint,
        address _owner
    ) OApp(_layerZeroEndpoint, _owner) Ownable(_owner) {
        require(_nftAddress != address(0), "Invalid NFT address");
        require(_layerZeroEndpoint != address(0), "Invalid endpoint");
        require(_owner != address(0), "Invalid owner");
        nft = ERC721(_nftAddress);
        console.log("NFT assigned successfully");
    }

    ERC721 public immutable nft;

    function sendNFT(
        uint16 _dstChainId,
        uint256 _tokenId,
        bytes calldata _options
    ) external payable {
        nft.transferFrom(msg.sender, address(this), _tokenId);
        bytes memory payload = abi.encode(msg.sender, _tokenId);
        _lzSend(_dstChainId, payload, _options, MessagingFee(msg.value, 0), msg.sender);
    }

    function _lzReceive(
        Origin calldata,
        bytes32,
        bytes calldata _payload,
        address,
        bytes calldata
    ) internal override {
        (address to, uint256 tokenId) = abi.decode(_payload, (address, uint256));
        nft.transferFrom(address(this), to, tokenId);
    }
}

Key Features:

  • sendNFT: Locks the NFT and sends a cross-chain message via LayerZero.

  • _lzReceive: Receives the message and transfers the NFT to the recipient.

  • Integrates with LayerZero’s endpoint (e.g., 0x6C7Ab2202C98C4227C5c46f1417D81144DA716Ff for Rootstock).

Contract 3: WrappedNFT.sol

Mints and burns wrapped NFTs on Sepolia.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract WrappedNFT is ERC721URIStorage, Ownable {
    uint256 public _nextTokenId = 1;

    event NFTMinted(address indexed owner, uint256 tokenId, string tokenURI);
    event NFTBurned(uint256 tokenId);

    constructor() ERC721("WrappedNFT", "WNFT") Ownable(msg.sender) {}

    function mintNFT(address recipient, string memory tokenURI) external onlyOwner {
        require(recipient != address(0), "Invalid recipient address");
        uint256 tokenId = _nextTokenId++;
        _safeMint(recipient, tokenId);
        _setTokenURI(tokenId, tokenURI);
        emit NFTMinted(recipient, tokenId, tokenURI);
    }

    function burnNFT(uint256 tokenId) external onlyOwner {
        _burn(tokenId);
        emit NFTBurned(tokenId);
    }

    function getNextTokenId() external view returns (uint256) {
        return _nextTokenId;
    }
}

Step 5: Deploying the Contracts

1. Create Deployment Scripts

In scripts/deploy.js:

const hre = require("hardhat");

async function main() {
  const [deployer] = await hre.ethers.getSigners();
  console.log("Deploying contracts with the account:", deployer.address);

  // Deploy MyNFT on Rootstock
  const MyNFT = await hre.ethers.getContractFactory("MyNFT");
  const myNFT = await MyNFT.deploy();
  await myNFT.waitForDeployment();
  const myNFTAddress = await myNFT.getAddress();
  console.log("MyNFT deployed to:", myNFTAddress);

  // Deploy LayerZeroNFTBridge on Rootstock
  const rootstockEndpoint = "0x6C7Ab2202C98C4227C5c46f1417D81144DA716Ff";
  const RootstockBridge = await hre.ethers.getContractFactory("LayerZeroNFTBridge");
  const rootstockBridge = await RootstockBridge.deploy(myNFTAddress, rootstockEndpoint, deployer.address);
  await rootstockBridge.waitForDeployment();
  console.log("LayerZeroNFTBridge (Rootstock) deployed to:", await rootstockBridge.getAddress());

  // Deploy WrappedNFT on Sepolia
  const WrappedNFT = await hre.ethers.getContractFactory("WrappedNFT");
  const wrappedNFT = await WrappedNFT.deploy();
  await wrappedNFT.waitForDeployment();
  const wrappedNFTAddress = await wrappedNFT.getAddress();
  console.log("WrappedNFT deployed to:", wrappedNFTAddress);

  // Deploy LayerZeroNFTBridge on Sepolia
  const sepoliaEndpoint = "0x6edce65403992e310A62460808c4b910B2B5B28F"; // Sepolia Endpoint
  const SepoliaBridge = await hre.ethers.getContractFactory("LayerZeroNFTBridge");
  const sepoliaBridge = await SepoliaBridge.deploy(wrappedNFTAddress, sepoliaEndpoint, deployer.address);
  await sepoliaBridge.waitForDeployment();
  console.log("LayerZeroNFTBridge (Sepolia) deployed to:", await sepoliaBridge.getAddress());
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Run the deployment:

Execute the script on Rootstock Testnet:

# Deploy on Rootstock
npx hardhat run scripts/deploy.js --network rootstock

# Deploy on Sepolia
npx hardhat run scripts/deploy.js --network sepolia

Expected Output:

Deploying contracts with the account: 0x575109e921C6d6a1Cb7cA60Be0191B10950AfA6C
MyNFT deployed to: 0xE51FADfC6BA752F11688834551776e4dA9270DB0
LayerZeroNFTBridge (Rootstock) deployed to: 0x274380BD54e53bc6b72810f12488e030cBd806c7
WrappedNFT deployed to: 0xc1b026cD8c9598A57BC7EE980C3784Cc04b74a10
LayerZeroNFTBridge (Sepolia) deployed to: 0x34973FC41E518b52d89a5aE736fD8fd6b22f7325

Step 6: Interacting with Contracts

After deployment, update your .env file with the new contract addresses:

MY_NFT_ADDRESS=0xE51FADfC6BA752F11688834551776e4dA9270DB0
ROOTSTOCK_BRIDGE_ADDRESS=0x274380BD54e53bc6b72810f12488e030cBd806c7
WRAPPED_NFT_ADDRESS=0xc1b026cD8c9598A57BC7EE980C3784Cc04b74a10
SEPOLIA_BRIDGE_ADDRESS=0x34973FC41E518b52d89a5aE736fD8fd6b22f7325

1. mintNFT.js

Mints a test NFT on Rootstock (prerequisite for bridging):

const { ethers } = require("hardhat");
require("dotenv").config();

async function main() {
  const provider = new ethers.JsonRpcProvider(process.env.RSK_TESTNET_RPC_URL);
  const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY, provider);

  const nftContract = new ethers.Contract(
    process.env.MY_NFT_ADDRESS,
    ["function safeMint(address to, string memory uri) public"],
    wallet
  );

  const tx = await nftContract.safeMint(
    wallet.address,
    "ipfs://QmXJ9.../metadata.json" // Replace with your NFT metadata URI
  );
  await tx.wait();
  console.log(`🎉 Minted NFT! TX Hash: ${tx.hash}`);
}

main().catch(console.error);

Mint a Test NFT:

npx hardhat run scripts/mintNFT.js --network rootstock

Expected Output:

🎉 Minted NFT! TX Hash: 0x...

2. lockNFT.js

Locks an NFT in the Rootstock bridge:

const { ethers } = require("hardhat");
require("dotenv").config();

async function main() {
  const provider = new ethers.JsonRpcProvider(process.env.RSK_TESTNET_RPC_URL);
  const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider);

  const bridge = new ethers.Contract(
    process.env.ROOTSTOCK_BRIDGE_ADDRESS,
    ["function lockNFT(address nftContract, uint256 tokenId)"],
    wallet
  );

  // Replace with your NFT contract and token ID
  const tx = await bridge.lockNFT(
    "YOUR_NFT_CONTRACT_ON_ROOTSTOCK", 
    1 // Token ID to lock
  );
  await tx.wait();
  console.log(`🔒 Locked NFT! TX Hash: ${tx.hash}`);
}

main().catch(console.error);

Lock the NFT:

npx hardhat run scripts/lockNFT.js --network rootstock

Expected Output:

Approved bridge for NFT
NFT ready for cross-chain transfer

3. bridgeNFT.js

Sends the NFT from Rootstock to Sepolia using LayerZero:

const { ethers } = require("hardhat");
require("dotenv").config();

async function delay(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function bridgeNFT() {
  const rootstockProvider = new ethers.providers.JsonRpcProvider(process.env.RSK_TESTNET_RPC_URL);
  const sepoliaProvider = new ethers.providers.JsonRpcProvider(process.env.SEPOLIA_RPC);

  const wallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY, rootstockProvider);
  const sepoliaWallet = new ethers.Wallet(process.env.WALLET_PRIVATE_KEY, sepoliaProvider);

  console.log("Deployer address (Rootstock):", wallet.address);

  const ERC721_ABI = [
    "function ownerOf(uint256 tokenId) public view returns (address)",
    "function approve(address to, uint256 tokenId) public",
    "function getApproved(uint256 tokenId) public view returns (address)"
  ];

  const nftRootstock = new ethers.Contract(
    process.env.MY_NFT_ADDRESS,
    ERC721_ABI,
    wallet
  );

  const nftSepolia = new ethers.Contract(
    process.env.WRAPPED_NFT_ADDRESS,
    ERC721_ABI,
    sepoliaWallet
  );

  const bridgeRootstock = new ethers.Contract(
    process.env.ROOTSTOCK_BRIDGE_ADDRESS,
    ["function sendNFT(uint16 dstChainId, uint256 tokenId, bytes options) external payable"],
    wallet
  );

  const bridgeSepolia = new ethers.Contract(
    process.env.SEPOLIA_BRIDGE_ADDRESS,
    ["function nft() public view returns (address)"],
    sepoliaWallet
  );

  const tokenId = 2;

  // Check if tokenId exists
  console.log("Checking if tokenId=2 exists on Rootstock");
  try {
    const owner = await nftRootstock.ownerOf(tokenId);
    console.log("Owner of tokenId=2 on Rootstock:", owner);
  } catch (error) {
    console.error("tokenId=2 not minted:", error.message);
    return;
  }

  // Check approval
  console.log("Checking approval for tokenId=2");
  try {
    const approved = await nftRootstock.getApproved(tokenId);
    if (approved.toLowerCase() !== process.env.ROOTSTOCK_BRIDGE_ADDRESS.toLowerCase()) {
      console.log("Approving Rootstock bridge for tokenId=2");
      const approveTx = await nftRootstock.approve(process.env.ROOTSTOCK_BRIDGE_ADDRESS, tokenId, { gasLimit: 200000 });
      await approveTx.wait();
      console.log("Approved Rootstock bridge");
    } else {
      console.log("Rootstock bridge already approved for tokenId=2");
    }
  } catch (error) {
    console.error("Approval failed:", error.message);
    return;
  }

  // Send NFT to Sepolia
  console.log("Sending tokenId=2 to Sepolia");
  let attempts = 0;
  const maxAttempts = 3;
  while (attempts < maxAttempts) {
    try {
      const dstChainId = 10161; // LayerZero Sepolia EID
      const options = "0x";
      const value = ethers.utils.parseEther("0.01");

      const sendTx = await bridgeRootstock.sendNFT(dstChainId, tokenId, options, {
        value,
        gasLimit: 1000000
      });

      const receipt = await sendTx.wait();
      console.log("sendNFT Events:", receipt.events);
      break;
    } catch (error) {
      console.error(`sendNFT failed (Attempt ${attempts + 1}):`, error.message);
      attempts++;
      if (attempts < maxAttempts) {
        console.log("Retrying after 10 seconds...");
        await delay(10000);
      } else {
        console.error("Max retry attempts reached.");
        return;
      }
    }
  }

  // Simulate transfer on Sepolia
  console.log("Simulating transfer on Sepolia");
  try {
    const nftAddress = await bridgeSepolia.nft();
    const nft = new ethers.Contract(
      nftAddress,
      [
        "function mintNFT(address recipient, string memory tokenURI) external",
        "function ownerOf(uint256 tokenId) public view returns (address)"
      ],
      sepoliaWallet
    );

    console.log("Minting tokenId=2 to bridge on Sepolia");
    const mintTx = await nft.mintNFT(sepoliaWallet.address, "ipfs://QmXJ9.../metadata.json", { gasLimit: 500000 });
    await mintTx.wait();
    console.log("Minted wrapped NFT on Sepolia");

    console.log("Checking ownership on Sepolia");
    const owner = await nft.ownerOf(tokenId);
    console.log("Owner of tokenId=2 on Sepolia:", owner);
  } catch (error) {
    console.error("Sepolia transfer failed:", error.message);
  }
}

bridgeNFT().catch((error) => {
  console.error(error);
  process.exitCode = 1);
});

Run:

npx hardhat run scripts/bridgeNFT.js --network rootstock

Expected Output:

Deployer address (Rootstock): 0x575109e921C6d6a1Cb7cA60Be0191B10950AfA6C
Checking if tokenId=2 exists on Rootstock
Owner of tokenId=2 on Rootstock: 0x575109e921C6d6a1Cb7cA60Be0191B10950AfA6C
Checking approval for tokenId=2
Rootstock bridge already approved for tokenId=2
Sending tokenId=2 to Sepolia
sendNFT Events: [...]
Simulating transfer on Sepolia
Minted wrapped NFT on Sepolia
Checking ownership on Sepolia
Owner of tokenId=2 on Sepolia: 0x575109e921C6d6a1Cb7cA60Be0191B10950AfA6C

4. checkBalance.js

Verifies NFT ownership on Sepolia:

const { ethers } = require("hardhat");
require("dotenv").config();

async function main() {
  const provider = new ethers.JsonRpcProvider(process.env.RSK_TESTNET_RPC_URL);
  const wallet = new ethers.Wallet(process.env.PRIVATE_KEY, provider);

  const nftContract = new ethers.Contract(
    process.env.WRAPPED_NFT_ADDRESS,
    ["function balanceOf(address owner) view returns (uint256)"],
    provider
  );

  const balance = await nftContract.balanceOf(wallet.address);
  console.log(`💰 Wrapped NFTs owned: ${balance}`);
}

main().catch(console.error);

Check Balance:

npx hardhat run scripts/checkBalance.js --network rootstock

Expected Output:

💰 Wrapped NFTs owned on Sepolia: 1

Step 7: Integrating LayerZero Messaging

LayerZero’s V2 protocol enables cross-chain NFT transfers by sending messages through endpoint contracts. Rootstock Testnet (EID: 10130, Endpoint: 0x6C7Ab2202C98C4227C5c46f1417D81144DA716Ff) and Sepolia (EID: 10161, Endpoint: 0x6edce65403992e310A62460808c4b910B2B5B28F) are supported, as documented at: https://docs.layerzero.network/v2/developers/evm/technical-reference/testnet/testnet-addresses.

The LayerZeroNFTBridge contract facilitates this process:

  1. Approve and Lock: The sendNFT function requires the NFT to be approved for the bridge, which then locks it by transferring it to the contract.

  2. Send Message: sendNFT encodes the sender’s address and tokenId into a payload and calls _lzSend to transmit the message to the destination chain’s endpoint.

  3. Receive Message: The destination chain’s LayerZeroNFTBridge contract receives the message via _lzReceive, decodes the payload, and transfers the NFT (or mints a wrapped NFT) to the recipient.

  4. Reverse Flow: Burning a wrapped NFT on Sepolia triggers a message to unlock the original NFT on Rootstock.

The bridgeNFT.js script automates this workflow, handling approval, cross-chain messaging, and wrapped NFT minting. You can track transfers on LayerZero Scan: https://testnet.layerzeroscan.com/.

Troubleshooting

  • Insufficient Funds: Ensure your Rootstock account has at least 0.02 RBTC (0.01 RBTC for sendNFT + gas) and Sepolia account has 0.01 ETH.

  • RPC Errors: If 403 Forbidden occurs, use a private RPC URL in .env (e.g., https://rpc.testnet.rootstock.io/<your-api-key>).

  • Transaction Reverts: Verify contract addresses, token IDs, and endpoint settings. Check Rootstock Explorer (https://explorer.testnet.rsk.co/) or Sepolia Etherscan (https://sepolia.etherscan.io/).

  • Endpoint Issues: Confirm the LayerZero endpoints match the documentation. For Rootstock, use 0x6C7Ab2202C98C4227C5c46f1417D81144DA716Ff.

Conclusion

This tutorial demonstrated how to build a Cross-Chain NFT Bridge on Rootstock using LayerZero, enabling NFT transfers to Sepolia. With LayerZero’s robust support for Rootstock, developers can create interoperable applications that leverage Rootstock’s Bitcoin security and Ethereum’s ecosystem. Extend this project by adding support for more chains or enhancing metadata handling.

For more details, visit:

Happy bridging!

0
Subscribe to my newsletter

Read articles from Segun Stephen Joseph directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Segun Stephen Joseph
Segun Stephen Joseph

I'm a versatile developer and builder with a passion for creating impactful, scalable solutions at the intersection of web3, AI, and full-stack development. I thrive on turning complex ideas into smooth, user-centered experiences — whether that means designing smart contract systems, building AI-driven analytics tools, or launching full-scale platforms from scratch. My work spans NFT infrastructure, zero-knowledge proofs, cross-chain bridges, asset tokenization, and social intelligence — always with a focus on pushing boundaries and solving real problems with code. I’m driven by curiosity, fueled by execution, and always open to collaborating on bold ideas that shape the future.