The Developer's Guide to Building Cross-chain Dapps

Overview and step-by-step developer quickstart.

TLDR:

In the future of widespread consumer adoption, users will need the freedom to select their chain based on what they value: speed, security, fees, decentralization, UX, etc. So, instead of choosing the chain based on the ecosystem of applications, users will choose the chain to optimize for the things they value.

Although there has been a shift from "one chain to rule them all" to a multichain future, it is still unclear who will be part of this multichain future. Some opine that alt L1s will continue to be built and gain adoption; others think multichain is more like "multi-rollup." (Rollups are still blockchains, but the distinction is that they are not entirely separate, monolithic L1s, but instead, rollup chains that inherit mainnet's security.)

Either way, we need to build cross-chain applications to create the interoperability needed for widespread consumption by everyday users.

Cross-chain 101

Blockchains have no awareness of other blockchains. There is no built-in way for blockchains to communicate with each other, which limits ecosystem activity and participation in the applications that live on that chain.

Cross-chain refers to allowing assets and data to flow between chains, allowing for users to access dapps from multiple chains.

Because there is no built-in way for all these chains to communicate with each other, we have cross-chain protocols like Connext, Hop, Axelar, IBC that have worked on making cross-chain efficient.

Is the future multichain?

With the rise in popularity of Cosmos, Solana, Binance, and Avalance over the last year, the cross-chain and app-chain thesis seemed to capture the minds and hearts of those in the Ethereum ecosystem.

Although there has been a shift from "one chain to rule them all" to a multichain future, it is still unclear who will be part of this multichain future. Some opine that alt L1s will continue to be built and gain adoption; others think multichain is more like "multi-rollup." (Rollups are still blockchains, but the distinction is that they are not entirely separate, monolithic L1s, but instead, rollup chains that inherit mainnet's security.)

Either way, multichain will be a thing for one reason if none other: vertical scaling of computing can only scale so far. Just like gas fees on Ethereum, the need for a multichain world is driven simply by the ratio of supply to demand of block space. If there is more demand than supply, the market will incentivize the creation of other chains that can provide additional block space to meet that demand.

Why Cross-chain?

Generalized message-passing allows transactions on one chain to be reflected on another. You could execute a smart contract function on one chain and have that state reflected in another. Generalized message passing refers to the broader set of data that can be passed, not limited to tokens.

Multichain describes a world with multiple chains, and cross-chain describes a world where these chains are interoperable.

The way I see it, there are core reasons developers should consider building cross-chain applications:

  1. Leverage the strengths of other chains in your applications.

  2. Empower users to select their ecosystem/blockchain based on what they want to optimize instead of what dapps exist on that chain.

Leverage the strengths of other chains in your applications.

All blockchains make tradeoffs and have their own set of strengths and weaknesses. These include throughput, speed, programming language, developer experience, cost, network effects, etc.

A developer may want to deploy their application on Ethereum to leverage the existing user base but may want to offer users the option of low transaction fees from a sidechain like Polygon.

For example, you could deploy a DAO smart contract on Ethereum to leverage the chain's popularity. However, you may want to offer users a way to execute on-chain voting more cost-effectively. In this example, voting during times of high network traffic may make fees so high that people don't vote. By making this smart contract cross-chain, users could bridge their governance tokens to a chain that optimizes for cheap transactions, and execute the vote via a contract on that destination chain, and then bridge their tokens back to Ethereum.

Empower users to select their ecosystem/blockchain based on what they want to optimize for instead of what dapps exist on that chain.

If you want to use a certain dapp, you are forced to become a user of the chain that hosts the application. This leads to fragmentation in the UX of dapps: multiple wallets, network changes, and the annoying "you're on the wrong network" notification.

In the future of widespread consumer adoption, users will need the freedom to select their chain based on what they value: speed, security, fees, decentralization, UX, etc. So, instead of choosing the chain based on the ecosystem of applications, users will choose the chain to optimize for the things they value.

Everyday normies won't want to think too deeply about which chain they want to operate on. Users should have all that abstracted away, and their decisions will likely be based on UX. Bridges and cross-chain applications will make it so that any app can be served up to users on any chain.

Browsers and blockchains. What do they have in common?

All browsers have the same core functionality and access to all webapps that live on the internet. Why do users choose one browser over another?

The way users pick browsers today is the way users will pick blockchains in the future: optimized for their needs and the things they care about.

You want good developer tooling? Chrome.

Privacy and adblocker? Brave.

Browsing from iPhone? Safari.

Old school? Firefox.

This is not representative of all cases, but regardless, users are choosing the tool that works best for them and the things they care about.

Once all apps are available on all (major) chains, users will get to choose the chain that best aligns with their needs, removing some of the friction in UX that currently exists with blockchain.

Developer Quickstart

TLDR

Developers deploy a smart contract on the source, or initial, chain using xcall. This contract is a normal contract that defines the functionality of the smart contract.

Then, developers deploy smart contracts on destination, or target, chains using xrecieve. These are chains that will have access to the functionality on the source chain.

Developers on chains supported by Connext can use Connext's xcall primitive in their smart contracts.

The xcall method defines the following:

destinationDomain: Domain ID of the destination chain as defined by Connext
target: address of the target contract
address(token):address of the token contract
msg.sender: address that can revert or forceLocal on destination
cost:amount of tokens to transfer
30: the max slippage the user will accept in BPS (0.3%)
callData:the encoded calldata to send

At the time of writing this, the following chains are supported by Connext:

  • Goerli

  • Optimism-Goerli

  • Mumbai

  • Arbitrum-Goerli

Step-by-step Guide

This is pulled in from the Connext's Developer Quickstart for maintanability. This blog will not be maintained, but the quickstart will be. If you are reading this in 2023, refer to the quickstart linked in Connext's documentation to ensure you are referencing the most up-to-date version.

Quickstart

In this guide, we will build a cross-chain Greeter. We'll deploy two conctracts: the source contract on Goerli and the destination contract on mumbai.

Our destination contract will have a value, greeting along with a method to update that greeting, _updateGreeting.

Our source contract will use xcall to send update the greeting value on the destination contract, passing the updated greeting as call data, along with other values like the target address.

Prerequisites

  • Node v18 installed

Follow these instructions to install Node.js and use Node.js v16. We also recommend installing nvm, a node version manager, which will make switching versions easier.

  • An Ethereum development environment like Foundry, Hardhat, Truffle, etc. This guide will be using Hardhat.

Follow these instructions to install Hardhat.

Create a new project

Create a new project by running the following command:

$ npx hardhat
888    888                      888 888               888
888    888                      888 888               888
888    888                      888 888               888
8888888888  8888b.  888d888 .d88888 88888b.   8888b.  888888
888    888     "88b 888P"  d88" 888 888 "88b     "88b 888
888    888 .d888888 888    888  888 888  888 .d888888 888
888    888 888  888 888    Y88b 888 888  888 888  888 Y88b.
888    888 "Y888888 888     "Y88888 888  888 "Y888888  "Y888

👷 Welcome to Hardhat v2.12.1 👷‍

? What do you want to do? …
❯ Create a JavaScript project
  Create a TypeScript project
  Create an empty hardhat.config.js
  Quit

Choose a Javascript project. Choose y on all of the prompts.

Open your project in a code editor and rename your contract file under src to SourceGreeting.sol.

Install the latest beta version of Connext contracts package in your project:

$ npm install @connext/nxtp-contracts

Next, install the OpenZepplin contract package:

$ npm install @openzeppelin/contracts

You'll need to manually install the library @openzeppelin/contracts-upgradeable

npm install @openzeppelin/contracts-upgradeable 

Install dotenv to protect your private key needed to deploy your contract:

npm install dotenv

In the root of your project, create a new file .env. Here you will store your private key used to deploy your contract.

Update your .env file to only have the following line:

PRIVATE_KEY=YOUR-PRIVATE-KEY-HERE

Source Contract

The source contract initiates the cross-chain operation with xcall and passes the encoded greeting into the call. All xcall params are detailed here.

Notice the token that is passed in is the TEST token on Goerli. You can read more about token flavors and why they matter here.

Note that in this contract, we're defining a constructor that takes in the address of the deployed Connext diamond contract on the same chain that this contract will be deployed to. Find the list of all Connext diamond contracts here.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.15;

import {IConnext} from "@connext/nxtp-contracts/contracts/core/connext/interfaces/IConnext.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/**
 * @title SourceGreeting
 * @notice Example source contract that updates a greeting in DestinationGreeting.
 * @dev Must pay at least 1 TEST to update the greeting.
 */
contract SourceGreeting {
    // The connext contract on the origin domain
    IConnext public immutable connext;

    // Hardcoded cost to update the greeting, in wei units
    // Exactly 0.05% above 1 TEST to account for router fees
    uint256 public cost = 1.0005003e18;

    // The canonical TEST Token on Goerli
    IERC20 public token = IERC20(0x7ea6eA49B0b0Ae9c5db7907d139D9Cd3439862a1);

    constructor(IConnext _connext) {
        connext = _connext;
    }

    /** @notice Updates a greeting variable on the DestinationGreeting contract.
     * @param target Address of the DestinationGreeting contract.
     * @param destinationDomain The destination domain ID.
     * @param newGreeting New greeting to update to.
     * @param relayerFee The fee offered to relayers. On testnet, this can be 0.
     */
    function updateGreeting(
        address target,
        uint32 destinationDomain,
        string memory newGreeting,
        uint256 relayerFee
    ) external {
        require(
            token.allowance(msg.sender, address(this)) >= cost,
            "User must approve amount"
        );

        // User sends funds to this contract
        token.transferFrom(msg.sender, address(this), cost);

        // This contract approves transfer to Connext
        token.approve(address(connext), cost);

        // Encode the data needed for the target contract call.
        bytes memory callData = abi.encode(newGreeting);

        connext.xcall{value: relayerFee}(
            destinationDomain, // _destination: Domain ID of the destination chain
            target, // _to: address of the target contract
            address(token), // _asset: address of the token contract
            msg.sender, // _delegate: address that can revert or forceLocal on destination
            cost, // _amount: amount of tokens to transfer
            30, // _slippage: the max slippage the user will accept in BPS (0.3%)
            callData // _callData: the encoded calldata to send
        );
    }
}

Compile Contract

Compile the contract with the following command:

npx hardhat compile

Note: Hardhat may require you to manually install dependencies for @nomicfoundation/hardhat-toolbox. If you get an error about missing dependencies for that plugin, run the follwing command:

npm install --save-dev "@nomicfoundation/hardhat-network-helpers@^1.0.0" "@nomicfoundation/hardhat-chai-matchers@^1.0.0" "@nomiclabs/hardhat-ethers@^2.0.0" "@nomiclabs/hardhat-etherscan@^3.0.0" "@types/chai@^4.2.0" "@types/mocha@^9.1.0" "@typechain/ethers-v5@^10.1.0" "@typechain/hardhat@^6.1.2" "solidity-coverage@^0.8.1" "ts-node@>=8.0.0" "typescript@>=4.5.0"

To deploy your contract, update your hardhat.config file:

require("@nomicfoundation/hardhat-toolbox");
require('@openzeppelin/hardhat-upgrades');
// Any file that has require('dotenv').config() statement 
// will automatically load any variables in the root's .env file.
require('dotenv').config();
 
module.exports = {
  solidity: "0.8.17",
  networks:{
    goerli:{
      url: "https://rpc.ankr.com/eth_goerli",
     // PRIVATE_KEY loaded from .env file
      accounts: [`0x${process.env.PRIVATE_KEY}`]
    }
  }
};

Deploy Contract

Update your deploy.js file, found under scripts, with the following:

const main = async () => {
  const SourceGreeting = await hre.ethers.getContractFactory('SourceGreeting');
  const sourceGreetingContract = await SourceGreeting.deploy("0x0C70d6E9760DEE639aC761f3564a190220DF5E44");
  await sourceGreetingContract.deployed();
  console.log("Contract deployed to:", sourceGreetingContract.address);
  
};

const runMain = async () => {
  try {
    await main();
    process.exit(0);
  } catch (error) {
    console.log(error);
    process.exit(1);
  }
};

runMain();

Then call the deploy script:

$ npx hardhat run scripts/deploy.js --network goerli
Contract deployed to: 0x8CC1DB2a76ea4bc40089f0Db2B25f8B13032F72d

Note the contract address because you'll want to verify your contracts later.

Target Contract

Follow the same steps above to create a new hardhat project, this time name the project DestinationGreeting. Install all the same dependencies as above.

All target contracts must implement Connext's IXReceiver interface. This interface ensures that Connext can call the contract and pass necessary data.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.15;

import {IXReceiver} from "@connext/nxtp-contracts/contracts/core/connext/interfaces/IXReceiver.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract DestinationGreeting is IXReceiver {
    string public greeting;

    // Hardcoded cost to update the greeting, in wei units
    uint256 public cost = 1e18;

    // The TEST Token on Mumbai
    IERC20 public token = IERC20(0xeDb95D8037f769B72AAab41deeC92903A98C9E16);

    /** @notice The receiver function as required by the IXReceiver interface.
     * @dev The Connext bridge contract will call this function.
     */
    function xReceive(
        bytes32 _transferId,
        uint256 _amount,
        address _asset,
        address _originSender,
        uint32 _origin,
        bytes memory _callData
    ) external returns (bytes memory) {
        // Enforce the cost to update the greeting
        require(
            _asset == address(token) && _amount >= cost,
            "Must pay at least 1 TEST"
        );

        // Unpack the _callData
        string memory newGreeting = abi.decode(_callData, (string));

        _updateGreeting(newGreeting);
    }

    /** @notice Internal function to update the greeting.
     * @param newGreeting The new greeting.
     */
    function _updateGreeting(string memory newGreeting) internal {
        greeting = newGreeting;
    }
}

With this code, you'll be able to update the greeting from the source contract.

Executing the Transaction

Now that you've deployed your contracts, let's verify that we're actually able to access the source contract's functionality from the destination contract.

If you don't already have gas funds on Goerli, try these faucets to get some:

For the following steps, you should try deploying (and verifying) your own contracts. For ease, you can use the contracts we deployed for this part:

Note: These contracts are named differently than the ones you just wrote, but the content of the contract is the same.

  • HelloSource.sol

  • HelloTarget.sol

TEST Token Minting

Mint some TEST tokens that will be used for this example.

You can use Etherscan to call functions on (verified) contracts. Go to the TEST Token on Etherscan and click on the "Write Contract" button.

A new tab will show up with all write functions of the contract. Connect your wallet, switch to the Goerli network, and enter the parameters for the mint function:

  • account: <YOUR_WALLET_ADDRESS>

  • amount: 10000000000000000000 (10 TEST)

TEST Token Spending Approval

Tokens will move from User's wallet => SourceGreeting => Connext => DestinationGreeting.

The user must first approve a spending allowance of the TEST ERC20 to the SourceGreeting contract. The require clause starting on line 37 checks for this allowance.

Again, on the Etherscan page, enter the parameters for the approve function:

  • spender: 0x9ce3799f033d89d316f373f1db161c84a401a26c

    • This is the address of SourceGreeting.
  • amount: 10000000000000000000 (10 TEST)

    • Recall that DestinationGreeting requires a payment of at least 1 TEST (1e18 wei units). In SourceGreeting, this is hardcoded as 1000500300000000000 to account for a 0.05% fee that routers will take on the bridged asset. But you can approve as much as you want. This way, you can call updateGreeting without having to do an approval every time.

    • If earning fees as a router sounds interesting, check out the Routers documentation.

Then "Write" to the approve function.

Execute updateGreeting

Similarly to the approval function for TEST, navigate to the HelloSource contract on Etherscan. Fill out the updateGreeting function parameters and "Write" to the contract.

Let's talk about the different parameters.

  • target: 0x9094da44ec4335632c28749437f616a8a6cadcb6

    • The address of DestinationGreeting.
  • destinationDomain: 9991

    • The Domain ID of the destination chain. You can find a mapping of Domain IDs here. Remember, DestinationGreeting is deployed to Mumbai.
  • newGreeting: hello chain!

    • Whatever string you want to update the greeting to.
  • relayerFee: 0

    • IMPORTANT! This is a fee paid to relayers, which are off-chain agents that help execute the final leg of the cross-chain transfer on the destination. Relayers get paid in the origin chain's native asset. This is why SourceGreeting passes the fee like so:

      connext.xcall{value: relayerFee}(...)
      

      However, we're paying 0 fees here! Only because on testnet, our relayers are not taking fees.

      As a xApp developer, you have some tools available to estimate what this relayerFee should be. Check out the guide on Estimating Fees.

Check DestinationGreeting

After executing updateGreeting, DestinationGreeting should be updated in just a few minutes.

  • Would it always be this fast? See our guide on Authentication to learn when xcall is fast or slow.

Head over to the DestinationGreeting contract on Etherscan. This time, we'll go to the Read Contract tab and look at the value of greeting. It has been updated!

Send a couple more updates from HelloSource but make it a different string. At some point, your TEST allowance to HelloSource will run out and you'll need to do the approval dance again.

Congrats! You've gone cross-chain!


Additional Resources

  • Try tracking the status of an xcall after you send it.

  • Fork the xApp Starter Kit below (includes code for this example) and build your own xApp.

Subscribe to camiinthisthang
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.