πŸ’‘
Integration Guide

Overview

In this tutorial, we will go through the implementation of a cross-chain batch transfer app. We will use Goerli Testnet and BSC Testnet as the chains of choice. We will omit some of the more advanced topics such as dealing with bridge failures, execution reverts, and fee mechanisms. These topics are covered in Integration Tutorial: Advanced.
As an overview, our app does the following things:
  1. 1.
    takes sender's money on Goerli Testnet
  2. 2.
    sends the fund along with a message through the Celer IM infrastructure
  3. 3.
    the executor we run polls Celer's SGN for executable messages and automatically submits those of ours on BSC Testnet
  4. 4.
    on BSC Testnet, distribute the fund to the receivers specified in the message
Please check out the architecture of the Celer Interchain Message (IM) infrastructure if you haven't yet.
There are only three components we are going to deploy:
  1. 1.
    the BatchTransfer contract on Goerli Testnet
  2. 2.
    the BatchTransfer contract on BSC Testnet
  3. 3.
    the executor

Prerequisites

  1. 1.
    Solidity Knowledge
  2. 2.
    Wallet
  3. 3.
    Node.js 12 installed
  4. 4.
    Typescript installed
  5. 5.
    Experience with basic Unix commands

Contract

Feel free to skip to the Executor section if you already have a good idea on how to implement an app contract using the application framework​
We are using Hardhat to develop and deploy the contract, if Hardhat is not your weapon of choice, you can use your preferred tools or you can learn more at their official website.

Preparation

Initialize a Repo

1
git init batch-transfer-app && cd batch-transfer-app
Copied!

Get the dependencies

1
npm init --yes
2
npm install -D hardhat [email protected] @openzeppelin/[email protected] @nomiclabs/hardhat-etherscan @nomiclabs/hardhat-ethers ethers
Copied!

Initialize a Hardhat Project

1
npx hardhat
2
# select "Create an empty hardhat.config.js" in the prompt
3
> Create an empty hardhat.config.js
Copied!

Implement SimpleBatchTransfer.sol

