Key management
This is some text inside of a div block.
Security
This is some text inside of a div block.
Wallets
This is some text inside of a div block.
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.
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 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!
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.
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!
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:
CrossChainSender
in this example)
store()
's invocation of payForCrossChainCall()
in this example)
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.
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:
CrossChainReceiver
in this example).
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.
In this section, we implement sender and receiver contracts using Axelar as our cross-chain bridge provider.
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.
callContract()
The AvaStorage contract invokes callContract()
(docs here) with three arguments:
"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.
_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).
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:
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.
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).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:
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.
string
for cross-chain compatibility.
callContract()
.
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.
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.
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):
EthStorageReceiver
where the message it just received is coming from (once again in string
form).
string
for cross-chain compatibility.
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()
.
In this section, we see what AvaStorageSender
and EthStorageReceiver
look like using the LayerZero cross-chain bridge provider.
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):
uint16
chain ID of the destination chain (in this case, for Ethereum).
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.
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.
address
that will pay ZRO
tokens for the call. Note that at the time of this writing, ZRO tokens are not yet available.
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.
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:
uint16
source chain ID (not used here), which tells EthStorageReceiver
where the message it just received is coming from.
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.
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.
payload
. Once again note that payload
is encoded in bytes, so must be decoded in order to be used.
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.
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.
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:
LzApp
, we inherit from NonblockingLzApp
.
_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.
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!
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.
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.
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.