Nonce Fundamentals
In Ethereum and all EVM-compatible chains, every account has a transaction count — called a nonce — that starts at 0 and increments by exactly 1 with each confirmed transaction. The nonce is a strict sequence enforcer: transaction N+1 cannot be processed until transaction N is confirmed.
This design prevents replay attacks (you cannot re-submit an already-confirmed transaction because its nonce is now in the past) and enforces ordering (a spender cannot spend funds twice simultaneously). For human users submitting one transaction at a time, nonces are invisible. For AI agents submitting dozens of transactions per minute, they are a fundamental bottleneck.
The Two Nonce Values You Need to Track
When querying an Ethereum node, you get two different nonce values depending on how you ask:
- Confirmed nonce (
eth_getTransactionCountwith"latest"): The number of transactions included in confirmed blocks. This is what you've successfully committed to the chain. - Pending nonce (
eth_getTransactionCountwith"pending"): Includes transactions waiting in the mempool that the node has accepted. This is the value to use when queuing the next transaction.
If you use "latest" and you have a transaction pending in the mempool, you will accidentally reuse the same nonce. The node will accept it but the second transaction will replace or conflict with the first. This is a very common agent bug.
Nonce in Multi-Chain Context
Each EVM chain maintains an independent nonce per address. Your agent's address on Ethereum has a completely separate nonce from the same address on Polygon, BNB Chain, or Avalanche. Solana, Bitcoin, Monero, and TRON use entirely different transaction models — Solana has no nonce in the EVM sense; it uses recent blockhashes.
Purple Flea's Wallet API manages nonces per-chain internally for all 8 supported networks, but understanding the mechanics helps you reason about failures when they occur.
Common Nonce Failure Modes
Nonce failures manifest in several distinct ways. Each has a different root cause and recovery procedure. Understanding the failure taxonomy is essential before writing any nonce management code.
Nonce Gap
Transaction with nonce N submitted but nonce N-1 never confirmed. All subsequent transactions (N+1, N+2...) are also stuck.
Nonce Reuse
Two transactions submitted with the same nonce. The mempool keeps the higher-fee one; the other is silently dropped.
Stale Nonce
Agent reads nonce from a lagging node. Submits nonce that is already confirmed, resulting in immediate rejection.
Underpriced Stuck
Transaction submitted with too-low gas price never mines. Subsequent transactions queue up behind it indefinitely.
Anatomy of a Nonce Gap
Confirmed nonce: 42
Mempool state:
Nonce 43: ✅ Pending (valid, queued after 42)
Nonce 44: ✅ Pending (valid, queued after 43)
Nonce 45: ❌ MISSING — gap created here
Nonce 46: ⏳ Future (agent submitted this but it is blocked by gap)
Nonce 47: ⏳ Future (also blocked)
Nonce 48: ⏳ Future (also blocked)
Result: Transactions 46, 47, 48 cannot execute until nonce 45 is filled.
Every minute they sit unconfirmed, they are at risk of expiring from mempool.
Recovery options:
① Submit a transaction WITH nonce 45 at any gas price (even 0-value self-send)
② Cancel 43, 44 then resubmit all from nonce 43 in sequence
③ If using Purple Flea API: call /wallet/recover-nonce — API handles it
The Replacement Transaction Pattern
When a transaction is stuck in the mempool due to low gas, the standard fix is to submit a replacement transaction with the same nonce but higher gas price (at least 10% higher than the stuck transaction). Miners/validators will evict the lower-fee transaction and include the replacement.
This is how "cancel" transactions work: you send a 0-ETH transfer to yourself with the same nonce but enough gas to get mined quickly. The cancellation consumes the nonce slot, unblocking everything queued after it.
Post-EIP-1559 transactions have maxFeePerGas and maxPriorityFeePerGas instead of a single gasPrice. The replacement threshold still applies to the effective gas price. To replace a stuck EIP-1559 transaction, bump both values by at least 10%.
Concurrent Agent Hazards
Single-threaded, sequential transaction submission rarely has nonce issues. The complexity explodes when an agent submits multiple transactions concurrently — which is common in high-frequency trading agents, casino agents running multiple games simultaneously, or agents managing multiple open positions.
The Race Condition
Time → T1 T2 T3 T4
──────────────────────────────────────────────────────
Thread A: read(pending)=43 sign(43) submit(43)
|
Thread B: read(pending)=43 sign(43) submit(43)
↑
COLLISION — both submitted nonce 43!
Higher gas wins, lower gas DROPPED.
Result: One transaction is silently lost. No error thrown by the RPC.
The agent may not know which transaction succeeded until checking receipts.
This is the most insidious failure: the dropped transaction generates no exception at submission time. Your agent receives a transaction hash for both transactions. Only one will ever be mined. If the dropped transaction was a casino bet, a trade entry, or a payment, it simply never happens.
Async Event Loop Hazards
Python's asyncio single-thread model might seem safe, but the await keyword is a yield point. Between querying the nonce and submitting the transaction, other coroutines can run and themselves read the same nonce:
# DANGEROUS: Both coroutines read nonce=43 at the same time # because await is a yield point between read and use async def bad_send_tx(web3, account, to, value): nonce = await web3.eth.get_transaction_count(account, 'pending') # ← YIELD POINT: another coroutine can run here and also read nonce=43 tx = {'nonce': nonce, 'to': to, 'value': value, ...} signed = account.sign_transaction(tx) # ← YIELD POINT: again vulnerable return await web3.eth.send_raw_transaction(signed.rawTransaction) # If two coroutines run concurrently, both get nonce=43: await asyncio.gather( bad_send_tx(w3, acct, addr1, value1), # gets nonce 43 bad_send_tx(w3, acct, addr2, value2), # ALSO gets nonce 43 — COLLISION )
Production Nonce Manager: Full Implementation
The following NonceManager class provides thread-safe, async-safe nonce management with automatic gap detection, stuck transaction recovery, and mempool reconciliation. It is designed for agents that submit high volumes of concurrent transactions.
""" Production-grade NonceManager for AI agents. Thread-safe, async-safe, with gap detection and recovery. Compatible with Purple Flea Wallet API. """ import asyncio import logging import time from collections import defaultdict from dataclasses import dataclass, field from typing import Dict, Optional, Set import aiohttp log = logging.getLogger('nonce_manager') # Chains where nonce management applies (non-EVM chains handled differently) EVM_CHAINS = {"ETH", "MATIC", "BNB", "AVAX"} STUCK_TX_TIMEOUT_S = 300 # 5 minutes before a tx is considered stuck GAS_BUMP_FACTOR = 1.15 # 15% gas bump for replacement transactions MAX_PENDING_QUEUE = 50 # max unconfirmed txs before we pause submission @dataclass class PendingTx: tx_hash: str nonce: int gas_price_gwei: float submitted_at: float chain: str description: str = "" class NonceManager: """ Thread-safe nonce manager for concurrent EVM transaction submission. Features: - Atomic nonce allocation via asyncio.Lock - Pending transaction tracking per chain - Gap detection with automatic fill-gap recovery - Stuck transaction detection and gas bumping - Mempool reconciliation via RPC """ def __init__(self, rpc_urls: Dict[str, str], address: str): # RPC endpoints per chain self.rpc_urls = rpc_urls self.address = address.lower() # Nonce state: chain → current local nonce self._local_nonce: Dict[str, int] = {} # One lock per chain — prevents concurrent nonce reads on same chain self._locks: Dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) # Track pending transactions: chain → {nonce: PendingTx} self._pending: Dict[str, Dict[int, PendingTx]] = defaultdict(dict) # Set of nonces known to be confirmed: chain → set of nonces self._confirmed: Dict[str, Set[int]] = defaultdict(set) self._session: Optional[aiohttp.ClientSession] = None async def __aenter__(self): self._session = aiohttp.ClientSession() return self async def __aexit__(self, *args): if self._session: await self._session.close() async def _rpc(self, chain: str, method: str, params: list) -> dict: """Execute JSON-RPC call to chain node.""" payload = { "jsonrpc": "2.0", "id": 1, "method": method, "params": params, } async with self._session.post( self.rpc_urls[chain], json=payload ) as resp: data = await resp.json() if "error" in data: raise RuntimeError(f"RPC error on {chain}: {data['error']}") return data["result"] async def _fetch_pending_nonce(self, chain: str) -> int: """Get pending nonce from chain node.""" result = await self._rpc( chain, "eth_getTransactionCount", [self.address, "pending"] ) return int(result, 16) async def _fetch_confirmed_nonce(self, chain: str) -> int: """Get confirmed (latest) nonce from chain node.""" result = await self._rpc( chain, "eth_getTransactionCount", [self.address, "latest"] ) return int(result, 16) async def acquire_nonce(self, chain: str) -> int: """ Atomically acquire the next nonce for a chain. This is the core method — always use this instead of querying the RPC directly. Guarantees no two callers get the same nonce even under concurrent load. """ if chain not in EVM_CHAINS: raise ValueError(f"{chain} does not use EVM nonces") async with self._locks[chain]: # Initialize local nonce from chain if not yet cached if chain not in self._local_nonce: self._local_nonce[chain] = await self._fetch_pending_nonce(chain) log.info(f"[{chain}] Initialized local nonce: {self._local_nonce[chain]}") # Check if we are overloading the mempool pending_count = len(self._pending[chain]) if pending_count >= MAX_PENDING_QUEUE: log.warning(f"[{chain}] Pending queue full ({pending_count}), pausing") await self._wait_for_confirmation(chain) nonce = self._local_nonce[chain] self._local_nonce[chain] += 1 log.debug(f"[{chain}] Allocated nonce {nonce}") return nonce def register_pending( self, chain: str, nonce: int, tx_hash: str, gas_price_gwei: float, description: str = "", ): """Register a submitted transaction as pending.""" self._pending[chain][nonce] = PendingTx( tx_hash=tx_hash, nonce=nonce, gas_price_gwei=gas_price_gwei, submitted_at=time.time(), chain=chain, description=description, ) log.debug(f"[{chain}] Registered pending nonce={nonce} hash={tx_hash[:10]}...") async def reconcile(self, chain: str): """ Reconcile local nonce state with on-chain state. Call this periodically (e.g., every 60s) or after detecting anomalies. """ async with self._locks[chain]: on_chain_confirmed = await self._fetch_confirmed_nonce(chain) on_chain_pending = await self._fetch_pending_nonce(chain) # Remove confirmed txs from pending dict confirmed_nonces = [n for n in self._pending[chain] if n < on_chain_confirmed] for n in confirmed_nonces: log.info(f"[{chain}] Nonce {n} confirmed") self._confirmed[chain].add(n) del self._pending[chain][n] # Detect gap: confirmed < on_chain_pending means the node sees # pending txs we might not have. Update local nonce to be safe. if on_chain_pending > self._local_nonce.get(chain, 0): log.warning( f"[{chain}] Local nonce {self._local_nonce.get(chain)} behind" f" pending nonce {on_chain_pending} — syncing" ) self._local_nonce[chain] = on_chain_pending log.info( f"[{chain}] Reconciled: confirmed={on_chain_confirmed}" f" pending_queue={on_chain_pending}" f" local={self._local_nonce.get(chain, 'uninit')}" ) async def detect_stuck(self, chain: str) -> list[PendingTx]: """Return list of transactions stuck beyond STUCK_TX_TIMEOUT_S.""" now = time.time() stuck = [ tx for tx in self._pending[chain].values() if now - tx.submitted_at > STUCK_TX_TIMEOUT_S ] if stuck: log.warning(f"[{chain}] {len(stuck)} stuck transactions detected") return stuck async def fill_gap( self, chain: str, gap_nonce: int, signer_fn, current_gas_gwei: float, ) -> str: """ Fill a nonce gap by submitting a 0-value self-transfer. signer_fn(tx_dict) → signed transaction bytes """ log.warning(f"[{chain}] Filling gap at nonce {gap_nonce}") cancel_tx = { "nonce": gap_nonce, "to": self.address, "value": 0, "gas": 21000, "maxFeePerGas": int(current_gas_gwei * 1e9 * 1.5), "maxPriorityFeePerGas": int(2e9), } signed_bytes = signer_fn(cancel_tx) result = await self._rpc( chain, "eth_sendRawTransaction", ["0x" + signed_bytes.hex()] ) log.info(f"[{chain}] Gap fill tx: {result}") self.register_pending(chain, gap_nonce, result, current_gas_gwei * 1.5, "gap_fill") return result async def _wait_for_confirmation(self, chain: str, timeout_s: float = 120): """Block until pending queue drops below half of MAX_PENDING_QUEUE.""" deadline = time.time() + timeout_s while len(self._pending[chain]) >= MAX_PENDING_QUEUE // 2: if time.time() > deadline: raise TimeoutError(f"[{chain}] Pending queue still full after {timeout_s}s") await asyncio.sleep(5) await self.reconcile(chain) def stats(self) -> Dict: return { chain: { "local_nonce": self._local_nonce.get(chain, "uninit"), "pending_count": len(self._pending[chain]), "confirmed_count": len(self._confirmed[chain]), } for chain in EVM_CHAINS }
Usage Example: Concurrent Casino Bets
import asyncio from nonce_manager import NonceManager RPC_URLS = { "ETH": "https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", "MATIC": "https://polygon-rpc.com", "BNB": "https://bsc-dataseed.binance.org", "AVAX": "https://api.avax.network/ext/bc/C/rpc", } AGENT_ADDRESS = "0xYourAgentAddress" async def place_bet( nm: NonceManager, chain: str, game: str, amount: float, signer, ) -> str: """Place a casino bet with nonce-safe transaction submission.""" # ✅ Acquire nonce BEFORE any async work (atomic allocation) nonce = await nm.acquire_nonce(chain) try: # Build casino API transaction payload tx = { "nonce": nonce, "to": "0xPurpleFleCasinoContract", "data": encode_bet(game, amount), "gas": 200_000, "maxFeePerGas": int(30e9), "maxPriorityFeePerGas": int(2e9), } signed = signer(tx) tx_hash = await nm._rpc(chain, "eth_sendRawTransaction", [signed.hex()]) nm.register_pending(chain, nonce, tx_hash, 30.0, f"casino:{game}:${amount}") log.info(f"Bet placed: {game} ${amount} nonce={nonce} tx={tx_hash[:10]}...") return tx_hash except Exception as e: # If submission failed, we have a nonce gap — fill it immediately log.error(f"Bet submission failed at nonce {nonce}: {e}") await nm.fill_gap(chain, nonce, signer, current_gas_gwei=35.0) raise async def run_multi_game_agent(): async with NonceManager(RPC_URLS, AGENT_ADDRESS) as nm: signer = get_signer() # Your signing function # Start background reconciliation task asyncio.create_task(reconcile_loop(nm)) # Place 5 concurrent bets safely results = await asyncio.gather(*[ place_bet(nm, "ETH", "blackjack", 50, signer), place_bet(nm, "ETH", "roulette", 25, signer), place_bet(nm, "ETH", "dice", 10, signer), place_bet(nm, "ETH", "slots", 5, signer), place_bet(nm, "ETH", "poker", 100, signer), ]) # ✅ All 5 bets get sequential nonces 43,44,45,46,47 — no collisions log.info(f"All bets placed: {results}") log.info(f"Nonce stats: {nm.stats()}") async def reconcile_loop(nm: NonceManager): while True: await asyncio.sleep(60) for chain in EVM_CHAINS: try: await nm.reconcile(chain) stuck = await nm.detect_stuck(chain) if stuck: log.warning(f"Stuck txs on {chain}: {[t.nonce for t in stuck]}") except Exception as e: log.error(f"Reconcile failed for {chain}: {e}")
How Purple Flea's Wallet API Handles Nonces
Purple Flea's Wallet API at purpleflea.com/wallet-api manages nonces entirely on your behalf. When you submit a transaction through the API, the nonce is allocated atomically server-side, eliminating the race conditions described above.
What the API Does Under the Hood
The nonce manager class above is valuable if you are running your own transaction infrastructure. If you route transactions through Purple Flea's Wallet API, nonce management is handled automatically. The custom NonceManager is mainly useful for hybrid setups where you submit some transactions directly.
Wallet API Transaction Endpoint
import aiohttp async def send_via_purpleflea( api_key: str, chain: str, to: str, value_wei: int, data: str = "", ) -> dict: """ Send a transaction via Purple Flea Wallet API. Nonce is managed automatically — no NonceManager needed. """ async with aiohttp.ClientSession() as session: async with session.post( "https://purpleflea.com/wallet-api/send", headers={"Authorization": f"Bearer {api_key}"}, json={ "chain": chain, # "ETH", "MATIC", "BNB", "AVAX" "to": to, "value": str(value_wei), "data": data, "gas_mode": "auto", # API handles gas estimation "priority": "normal", # "slow" | "normal" | "fast" | "urgent" }, ) as resp: resp.raise_for_status() result = await resp.json() # result = {"tx_hash": "0x...", "nonce": 47, "status": "submitted"} return result # The API handles all nonce allocation, gas estimation, and retry logic. # Your agent just calls send_via_purpleflea() for each transaction.
Best Practices Summary
| Practice | Why It Matters | Priority |
|---|---|---|
| Always use "pending" nonce query | Prevents reuse of already-queued nonces | Critical |
| Lock before read-increment-submit | Prevents concurrent nonce collisions | Critical |
| Fill gaps immediately on failure | Unblocks queued transactions | High |
| Reconcile with chain every 60s | Detects node sync lag and missing confirmations | High |
| Cap pending queue at 50 | Prevents mempool flood and chain-specific limits | Medium |
| Monitor for stuck txs every 5 min | Auto-bump gas before expiry from mempool | Medium |
| Use Purple Flea Wallet API | Offloads all nonce management complexity | Recommended |
Solana uses recent blockhashes instead of sequential nonces — transactions expire if not included within ~2 minutes. Bitcoin uses UTXO model with no nonce. Monero uses key images for double-spend prevention. TRX uses block references. Purple Flea's Wallet API normalizes all of these into a single transaction submission interface.
Stop Worrying About Nonces
Purple Flea's Wallet API handles nonce management, gas estimation, and transaction retry automatically across all 8 supported chains. Get started for free.