We want to take in some coins and instructions on how to distribute them, then send them to our BatchTransfer contract on the destination chain. For this purpose, we need to have the following function as an entry point. Create contracts/SimpleBatchTransfer.sol
1
uint64 nonce;
2
​
3
function batchTransfer(
4
address _receiver, // destination contract address
5
address _token, // the input token
6
uint256 _amount, // the input token amount
7
uint64 _dstChainId, // destination chain id
8
uint32 _maxSlippage, // the max amount of slippage allowed at bridge, represented in 1e6 as 100% (i.e. 1e4 = 1%)
9
MsgDataTypes.BridgeSendType _bridgeType, // the bridge type, for this tutorial, we are using liquidity bridge
10
address[] calldata _accounts, // the accounts on the destination chain that should receive the transfered fund
11
uint256[] calldata _amounts // the amounts for each account
12
) external payable {
13
// each transfer is assigned a nonce
14
nonce += 1;
15
16
// pull funds from the sender
17
IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount);
18
19
// encode a message, specifying how we want to distribute the funds on the destination chain
20
bytes memory message = abi.encode(
21
TransferRequest({accounts: _accounts, amounts: _amounts})
22
);
23
24
// MessageSenderLib is your swiss army knife of sending messages
25
MessageSenderLib.sendMessageWithTransfer(
26
_receiver,
27
_token,
28
_amount,
29
_dstChainId,
30
nonce,
31
_maxSlippage,
32
message,
33
_bridgeType,
34
messageBus,
35
msg.value
36
);
37
}
Copied!
On the destination chain, we need a function to handle the message and the received tokens. The executor we run will periodically call MessageBus and the MessageBus in turn calls this function. This function is required by the IMessageReceiverApp interface
1
// functions in the destination chain contract handles funds and we want to make sure only MessageBus can call it
2
modifier onlyMessageBus() {
3
require(msg.sender == messageBus, "caller is not message bus");
4
_;
5
}
6
​
7
function executeMessageWithTransfer(
8
address _sender,
9
address _token,
10
uint256 _amount,
11
uint64 _srcChainId,
12
bytes memory _message,
13
address _executor
14
) external onlyMessageBus returns (IMessageReceiverApp.ExecutionStatus) {
15
// decode the message
16
TransferRequest memory transfer = abi.decode((_message), (TransferRequest));
17
// distribute the funds transfered
18
for (uint256 i = 0; i < transfer.accounts.length; i++) {
19
IERC20(_token).safeTransfer(transfer.accounts[i], transfer.amounts[i]);
20
}
21
// returning true indicates that the handling is successful
22
return IMessageReceiverApp.ExecutionStatus.Success;
23
}
Copied!
Note the use of onlyMessageBus modifier, we don't want other people to trigger our fund allocation logic. And that's it, here is the entire contract
1
// SPDX-License-Identifier: GPL-3.0-only
2
​
3
pragma solidity 0.8.9;
4
​
5
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
6
import "sgn-v2-contracts/contracts/message/libraries/MessageSenderLib.sol";
7
import "sgn-v2-contracts/contracts/message/libraries/MsgDataTypes.sol";
8
import "sgn-v2-contracts/contracts/message/interfaces/IMessageReceiverApp.sol";
9
​
10
contract SimpleBatchTransfer {
11
using SafeERC20 for IERC20;
12
13
struct TransferRequest {
14
address[] accounts;
15
uint256[] amounts;
16
}
17
​
18
uint64 nonce;
19
address messageBus;
20
​
21
// functions in the destination chain contract handles funds and we want to make sure only MessageBus can call it
22
modifier onlyMessageBus() {
23
require(msg.sender == messageBus, "caller is not message bus");
24
_;
25
}
26
​
27
constructor(address _messageBus) {
28
messageBus = _messageBus; // we need to know where to send the messages
29
}
30
​
31
function batchTransfer(
32
address _receiver, // destination contract address
33
address _token, // the input token
34
uint256 _amount, // the input token amount
35
uint64 _dstChainId, // destination chain id
36
uint32 _maxSlippage, // the max amount of slippage allowed at bridge, represented in 1e6 as 100% (i.e. 1e4 = 1%)
37
MsgDataTypes.BridgeSendType _bridgeType, // the bridge type, for this tutorial, we are using liquidity bridge
38
address[] calldata _accounts, // the accounts on the destination chain that should receive the transfered fund
39
uint256[] calldata _amounts // the amounts for each account
40
) external payable {
41
// each transfer is assigned a nonce
42
nonce += 1;
43
44
// pull funds from the sender
45
IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount);
46
47
// encode a message, specifying how we want to distribute the funds on the destination chain
48
bytes memory message = abi.encode(
49
TransferRequest({accounts: _accounts, amounts: _amounts})
50
);
51
52
// MessageSenderLib is your swiss army knife of sending messages
53
MessageSenderLib.sendMessageWithTransfer(
54
_receiver,
55
_token,
56
_amount,
57
_dstChainId,
58
nonce,
59
_maxSlippage,
60
message,
61
_bridgeType,
62
messageBus,
63
msg.value
64
);
65
}
66
​
67
function executeMessageWithTransfer(
68
address _sender,
69
address _token,
70
uint256 _amount,
71
uint64 _srcChainId,
72
bytes memory _message,
73
address _executor
74
) external onlyMessageBus returns (IMessageReceiverApp.ExecutionStatus) {
75
// decode the message
76
TransferRequest memory transfer = abi.decode((_message), (TransferRequest));
77
// distribute the funds transfered
78
for (uint256 i = 0; i < transfer.accounts.length; i++) {
79
IERC20(_token).safeTransfer(transfer.accounts[i], transfer.amounts[i]);
80
}
81
// returning true indicates that the handling is successful
82
return IMessageReceiverApp.ExecutionStatus.Success;
83
}
84
}
Copied!

Deploy the Contract

