Cross-chain
Security
Blog posts
go back

The cross-chain status quo

Writing cross-chain smart contracts

January 12, 2023
tags
Cross-chain
Security
Blog posts

Writing, testing, and deploying an application that bridges multiple blockchains is tough, but this three-part blog series aims to make it easier. We walk through the cross-chain development status quo—from building (Part I) to testing (Part II) to deployment and monitoring (Part III)—with code examples for multiple chains and bridge providers. This post, the first installment, covers writing cross-chain smart contracts.

 

What is a cross-chain application?

A cross-chain application sends some state (e.g., tokens, numbers, etc) from a source chain to a destination chain. For example, consider the UniSwap protocol. UniSwap includes contracts on many different chains that users can invoke to send assets between one supported chain and another.

 

Writing smart contracts that communicate cross-chain

Writing cross-chain smart contracts is a fundamentally different beast than writing, say, a single smart contract for deployment on multiple chains. In the cross-chain case, there's a sender contract on chain A that sends messages to a receiver contract on chain B. The messages are relayed from chain A to chain B using some kind of relayer service, or bridge (discussed next). If you use an off-the-shelf relayer, writing sender and receiver contracts is (usually) a matter of implementing sender and receiver interfaces provided by the relayer you've selected. We'll make this more concrete with some code examples right after discussing cross-chain bridge provider options.

Cubist's SDK makes cross-chain development easy by automatically handling the details of cross-chain interactions. This makes building cross-chain dapps easier and safer, and it means you can change bridge providers with one line of configuration. Try the Cubist SDK here!

 

Cross-chain bridge solutions

Rather than building your own bridge from scratch, it's almost certainly easier (and safer!) to use an existing bridging solution. Here are some well-known cross-chain bridge providers to consider:

Different bridges have different costs and security properties; this blog post doesn't outline the pros and cons of each provider. Before deciding on a provider, it's worth reading the documentation for the bridges you're considering, trying the examples in their documentation, etc.

Typically, cross-chain bridge providers have a messaging API that developers can use by implementing sender contract interfaces and receiver contract interfaces. To send and receive data using Axelar, for example, smart contracts implement the AxelarExecutable interface. In the next sections, we walk through how sender and receiver interfaces work, and give concrete examples of each using the Axelar and LayerZero bridge services.

 

Example overview: making a simple storage contract cross-chain

Let's say we want to build an Avalanche smart contract that stores a number both locally and on Ethereum; any time store(number) is called on Avalanche, that number will also eventually be stored on Ethereum.

Here's the Ethereum storage code:

contract EthStorage {

    uint256 _stored;

    function store(uint256 num) {
        _stored = num;
    }
}

This smart contract's store() function takes a number as input and stores that number in the stored field of the contract.

Here's the Avalanche code we would like to write:

import 'EthStorage.sol';

contract AvaStorage {

    uint256 _stored;
    EthStorage _ethStorage;

    constructor(EthStorage ethStorage) {
        _ethStorage = ethStorage;
        _stored = 0;
    }

    function store(uint256 num) {
        _stored = num;
        _ethStorage.store(num);
    }
}

This smart contract's store function takes a number as input and (1) stores it in AvaStorage's stored field and (2) calls EthStorage's store() with it.

The AvaStorage and EthStorage smart contracts would both work fine if they were deployed on the same blockchain—AvaStorage would call EthStorage's store(), and num would get stored in both contracts. Unfortunately, AvaStorage and EthStorage are on different chains, so they can't interact directly; they'll have to interact using a cross-chain bridge provider. To use an existing bridge provider, you typically have to implement a sender contract (in this case, on Avalanche) and a receiver contract (in this case, on Ethereum). We'll turn AvaStorage into a sender contract and EthStorage into a receiver contract in the next two sections.

Cubist's SDK automatically converts smart contracts just like the ones above into cross-chain contracts that are bridge-provider agnostic. In other words, if you were using Cubist's SDK, you'd already be done creating your cross-chain dapp! Start building with the Cubist SDK!

 

The sender contract, AvaStorageSender

Conceptually, here's how our sender contract on Avalanche will work:

