Building a High-Priority Transaction Queue with Alloy Fillers
In this guide, we will explore more advanced use cases of Alloy Providers APIs. We will cover non-standard ways to instantiate and customize providers and deep dive into custom layers and fillers implementations. We have a lot to cover, so let's get started!
Fillers
Fillers decorate a Provider, and hook into the transaction lifecycle filling details before they are sent to the network. We can use fillers to build a transaction preprocessing pipeline, "filling" all the missing properties such as nonce
, chain_id
, max_fee_per_gas
, and max_priority_fee_per_gas
etc.
Since, alloy v0.11.0
the most essential fillers are enabled by default when building a provider using ProviderBuilder::new()
.
These core fillers are termed as RecommendedFillers
and consists of the following:
NonceFiller
: Fills thenonce
field of a transaction with the next available nonce.ChainIdFiller
: Fills thechain_id
field of a transaction with the chain ID of the provider.GasFiller
: Fills the gas related fields such asgas_price
,gas_limit
,max_fee_per_gas
andmax_priority_fee_per_gas
fields of a transaction with the current gas price.BlobGasFiller
: Fills themax_fee_per_blob_gas
field for EIP-4844 transactions.
In a world without the above fillers, sending a simple transfer transaction looks like the following:
//! Instantiate a basic provider without any fillers or layers.
use eyre::Result;
use alloy::{
network::TransactionBuilder,
node_bindings::Anvil,
primitives::{Address, U256},
providers::{Provider, ProviderBuilder},
rpc::types::TransactionRequest,
signers::local::PrivateKeySigner,
};
#[tokio::main]
async fn main() -> Result<()> {
// Spawn an Anvil instance
// Make sure `anvil` is in $PATH
let anvil = Anvil::new().try_spawn()?;
let signer: PrivateKeySigner = anvil.keys()[0].clone().into();
let alice = signer.address();
let provider = ProviderBuilder::new()
// Disable the recommended fillers that are enabled by default
.disable_recommended_fillers()
// Add the signer to the provider for signing transactions
.wallet(signer)
.on_http(anvil.endpoint().parse()?);
let bob = Address::from([0x42; 20]);
let fees = provider.estimate_eip1559_fees().await?;
let nonce = provider.get_transaction_count(alice).await?;
let chain_id = provider.get_chain_id().await?;
let tx = TransactionRequest::default()
.with_value(U256::from(1))
.with_chain_id(chain_id)
.with_from(alice)
.with_nonce(nonce)
.with_max_fee_per_gas(fees.max_fee_per_gas)
.with_max_priority_fee_per_gas(fees.max_priority_fee_per_gas)
.with_gas_limit(21000)
.with_to(bob)
.with_value(U256::from(1));
let bob_balance_before = provider.get_balance(bob).await?;
let receipt = provider.send_transaction(tx).await?.get_receipt().await?;
assert!(receipt.status(), "Transaction failed");
let bob_balance_after = provider.get_balance(bob).await?;
println!("Balance before: {}\nBalance after: {}", bob_balance_before, bob_balance_after);
Ok(())
}
In this example, we sent 1 wei from alice
(default anvil account) to bob
. You can see that a lot of boilerplate is involved in building the transaction data. We must manually check the account's current nonce
, network fees, gas_limit
, and chain_id
.
If we omitted any of the transaction properties we'd see an error like:
Caused by:
missing properties: [("Wallet", ["nonce", "gas_limit", "max_fee_per_gas", "max_priority_fee_per_gas"])]
Now, let's see how using RecommendedFillers
improves this:
#[tokio::main]
async fn main() -> Result<()> {
let provider = ProviderBuilder::new().on_anvil_with_wallet();
let bob = Address::from([0x42; 20]);
let tx = TransactionRequest::default()
.with_to(bob)
.with_value(U256::from(1));
let bob_balance_before = provider.get_balance(bob).await?;
_ = provider.send_transaction(tx).await?.get_receipt().await?;
let bob_balance_after = provider.get_balance(bob).await?;
println!(
"Balance before: {}\nBalance after: {}",
bob_balance_before, bob_balance_after
);
Ok(())
}
We've removed ~15 LOC while preserving the same functionality! Most heavy lifting was taken over by recommended fillers that are enabled upon ProviderBuilder::new()
and the on_anvil_with_wallet
method.
on_anvil_with_wallet
is a helper method that implicitly spawns the Anvil process and enables the WalletFiller
that sets the from
field based on the wallet's signer address and signs the transaction.
This explains why we could omit filling out nonce
, chain_id
, max_fee_per_gas
and max_priority_fee_per_gas
in the second example.
In case you want you want to disable the default fillers you can do so by calling disable_recommended_fillers()
on the ProviderBuilder
, and setting the fillers of your choice manually.
Alloy comes with builder methods for automatically applying fillers to providers:
-
wallet
- setfrom
based on the wallet's signer address -
fetch_chain_id
- automatically setchain_id
based on data from the provider -
with_chain_id
- automatically setchain_id
based on provided value -
with_simple_nonce_management
- setnonce
based on txs count from provider -
with_cached_nonce_management
- like above but with caching -
with_nonce_management
- provided customnonce
management strategy -
with_gas_estimation
- set gas prices based on data from the provider
Let's go beyond the basics and implement a custom filler to better understand the inner workings.
Implementing a custom filler
Submitting txs with a high-enough gas price, to land in the next block is a common use case. We will implement a custom filler to automatically check and fill the correct gas price.
We will query the free Blocknative Gas API to check the recommended gas price, and land our payload in the next block.
We will be working with the following API output:
curl https://api.blocknative.com/gasprices/blockprices
It shows gas prices needed to commit tx in the next block, with a specified confidence.
To build a custom filler, you have to implement a TxFiller
trait. Here's a sample implementation for our UrgentQueue
filler:
#[derive(Clone, Debug, Default)]
pub struct UrgentQueue {
client: Client,
}
impl UrgentQueue {
pub fn new() -> Self {
Self {
client: Client::new(),
}
}
}
#[derive(Debug)]
pub struct GasPriceFillable {
max_fee_per_gas: u128,
max_priority_fee_per_gas: u128,
}
impl<N: Network> TxFiller<N> for UrgentQueue {
type Fillable = GasPriceFillable;
fn status(&self, tx: &<N as Network>::TransactionRequest) -> FillerControlFlow {
if tx.max_fee_per_gas().is_some() && tx.max_priority_fee_per_gas().is_some() {
FillerControlFlow::Finished
} else {
FillerControlFlow::Ready
}
}
fn fill_sync(&self, _tx: &mut SendableTx<N>) {}
async fn fill(
&self,
fillable: Self::Fillable,
mut tx: SendableTx<N>,
) -> TransportResult<SendableTx<N>> {
if let Some(builder) = tx.as_mut_builder() {
builder.set_max_fee_per_gas(fillable.max_fee_per_gas);
builder.set_max_priority_fee_per_gas(fillable.max_priority_fee_per_gas);
} else {
panic!("Expected a builder");
}
Ok(tx)
}
async fn prepare<P, T>(
&self,
_provider: &P,
_tx: &<N as Network>::TransactionRequest,
) -> TransportResult<Self::Fillable>
where
P: Provider<T, N>,
T: Transport + Clone,
{
let data = match self
.client
.get("https://api.blocknative.com/gasprices/blockprices")
.send()
.await
{
Ok(res) => res,
Err(e) => {
return Err(RpcError::Transport(TransportErrorKind::Custom(Box::new(
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to fetch gas price, {}", e),
),
))));
}
};
let body = data.text().await.unwrap();
let json = serde_json::from_str::<serde_json::Value>(&body).unwrap();
let prices = &json["blockPrices"][0]["estimatedPrices"][0];
let max_fee_per_gas = (prices["maxFeePerGas"].as_f64().unwrap() * 1e9) as u128;
let max_priority_fee_per_gas =
(prices["maxPriorityFeePerGas"].as_f64().unwrap() * 1e9) as u128;
let fillable = GasPriceFillable {
max_fee_per_gas,
max_priority_fee_per_gas,
};
Ok(fillable)
}
}
The above implementation fetches gas prices from the Blocknative API and injects them into our transaction. With this implementation, we'll have 99% confidence that our transaction will land in the next block. Here's how you can build the provider with the UrgentQueue
filler:
let provider = ProviderBuilder::new()
.filler(UrgentQueue::default())
.on_anvil_with_wallet();
The rest of the example remains the same. It shows a great feature of fillers, i.e. composability. They are processed in reverse order, meaning that our UrgentQueue
filler will take precedence over the built-in GasFiller
.
Summary
Fillers are helpful in reworking txs submission logic, depending on any custom conditions. The presented UrgentQueue
implementation is relatively basic, but should serve you as a starting point for building your custom fillers.