Develop an Ethereum bridge with Rust

Lorenzo Zaccagnini,blockchainoraclebridgerustalchemyinfuraweb3
Photo

A blockchain bridge is a system that allows the transfer of assets between two different blockchains. It is a crucial component of the blockchain ecosystem as it allows the interoperability of different blockchains. In this article, we will develop a bridge between two EVM-compatible blockchains. We will use the Rust programming language.

Rust is a programming language that is gaining popularity in the blockchain ecosystem. It is a multi-paradigm language that is safe, fast, and concurrent. It is also a systems programming language that is designed to build low-level software. It is a great choice for developing blockchain bridges because of its performance and security.

1. Bridges are oracles

Bridges are two-way oracles, which means that they can be used to send data from one blockchain to another. The data can be anything, but in this article, we will focus on sending and burning tokens in two different EVM-compatible blockchains.

More information about oracles can be found in my previous article: Develop an Ethereum oracle with Rust (opens in a new tab)

2. A bridge architecture

The bridge architecture is very simple from a general point of view. It consists of two smart contracts, one on each blockchain. The first contract is called the bridge contract. It is deployed on the source blockchain and it is responsible for locking or burning the tokens that need to be transferred. The second contract is called the destination contract. It is deployed on the destination blockchain and it is responsible for minting the tokens that need to be transferred. This architecture can work in both directions, but in this article, we will focus on the transfer of tokens from the source blockchain to the destination blockchain.

3. The bridge contract

In this case we will develop a "Burn and mint" architecture, so the smart contract on the source blockchain will be responsible for burning the tokens that need to be transferred. The smart contract on the destination blockchain will be responsible for minting the tokens that need to be transferred. Again this can work in both directions.

3.1 The bridge token

The bridge token will be a simple ERC20 token. ERC20 token is a standard for tokens on the Ethereum blockchain. It is a very simple standard that allows the creation of tokens that can be transferred, received, and burned. I will use openzeppelin contracts to develop the bridge token. Openzeppelin is a very popular library for smart contracts development. It contains a lot of useful contracts that can be used to develop smart contracts.

Burning means that the tokens are destroyed. Burning tokens is a very useful feature for a token. It allows the token to be deflationary. Technically means to send the tokens to the address 0x0000000000000000000000000000000000000000. This address is called the zero address and it is a special address that is used to burn tokens. No one can access the zero address, so the tokens are destroyed forever.

Transferring assets between two blockchains without burning or locking will cause a double spending problem. The double spending problem is a problem that occurs when the same asset is spent more than once. In this case, the asset is the token. If the token is not burned or locked, it can be spent on both blockchains. This will cause a double spending problem and makes the token worthless like your developer skills.

This will happen with a probability of 101% because bridges are oracles. Oracles are not 101% reliable. They can fail and they often fail. If this bridge fails, the token will be spent on both blockchains and people on twitter will call you a scammer. So if you are a scammer you can skip this step.

3.2 Coding the bridge contract

The smart contract code will be the same on both blockchains, but deployed obviously on both blockchains. The code is very simple and it is composed of two functions: burn and mint. The burn function is responsible for burning the tokens that need to be transferred. The mint function is responsible for minting the tokens that need to be transferred.

Only the owner of the smart contract can call the mint function. The owner of the smart contract is the address that deployed the smart contract. If someone that is not the owner of the smart contract can call the mint function, the bridge will be vulnerable to attacks and people on twitter will call you a scammer, again. So please use a multisig wallet to deploy the smart contract, so people on twitter will not call only you a scammer, but also the other people in the multisig wallet. LGTM.

Please don't be the owner of all the multisig wallets, because people on twitter will call you a scammer and a dictator.

The burn function is responsible for burning the tokens that need to be transferred and can be called by any token holder that has a balance greater than zero.

pragma solidity ^0.8.9;
 
import "@openzeppelin/contracts@4.8.0/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts@4.8.0/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts@4.8.0/access/Ownable.sol";
 
