Skip to content

nonce manager not resetting after network error #4364

@spypsy

Description

@spypsy

Check existing issues

Viem Version

2.38.2

Current Behavior

when using nonce manager, if a transaction fails with e.g. network error, the nonce gets consumed and not reset, meaning subsequent transactions will be sending the wrong nonce.
Not that this happens only after we've sent at least one transaction (nonce > 0) so tests with fresh accounts won't reproduce.

Expected Behavior

nonce manager should recover after a failed transaction request.

I ran into this issue using the nonceManager externally (not inside account) and confirmed that this happens when used at account creation as well.

I think expected behaviour should be that nonceManager.reset() also resets nonceMap

Steps To Reproduce

repro script using default anvil setup.
It uses a proxy to return an error every other request.
3rd request ends up with nonce 2 which should be 1 since 2nd tx was never sent

/**
 * Minimal reproduction: viem NonceManager nonce gap after failed sendRawTransaction.
 *
 * When `sendRawTransaction` fails AFTER `nonceManager.consume()` has stored
 * the nonce in its internal `nonceMap`, subsequent `consume()` calls return
 * `localNonce + 1` instead of re-fetching from the chain. This is because
 * `reset()` only clears `deltaMap` and `promiseMap` — it does NOT clear
 * `nonceMap`, so the stale nonce persists and creates an unrecoverable nonce gap.
 *
 * Prerequisites: anvil running on port 8545
 *   anvil --port 8545
 *
 * Run:
 *   npx tsx viem_nonce_manager_bug.ts
 */
import http from 'node:http';
import { createPublicClient, createWalletClient, http as viemHttp } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { foundry } from 'viem/chains';
import { createNonceManager, jsonRpc } from 'viem/nonce';

const ANVIL_URL = process.env.ANVIL_URL ?? 'http://127.0.0.1:8545';
const RECIPIENT = '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as const;
const PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80' as const;

/**
 * Starts a tiny proxy that returns 429 for every Nth `eth_sendRawTransaction`
 * and forwards everything else to the upstream anvil RPC.
 */
function startFailingProxy(upstreamUrl: string, failEveryNth: number): Promise<{ url: string; close: () => void }> {
  return new Promise(resolve => {
    let sendCount = 0;
    const server = http.createServer(async (req, res) => {
      let body = '';
      req.on('data', chunk => (body += chunk));
      req.on('end', async () => {
        const rpc = JSON.parse(body);
        if (rpc.method === 'eth_sendRawTransaction' && ++sendCount % failEveryNth === 0) {
          res.writeHead(429, { 'Content-Type': 'application/json' });
          res.end(
            JSON.stringify({ jsonrpc: '2.0', error: { code: -32005, message: 'Rate limit exceeded' }, id: rpc.id }),
          );
          return;
        }
        const upstream = await fetch(upstreamUrl, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body,
        });
        const result = await upstream.text();
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(result);
      });
    });
    server.listen(0, () => {
      const port = (server.address() as any).port;
      resolve({ url: `http://127.0.0.1:${port}`, close: () => server.close() });
    });
  });
}

async function main() {
  // Fail every 2nd eth_sendRawTransaction: 1st succeeds, 2nd fails, 3rd succeeds, ...
  const proxy = await startFailingProxy(ANVIL_URL, 2);

  try {
    const nonceManager = createNonceManager({ source: jsonRpc() });
    const account = privateKeyToAccount(PRIVATE_KEY, { nonceManager });
    const client = createWalletClient({ account, chain: foundry, transport: viemHttp(proxy.url) });
    const publicClient = createPublicClient({ chain: foundry, transport: viemHttp(proxy.url) });

    // 1. Successful send (1st sendRawTransaction — passes)
    console.log('TX 1: Sending (should succeed)');
    const hash1 = await client.sendTransaction({ to: RECIPIENT, value: 1n });
    await publicClient.waitForTransactionReceipt({ hash: hash1 });
    console.log(`  Mined with nonce ${(await publicClient.getTransaction({ hash: hash1 })).nonce}`);

    const chainNonce = await publicClient.getTransactionCount({ address: account.address, blockTag: 'pending' });
    console.log(`Chain pending nonce after successful send: ${chainNonce}`);

    // 2. Failed send (2nd sendRawTransaction — 429)
    //    nonceManager.consume() stores the nonce in nonceMap, but the tx never reaches the chain.
    console.log('\nTX 2: Sending (will fail at sendRawTransaction)');
    try {
      await client.sendTransaction({ to: RECIPIENT, value: 1n });
      console.log('  ERROR: should have failed!');
    } catch {
      console.log('  Failed as expected (429)');
    }

    const chainNonceAfter = await publicClient.getTransactionCount({ address: account.address, blockTag: 'pending' });
    console.log(`  Chain pending nonce (unchanged): ${chainNonceAfter}`);

    // 3. Recovery send (3rd sendRawTransaction — passes)
    //    Should reuse nonce from failed TX 2, but nonceManager returns nonce+1.
    console.log('\nTX 3: Recovery send (should reuse nonce from failed TX 2)');
    try {
      const hash3 = await client.sendTransaction({ to: RECIPIENT, value: 1n });
      const nonce3 = (await publicClient.getTransaction({ hash: hash3 })).nonce;
      console.log(`  Sent with nonce ${nonce3}`);
      if (nonce3 === chainNonce) {
        console.log(`  OK: Correctly reused nonce ${chainNonce}`);
      } else {
        console.log(`  BUG: Used nonce ${nonce3}, but chain expected ${chainNonce}`);
      }
    } catch (err: any) {
      console.log(`  FAILED: ${err.shortMessage ?? err.message}`);
      console.log(`  BUG: nonceManager returned nonce ${chainNonce + 1}, chain expected ${chainNonce}`);
    }
  } finally {
    proxy.close();
  }
}

main().catch(err => {
  console.error(err);
  process.exit(1);
});

result:

TX 1: Sending (should succeed)
  Mined with nonce 0
Chain pending nonce after successful send: 1

TX 2: Sending (will fail at sendRawTransaction)
  Failed as expected (429)
  Chain pending nonce (unchanged): 1

TX 3: Recovery send (should reuse nonce from failed TX 2)
  Sent with nonce 2
  BUG: Used nonce 2, but chain expected 1

Link to Minimal Reproducible Example

No response

Anything else?

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions