Alloy in Action - Part 2: Advanced Transaction Composition and Gas Management in Rust
In the first post of this series, we explored how you can easily connect your Rust application to the blockchain and interact with a smart contract using Alloy’s high-level features. We utilized the functions generated by the sol!
macro for our SampleContract
. Since this series serves as a prelude to the upcoming series about REVM, we’ll now delve into building transactions using TransactionRequest
, manually encoding transaction data using ABI, and adjusting gas parameters ourselves.
Note: The full code for this tutorial is available on GitHub: eierina/alloy-in-action/02-advanced-transaction-composition.
Note: All examples and code in this post assume we’re dealing exclusively with EIP-1559 transactions. Additionally, we’re focusing on simplifying the reader’s understanding of the problem solution rather than adhering strictly to coding best practices.
Setting Up the Environment
Before diving into the code, ensure you have the following set up:
- Rust installed on your machine. Install Rust here.
- Foundry toolchain installed on your machine. Install Foundry here.
- Anvil running as our local Ethereum node simulator with default options. Simply run
anvil --block-time=3
on the command line to start a local testnet on http://127.0.0.1:8545. The--block-time=3
option sets the block time to 3 seconds, which is useful for observing transaction confirmations.
- Anvil running as our local Ethereum node simulator with default options. Simply run
- Solidity 0.8.24 compiler installed on your machine. Install the Solidity compiler here.
Create a new Rust project with the required dependencies and features:
mkdir alloy-in-action
cd alloy-in-action # Root folder
cargo new 02-advanced-transaction-composition --bin --name advanced_transaction_composition
cd 02-advanced-transaction-composition # Rust project folder
cargo add alloy-chains@0.1.47 \
alloy-contract@0.5.4 \
alloy-network@0.5.4 \
alloy-primitives@0.8.9 \
alloy-provider@0.5.4 \
alloy-rpc-types@0.5.4 \
alloy-signer-local@0.5.4 \
alloy-sol-macro@0.8.9 \
alloy-sol-types@0.8.9 \
alloy-transport@0.5.4 \
dotenv@0.15.0 \
eyre@0.6.12 \
tokio@1.41.0 \
tracing-subscriber@0.3.18 \
url@2.5.2 \
--features alloy-provider@0.5.4/ws,tokio@1.41.0/rt,tokio@1.41.0/rt-multi-thread,tokio@1.41.0/macros
Create a .env
file in the root folder with the following variables:
# Private key for the first default Anvil account
ANVIL_PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
# RPC URL for the Anvil local Ethereum node
ANVIL_RPC_URL=http://127.0.0.1:8545
# WebSocket URL for the Anvil local Ethereum node
ANVIL_WS_URL=ws://127.0.0.1:8545
# Default Chain ID for the Anvil network
ANVIL_CHAIN_ID=31337
In the root directory (alloy-in-action
), create a new Solidity project:
forge init solidity-smart-contracts
cd solidity-smart-contracts # Solidity root folder
echo 'solidity = "0.8.24"' >> foundry.toml
forge install
Create a SampleContract.sol
file in the src
folder of the Solidity project with the following content:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract SampleContract {
// State variable to store a single unsigned integer value
uint256 public value;
// Event to be emitted when the 'value' state variable is updated
event ValueChanged(address indexed updater, uint256 indexed oldValue, uint256 newValue);
// Event to be emitted when Ether is received via the deposit function
event EtherReceived(address indexed sender, uint256 amount, uint256 newBalance);
// Event to be emitted when Ether is withdrawn via the withdraw function
event EtherWithdrawn(address indexed recipient, uint256 amount, uint256 remainingBalance);
// Custom error used to demonstrate Solidity's revert mechanism
error SampleError(string cause);
/// @notice Constructor to set the initial value of the contract
/// @param _initialValue The initial value assigned to 'value'
constructor(uint256 _initialValue) {
value = _initialValue;
}
/// @notice Sets a new value for the 'value' state variable
/// @param _value The new value to be set
function setValue(uint256 _value) external {
uint256 oldValue = value;
value = _value;
emit ValueChanged(msg.sender, oldValue, _value);
}
/// @notice Retrieves the current value of the 'value' state variable
/// @return currentValue The current value stored in 'value'
function getValue() external view returns (uint256 currentValue) {
currentValue = value;
}
/// @notice Accepts Ether deposits and logs the sender and amount
function deposit() external payable {
emit EtherReceived(msg.sender, msg.value, address(this).balance);
}
/// @notice Withdraws the entire balance of the contract to the caller
function withdraw() external {
uint256 balance = address(this).balance;
payable(msg.sender).transfer(balance);
emit EtherWithdrawn(msg.sender, balance, 0);
}
/// @notice Retrieves the contract's current Ether balance
/// @return balance The current balance of the contract in wei
function getBalance() external view returns (uint256 balance) {
balance = address(this).balance;
}
/// @notice Reverts the transaction with a custom error message
/// @dev Used to demonstrate custom error handling in Solidity
function revertWithError() external pure {
revert SampleError("hello from revert!");
}
}
Declaring the External Smart Contract Interface
The sol!
macro enables defining Solidity contracts’ ABI directly within Rust. This macro generates Rust types and functions that facilitate interaction with the contract. It can be used at the global scope or even inlined within functions. While it allows defining individual functions or types, defining the entire contract with its types and functions brings additional benefits in our example.
Two attributes are used in this example:
rpc
attribute: Generates Rust functions corresponding to the contract’s functions.bytecode
attribute: Includes the contract’s compiled bytecode for deployment.
Replace the contents of the src/main.rs
file in the Rust project folder with the following code:
use std::path::Path;
use alloy_contract::Error;
use alloy_network::EthereumWallet;
use alloy_primitives::{utils, U256};
use alloy_provider::{Provider, ProviderBuilder};
use alloy_signer_local::PrivateKeySigner;
use alloy_sol_macro::sol;
use alloy_sol_types::SolEventInterface;
use utils::format_ether;
use eyre::Result;
use url::Url;
use crate::SampleContract::SampleContractErrors;
use crate::SampleContract::SampleContractEvents;
sol! {
#[sol(rpc, bytecode = "<BYTECODE>")]
contract SampleContract {
// Events
event ValueChanged(address indexed updater, uint256 indexed oldValue, uint256 newValue);
event EtherReceived(address indexed sender, uint256 amount, uint256 newBalance);
event EtherWithdrawn(address indexed recipient, uint256 amount, uint256 remainingBalance);
// Errors
error SampleError(string cause);
// Constructor
constructor(uint256 _initialValue);
// Functions
/// @notice Sets a new value for the 'value' state variable
/// @param _value The new value to be set
function setValue(uint256 _value) external;
/// @notice Retrieves the current value of the 'value' state variable
/// @return currentValue The current value stored in 'value'
function getValue() external view returns (uint256 currentValue);
/// @notice Accepts Ether deposits and logs the sender and amount
function deposit() external payable;
/// @notice Withdraws the entire balance of the contract to the caller
function withdraw() external;
/// @notice Retrieves the contract's current Ether balance
/// @return balance The current balance of the contract in wei
function getBalance() external view returns (uint256 balance);
/// @notice Reverts the transaction with a custom error message
/// @dev Used to demonstrate custom error handling in Solidity
function revertWithError() external pure;
}
}
// Rest of the code ...
Note: Replace the <BYTECODE>
placeholder with the actual bytecode generated by the Solidity compiler in the next step.
Open a terminal in the Solidity project folder and compile the contract:
solc src/SampleContract.sol --bin --via-ir --optimize --optimize-runs 1
======= src/SampleContract.sol:SampleContract =======
Binary:
608034604d57601f61024238819003918201601f19168301916001600160401b03831184841017605157808492602094604052833981010312604d57515f556040516101dc90816100668239f35b5f80fd5b634e487b7160e01b5f52604160045260245ffdfe6080806040526004361015610012575f80fd5b5f3560e01c90816312065fe01461018e5750806320965255146101375780633ccfd60b146101535780633fa4f2451461013757806355241077146100f457806357eca1a5146100a95763d0e30db014610069575f80fd5b5f3660031901126100a5577f1e57e3bb474320be3d2c77138f75b7c3941292d647f5f9634e33a8e94e0e069b60408051338152346020820152a1005b5f80fd5b346100a5575f3660031901126100a5576040516335fdd7ab60e21b815260206004820152601260248201527168656c6c6f2066726f6d207265766572742160701b6044820152606490fd5b346100a55760203660031901126100a5577f93fe6d397c74fdf1402a8b72e47b68512f0510d7b98a4bc4cbdf6ac7108b3c596020600435805f55604051908152a1005b346100a5575f3660031901126100a55760205f54604051908152f35b346100a5575f3660031901126100a5575f80808047818115610185575b3390f11561017a57005b6040513d5f823e3d90fd5b506108fc610170565b346100a5575f3660031901126100a557602090478152f3fea26469706673582212206f147fef9942d5bc4d46bb70de766fa699b9f8ee6dbc970d61eec1572c1a1e7c64736f6c634300081b0033
Replace the <BYTECODE>
placeholder in your Rust code with the actual bytecode output.
Asynchronous Execution and Logging
Alloy is asynchronous, so we’ll use tokio
for asynchronous execution. We also set up logging to inspect Alloy’s internal operations.
#[tokio::main]
async fn main() -> Result<()> {
// Load .env file
let env_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join(".env");
dotenv::from_path(env_path).ok();
// Initialize tracing subscriber for logging
tracing_subscriber::fmt::init();
// Rest of the code...
Ok(())
}
Loading Environment Variables
We use the dotenv
crate to load environment variables from the .env
file.
Setting Up Tracing
We initialize the tracing subscriber to enable logging. This helps us inspect Alloy logs by running the application with RUST_LOG=info/warn/debug/trace cargo run
.
Creating a Local Signer
To interact with the blockchain, we need a signer. Alloy offers various signer providers, but we’ll use the PrivateKeySigner
for this example, which takes a K-256 private key as input. We’re using the private key defined in our .env
file (ANVIL_PRIVATE_KEY
), which corresponds to the first default Anvil account. This account comes with a balance of 10k Ether that we’ll use for paying gas and testing transfers.
// Create signer and wallet
let private_key = std::env::var("ANVIL_PRIVATE_KEY")?;
let signer: PrivateKeySigner = private_key.parse()?;
let signer_address = signer.address();
let wallet = EthereumWallet::from(signer);
Available Signer Providers:
- Local Signers:
PrivateKeySigner
,MnemonicSigner
- Hardware Wallets: Ledger, Trezor
- Cloud-Based: Amazon AWS KMS, Google Cloud Platform KMS
- Hardware Security Modules: YubiHSM2
Connecting to the Network
We create a provider to connect to the Ethereum-like network (our local Anvil testnet in this case). The provider builder pattern allows us to configure the provider with multiple properties. For our case, these are:
.with_chain(NamedChain::...)
sets the provider’s poll interval based on the average block time for the chain when using the HTTP provider..with_chain_id(...)
sets the chain ID that the provider will use for all transactions, unless explicitly overridden by the transaction..wallet(...)
adds a wallet layer for signing the transactions.
Using HTTP:
// Set up provider with chain ID, wallet, and network details (using HTTP)
let rpc_url = std::env::var("ANVIL_RPC_URL")?;
let rpc_url = Url::parse(&rpc_url)?;
let provider = ProviderBuilder::new()
.with_chain(NamedChain::AnvilHardhat)
.with_chain_id(31337)
.wallet(wallet)
.on_http(rpc_url);
Alternatively, using WebSocket:
// Set up provider with chain ID, wallet, and network details (using WebSocket)
let ws_url = std::env::var("ANVIL_WS_URL")?;
let ws_url = Url::parse(&ws_url)?;
let provider = ProviderBuilder::new()
.with_chain(NamedChain::AnvilHardhat)
.with_chain_id(31337)
.wallet(wallet)
.on_ws(WsConnect::new(ws_url)).await?;
Choosing the Right Transport
- HTTP: Good for simple requests with less overhead.
- WebSocket: Ideal for subscriptions and real-time data.
- IPC: Offers the best performance but is limited to local nodes.
Setting the Blockchain Identifier
Since the provider used to send our transactions will be the same for all transactions, we set the blockchain identifier on the provider using the .with_chain_id(...)
function. This takes an integer value representing the blockchain identifier (Chain ID). It ensures the transactions are signed with the unique chain identifier, preventing a transaction signed for one blockchain from being replayed on another blockchain.
Transaction Confirmation Strategy
When composing transactions, we’ll apply a transaction confirmation strategy where we consider a transaction final and irreversible after a certain number of blocks have been mined on top of it. This is important because even after a transaction is included in a block, there is a possibility (though small) that the block could be orphaned due to network reorganization. By waiting for additional blocks (confirmations), we reduce the risk of our transaction being reverted.
In our examples, we’ll set the number of confirmations to 3, which is suitable for low-value transactions in a test environment.
// Set the number of confirmations to wait for a transaction to be considered confirmed
// (6-12) for high-value transactions, (1-3) for low-value transactions
let confirmations = 3u64;
Deploying the Contract
Earlier, we deployed the SampleContract
using the deploy
method generated by the sol!
macro. Now, we’ll see how to compose our own transaction and generate the transaction input data by appending the ABI-encoded constructor call to the deploy bytecode generated by the compiler.
Preparing the Deployment Bytecode
The deployment bytecode we receive when running the solc
compiler on a Solidity contract gives us the compiled code without the constructor parameters. To deploy this bytecode with constructor arguments, we need to generate an ABI-encoded constructor call and append it to the deployment bytecode.
// Prepare contract deployment bytecode with initialization of value to 1
let initial_value = U256::from(1);
let deploy_bytecode: Bytes = [
&SampleContract::BYTECODE[..],
&SampleContract::constructorCall { _initialValue: initial_value }.abi_encode()[..],
]
.concat()
.into();
In the code above, we use SampleContract::constructorCall
to create an ABI-encoded initialization code for the initial value and append it to the compiler-generated bytecode.
Managing Nonce for Transactions
The nonce ensures a signed transaction can be included in a block only once and prevents it from being reused on the current blockchain. Since the transaction signer (Externally Owned Account, or EOA) stores the nonce and it is only incremented once a transaction from its signer is successfully included in a block, it is necessary to take into account any pending transactions by the current signer that have not yet been included in a block.
Alloy allows you to chain the .pending()
function to the provider’s get_transaction_count(signer_address)
, which includes the pending transactions in the nonce count.
// Get the nonce for the signer address, including pending transactions
let nonce = provider.get_transaction_count(signer_address).pending().await?;
Calculating Transaction’s Gas Parameters
To determine the total cost of a transaction, we need two components: the amount of computational work (gas units) and the price per unit of that work. The price per unit consists of the network’s base fee plus any optional tip we choose to add. Let’s break down how we calculate these components.
Gas Fees and Their Components
EIP-1559 splits gas fees into two components that together determine the price per unit of computational work:
- Base Fee: The minimum gas fee per unit that must be paid for the transaction to be included in a block. This fee is dynamically adjusted based on network demand and is burned.
- Priority Fee (Tip): An optional fee to incentivize miners/validators for faster transaction inclusion.
We set the gas fee components as follows:
// Fetch the latest block to obtain current gas parameters
let latest_block = provider
.get_block(BlockId::latest(), BlockTransactionsKind::Hashes)
.await?
.unwrap();
// Calculate next block's base fee based on the latest block
let base_fee = calculate_base_fee_per_gas(
latest_block.header.base_fee_per_gas.unwrap(),
latest_block.header.gas_used,
latest_block.header.gas_limit
);
// We set a fixed tip of 2.5 Gwei for simplicity.
let tip = parse_units("2.5", "gwei")?.try_into()?;
To calculate the minimum gas fee, we use the following formula, which computes the base fee for the next block () based on the base fee of the current block (). The adjustment is based on the deviation of current block gas usage from the target, with the maximum variation per block capped at 12.5%. For Ethereum, the block gas limit is fixed at 30 million at the time of writing, hence the target gas usage (50% of the gas limit) is 15 million.
Parameters:
- : Base fee of the current block.
- : Total gas used by the current block.
- : Typically 50% of the gas limit. Note that as of the London hard fork, the block gas limit is set to 30 million.
Here’s the function to calculate the base fee according to the formula:
// Calculates the base fee per gas for the next block based on EIP-1559 specifications.
pub fn calculate_base_fee_per_gas(
base_fee: u64,
gas_used: u64,
gas_limit: u64,
) -> u64 {
// Calculate the target gas usage (50% of the gas limit)
let gas_target = gas_limit / 2;
// Calculate the difference between gas used and gas target
let gas_delta = gas_used as i64 - gas_target as i64;
// Maximum base fee change is 12.5% of the current base fee
let max_base_fee_change = base_fee / 8;
// If gas usage is exactly at the target, base fee remains the same
if gas_delta == 0 {
return base_fee;
}
// Calculate the absolute value of gas delta for adjustment calculation
let gas_delta_abs = gas_delta.abs() as u64;
// Compute the base fee change
// Using u128 to prevent potential overflow in intermediate calculations
let base_fee_change = ((max_base_fee_change as u128 * gas_delta_abs as u128)
/ gas_target as u128) as u64;
if gas_delta > 0 {
// Increase base fee by the calculated change
base_fee + base_fee_change
} else {
// Decrease base fee by the calculated change, ensuring it doesn't go below zero
if base_fee > base_fee_change {
base_fee - base_fee_change
} else {
0
}
}
}
Gas Estimation
The second component we need is the amount of computational work, measured in gas units. This represents the computational effort (in units) required for the transaction. Using the provider’s estimate_gas()
function, we’ll estimate this value in the next section when we have our basic transaction request prepared, as the estimation needs to know what operation we’re trying to perform. The estimate will tell us approximately how many gas units our transaction will consume.
Ultimately the total transaction cost will be: . Therefore, by getting both components right, we ensure our transaction has enough gas to complete while remaining cost-effective.
Crafting the Transaction
Finally, we have all we need to send the transaction. We start by creating a default transaction request, add the deployment bytecode with the initialization data, specify the kind of transaction, and set the nonce. With this information, we can request an estimate of the gas consumption expected by the execution of such a transaction.
// Create base transaction for contract deployment
let tx_base = TransactionRequest::default()
.with_deploy_code(deploy_bytecode)
.with_nonce(nonce);
// Estimate gas for the deployment transaction
let estimated_gas = provider.estimate_gas(&tx_base).await?;
With the gas parameters, we can now pass all parameters to the transaction and send it.
// Build the final deployment transaction with gas parameters
let tx = tx_base
.with_gas_limit(estimated_gas)
.with_max_priority_fee_per_gas(tip)
.with_max_fee_per_gas(base_fee as u128 + tip);
// Send deployment transaction
let tx_builder = provider.send_transaction(tx).await?;
println!("🔄 Transaction sent ({:#x}).", tx_builder.tx_hash());
With the .send_transaction(tx)
line above, we’ve actually sent the transaction to the node (eth_sendRawTransaction
).
Awaiting Transaction Confirmation
We need to wait for the transaction to be included in a block and reach the desired number of confirmations. The required confirmations are set by configuring the transaction with .with_required_confirmations(confirmations)
.
// Await confirmation
let tx_hash = tx_builder.with_required_confirmations(confirmations).watch().await?;
println!("✅ Transaction confirmed ({:#x}).", tx_hash);
The .watch()
method simplifies the process of waiting for the transaction to be mined and confirmed, as it waits for the transaction to confirm with the given number of confirmations and returns either the transaction hash or a transaction error.
Getting the Transaction Receipt
With the transaction hash of the now confirmed transaction, we can get the transaction receipt.
// Retrieve transaction receipt
let receipt = provider
.get_transaction_receipt(tx_hash)
.await?
.expect("Deploy transaction receipt not found");
println!("🧾 Deploy transaction receipt obtained ({:#x}).", receipt.transaction_hash);
let deploy_address = receipt.contract_address.unwrap();
println!("📍 Contract deployed at address ({:#x}).", deploy_address);
Since the transaction deployed a contract, the transaction receipt contains the contract deployment address.
Sending Transactions to the Contract
We can interact with the deployed contract by sending transactions to it. Similar to the deployment transaction, we need to calculate gas parameters based on the latest block.
// Fetch the latest block to obtain current block gas parameters
let latest_block = provider
.get_block(BlockId::latest(), BlockTransactionsKind::Hashes)
.await?
.unwrap();
// Calculate next block's base fee
let base_fee = calculate_base_fee_per_gas(
latest_block.header.base_fee_per_gas.unwrap(),
latest_block.header.gas_used,
latest_block.header.gas_limit
);
Using the SampleContract
generated code, we create the ABI-encoded call data for the setValue
function:
// Prepare setValue transaction to update the value to 2
let tx_data = SampleContract::setValueCall { _value: U256::from(2u64) }.abi_encode();
We then prepare the transaction with the input data, nonce, sender and target address, and transaction kind. We estimate the gas required to run the transaction to completion and configure the transaction with the gas parameters.
let nonce = provider.get_transaction_count(signer_address).pending().await?;
let tx_base = TransactionRequest::default()
.with_input(tx_data)
.with_to(deploy_address)
.with_from(signer_address)
.with_nonce(nonce)
.with_kind(TxKind::Call(deploy_address));
let estimated_gas = provider.estimate_gas(&tx_base).await?;
let tx = tx_base.with_gas_limit(estimated_gas)
.with_max_priority_fee_per_gas(tip)
.with_max_fee_per_gas(base_fee as u128 + tip);
We then send the transaction and wait until it is confirmed, to get the transaction receipt.
// Send setValue transaction
let tx_builder = provider.send_transaction(tx).await?;
println!("🔄 setValue transaction sent ({:#x}).", tx_builder.tx_hash());
// Await confirmation
let tx_hash = tx_builder.with_required_confirmations(confirmations).watch().await?;
println!("✅ setValue transaction confirmed ({:#x}).", tx_hash);
// Retrieve transaction receipt for setValue
let receipt = provider
.get_transaction_receipt(tx_hash)
.await?
.expect("setValue transaction receipt not found");
println!("🧾 setValue transaction receipt obtained ({:#x}).", receipt.transaction_hash);
A simple call to the contract to read data is a bit simpler:
// Prepare getValue call to fetch the current value
let tx_data = SampleContract::getValueCall { }.abi_encode();
let tx = TransactionRequest::default()
.with_input(tx_data)
.with_to(deploy_address)
.with_from(signer_address)
.with_kind(TxKind::Call(deploy_address));
// Execute getValue call
let result = provider.call(&tx).await?;
let decoded_value = SampleContract::getValueCall::abi_decode_returns(&result, true)?;
let current_value = decoded_value.currentValue;
println!("🔍 Current value from contract: {}", current_value);
In this case, we can decode the returned ABI-encoded result using the code generated for the SampleContract
.
Running the Example
To successfully run the example, ensure that Anvil is operating locally with the block time
option set to 3
. This configuration simulates the time interval between block generations, allowing you to observe transaction confirmations in a controlled environment.
Steps to Run the Example
-
Start Anvil Locally:
Begin by launching Anvil with the specified block time. Open your terminal and execute the following command:
anvil --block-time 3
This command initializes Anvil with a block time of 3 seconds, meaning a new block is mined every 3 seconds.
-
Execute Your Code:
With Anvil running, execute the code we wrote so far.
cargo run
-
Observe the Output:
As the code runs, you should see output similar to the example below. This output provides real-time feedback on the status of each transaction and the state of the contract.
Example Output
🔄 Transaction sent (0xc610b765f0632d08269330ce0e0fd1585a0697eb706450aa065cdac2e4730a86).
✅ Transaction confirmed (0xc610b765f0632d08269330ce0e0fd1585a0697eb706450aa065cdac2e4730a86).
🧾 Deploy transaction receipt obtained (0xc610b765f0632d08269330ce0e0fd1585a0697eb706450aa065cdac2e4730a86).
📍 Contract deployed at address (0x5fbdb2315678afecb367f032d93f642f64180aa3).
🔄 setValue transaction sent (0xbf9c3cd42e3c1b2c5313dc728e4fe401c74a743ca3535dbf9dd4c1ad5873bd49).
✅ setValue transaction confirmed (0xbf9c3cd42e3c1b2c5313dc728e4fe401c74a743ca3535dbf9dd4c1ad5873bd49).
🧾 setValue transaction receipt obtained (0xbf9c3cd42e3c1b2c5313dc728e4fe401c74a743ca3535dbf9dd4c1ad5873bd49).
🔍 Current value from contract: 2
Timing Note: Notice that after a transaction is sent, there is an approximate delay of 6 to 9 seconds before the transaction is confirmed. This delay is influenced by the product of
block time
setting in Anvil, which controls how frequently new blocks are mined, and theconfirmations
variable we set in the code.
Conclusion
In this tutorial, we’ve:
- Manually Composed Transactions: Utilized TransactionRequest to build transactions without high-level abstractions.
- Encoded Transaction Data: Employed ABI encoding to prepare constructor and function calls for the smart contract.
- Managed Gas Parameters: Calculated base_fee and tip according to EIP-1559 specifications to optimize transaction costs.
- Handled Nonces Effectively: Ensured transaction uniqueness and prevented reuse by accurately managing nonces, including pending transactions.
- Deployed and Interacted with Contracts: Successfully deployed the SampleContract and performed interactions such as setting and retrieving values.
- Implemented Confirmation Strategies: Applied a confirmation strategy to wait for a specified number of block confirmations, enhancing transaction reliability. These steps deepen your understanding of transaction mechanics and gas management on the Ethereum blockchain using Rust and Alloy, setting the stage for more complex blockchain interactions in the upcoming REVM series.