Skip to content

Performant Static and Dynamic ABI Encoding

In this guide, we will discuss new ways to work with blockchain ABIs introduced in Alloy. We will showcase basic smart contract interactions and how they compare to ethers-rs. We will also discuss more advanced ways to interact with runtime-constructed dynamic ABIs.

Alloy ABI 101

Below we have implemented a simulation of an arbitrage swap between two UniswapV2 pairs. We've used the following ABI definitions:

sol! {
    function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
}
 
sol!(
    #[sol(rpc)]
    contract IERC20 {
        function balanceOf(address target) returns (uint256);
    }
);
 
sol!(
    #[sol(rpc)]
    FlashBotsMultiCall,
    "artifacts/FlashBotsMultiCall.json"
);

You can find the complete example and ABI artifacts here.

Let's look into these examples in more detail.

Static calldata encoding

sol! {
    function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
}

Alloy introduces the sol! macro that allows to embed compile-time-safe Solidity code directly in your Rust project. In the example, we use it to encode calldata needed for executing swap methods for our arbitrage. Here's how you can encode calldata:

// swapCall is the struct generated by the sol! macro
let swap_calldata = swapCall {
    amount0Out: U256::from(1000000000000000000_u128)
    amount1Out: U256::ZERO,
    to: sushi_pair.address,
    data: Bytes::new(),
}
.abi_encode();

As a result, you'll get a Vec<u8> type that you can assign to an input field of a transaction.

It's worth noting that sol! macro works with any semantically correct Solidity snippets. Here's how you can generate a calldata for a method that accepts a struct. For example:

sol! {
    // Define your custom solidity struct
    struct MyStruct {
        uint256 id;
        string name;
        bool isActive;
    }
 
    // And a function that accepts it as an argument
    function setStruct(MyStruct memory _myStruct) external;
}
 
let my_struct = MyStruct {
    id: U256::from(1),
    name: "Hello".to_string(),
    isActive: true,
};
 
// Encode the calldata for the `setStruct` fn call
let calldata = setStructCall {
    _myStruct: my_struct.clone(),
}.abi_encode();

We've just imported a Solidity struct straight into the Rust code. You can use cast interface CLI to generate Solidity snippets for any verified contract:

cast
cast interface 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 --etherscan-api-key API_KEY

Performance benchmark ethers-rs vs. Alloy

On top of a nicer API, Alloy also comes with significantly better performance benefits.

We've used the criterion.rs crate to produce reliable benchmarks for comparing encoding of static calldata for a method call. The reproducible benchmarks can be found here.

EthersAlloySpeedup
997.39ns92.69 ns10.76x 🚀

Alloy is ~10x faster than ethers-rs! Here's a chart to visualize the difference:

Static ABI encoding performance comparison

Interacting with on-chain contracts

Using #[sol(rpc)] marco, you can easily generate an interface to an on-chain contract. Let's see it in action:

sol!(
    #[sol(rpc)]
    contract IERC20 {
        function balanceOf(address target) returns (uint256);
        function name() returns (string);
    }
);
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let provider = ProviderBuilder::new().connect("https://reth-ethereum.ithaca.xyz/rpc").await?;
    let iweth = IERC20::new(address!("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"), provider.clone());
    let name = weth.name().call().await?;
    println!("Name: {}", name); // => Wrapped Ether
}

Alternatively, instead of defining the interface methods in Solidity, you can use the standard JSON ABI file generated using cast interface with --json flag:

cast interface 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 --json --etherscan-api-key API_KEY > abi/weth.json

Later you can use it like that:

sol!(
    IERC20,
    "abi/WETH.json"
);

Deploying Smart Contract with sol! macro

To deploy a smart contract, you must use its "build artifact file". It's a JSON file you can generate by running the forge build command:

forge build contracts/FlashBotsMultiCall.sol

It produces a file containing contract's ABI, bytecode, deployed bytecode, and other metadata.

Alternatively, you can use a recently added cast artifact method:

cast artifact 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 --etherscan-api-key $API_KEY --rpc-url $ETH_RPC_URL -o weth.json

It generates a minimal artifact file containing contract's bytecode and ABI based on Etherscan and RPC data. Contrary to forge build, you don't have to compile contracts locally to use it.

You can later use an artifact file with the sol! macro like this:

sol!(
    #[sol(rpc)]
    FlashBotsMultiCall,
    "artifacts/FlashBotsMultiCall.json"
);
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let anvil = Anvil::new().fork("https://reth-ethereum.ithaca.xyz/rpc").try_spawn()?;
    let wallet_address = anvil.addresses()[0];
    let provider = ProviderBuilder::new().connect(anvil.endpoint()).await?;
    let executor = FlashBotsMultiCall::deploy(provider.clone(), wallet_address).await?;
 
    println!("Executor deployed at: {}", *executor.address());
}

It uses Anvil to fork the mainnet and deploy smart contract to the local network.

You can also use cast constructor-args command to check what were the original deployment arguments:

cast constructor-args 0x6982508145454ce325ddbe47a25d4ec3d2311933 --etherscan-api-key $API_KEY --rpc-url $ETH_RPC_URL
 
# 0x00000000000000000000000000000000000014bddab3e51a57cff87a50000000 → Uint(420690000000000000000000000000000, 256)

We've only discussed a few common ways to use ABI and sol! macro in Alloy. Make sure to check the official docs for more examples.

How to use dynamic ABI encoding?

All the previous examples were using so-called static ABIs, i.e., with format known at the compile time. However, there are use cases where you'll have to work with ABI formats interpreted in the runtime.

One practical example is a backend for a web3 wallet. It's not possible to include all the possible ABIs in the binary. So you'll have to work with JSON ABI files downloaded from the Etherscan API based on a user-provided address.

Let's assume that we want to generate a calldata for a UniswapV2 WETH/DAI pair swap method with arguments provided by a user:

// Solidity: function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
use alloy::{
    dyn_abi::{DynSolValue, JsonAbiExt},
    hex,
    json_abi::Function,
    primitives::{uint, Address, Bytes, U256},
};
use std::error::Error;
 
fn main() -> Result<(), Box<dyn Error>> {
    // Setup the dynamic inputs for the `swap` function
    let input = vec![
        DynSolValue::Uint(uint!(100000000000000000_U256), 256),
        DynSolValue::Uint(U256::ZERO, 256),
        DynSolValue::Address(Address::from([0x42; 20])),
        DynSolValue::Bytes(Bytes::new().into()),
    ];
 
    // Parse the function signature
    let func = Function::parse(
        "function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external",
    )?;
 
    // Dynamically encode the function call
    let input = func.abi_encode_input(&input)?;
    println!("Calldata: {}", hex::encode(&input));
 
    Ok(())
}

In the above example, we used the DynSolValue enum type to setup the input values for the swap function. We then parsed the funtion signature into the Function type which gives us access to the abi_encode_input method. This method accepts a vector of DynSolValue allowing us to dynamically encode the function calldata.

This approach offers flexibility for interacting with virtually any ABIs that can be defined in a runtime.

Performance benchmark for dynamic vs static ABI encoding

Let's now compare the performance of generating swap method calldata for the same arguments with with Alloy and ethers-rs. You can find the reproducible criterion benchmark here.

EthersAlloySpeedup
2.12μs1.79μs1.19x 🚀

Here's a chart to visualize the difference:

Dynamic ABI encoding performance comparison

This time difference is not as dramatic as in static encoding. But Alloy is still ~20% faster than ethers-rs.

Summary

We've discussed common ways to interact with smart contracts ABI using the new Alloy stack.