Now we have our little happy BatchTransfer.sol, let's deploy it on Goerli and BSC Testnet. We are using Hardhat to do this, so let's modify the config file.
1
require("@nomiclabs/hardhat-ethers");
2
require("@nomiclabs/hardhat-etherscan");
3
​
4
// Remember to add your RPC provider URL for Goerli and populate the accounts
5
// arrays with your testing private key.
6
module.exports = {
7
solidity: "0.8.9",
8
networks: {
9
goerli: {
10
url: "<your_goerli_rpc_url>",
11
accounts: ["<your_testing_private_key>"],
12
},
13
bscTestnet: {
14
url: "https://data-seed-prebsc-1-s1.binance.org:8545",
15
accounts: ["<your_testing_private_key>"],
16
},
17
},
18
};
Copied!
Prepare scripts/deploy.js
1
async function main() {
2
const Contract = await ethers.getContractFactory("SimpleBatchTransfer");
3
const contract = await Contract.deploy(
4
"0x942E8e0e4b021F55b89660c886146e0Ec57F4b5B" // goerli MessageBus
5
// "0xAd204986D6cB67A5Bc76a3CB8974823F43Cb9AAA" // bsc testnet MessageBus
6
);
7
console.log("contract address:", contract.address);
8
}
9
​
10
main()
11
.then(() => process.exit(0))
12
.catch((error) => {
13
console.error(error);
14
process.exit(1);
15
});
Copied!
Deploy the contracts and remember to record the addresses of the deployed contracts as we will need them in the next step
1
npx hardhat run scripts/deploy.js --network goerli
2
# after the above step, don't forget to comment/uncomment the deploy script to use
3
# the correct MessageBus address for bsc testnet
4
npx hardhat run scripts/deploy.js --network bscTestnet
Copied!

Verify the Contracts

You should now have the contract address on both networks, let's verify them. We are using a Hardhat plugin to do this, make sure you have an API key for both Goerli and BSC testnet.
It is strongly recommended to get your api keys if you have not done so, we will use the both block explorers to interact with our contracts.
Add the following etherscan entry in your hardhat.config.js
1
module.exports = {
2
...
3
etherscan: {
4
apiKey: {
5
goerli: "<your-goerli-scan-api-key>",
6
bscTestnet: "<your-bsc-scan-api-key>",
7
},
8
},
9
}
Copied!
Now run the hardhat verify tasks. Note the last param is our contract's constructor param used when deploying the contract, which is the address of the message bus.
1
npx hardhat verify --network goerli <your-deployed-address-on-goerli> 0x942E8e0e4b021F55b89660c886146e0Ec57F4b5B
2
npx hardhat verify --network bscTestnet <your-deployed-address-on-bsc> 0xAd204986D6cB67A5Bc76a3CB8974823F43Cb9AAA
Copied!
Woot, that's quite some work, if everything went right, you should be able to see your contracts on GoerliScan and BscScan. Now we are just one component short of making an inter-chain app. Let's look into how to deploy the executor in the next section.

Executor

In this section, we will learn what the executor is and how it should be configured and deployed
The executor is a simple program: it polls SGN for available messages sent out by our SimpleBatchTransfer contract and calls MessageBus on the destination chain which in turn calls our SimpleBatchTransfer's executeMessageWithTransfer() on the destination chain.
Note: "available messages" are messages that
  1. 1.
    have been verified by enough SGN validators
  2. 2.
    have their corresponding token transfer verified
In addition, the executor also doesn't submit the message until the transfer associated with the message is executed on-chain.
Now let's start deploying the executor for our app.

Preparation

Let's create a home folder for the executor first, this is where the config files will live
1
mkdir ~/.executor
Copied!
Download the executor binary from this repo, or use curl
1
# Linux amd64
2
curl -L https://github.com/celer-network/sgn-v2-networks/raw/main/binaries/executor-v1.0.0-linux-amd64.tar.gz -o executor.tar.gz
3
# Linux arm64
4
curl -L https://github.com/celer-network/sgn-v2-networks/raw/main/binaries/executor-v1.0.0-linux-arm64.tar.gz -o executor.tar.gz
5
# MacOS Intel chip
6
curl -L https://github.com/celer-network/sgn-v2-networks/raw/main/binaries/executor-v1.0.0-darwin-amd64.tar.gz -o executor.tar.gz
7
# MacOS Apple chip
8
curl -L https://github.com/celer-network/sgn-v2-networks/raw/main/binaries/executor-v1。0.0-darwin-arm64.tar.gz -o executor.tar.gz
Copied!
Unzip it and move it to a directory on $PATH. We will use /usr/local/bin
1
tar -xvf executor.tar.gz && rm executor.tar.gz
2
mv executor-* /usr/local/bin/executor # may need sudo, or change this to your preferred location
Copied!
Make sure the binary runs
1
# shell
2
executor start
3
# output
4
2022-04-20 17:19:23.126 |INFO | root.go:49: Reading executor configs
5
2022-04-20 17:19:23.127 |INFO | start.go:48: Starting executor
6
...
Copied!
It won't actually run since we haven't setup any configs yet, but good to know it at least starts. If it doesn't, make sure you got the right distribution for your system arch.

Database Setup