contract GBridgeToken is ERC20, ERC20Burnable, Ownable {
    constructor() ERC20("gBridgeToken", "GBT") {
        _mint(msg.sender, 1000 * 10**decimals());
    }
 
    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }
}

The functions with the underscore are inherited from the openzeppelin contracts. The mint function is responsible for minting the tokens. The burn function is responsible for burning the tokens. The onlyOwner modifier is responsible for checking if the caller of the function is the owner of the smart contract. The owner is the address that deployed the smart contract. The msg.sender is the address that called the function.

In the constructor I premint 1000 tokens and I assign them to the address that deployed the smart contract. This is not necessary, but it is a good practice to premint some tokens to the address that deployed the smart contract. This will allow the owner of the smart contract to test the bridge before deploying it on the mainnet.

3.3 Deploy the bridge contracts

The bridge contracts can be deployed on any EVM-compatible blockchain. In this article, I will deploy the bridge contracts on two local ganache blockchains. One on localhost:8545 and one on localhost:7545.

I use remix to connect to the ganache blockchains. Remix is a web IDE for smart contracts development. It is very useful for testing smart contracts. It allows you to connect to different blockchains and to deploy smart contracts. It also allows you to interact with the smart contracts.

Copy the code into remix, compile, select the ganache local chain and deploy the smart contract. If you don't know how to do this you are on the dunning-kruger curve bad side and you should not be developing smart contracts. If you are scammer you can skip this step.

Jokes apart, learn the basics before developing smart contracts and oracles, people can get really angry on twitter.

4. Events

Events are a very useful feature of smart contracts. They allow you to log data in the blockchain. The data can be anything, but in this case, we will listen to the transfer event of the bridge token. The transfer event is emitted every time a token is transferred.

If the contracts are deployed correctly you will see transfer events mint or burn are called. The burn event should look like this:

"topics": [
    "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
    "0x000000000000000000000000399cf2e8d5c14ac04f1599c844a42be4d712b3eb",
    "0x0000000000000000000000000000000000000000000000000000000000000000"
]

The event signature for ERC20 transfers, equals sha3("Transfer(address,address,uint256)"), to know more about events and topic check my other article Develop an Ethereum oracle with Rust (opens in a new tab)

5. The bridge rust code

The rust code is in part taken from the article Develop an Ethereum oracle with Rust (opens in a new tab). The rust code is responsible for listening to the transfer events of the bridge token and for calling the mint function of the bridge contract on the destination blockchain when a transfer event to the zero address (a burn) is detected.

5.1 The bridge crates

This the Cargo.toml file used:

[package]
name = "blockchain_oracle"
version = "0.1.0"
edition = "2021"
 
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
[dependencies]
web3 = "0.17.0"
tokio = { version= "1", features = ["full"] }
hex = "0.4.3"
ethnum = "1.3.0"

5.2 Connecting to the blockchain

Let's start by connecting to the blockchain.

use ethnum::U256;
use web3::contract::{Contract, Options};
use web3::futures::StreamExt;
 
#[tokio::main]
async fn main() -> web3::contract::Result<()> {
    let web3_source_chain_ws =
        web3::Web3::new(web3::transports::WebSocket::new("ws://localhost:8545").await?);
}

The first part imports the crates, the second part is the main function, the third part is the code that connects to the blockchain. The web3_source_chain_ws is the web3 instance used to connect to the source blockchain.

5.3 Listening to the transfer events

Here I filter and decode the transfer event, dividing the burn and normal transfer events.

use ethnum::U256;
use web3::contract::{Contract, Options};
use web3::futures::StreamExt;
 
