Home / Blog / Nonce Management
Deep Dive EVM Wallet API Concurrency

Nonce Management for AI Agents: Avoiding Transaction Failures

By Purple Flea Engineering March 4, 2026 16 min read

The nonce is the most underappreciated source of transaction failures for AI agents. A single misstep creates a nonce gap that freezes every subsequent transaction until resolved. This guide covers EVM nonce mechanics from first principles, common failure modes in concurrent agent architectures, and a production-grade Python nonce manager class that handles thread safety, gap recovery, and mempool state reconciliation.

Table of Contents

  1. Nonce Fundamentals
  2. Common Failure Modes
  3. Concurrent Agent Hazards
  4. Nonce Manager Class
  5. Purple Flea Integration
  6. Best Practices
01 — Nonce Fundamentals

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.

0 First account nonce
+1 Increment per tx
Max nonce value
strict Ordering

The Two Nonce Values You Need to Track

When querying an Ethereum node, you get two different nonce values depending on how you ask:

Always use "pending" when building transactions, never "latest"

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.

02 — Common Failure Modes

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
      
Figure 1: Nonce gap blocking downstream transactions

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.

ℹ️
EIP-1559 changes gas calculation but not nonce behavior

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%.

03 — Concurrent Agent Hazards

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.
      
Figure 2: Concurrent threads racing on nonce assignment

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:

Python race_condition_example.py
# 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
)
04 — Nonce Manager Class

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.

Python nonce_manager.py
"""
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

Python casino_with_nonce_manager.py
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}")
05 — Purple Flea Integration

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

Step 1 — Request Received
API receives your transaction request. A per-address, per-chain lock is acquired to prevent any concurrent nonce allocation for the same address.
Step 2 — Nonce Allocation
The API queries its internal nonce state (which it continuously reconciles against chain state). The next sequential nonce is assigned and the local counter incremented atomically before the lock is released.
Step 3 — Transaction Signing
The API signs the transaction with the allocated nonce using HSM-backed key storage. Your private key never leaves secure hardware.
Step 4 — Submission with Retry
Transaction broadcast to multiple redundant RPC endpoints. If the primary RPC rejects due to nonce collision (shouldn't happen, but defensive), the API retries with correct state.
Step 5 — Monitoring
The API monitors all submitted transactions for confirmation. Stuck transactions are automatically speed-up after the configurable timeout (default: 5 minutes). You receive webhook callbacks on confirmation.
For most agents: just use the Wallet API

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

Python purpleflea_wallet_tx.py
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.
06 — Best Practices

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
ℹ️
Nonce behavior on non-EVM chains

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.