Since the executor monitors on-chain events and keeps track of message execution, we'll need a database. In theory, the executor supports any databases that support any Postgresql dialect database, but it's only tested with CockroachDB for now

Installation

You can visit their website for more detailed instructions. Below is an example for running CockroachDB on macOS via Homebrew
1
brew install cockroachdb/tap/cockroach
Copied!

Start DB Instance

Start a single node instance in the background
1
cockroach start-single-node --store="$HOME/.crdb-node0" --listen-addr=localhost:26257 --http-addr=localhost:38080 --background --insecure
Copied!
Test connection
1
cockroach sql --insecure
Copied!
If you see this prompt then everything is right
1
# Welcome to the CockroachDB SQL shell.
2
# All statements must be terminated by a semicolon.
3
# To exit, type: \q.
4
#
5
# Server version: CockroachDB CCL v21.2.3 (x86_64-apple-darwin19, built 2021/12/14 15:26:20, go1.16.6) (same version as client)
6
# Cluster ID: 67881086-b544-4159-803f-2f7b952e1436
7
#
8
# Enter \? for a brief introduction.
9
#
10
[email protected]:26257/defaultdb>
Copied!
I know that's a lot of steps ... but thankfully that's all for the database. We are almost done here, just a little more configs, then we are off!

Configurations

The config is simple, you only need two config files and an ETH keystore file.
First, let's create the folders and files in the executor home
1
.executor/
2
- config/
3
- executor.toml
4
- cbridge.toml
5
- eth-ks/
6
- signer.json
Copied!
Now we have the files in place, let's take a look at each individual file and what they do.

signer.json

Since the job of the executor is to submit messages on-chain, a signer keystore is required. Eventually, you may want to delegate the gas cost of the transactions the executor makes to your users, but that's outside of the scope of this tutorial. We will discuss this topic in later chapters.

executor.toml

This config file houses information about app contract, connectivity, and keystore location. A standard executor.toml looks like this. Remember to fill in the contract addresses and the keystore passphrase.
1
# since we don't want the executor to execute messages that are not sent by our
2
# SimpleBatchTransfer contract, the following items are added to filter only
3
# the ones we care about
4
[[service]]
5
# Fully qualified absolute path only, "~" would not work
6
signer_keystore = "/Users/patrickmao/.executor/eth-ks/signer.json"
7
signer_passphrase = "<your-keystore-passphrase>"
8
[[service.contracts]]
9
chain_id = 5 # Goerli
10
address = "<SimpleBatchTransfer-address>"
11
[[service.contracts]]
12
chain_id = 97 # Bsc testnet
13
address = "<SimpleBatchTransfer-address>"
14
​
15
[sgnd]
16
# SGN testnet node0 grpc. executor reads available messages from this endpoint
17
sgn_grpc = "cbridge-v2-test.celer.network:9094"
18
# SGN testnet gateway grpc. all tx operations to the SGN is delegated through it
19
gateway_grpc = "cbridge-v2-test.celer.network:9094"
20
​
21
[db]
22
url = "localhost:26257"
Copied!

cbridge.toml

Executor relies on multiple on-chain events to do its job. This config file is where we configure on-chain event monitoring behaviors. The only things we need to care about for now is the address of the contracts and RPC endpoint URLs
1
[[multichain]]
2
chainID = 5
3
name = "Goerli"
4
gateway = "<your-goerli-rpc>" # fill in your Goerli rpc provider url
5
# cBridge (liquidity bridge) contract address. Executor relies on events from this
6
# contract to double check and make sure funds are transfered to the destination
7
# before it attempts messages on the destination chain
8
cbridge = "0x358234B325EF9eA8115291A8b81b7d33A2Fa762D"
9
# MessageBus contract address. Executor relies this to keep a message execution
10
# history (just so you can debug or help out angry customers).
11
msgbus = "0x942E8e0e4b021F55b89660c886146e0Ec57F4b5B"
12
blkinterval = 15 # polling interval
13
blkdelay = 5 # how many blocks confirmations are required
14
maxblkdelta = 5000 # max number of blocks per poll request
15
​
16
[[multichain]]
17
chainID = 97
18
name = "BSC Testnet"
19
gateway = "https://data-seed-prebsc-2-s3.binance.org:8545/"
20
cbridge = "0xf89354F314faF344Abd754924438bA798E306DF2"
21
msgbus = "0xF25170F86E4291a99a9A560032Fe9948b8BcFBB2"
22
blkinterval = 3
23
blkdelay = 8
24
maxblkdelta = 5000
25
# on some EVM chains the gas estimation can be off. the below fields
26
# are added to make up for the inconsistancies.
27
addgasgwei = 2 # add 2 gwei to gas price
28
addgasestimateratio = 0.3 # multiply gas limit by this ratio
Copied!