#[tokio::main]
async fn main() -> web3::contract::Result<()> {
    let web3_source_chain_ws =
        web3::Web3::new(web3::transports::WebSocket::new("ws://localhost:8545").await?);
 
    let event_signature = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
 
    let source_sc_address = "0xB9d01d2E0FF04A2Ff2f0720Dd69e73F7671b55CE";
 
 
    let filter_source_transfer = web3::types::FilterBuilder::default()
        .address(vec![source_sc_address.parse().unwrap()])
        .from_block(web3::types::BlockNumber::Latest)
        .topics(
            Some(vec![event_signature.parse().unwrap()]),
            None,
            None,
            None,
        )
        .build();
 
    let sub_ganache = web3_source_chain_ws
        .eth_subscribe()
        .subscribe_logs(filter_source_transfer)
        .await?;
 
    let sub_ganache_logging = sub_ganache.for_each(|log| async move {
        let address = format!("{:?}", log.clone().unwrap().topics[2]);
 
        match address.as_str() {
            "0x0000000000000000000000000000000000000000000000000000000000000000" => {
                println!("Burned");
                let amount_decoded =
                    U256::from_str_radix(&hex::encode(log.unwrap().data.0), 16).unwrap();
                println!("Amount burned: {}", amount_decoded);
            }
            _ => {
                println!("Transferred");
            }
        }
    });
 
    sub_ganache_logging.await;
 
    Ok(())
}

The amount is not indexed so I decode it from the data field. The address is the second topic of the event, is where the token are sent, so I check if it is the zero address to know if it is a burn or a normal transfer.

If everything is working correctly you should see the Burned and Amount burned printed in the console, when you call the burn function on the source blockchain.

Burned
Amount burned: 666

5.4 Load the smart contract

Now we can listen, half of the work is done. Now we need to load the smart contract on the destination blockchain. We create a new function mint_tokens

