This page describes the complete lifecycle for an agent submitting a trade on Base, with decision points at each stage.
Overview: Transaction Lifecycle
1. SIMULATE → eth_call / eth_simulateV1 (catch reverts before spending gas)
2. ESTIMATE → eth_estimateGas (get accurate gas limit)
3. FEE CALC → eth_feeHistory + GasPriceOracle (compute maxFeePerGas)
4. SIGN → local or remote signer
5. SUBMIT → eth_sendRawTransaction
6. MONITOR → eth_getTransactionReceipt (poll) or eth_subscribe newHeads (WS)
7. RECOVER → replace (same nonce, higher fees) or cancel (zero-value self-send)
The most important step is simulation. Agents that simulate before submitting eliminate the largest class of wasted gas: transactions that were always going to revert.
Step 1 — Simulate
Always simulate before signing. Simulation detects:
- Insufficient token allowance
- Slippage bounds that will fail at current price
- Contract logic errors (incorrect calldata, wrong function selector)
- Balance/state conditions not met
Standard simulation with eth_call
{
"jsonrpc": "2.0",
"method": "eth_call",
"params": [
{
"from": "0xYourAgentAddress",
"to": "0xTargetContract",
"data": "0xEncodedCalldata",
"gas": "0x186A0"
},
"latest"
],
"id": 1
}
If the call succeeds, result contains the return data. If it reverts, the error contains data with the revert payload — decode it as described in Transaction Error Handling.
Simulation against preconfirmed state (Flashblocks)
For trades where current mempool state matters (e.g., a swap whose price depends on other pending transactions), simulate against the "pending" block via a Flashblocks endpoint:
{
"jsonrpc": "2.0",
"method": "eth_call",
"params": [
{
"from": "0xYourAgentAddress",
"to": "0xUniswapRouter",
"data": "0xEncodedSwapCalldata"
},
"pending"
],
"id": 1
}
This uses the Flashblocks endpoint (https://mainnet-preconf.base.org) and reflects state from the most recent Flashblock — up to 200ms fresher than "latest".
Multi-call simulation with eth_simulateV1
eth_simulateV1 lets you test a sequence of calls atomically with optional state overrides — useful when your trade involves multiple steps (approve + swap):
{
"jsonrpc": "2.0",
"method": "eth_simulateV1",
"params": [
{
"blockStateCalls": [
{
"calls": [
{
"from": "0xYourAddress",
"to": "0xUSDC",
"data": "0x095ea7b3000000000000000000000000UniswapRouter...ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
},
{
"from": "0xYourAddress",
"to": "0xUniswapRouter",
"data": "0xEncodedSwapCalldata"
}
]
}
],
"traceTransfers": true,
"validation": false
},
"pending"
],
"id": 1
}
Set validation: false to test state mutations without requiring valid signatures.
Step 2 — Estimate Gas
After confirming the simulation succeeds, get an accurate gas limit:
{
"jsonrpc": "2.0",
"method": "eth_estimateGas",
"params": [
{
"from": "0xYourAddress",
"to": "0xContract",
"data": "0xCalldata"
}
],
"id": 1
}
eth_estimateGas reflects state at the time of the call. By the time your transaction executes, state may have changed (e.g., price moved, other swaps executed). Always add a gas buffer.
Recommended gas buffer by transaction type:
| Transaction type | Buffer |
|---|
| Simple ETH transfer | 0% (fixed 21,000 gas) |
| ERC-20 transfer | 10% |
| DEX swap (Uniswap V3) | 20% |
| DEX swap (multi-hop) | 25% |
| Complex DeFi (multi-step) | 30% |
const gasEstimate = await client.estimateGas({ account, to, data });
const gasLimit = (gasEstimate * 120n) / 100n; // 20% buffer
Step 3 — Compute Fees
See Fee Estimation for Agents for the full guide. Quick reference:
// Fetch next base fee and priority fee percentiles
const feeHistory = await client.getFeeHistory({ blockCount: 5, rewardPercentiles: [50, 90] });
const nextBaseFee = feeHistory.baseFeePerGas[feeHistory.baseFeePerGas.length - 1]!;
// For standard inclusion: 50th percentile priority fee, 2x base fee buffer
const maxPriorityFeePerGas = median(feeHistory.reward!.map(r => r[0]));
const maxFeePerGas = nextBaseFee * 2n + maxPriorityFeePerGas;
Step 4 — Submit
const hash = await walletClient.sendTransaction({
to: contractAddress,
data: calldata,
gas: gasLimit,
maxFeePerGas,
maxPriorityFeePerGas,
nonce, // always set explicitly; see Nonce Management
chainId: 8453, // Base Mainnet; 84532 for Sepolia
});
Always set chainId explicitly. An incorrect chain ID produces an invalid signature that will be rejected with invalid sender.
Step 5 — Monitor
Option A: Polling (HTTP)
async function waitForReceipt(
hash: `0x${string}`,
timeoutMs = 30_000,
pollIntervalMs = 500
): Promise<TransactionReceipt> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const receipt = await client.getTransactionReceipt({ hash }).catch(() => null);
if (receipt) return receipt;
await sleep(pollIntervalMs);
}
throw new Error(`Transaction ${hash} not confirmed after ${timeoutMs}ms`);
}
Recommended polling interval: 500ms (aligns with Flashblocks cadence, won’t miss inclusion).
Option B: WebSocket subscription
const unwatch = client.watchBlockNumber({
onBlockNumber: async (blockNumber) => {
const receipt = await client.getTransactionReceipt({ hash }).catch(() => null);
if (receipt) {
unwatch();
handleReceipt(receipt);
}
if (blockNumber > submittedAtBlock + 10n) {
unwatch();
handleStuckTransaction(hash, lastKnownNonce);
}
},
});
Interpreting the receipt
if (receipt.status === 'success') {
// Transaction included and executed successfully
} else {
// status === 'reverted': transaction included but execution failed
// Re-simulate at the inclusion block number to get the revert reason
const revertData = await client.call({
...originalTxParams,
blockNumber: receipt.blockNumber,
});
}
Step 6 — Recovery
Replace a pending transaction (speed up or modify)
Submit a new transaction with the same nonce and fees increased by at least 10%:
async function replaceTransaction(
originalNonce: number,
newTxParams: TransactionParams,
currentMaxFeePerGas: bigint,
currentMaxPriorityFeePerGas: bigint
) {
const bumpedMaxFeePerGas = (currentMaxFeePerGas * 115n) / 100n; // +15%
const bumpedPriorityFee = (currentMaxPriorityFeePerGas * 115n) / 100n; // +15%
return walletClient.sendTransaction({
...newTxParams,
nonce: originalNonce, // same nonce replaces the original
maxFeePerGas: bumpedMaxFeePerGas,
maxPriorityFeePerGas: bumpedPriorityFee,
});
}
Cancel a pending transaction
Send a zero-value self-transfer with the same nonce to effectively cancel:
async function cancelTransaction(nonce: number, agentAddress: `0x${string}`) {
// Use high fees to ensure this cancellation lands before the original
const { maxFeePerGas, maxPriorityFeePerGas } = await computeOptimalFees('urgent', 0);
return walletClient.sendTransaction({
to: agentAddress, // self-transfer
value: 0n,
nonce, // same nonce as the tx to cancel
maxFeePerGas: (maxFeePerGas * 150n) / 100n,
maxPriorityFeePerGas: (maxPriorityFeePerGas * 150n) / 100n,
gas: 21000n, // minimum for ETH transfer
});
}
Flashblocks Timing Model
Understanding when your transaction will land:
Time 0ms: Flashblock N seals — transactions in it are preconfirmed
Time 0–200ms: New Flashblock N+1 being built — submit now for N+1
Time 200ms: Flashblock N+1 seals
...
Time 2000ms: Full block seals, containing all 10 Flashblocks
Key implication: A transaction submitted immediately after a Flashblock seals has ~200ms to land in the next one. A transaction submitted 150ms into the window may miss the next Flashblock if processing is delayed. For latency-critical agents, maintain a WebSocket connection and submit immediately after observing a new Flashblock via newFlashblocks subscription.
// Subscribe to Flashblock events on a Flashblocks-aware endpoint
const ws = new WebSocket('wss://mainnet-preconf.base.org');
ws.onopen = () => {
ws.send(JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'eth_subscribe',
params: ['newFlashblocks'],
}));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.method === 'eth_subscription') {
// New Flashblock sealed — window opens for next one
onFlashblockSeal(msg.params.result);
}
};
Complete Agent Decision Tree
START
│
▼
SIMULATE tx via eth_call("pending")
│
├─ REVERT: slippage → re-quote, adjust amountOutMin, retry
├─ REVERT: allowance → approve(), then retry
├─ REVERT: other → log selector, abort
│
▼
ESTIMATE gas + add buffer
│
▼
CHECK L1 fee ratio (via GasPriceOracle)
│
├─ L1 fee > 80% of total AND trade < threshold → DEFER
│
▼
COMPUTE fees (eth_feeHistory, urgency level)
│
▼
SUBMIT eth_sendRawTransaction
│
├─ RPC ERROR → see error handling guide
│
▼
MONITOR (poll every 500ms, timeout after 30s)
│
├─ receipt.status = success → DONE
├─ receipt.status = reverted → re-simulate at block, log reason
├─ timeout → REPLACE with +15% fees, same nonce
│ └─ second timeout → CANCEL (self-transfer, same nonce)
│
▼
END