Running the Executor

Now with the configs and database out of the way, running the executor is as simple as a line of command (we'll discuss more reliable deployment methods in Integration Tutorial: Advanced)
1
executor start --loglevel debug --home $HOME/.executor
Copied!
Sometimes executor start might fail because of failures to dial either SGN node or SGN gateway gRPC. It's probably because we are deploying something. Just wait a while and it'll most likely resolve.
That's it, the entire app stack is fully functional now. We've come a long way, and now is the moment of truth, will it work or not?

Testing the App

For testing, we are using test CELR on Goerli. Please add it to your wallet 0x5d3c0f4ca5ee99f8e8f59ff9a5fab04f6a7e007f

Prepare Funds

Before we start testing make sure that executor's signer and test sender account have funds. You can go to the faucet on Goerli to and call drip(0x5d3c0f4ca5ee99f8e8f59ff9a5fab04f6a7e007f) to get some CELR

Approve Token

Make sure you approve SimpleBatchTransfer for CELR usage before interacting with it.

Send the Transfer Request

Now we are ready to call our SimpleBatchTransfer contract on Goerli to initiate the whole cross-chain batch transfer process.
Note: the first param payable amount is the fee for this cross-chain transaction, we are omitting this for now.
1
batchTransfer 0
2
_receiver <SimpleBatchTransfer-address-on-bsc>
3
_token 0x5D3c0F4cA5EE99f8E8F59Ff9A5fAb04F6a7e007f # CELR
4
_amount 100000000000000000000 # 100
5
_dstChainId 97 # BSC testnet
6
_maxSlippage 1000000 # slippage allowed at bridge, pools on testnets tend to be imbalanced, using 100% to avoid bridge failure
7
_bridgeType 1 # pool-based liquidity bridge
8
_accounts 0x05A0540E71198cF0876ECa1072b3C5D091bC26fA,0x9B5fc6C0e7163e69154168510504388E1FD9d882
9
_amounts 40000000000000000000,40000000000000000000 # 40, 40 to be safe since bridge takes some fee
Copied!
After calling the contract, it may take around 30 ~ 120 seconds or so for SGN to monitor, verify and sign the transfer and the message. The executor will automatically pick up the message. The logs should look like this
1
β”‚2022-04-27 01:19:42.781 |INFO | executor.go:542: executed xferMsg (id f66aec9401cbc77b525eafccaec49b6f4fe1a0af10c2c26858ee6d47c7628ef0): txhash 2c7fdaa4052af312119553bbae56d47322b981859254fb013afb29a3a40e19e2 β”‚
Copied!
Let's copy the txhash and check it out on BscScan​
If somehow the transaction on BSC testnet fails, it is likely that due to the pool imbalance on testnet, the total amount of CELR transferred from Goerli to BSC testnet is lower than the sum of _amounts. Lower the _amounts and try again.
And let's check if the test accounts have got their 40 CELR.
Address 0x9B5fc6C0e7163e69154168510504388E1FD9d882 | BscScan
Binance (BNB) Blockchain Explorer
Address 0x05A0540E71198cF0876ECa1072b3C5D091bC26fA | BscScan
Binance (BNB) Blockchain Explorer
They indeed got it! At this stage our simple app is fully functional, let's round up what we have done in this tutorial.

Conclusion

We built a SimpleBatchTransfer contract that takes the sender's money on Goerli, sends out a message along with a transfer.
We also set up an executor that pulls available messages from Celer SGN then calls the MessageBus on BSC testnet, which in turn calls our contract's executeMessageWithTransfer(). The fund is then distributed to the accounts we defined.
There are still some more advanced functionalities we want to add in in order to make our SImpleBatchTransfer not that simple but actually production-ready. These advanced topics are covered in the Integration Guide: Advanced:
  1. 1.
    Sending messages without transfers
  2. 2.
    Deploying executor using systemd
  3. 3.
    Dealing with Bridge failures (refunds)
  4. 4.
    Dealing with failed executions on destination chain
  5. 5.
    Fee mechanisms
  6. 6.
    Chaining messages