contract AvaStorageSender is CrossChainSender {

    uint256 _stored;
    EthStorage _ethStorage;

    // assume constructor initializes members in the obvious way

    function store(uint256 num) {
        _stored = num;
        // both of next two methods are defined in the parent
        // `CrossChainSender` contract
        payForCrossChainCall();
        crossChainCall(_ethStorage, encode(num));
    }
}

This is not executable code, but it gives you a sense of how the sender contract operates. It will:

  1. Implement a bridge provider—dependent interface for sending (represented by CrossChainSender in this example)

  2. Pay the bridge provider for a forthcoming cross-chain call (represented by store()'s invocation of payForCrossChainCall() in this example)

  3. Encode the payload (encode() in this example) as bytes and then call the cross-chain smart contract through the bridge provider (represented by crossChainCall() in this example).

Next, let's look at what our receiver contract will look like.

 

The receiver contract, EthStorageReceiver

Here's the logic of EthStorageReceiver deployed on Ethereum:

contract EthStorageReceiver is CrossChainReceiver {

    uint256 _stored;

    function receiveCrossChainCall(bytes calldata payload) override {
        _stored = decode(payload, (uint256)); 
    }
}

One again, this is not executable code—it just shows the logic that EthStorageReceiver should implement. It will:

  1. Implement a bridge provider—dependent interface for receiving (represented by CrossChainReceiver in this example).
  2. Implement a function that the bridge provider triggers whenever AvaStorageSender makes a cross-chain call (receiveCrossChainCall() in this example). Note that there is only one receiving method for most cross-chain bridge interfaces (e.g., Axelar and LayerZero). If the receiver contract contains multiple functions and wants to expose more than one cross-chain, the logic inside receiveCrossChainCall() will have to dispatch to the correct function. We don't discuss this in depth here, but our more complex examples show one solution for making multiple receiver functions work. In this example, all receiveCrossChainCall() has to do is update stored to correctly reflect the value sent by AvaStorageSender, so all that needs to be encoded in payload argument is that value; EthStorageReceiver simply decodes payload using the decode() function, then updates _stored.

One important consideration that this example does not handle is ensuring that only authorized callers can execute receiveCrossChainCall. We discuss this issue more below.

 

Axelar sender and receiver

In this section, we implement sender and receiver contracts using Axelar as our cross-chain bridge provider.

 

The Axelar AvaStorageSender

Here's the sender contract on Avalanche using Axelar as its bridge provider:

import {AxelarExecutable} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/executables/AxelarExecutable.sol";
import {IAxelarGateway} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGateway.sol";
import {IAxelarGasService} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGasService.sol";

contract AvaStorageSender is AxelarExecutable { // on Avalanche

    IAxelarGasService public immutable _gasReceiver;

    // The destination of the receiver contract, stored as a string
    // to accommodate different address formats on foreign chains.
    string _ethCounterReceiverAddress;
    uint256 _stored;
    
    constructor(
        address gateway,
        address gasReceiver,
        string memory ethCounterReceiverAddress
    ) AxelarExecutable(gateway) {
        // the following members are
        _gasReceiver = IAxelarGasService(gasReceiver);
        _ethCounterReceiverAddress = ethCounterReceiverAddress;
    }

    function store(uint256 num) {
        _stored = num;
        payload = abi.encode(num);
        _gasReceiver.payNativeGasForContractCall{value: msg.value}(
            address(this),
            "ethereum",
            _ethCounterInterfaceAddress,
            payload,
            msg.sender
        );
        gateway.callContract("ethereum", _ethCounterReceiverAddress, payload);
    }
}

This version is a lot more complex than the simple sender example from last section, but it follows the same basic structure: the smart contract implements the AxelarExecutable interface, uses gateway.callContract to make the cross-chain call, and pays for that call with the payNativeGasForContractCall function. To understand how this sender contract works, let's work backward from that cross-chain callContract invocation.

 

Cross-chain calling: callContract()

The AvaStorage contract invokes callContract() (docs here) with three arguments:

  1. "ethereum", the name of the destination chain. Axelar represents different supported chains using strings. See the strings representing Axelar's supported testnets and supported mainnets.

  2. _ethCounterReceiverAddress, the address of the receiver contract on Ethereum. We haven't shown the code for this smart contract yet; it appears in the next section. This argument is a string because addresses don't necessarily take the same form from one chain to another (e.g., EVM addresses are 20 bytes, but not all blockchains use 20 byte addresses).

  3. payload, the actual (encoded) data that we're sending to Ethereum. In this case, the payload is num, the number we just stored as _stored on Avalanche and that we'll be storing on Ethereum as well. The payload has type bytes and must be encoded into bytes using Solidity's ABI encoding functionality (e.g., abi.encode()).

In addition to these three arguments—the destination chain, the destination address, and the payload—callContract() is also called on the gateway object. gateway is the Axelar gateway contract address. There's one Gateway contract deployed on each chain Axelar supports, and the Gateway is what passes messages to/from the Axelar network to the chain. In other words, once the sender contract calls callContract() on gateway, the Gateway contract passes the payload and other information to the Axelar network, where it is ferried to the destination chain. See Axelar's blog for a discussion of its network and security properties; for more information, see the Axelar whitepaper.

Finally, it's also possible to call a smart contract on another chain and send tokens along with the call. See the Axelar docs for more information. callContractWithToken() takes two extra arguments after the payload:

  1. (argument 4) string symbol, the Axelar representation of the token type you'll be sending. Right now, the two options are both USDC: symbol "USDC" on Ethereum, and symbol "axlUSDC" (Axelar Wrapped USDC) on Avalanche, BNB, Phantom, Moonbean, and Polygon. USDC is native to Ethereum, so USDC tokens must be wrapped on other blockchains. For more information, refer to the “Symbol” column on the left in the Axelar documentation.

  2. (argument 5) uint256 amount. This is the number of tokens to transfer with the call; using 1 would correspond to sending 0.000001 USDC cross-chain (USDC has 6 decimals).

 

Paying: payNativeGasForContractCall()

Before invoking callContract(), the store() function must pay the gas fees associated with the cross-chain call by using payNativeGasForContractCall() (see Axelar's docs). This function signature is:

function payNativeGasForContractCall(
    address sender,
    string calldata destinationChain,
    string calldata destinationAddress,
    bytes calldata payload,
    address refundAddress
) external payable;

In more detail, its arguments are:

  1. The sender address (in this case, address(this)) indicates the sender whose gas we are pre-paying. Suppose AvaStorageSender was pre-paying for OtherSender to send the payload cross-chain; in that case, the sender address would be OtherSender's address.
  2. The destination chain's string representation (see Axelar's testnet chain strings).
  3. The destination address for the receiver contract. Recall that this argument is a string for cross-chain compatibility.
  4. The payload. This is the same payload we used in callContract().
  5. Address of the party receiving any refunds for gas overpayment. Note that this address does not have to be the same as the sender (or pay-er) address.

Finally, see that we've sent {value: msg.value}. This value specifies how much money to send to the receiving contract—in this case, the same amount that was sent to AvaStorageSender.

Note that payNativeGasForContractCall() is called on the _gasReceiver, an instance of an Axelar gas service. The Gas Receiver is a smart contract deployed on each supported blockchain. It provides different methods for pre-paying for cross-contract calls (i.e., in the words of the docs, “paying...the relayer gas fee upfront on the source chain, thereby covering the cost of gas to execute the final transaction on the destination chain”). The Gas Receiver supports a number of different payment options.

 

Encoding the payload

As we move upwards in AvaStorageSender to the line "payload = abi.encode(num)", recall that the payload must be encoded in bytes in order to send it across the network. See the documentation on Solidity's ABI encoding for details.

 

Axelar EthStorageReceiver

Here's what the EtherStorageReceiver Ethereum contract looks like using Axelar:

import {AxelarExecutable} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/executables/AxelarExecutable.sol";
import {IAxelarGateway} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGateway.sol";
import {IAxelarGasService} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGasService.sol";

contract EthStorageReceiver is AxelarExecutable {

    IAxelarGasService public immutable _gasReceiver;
    uint256 _stored;

    constructor(
        address gateway,
        address gasReceiver,
    ) AxelarExecutable(gateway) {
        _gasReceiver = IAxelarGasService(gasReceiver);
    }

    function _execute(
        string calldata, // source chain (not used here)
        string calldata, // source address (not used here)
        bytes calldata payload
    ) internal override {
        _stored = abi.decode(payload, (uint256)); 
    }
}

The key function that this smart contract must implement is _execute(): that's the function that Axelar invokes when a smart contract on another blockchain has made a call to EthStorageReceiver. _execute() takes the following parameters (see also the Axelar documentation):

  1. The source chain. This tells EthStorageReceiver where the message it just received is coming from (once again in string form).
  2. The source contract address. This also a string for cross-chain compatibility.
  3. The payload, payload. Once again note that payload is encoded in bytes, so must be decoded in order to be used.

Before calling _execute(), AxelarExecutable ensures that the message was indeed sent by the _gateway. The receiver contract is responsible for ensuring that the caller (identified by the first two arguments to _execute()) is authorized.

Receiver contracts are a bit less complicated than sender contracts, since (by convention) payment tends to happen on the sender side. See our runnable example for more sophisticated payloads: this example dispatches to different functions in the receiver contract from within _execute().

 

LayerZero sender and receiver

In this section, we see what AvaStorageSender and EthStorageReceiver look like using the LayerZero cross-chain bridge provider.

 

LayerZero AvaStorageSender

Here's a LayerZero implementation of a sender contract:

contract AvaStorageSender is LzApp {

    constructor(address lzEndpoint) LzApp(lzEndpoint) {}

    function store(unit256 num) {
        bytes memory payload = abi.encode(num);
        _lzSend(
            10121,
            payload,
            msg.sender,
            0x0,
            bytes("")
        );
    }
}

LayerZero's sender contract is similar to the senders in previous sections: the smart contract implements the LzApp interface, and uses _lzSend to both make the cross-chain call and pay for that call.

To make a cross-chain call, AvaStorageSender invokes _lzSend() with six arguments (see LayerZero's docs. Note that this doc starts with raw send(), which we describe later; let's focus on _lzSend() for now):

  1. The uint16 chain ID of the destination chain (in this case, for Ethereum).
  2. The bytes payload, the actual (encoded) data that the sender contract is sending to Ethereum. In this case, the payload is num, the number we just stored as stored on Avalanche and that we'll be storing on Ethereum as well; we encode num using the same Solidity encoding functionality we used for Axelar.
  3. The address refund address. If we've overpaid gas for this cross-chain call, the excess will be refunded to address, much as it is in Axelar.
  4. The address that will pay ZRO tokens for the call. Note that at the time of this writing, ZRO tokens are not yet available.
  5. The bytes adapterParams. Adapter parameters are supposed to let you customize the amount of gas you send with a cross-chain transaction.

The LayerZero docs on adapter parameters aren't complete as of January 2023, so we'd recommend avoiding them for now.

 

LayerZero EthStorageReceiver

Here's what the EtherCounterReceiver Ethereum contract looks like using LayerZero:

import {LzApp} from "@layerzerolabs/solidity-examples/contracts/lzApp/LzApp.sol";

contract EthStorageReceiver is LzApp {

    uint256 _stored;

    constructor(address lzEndpoint) LzApp(lzEndpoint) {}

    function _blockingLzReceive(uint16, bytes calldata, uint64, bytes calldata payload) override external {
        _stored = abi.decode(payload, (uint256));
    }
}

The key function that this smart contract must implement is _blockingLzReceive() (see LayerZero's docs). _blockingLzReceive() takes the same arguments as lzReceive(); we explain the difference later on in this blog post). LayerZero invokes _blockingLzReceive() when a smart contract on another blockchain has made a call to EthStorageReceiver. Before calling _blockingLzReceive(), the LzApp smart contract checks that the message sender is a LayerZero endpoint, and it checks that the source of the cross-chain message is a trusted contract. Since we emitted a SetTrustedRemoteAddress event from AvaStorageSender's constructor, _lzSend() calls from AvaStorageSender are allowed to connect via the LayerZero network with the _blockingLzReceive() function on EthStorageReceiver.

_blockingLzReceive takes the following parameters:

  1. The uint16 source chain ID (not used here), which tells EthStorageReceiver where the message it just received is coming from.
  2. The bytes source contract address (not used here), which tells EthStorageReceiver the originating address of the call. This parameter is not just the sender contract address encoded in bytes; rather, it's the sender “path” encoded in the same manner we encoded the argument to SetTrustedRemoteAddress(): the address of the sender and receiver contracts concatenated and encoded, as described in the LayerZero docs.
  3. The uint64 nonce of the message. Note that this is provided by LayerZero; in other words, _lzSend() on the source chain does not send a nonce value explicitly. The nonce is an ordered value that your application can use to determine the order in which messages were sent.
  4. The payload, payload. Once again note that payload is encoded in bytes, so must be decoded in order to be used.

 

Securing cross-chain calls

In LayerZero, cross-chain callers must be authorized before they can invoke LzApp receiver functions. This is handled by setting a trusted remote; see also the example code provided by LayerZero.

 

Going without the interface

Unlike Axlear, LayerZero allows you to use the bridge service without implementing a cross-chain interface; instead, your smart contract can store a reference to an ILayerZeroEndpoint that the contract can use to send messages cross-chain. There is a single LayerZero endpoint for each blockchain that LayerZero supports. Here's how AvaStorageSender might use the ILayerZeroEndpoint directly:

import {ILayerZeroEndpoint} from "@layerzerolabs/solidity-examples/contracts/interfaces/ILayerZeroEndpoint.sol";

contract AvaStorageSender {

    ILayerZeroEndpoint public immutable _lzEndpoint;
    
    // ...
    
    function store(uint256 num) {
        // ...
        _lzEndpoint.send(...);
    }
}

Despite the fact that using the LzEndpoint directly is possible, we recommend using LayerZero's LzApp interface instead. The LzApp interface automatically checks that messages came from a LayerZero endpoint and, further, that they originated from a trusted contract on the source chain. You almost certainly want to use an interface for security reasons; using the lzEndpoint directly requires re-implementing some LzApp functionality (e.g., trusted sender checks on the receiving side), which takes development time and risks security problems down the road.

 

Blocking in LayerZero

By default, if the delivery of a cross-chain message fails, it blocks the delivery channel until the delivery succeeds (i.e., during this time no other messages will be delivered through this channel). This enforces message ordering by nonce. For dapps that require failed messages to be non-blocking, LayerZero provides an abstract contract, NonblockingLzApp, that stores failed messages on the target chain for later retry.

A non-blocking version of the previous smart contract looks like this:

import {NonblockingLzApp} from "@layerzerolabs/solidity-examples/contracts/lzApp/LzApp.sol";

contract EthStorageReceiver is NonblockingLzApp {

    uint256 _stored;

    constructor(address lzEndpoint) NonBlockingLzApp(lzEndpoint) {}

    function _nonblockingLzReceive(uint16, bytes memory, uint64, bytes calldata payload) override external {
        _stored = abi.decode(payload, (uint256));
    }
}

As this example shows, only two changes are required to make our receiver non-blocking:

  1. Instead of inheriting from the abstract contract LzApp, we inherit from NonblockingLzApp.
  2. The method to override for receiving messages is _nonblockingLzReceive() instead of _blockingLzReceive().

Failed messages can be retried by calling retryMessage() on the smart contract. The abstract NonblockingLzApp contract also defines two events, MessageFailed and RetryMessageSuccess that indicate when a given message delivery has failed or a retry has succeeded, respectively.

 

Try it yourself!

We've created a slightly more complex executable Axelar example with instructions. In Part II of this blog series, we will cover the cross-chain testing status quo, and in Part III, the cross-chain deployment status quo. Stay tuned for more!

The Cubist SDK makes cross-chain development easy by automatically generating the bridge code that lets your smart contracts interact. As a result of this auto-generation, Cubist lets you change cross-chain bridge providers with a single line of configuration. Get started with the Cubist SDK!

Read more

Understanding and preventing the Bybit hack

Understanding and preventing the Bybit hack

This blog post digs into the hack itself, and then explains how a different approach to security would have made the attackers’ job much harder.

February 25, 2025
K3 brings wallet automations to CubeSigner users

K3 brings wallet automations to CubeSigner users

We are excited to announce that Cubist has partnered with K3 Labs to provide the secure wallet infrastructure underlying their new drag-and-drop Web3 automation platform.

February 5, 2025
Cubist teams up with Babylon and Lombard to bring Bitcoin to Sui

Cubist teams up with Babylon and Lombard to bring Bitcoin to Sui

Together with Lombard, we have been extending the CubeSigner hardware-backed key management platform to bring smart contract capabilities to Bitcoin and unlock Bitcoin liquid staking on Sui.

November 25, 2024