The transaction lifecycle
This article will walk you through the process of defining a transaction to send 100 wei
from Alice
to Bob
, signing the transaction and broadcasting the signed transaction to the Ethereum network.
Let’s express our intent in the form of a
TransactionRequest
:
// Build a transaction to send 100 wei from Alice to Bob.
let tx = TransactionRequest::default()
.with_from(alice)
.with_to(bob)
.with_nonce(nonce)
.with_chain_id(chain_id)
.with_value(U256::from(100))
.with_gas_price(gas_price)
.with_gas_limit(gas_limit);
Setup
First we will set up our environment:
We start by defining the RPC URL of our local Ethereum node Anvil node.
If you do not have Anvil
installed see the Foundry installation instructions.
// Spin up a local Anvil node.
// Ensure `anvil` is available in $PATH.
let anvil = Anvil::new().try_spawn()?;
// Get the RPC URL.
let rpc_url = anvil.endpoint().parse()?;
// Alternatively you can use any valid RPC URL found on https://chainlist.org/
let rpc_url = "https://eth.merkle.io".parse()?;
Next let’s define a signer
for Alice. By default Anvil
defines a mnemonic phrase: "test test test test test test test test test test test junk"
. Make sure to not use this mnemonic phrase outside of testing environments. We register the signer in an
EthereumWallet
to be used in the Provider
to sign our future transaction.
Derive the first key of the mnemonic phrase for Alice
:
// Set up signer from the first default Anvil account (Alice).
let signer: PrivateKeySigner = anvil.keys()[0].clone().into();
let wallet = EthereumWallet::from(signer);
Next lets grab the address of our users Alice
and Bob
:
// Create two users, Alice and Bob.
let alice = anvil.addresses()[0];
let bob = anvil.addresses()[1];
Next we can build the
Provider
using the
ProviderBuilder
.
// Create a provider with the wallet.
let provider = ProviderBuilder::new()
.with_recommended_fillers()
.wallet(wallet)
.on_http(rpc_url);
Note that we use .with_recommended_fillers()
method on the ProviderBuilder to automatically fill fields.
Let’s modify our original TransactionRequest
to make use of the RecommendedFiller installed on the Provider
to automatically fill out transaction details.
The RecommendedFillers
includes the following fillers:
Because of we are using RecommendedFillers
our TransactionRequest
we only need a subset of the original fields:
// Build a transaction to send 100 wei from Alice to Bob.
let tx = TransactionRequest::default()
- .with_from(alice)
.with_to(bob)
- .with_nonce(nonce)
- .with_chain_id(chain_id)
.with_value(U256::from(100))
- .with_gas_price(gas_price)
- .with_gas_limit(gas_limit);
Changes to:
// Build a transaction to send 100 wei from Alice to Bob.
// The `from` field is automatically filled to the first signer's address (Alice).
let tx = TransactionRequest::default()
.with_to(bob)
.with_value(U256::from(100));
Much better!
Signing and broadcasting the transaction
Given that we have configured a signer on our Provider
we can sign the transaction locally and broadcast in a single line:
There are three ways to listen for transaction inclusion after broadcasting the transaction, depending on your requirements:
// Send the transaction and listen for the transaction to be broadcasted.
let pending_tx = provider.send_transaction(tx).await?.register().await?;
// Send the transaction and listen for the transaction to be included.
let tx_hash = provider.send_transaction(tx).await?.watch().await?;
// Send the transaction and fetch the receipt after the transaction was included.
let tx_receipt = provider.send_transaction(tx).await?.get_receipt().await?;
Let’s dive deeper into what we just did.
By calling:
let tx_builder = provider.send_transaction(tx).await?;
The
Provider::send_transaction
method returns a
PendingTransactionBuilder
for configuring the pending transaction watcher.
On it we can for example, set the
required_confirmations
or set a
timeout
:
// Configure the pending transaction.
let pending_tx_builder = provider.send_transaction(tx)
.await?
.with_required_confirmations(2)
.with_timeout(Some(std::time::Duration::from_secs(60)));
By passing the TransactionRequest
, we populate any missing fields. This involves filling in details such as the nonce, chain ID, gas price, and gas limit:
// Build a transaction to send 100 wei from Alice to Bob.
let tx = TransactionRequest::default()
+ .with_from(alice)
.with_to(bob)
+ .with_nonce(nonce)
+ .with_chain_id(chain_id)
.with_value(U256::from(100))
+ .with_gas_price(gas_price)
+ .with_gas_limit(gas_limit);
As part Wallet’s fill
method, registered on the Provider
, we build a signed transaction from the populated TransactionRequest
using our signer, Alice.
At this point, the TransactionRequest
becomes a TransactionEnvelope
, ready to send across the network. By calling either
register
,
watch
or
get_receipt
we can broadcast the transaction and track the status of the transaction.
For instance:
// Send the transaction and fetch the receipt after the transaction was included.
let tx_receipt = provider.send_transaction(tx).await?.get_receipt().await?;
The
TransactionReceipt
provides a comprehensive record of the transaction’s journey and outcome, including the transaction hash, block details, gas used, and addresses involved.
pub struct TransactionReceipt {
// ...
/// Transaction Hash.
pub transaction_hash: TxHash,
/// Index within the block.
pub transaction_index: Option<TxIndex>,
/// Hash of the block this transaction was included within.
pub block_hash: Option<BlockHash>,
/// Number of the block this transaction was included within.
pub block_number: Option<BlockNumber>,
/// Gas used by this transaction alone.
pub gas_used: u128,
/// Address of the sender.
pub from: Address,
/// Address of the receiver. None when its a contract creation transaction.
pub to: Option<Address>,
/// Contract address created, or None if not a deployment.
pub contract_address: Option<Address>,
// ...
}
This completes the journey of broadcasting a signed transaction. Once the transaction is included in a block, it becomes an immutable part of the Ethereum blockchain, ensuring that the transfer of 100 wei
from Alice
to Bob
is recorded permanently.
Putting it all together
//! Example of how to transfer ETH from one account to another.
use alloy::{
network::TransactionBuilder,
primitives::U256,
providers::{Provider, ProviderBuilder},
rpc::types::TransactionRequest,
};
use eyre::Result;
#[tokio::main]
async fn main() -> Result<()> {
// Spin up a local Anvil node.
// Ensure `anvil` is available in $PATH.
let provider = ProviderBuilder::new().with_recommended_fillers().on_anvil_with_wallet();
// Create two users, Alice and Bob.
let accounts = provider.get_accounts().await?;
let alice = accounts[0];
let bob = accounts[1];
// Build a transaction to send 100 wei from Alice to Bob.
// The `from` field is automatically filled to the first signer's address (Alice).
let tx =
TransactionRequest::default().with_from(alice).with_to(bob).with_value(U256::from(100));
// Send the transaction and listen for the transaction to be included.
let tx_hash = provider.send_transaction(tx).await?.watch().await?;
println!("Sent transaction: {tx_hash}");
Ok(())
}