An Ethereum transaction from signing to finality
What actually happens between clicking Send in a wallet and an Ethereum transaction becoming final?
A transaction is a signed, typed message keyed by (sender, nonce). It waits in mempools until a block builder includes it, then gains confidence in stages: included, safe, and finalized about two epochs later.
THE MENTAL MODEL
Treat a transaction hash as a claim, not a result. The account nonce is a strict per-sender sequence: one stuck nonce blocks every later one, and any transaction with the same nonce competes for the same slot, so a higher-fee replacement or a 0-value self-send can win. eth_sendRawTransaction only means one node accepted it. Inclusion gives you a receipt (status, gas, logs) on the current head, which can still reorg. The chain's safe tag is unlikely to reorg, and finalized needs a slashing of at least a third of staked ETH to revert, which on mainnet arrives roughly 13 to 19 minutes after inclusion. On rollups the sequencer's instant confirmation is a promise from one operator; L1 inclusion and L1 finality come later.
HOW IT FITS TOGETHER
- Wallet / app → RPC nodeeth_getTransactionCount(addr, pending)
- RPC node → Wallet / appnext nonce: 7Different RPC nodes may disagree on pending state.
- Wallet / app → RPC nodeeth_feeHistory / maxPriorityFeePerGas
- RPC node → Wallet / appbase fee + suggested tipWallet sets maxFeePerGas with headroom over the base fee, then signs with chainId.
- Wallet / app → RPC nodeeth_sendRawTransaction(signed 0x02 tx)
- RPC node → Wallet / apptx hash (accepted by this node only)
- RPC node → Mempool / buildergossip to peers' mempoolsPrivate order flow skips the public mempool and goes straight to builders.
- Mempool / builder → Chain (L1)bid via relay; proposer signs headerMEV-Boost today. Enshrined PBS (EIP-7732) is scheduled for Glamsterdam.
- Chain (L1) → RPC nodeblock N includes tx
- Wallet / app → RPC nodeeth_getTransactionReceipt(hash)
- RPC node → Wallet / appreceipt: status, gasUsed, logsstatus 0 means reverted but still included: fee paid, nonce used.
- Chain (L1) → RPC nodesafe head passes block N
- Chain (L1) → RPC nodefinalized head passes N (~2 epochs)Recheck the block hash: a reorg before this point can drop or move the tx.
- Builtto, value, data, nonce, gas limit, maxFeePerGas, maxPriorityFeePerGas, chainId, type.
- SignedSignature over the typed payload including chainId. Hash is now fixed and known locally.
- Pending (mempool)Accepted by a node and gossiped. Waits if fees are below base fee or an earlier nonce is missing.
- Replaced or droppedA same-nonce tx with higher fees replaced it, or the pool evicted it. The old hash will never mine.
- IncludedIn a block on the canonical head. Receipt exists; status may be success or reverted.
- SafeBlock is at or below the safe tag: attested by a supermajority, reorg unlikely but possible.
- FinalizedCheckpoint finalized by two-thirds of stake. Reverting it costs at least a third of staked ETH.
- RPC accepted the txOne node's opinion. Nothing is ordered or executed yet.
- Sequencer soft confirmationL2 block from the sequencer, often within seconds. Trusts one operator.
- Batch posted to L1 (L2 safe)Data in an L1 block; ordering no longer depends on the sequencer, but L1 can still reorg.
- L1 finalized (L2 finalized)As final as an L1 transaction. Typically 15 to 30 minutes on OP Mainnet.
- Withdrawal provable on L1Separate bridge rule: optimistic rollups add a challenge window (7 days) to withdrawals only.
KEY TERMS
- Nonce
- Per-account counter. Transactions execute strictly in nonce order, a gap blocks all later ones, and two transactions with the same nonce are mutually exclusive.
- EIP-1559 fees
- Base fee is set by the protocol, moves at most 12.5% per block toward a 50% full target, and is burned. The priority fee goes to the block producer. You pay min(maxFeePerGas, baseFee + maxPriorityFeePerGas) per gas used.
- Typed transactions (EIP-2718)
- TransactionType || payload. 0 legacy, 1 access list (2930), 2 dynamic fee (1559), 3 blob (4844, used by rollups), 4 set-code (7702, lets an EOA delegate to contract code via an authorization list). Receipts are typed too.
- Chain ID and EIP-155
- Legacy transactions fold chainId into v (chainId * 2 + 35/36); typed transactions carry chainId as a field. Either way a signature for one chain is invalid on another.
- Builders and PBS
- Most mainnet blocks are built by specialised builders and delivered to the proposer through MEV-Boost relays. Order inside a block is the builder's choice, so frontrunning and sandwiching are real for public-mempool swaps.
- safe and finalized tags
- JSON-RPC block tags. Slots are 12 s and epochs 32 slots; finalized usually trails the head by about two epochs (12.8 min) plus the time to reach the next checkpoint.
- Replacement
- Send a new tx with the same nonce and higher fees. Geth's default txpool.pricebump requires at least 10% more on both maxFeePerGas and maxPriorityFeePerGas; other clients have similar rules.
IN YOUR STACK
TypeScript · viem Client + server
import { createPublicClient, createWalletClient, http, parseEther, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { mainnet } from "viem/chains";
const account = privateKeyToAccount(process.env.PRIVATE_KEY as Hex);
const transport = http(process.env.RPC_URL);
const wallet = createWalletClient({ account, chain: mainnet, transport });
const client = createPublicClient({ chain: mainnet, transport });
// Nonce, gas and EIP-1559 fees are filled in unless you pass them.
const hash = await wallet.sendTransaction({
to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
value: parseEther("0.01"),
});
const receipt = await client.waitForTransactionReceipt({
hash,
confirmations: 1,
onReplaced: (r) => console.warn(r.reason, r.transaction.hash), // repriced | cancelled | replaced
});
if (receipt.status === "reverted") throw new Error("reverted, fee still paid");
const finalized = await client.getBlock({ blockTag: "finalized" });
const isFinal = receipt.blockNumber <= finalized.number;- waitForTransactionReceipt resolves with status "reverted" rather than throwing, and times out after 180 s by default.
- onReplaced fires when a same-nonce transaction mined instead; follow the new hash, not the original.
- Comparing block numbers is not enough before finality: check the receipt's blockHash is still canonical if you credit funds earlier.
TypeScript · ethers v6 Client + server
import { ethers } from "ethers";
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY!, provider);
const fee = await provider.getFeeData();
const tx = await wallet.sendTransaction({
to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
value: ethers.parseEther("0.01"),
maxFeePerGas: fee.maxFeePerGas,
maxPriorityFeePerGas: fee.maxPriorityFeePerGas,
});
console.log("pending", tx.hash, "nonce", tx.nonce);
try {
const receipt = await tx.wait(1, 180_000); // throws CALL_EXCEPTION on revert
const finalized = await provider.getBlock("finalized");
console.log(receipt!.blockNumber <= finalized!.number ? "finalized" : "included");
} catch (err) {
if (ethers.isError(err, "TRANSACTION_REPLACED")) {
console.warn(err.reason, err.replacement.hash); // cancelled | replaced | repriced
} else throw err;
}- Unlike viem, tx.wait() throws on revert and on replacement; a repriced replacement still means the payment happened under a new hash.
- The signer populates nonce from the provider; parallel sends from one wallet can race for the same nonce unless you wrap it in a NonceManager.
TypeScript (React) · wagmi v3 Client
import { useSendTransaction, useWaitForTransactionReceipt } from "wagmi";
import { parseEther } from "viem";
export function PayButton() {
const send = useSendTransaction();
const receipt = useWaitForTransactionReceipt({
hash: send.data, // query stays disabled until the wallet returns a hash
confirmations: 2,
onReplaced: (r) => console.warn("replaced:", r.reason),
});
return (
<>
<button
disabled={send.isPending || receipt.isLoading}
onClick={() => send.mutate({ to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", value: parseEther("0.01") })}
>Pay 0.01 ETH</button>
{send.isPending && <p>Confirm in your wallet</p>}
{receipt.isLoading && <p>Submitted, waiting for inclusion</p>}
{receipt.data?.status === "success" && <p>Included in block {receipt.data.blockNumber.toString()}</p>}
{receipt.data?.status === "reverted" && <p>Reverted on-chain; the fee was still charged</p>}
</>
);
}- In v3 mutation hooks expose mutate/mutateAsync instead of a sendTransaction function; v2 code that destructures sendTransaction must change.
- The hash lives in component state: persist it (and the nonce) server-side so a refresh or closed tab does not lose track of an in-flight payment.
- The connected wallet signs and picks fees and nonce; check useChainId against the intended chain before sending.
Shell · Foundry cast Client
# Mined vs pending nonce: a gap means something is stuck
cast nonce $ADDR --block latest --rpc-url $RPC_URL
cast nonce $ADDR --block pending --rpc-url $RPC_URL
cast base-fee --rpc-url $RPC_URL
# EIP-1559 send: --gas-price is maxFeePerGas, --priority-gas-price the tip
cast send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 --value 0.01ether \
--priority-gas-price 2gwei --account deployer --rpc-url $RPC_URL --async
cast receipt $TX_HASH status --confirmations 2 --rpc-url $RPC_URL
cast block finalized --field number --rpc-url $RPC_URL
# Cancel nonce 7: 0-value self-send, same nonce, >=10% higher on both fees
cast send $ADDR --value 0 --nonce 7 \
--gas-price 60gwei --priority-gas-price 3gwei \
--account deployer --rpc-url $RPC_URL- Without --async, cast send waits for the receipt (one confirmation by default).
- --blob now builds the EIP-7594 (PeerDAS) blob format and --auth takes an EIP-7702 authorization; --legacy forces type 0 for chains without EIP-1559.
Python · web3.py v7 Server
import os
from web3 import Web3
w3 = Web3(Web3.HTTPProvider(os.environ["RPC_URL"]))
acct = w3.eth.account.from_key(os.environ["PRIVATE_KEY"])
base_fee = w3.eth.get_block("latest")["baseFeePerGas"]
tip = w3.eth.max_priority_fee
tx = {
"type": 2,
"chainId": w3.eth.chain_id, # signed in, so no cross-chain replay
"nonce": w3.eth.get_transaction_count(acct.address, "pending"),
"to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"value": w3.to_wei("0.01", "ether"),
"gas": 21_000,
"maxPriorityFeePerGas": tip,
"maxFeePerGas": 2 * base_fee + tip, # survives ~6 full blocks of +12.5%
}
signed = acct.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=180)
if receipt["status"] != 1:
raise RuntimeError(f"reverted in block {receipt['blockNumber']}")
final = receipt["blockNumber"] <= w3.eth.get_block("finalized")["number"]- v7 renamed SignedTransaction.rawTransaction to raw_transaction.
- wait_for_transaction_receipt raises TimeExhausted and does not detect replacement; on timeout, look up the nonce, not just the hash.
- Using the pending nonce from one node is fine for a single sender process; multiple workers sharing a key need a central nonce allocator.
Rust · alloy Server
use alloy::{
eips::BlockNumberOrTag,
network::TransactionBuilder,
primitives::{address, utils::parse_ether},
providers::{Provider, ProviderBuilder},
rpc::types::TransactionRequest,
signers::local::PrivateKeySigner,
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let signer: PrivateKeySigner = std::env::var("PRIVATE_KEY")?.parse()?;
let rpc = std::env::var("RPC_URL")?;
// new() enables the recommended fillers: nonce, chain ID, gas, EIP-1559 fees.
let provider = ProviderBuilder::new().wallet(signer).connect(&rpc).await?;
let tx = TransactionRequest::default()
.with_to(address!("0x70997970C51812dc3A010C7d01b50e0d17dc79C8"))
.with_value(parse_ether("0.01")?);
let pending = provider.send_transaction(tx).await?;
let receipt = pending.with_required_confirmations(2).get_receipt().await?;
let finalized = provider.get_block_by_number(BlockNumberOrTag::Finalized).await?;
let is_final = finalized.is_some_and(|b| receipt.block_number.unwrap_or(u64::MAX) <= b.header.number);
println!("success={} final={}", receipt.status(), is_final);
Ok(())
}- The nonce filler caches nonces per provider instance; restarting or sharing a key across processes can reuse or skip nonces.
- get_receipt waits for the configured confirmations; set with_timeout so a dropped transaction does not hang forever.
WHERE IT BITES
- Treating eth_sendRawTransaction success as payment success: the tx can still be dropped, replaced, or revert.
- Tracking only the hash: speed-ups and cancels create a new hash for the same nonce. Track (sender, nonce) and reconcile whichever hash mined.
- Crediting on one confirmation for high-value flows: blocks at the head can reorg. Use the safe or finalized tag, or re-verify the block hash later.
- Setting maxFeePerGas equal to the current base fee: a few full blocks later the tx is no longer includable and sits pending.
- Fetching the nonce independently in parallel workers or across load-balanced RPC nodes, producing duplicates and gaps.
- Showing an L2 sequencer confirmation as final settlement, or telling users an L2 tx takes 7 days to finalize (that is the bridge withdrawal window).
CLOSE THE AI. EXPLAIN THIS.
Your payment tx has been pending for ten minutes. Explain why it might be stuck, how you would replace it (which fields change and by how much), and at which block tag you would mark the order settled.WHEN IT BREAKS IN PRODUCTION
SOURCES
- ethereum.org: Transactions (opens in new tab)Checked
- EIP-1559: Fee market change for ETH 1.0 chain (opens in new tab)Checked
- EIP-7702: Set Code for EOAs (opens in new tab)Checked
- ethereum.org: Proof-of-stake (slots, epochs, finality) (opens in new tab)Checked
- Flashbots: MEV-Boost introduction (opens in new tab)Checked
- OP Stack: Transaction finality (opens in new tab)Checked
Explainer reviewed