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 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.
Ethers | Alloy | Speedup |
---|---|---|
997.39ns | 92.69 ns | 10.76x 🚀 |
Alloy is ~10x faster than ethers-rs! Here's a chart to visualize the difference:
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.
Ethers | Alloy | Speedup |
---|---|---|
2.12μs | 1.79μs | 1.19x 🚀 |
Here's a chart to visualize the difference:
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.