async fn mint_tokens(amount: u64, account_target: &str, smart_contract_address: &str) {
    let web3_destination_chain =
        web3::Web3::new(web3::transports::Http::new("http://localhost:7545").unwrap());
 
    let web3_destination_chain_contract = Contract::from_json(
        web3_destination_chain.eth(),
        smart_contract_address.parse().unwrap(),
        include_bytes!("GBridgeToken.json"),
    )
    .unwrap();

The web3_destination_chain_contract is the contract instance on the destination blockchain. The include_bytes! macro is used to include the ABI of the smart contract. The ABI is generated by the solc compiler. The json file is the ABI that is generated by the solc compiler or remix IDE.

5.5 Minting the tokens

Now we can mint the tokens on the destination blockchain when we receive a burn event on the source blockchain.

async fn mint_tokens(amount: u64, account_target: &str, smart_contract_address: &str) {
    let web3_destination_chain =
        web3::Web3::new(web3::transports::Http::new("http://localhost:7545").unwrap());
 
    let web3_destination_chain_contract = Contract::from_json(
        web3_destination_chain.eth(),
        smart_contract_address.parse().unwrap(),
        include_bytes!("GBridgeToken.json"),
    )
    .unwrap();
 
    let ganache_accounts = web3_destination_chain.eth().accounts().await.unwrap();
    let account = ganache_accounts[0];
 
    //convert account_target to address
    let account_target_address =
        web3::types::Address::from_slice(&hex::decode(account_target.replace("0x", "")).unwrap());
 
    web3_destination_chain_contract
        .call(
            "mint",
            (account_target_address, amount),
            account,
            Options::default(),
        )
        .await
        .unwrap();
}

First I get the accounts from the destination blockchain, then I convert the account_target to an address. The account_target sends token to the same address on the destination blockchain.

The account is the account that will call the mint function, it should be the owner of the smart contract on the destination blockchain. The Options::default() is used to set the gas price and gas limit. The web3_destination_chain_contract.call is used to call the mint function.

The amount is the amount of tokens that will be minted on the destination blockchain, it is same quantity of the burned tokens on the source blockchain.

If you see the token balance of the account_target on the destination blockchain, you should see the added the amount of tokens that you burned on the source blockchain.

5.6 Putting it all together

Now we can put it all together. We need to listen to the transfer events on the source blockchain and mint the tokens on the destination blockchain.

All the code can be found in this GitHub repository (opens in a new tab)

use ethnum::U256;
use web3::contract::{Contract, Options};
use web3::futures::StreamExt;
 
#[tokio::main]
async fn main() -> web3::contract::Result<()> {
    let web3_source_chain_ws =
        web3::Web3::new(web3::transports::WebSocket::new("ws://localhost:8545").await?);
 
    let event_signature = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
 
    let source_sc_address = "0xB9d01d2E0FF04A2Ff2f0720Dd69e73F7671b55CE";
    let destionation_sc_address = "0x4641B307794E29062906dc5fEd72152faEBB1C77";
 
    let filter_source_transfer = web3::types::FilterBuilder::default()
        .address(vec![source_sc_address.parse().unwrap()])
        .from_block(web3::types::BlockNumber::Latest)
        .topics(
            Some(vec![event_signature.parse().unwrap()]),
            None,
            None,
            None,
        )
        .build();
 
    let sub_ganache = web3_source_chain_ws
        .eth_subscribe()
        .subscribe_logs(filter_source_transfer)
        .await?;
 
    let sub_ganache_logging = sub_ganache.for_each(|log| async move {
        let address = format!("{:?}", log.clone().unwrap().topics[2]);
        let address_from_raw = format!("{:?}", log.clone().unwrap().topics[1]);
        let address_from_decoded = format!("0x{}", &address_from_raw[26..66]);
 
        match address.as_str() {
            "0x0000000000000000000000000000000000000000000000000000000000000000" => {
                println!("Burned");
                let amount_decoded =
                    U256::from_str_radix(&hex::encode(log.clone().unwrap().data.0), 16).unwrap();
                println!("Amount burned: {}", amount_decoded);
 
                //mint tokens on the destination chain
                mint_tokens(
                    amount_decoded.as_u64(),
                    &address_from_decoded,
                    &destionation_sc_address,
                )
                .await;
 
                println!("Burned from: {}", address_from_decoded);
            }
            _ => {
                println!("Transferred");
            }
        }
    });
 
    sub_ganache_logging.await;
 
    Ok(())
}
 
async fn mint_tokens(amount: u64, account_target: &str, smart_contract_address: &str) {
    let web3_destination_chain =
        web3::Web3::new(web3::transports::Http::new("http://localhost:7545").unwrap());
 
    let web3_destination_chain_contract = Contract::from_json(
        web3_destination_chain.eth(),
        smart_contract_address.parse().unwrap(),
        include_bytes!("GBridgeToken.json"),
    )
    .unwrap();
 
    let ganache_accounts = web3_destination_chain.eth().accounts().await.unwrap();
    let account = ganache_accounts[0];
 
    //convert account_target to address
    let account_target_address =
        web3::types::Address::from_slice(&hex::decode(account_target.replace("0x", "")).unwrap());
 
    web3_destination_chain_contract
        .call(
            "mint",
            (account_target_address, amount),
            account,
            Options::default(),
        )
        .await
        .unwrap();
}

I've added the mint function to the main function when a burn event is detected. The mint_tokens function is the same as the one we used in the previous section.

6. Aggregate transactions on a bridge oracle

An efficient bridge oracle will aggregate the transactions before sending them to the destination blockchain. It's really important to aggregate the transactions because it will reduce the gas cost and the transaction fees, imagine a bridge oracle use thousand of times every day that will send a transaction for each transfer event, it will be really expensive.

In order to do that is possible to use data structure like a Merkle tree. A Merkle tree is a data structure that allows to aggregate the transactions and verify the aggregated transactions. In the future we will see the use of Verkle trees that are more efficient than Merkle trees. Maybe this topic will be covered in a future article.

7. Do you need to develop an oracle or a bridge?

You can contact me Lorenzo Zaccagnini (opens in a new tab) or Elisa Romondia (opens in a new tab) on LinkedIn. If you want to support me you can donate eth or matic to 0xbf8d0d4be61De94EFCCEffbe5D414f911F11cBF8

© Lorenzo Zaccagnini.RSS