Skip to content
New guideAgent Cookbook 12 copy-ready prompts for AI coding agents.See recipes
All blog posts
Retry Ethereum transactions without paying twice

Retry Ethereum transactions without paying twice

By Elias Faltin10min read

Your payment service sends a signed withdrawal to a blockchain node. The connection closes before the reply arrives. The payment might already be on its way. Creating another payment could pay the user twice. Abandoning the request could leave the withdrawal unfinished.

eth_sendRawTransaction submits that signed transaction through a remote procedure call, or RPC. Recovering from a lost reply starts before submission. Save the signed bytes and their transaction hash. Retry those bytes, then check what the chain included before changing the payment's state.

This guide covers ordinary Ethereum transactions, with a runnable example for EIP-1559 type-2 transactions. Blob transactions need separate handling for their network wrapper and sidecars. The procedure preserves uncertainty instead of turning a timeout or missing receipt into permission to pay again.

Save the payment identity before submission

A nonce is the sender's transaction sequence number. On one canonical Ethereum chain, two ordinary transactions from the same sender cannot both execute with the same nonce. A replacement uses that same nonce, with different signed bytes and a different hash.

For a type-2 transaction, calculate its hash with keccak256(serializedTransaction). Persist these fields before the first network request:

  • Your business operation ID, such as the withdrawal ID.
  • The chain ID, sender address, and nonce.
  • The signed transaction bytes and calculated hash.
  • Every intentional replacement hash and its relationship to the original.

Enforce uniqueness for the business operation and coordinate nonce allocation across workers. Resume the saved record after a crash. Do not let a second worker create another withdrawal at the next nonce.

Treat signed bytes as sensitive. Anyone holding valid bytes can submit them. Store them with access controls and avoid putting them in routine logs.

Sending identical bytes again preserves the transaction's identity. This makes rebroadcast safe against duplicate execution of those bytes. It does not make your payment service idempotent. Your durable business record must prevent a second transaction for the same withdrawal.

Save the payment record, send the signed bytes, and resend the same bytes after a lost reply. A newly signed payment at the next nonce can pay twice.

Save the operation ID, nonce, signed bytes and hash before submission. Submit the signed bytes to the endpoint. If the reply is lost through a timeout or a closed connection, resend the same saved bytes with backoff. The same signed bytes keep one hash and one nonce, so the transaction can execute at most once. A newly signed payment gets a new hash at the next nonce. Both can execute, so the user is paid twice. Your durable payment record, not the RPC reply, prevents a second withdrawal.

The hash calculation follows EIP-1559's signed transaction encoding. Keep nonce allocation separate from submission recovery. The nonce monitoring guide covers blocked and competing nonces.

Read a returned hash as an acknowledgement

A matching hash acknowledges submission through that endpoint. It does not prove propagation, block inclusion, execution success, or finality.

Even pool admission depends on the implementation. Geth's submission backend can track temporary pool rejections locally and return without an error. A full pool therefore need not produce a failed RPC response.

A second provider's transaction lookup can help diagnose visibility. A null result still does not prove the transaction was dropped everywhere. Nodes have different local pools, and private submissions can remain outside public pools.

Choose the submission route before broadcasting. Sending private transaction bytes to a public endpoint reveals them. Use the private service's documented status and retry rules when privacy matters. Continue to check chain receipts for inclusion.

Classify errors without discarding the record

Keep the original JSON-RPC code, message, data, and endpoint. Libraries can wrap those fields. HTTP errors describe the request, while a transaction error describes one node's view of the submitted bytes.

The following strings appear in pinned client implementations. They are examples for diagnosis, not a complete cross-provider error contract.

Message fragmentWhat to do with saved bytes
already knownContinue receipt tracking. The node recognizes the transaction; this does not establish inclusion.
nonce too lowCheck every tracked hash and the sender's chain nonce. A missing receipt leaves the outcome unresolved.
replacement transaction underpricedReconcile competing same-nonce transactions. Make a deliberate replacement decision before signing again.
insufficient fundsInspect the sender balance and maximum transaction cost. Repeating unchanged bytes does not repair insufficient funds.
txpool is full, sub-pool is full, transaction underpricedReview pool and fee policy. Schedule a bounded retry of the same bytes if the route permits it.
Timeout, connection reset, HTTP 429 or 5xxKeep the transaction unresolved. Retry identical bytes with backoff and the provider's retry guidance.
Unknown error or malformed replyPreserve the evidence and investigate. Do not create a new payment.

Geth defines duplicate, replacement, and pool errors in its transaction pool errors. Reth exposes corresponding strings in its RPC error mapping.

Erigon's discard reasons include pool-specific messages. Its RPC handler can prefix the reason with an import result, such as ALREADY_EXISTS. Nethermind defines the common strings in its pool error messages.

Match specific errors before broader fragments. For example, replacement transaction underpriced also contains transaction underpriced. Never use a generic substring match to authorize another payment.

Replacement thresholds are node policy. Do not hard-code one percentage as a network rule. For EIP-1559 transactions, account for both fee caps and the policy of your submission route.

A missing receipt does not prove a nonce conflict

eth_getTransactionReceipt returns null when that node has no receipt for the hash. The transaction might be pending, unknown there, replaced, or affected by a chain reorganization. A failing receipt request provides even less evidence.

After nonce too low, inspect the receipts of the original and every replacement. Read the sender's transaction count at a recorded chain state. If the count exceeds the saved nonce, investigate how that nonce was consumed. Ethereum authorizations under EIP-7702 can also increment an account's nonce.

Do not infer another successful payment from nonce too low plus null. A backend can lag, or a request can reach another node behind a load balancer. Keep the business record unresolved until you identify the relevant chain outcome.

Four receipt checks run in order before settlement: a receipt exists, its block hash matches, status is 0x1, and the finality policy is met.

Look up every saved hash with eth_getTransactionReceipt. If no receipt exists for a saved hash, the payment is unresolved. Keep tracking every hash. If the receipt's block hash does not match the chain at that height, the payment is unresolved. Recheck after the next block. If status is not 0x1, execution reverted. The nonce is used and the payment is not made. If status is 0x1, verify the recipient, amount and logs. If your finality policy is not met, wait and recheck canonicality. When it is met, settle the withdrawal. A null receipt or a failed lookup is missing evidence, never permission to pay again.

Retry saved bytes with viem

The example performs one attempt, then reads the receipt. It never signs or allocates a nonce. The caller must load saved from durable storage before calling it.

Use Node.js 22 and install viem@2.38.5. Save this code as submit-saved.mjs. Import attemptSaved from your worker and pass an approved endpoint plus { raw, hash, chainId }.

JavaScript
import { createPublicClient, http, keccak256 } from 'viem';
import { sendRawTransaction } from 'viem/actions';

const hash32 = (value) => typeof value === 'string' && /^0x[0-9a-f]{64}$/i.test(value);
const quantity = (value) => typeof value === 'string' && /^0x(0|[1-9a-f][0-9a-f]*)$/i.test(value);

export async function attemptSaved(url, saved) {
  const hash = keccak256(saved.raw);
  if (!hash32(saved.hash) || saved.hash.toLowerCase() !== hash) {
    throw new Error('Saved bytes and hash disagree');
  }
  const client = createPublicClient({
    transport: http(url, { retryCount: 0, timeout: 10_000 }),
  });
  if (await client.getChainId() !== saved.chainId) {
    throw new Error('Wrong chain');
  }

  let returnedHash;
  let submissionError;
  try {
    returnedHash = await sendRawTransaction(client, {
      serializedTransaction: saved.raw,
    });
  } catch (error) {
    submissionError = error;
  }
  if (!submissionError && (!hash32(returnedHash) || returnedHash.toLowerCase() !== hash)) {
    throw new Error('Endpoint returned a different transaction hash');
  }

  // Raw RPC fields preserve types that SDK formatters can otherwise change.
  const receipt = await client.request({
    method: 'eth_getTransactionReceipt',
    params: [hash],
  });
  if (receipt === null) return { state: 'unresolved', hash, submissionError };
  if (!receipt || !hash32(receipt.transactionHash) ||
      receipt.transactionHash.toLowerCase() !== hash ||
      !hash32(receipt.blockHash) || !quantity(receipt.blockNumber) ||
      !['0x0', '0x1'].includes(receipt.status)) {
    throw new Error('Incomplete or mismatched receipt');
  }
  const block = await client.request({
    method: 'eth_getBlockByNumber',
    params: [receipt.blockNumber, false],
  });
  if (!block || !hash32(block.hash) || !quantity(block.number) ||
      BigInt(block.number) !== BigInt(receipt.blockNumber) ||
      block.hash.toLowerCase() !== receipt.blockHash.toLowerCase()) {
    return { state: 'unresolved', hash, submissionError };
  }
  return {
    state: 'included-provisional',
    hash,
    execution: receipt.status === '0x1' ? 'success' : 'reverted',
    receipt,
    submissionError,
  };
}

unresolved leaves the withdrawal open. A thrown error also leaves it open and requires investigation or another bounded read. Neither result authorizes a new payment.

Record each outcome. Retry transient failures with capped backoff and jitter. Honor rate limits, stop automatic retries after a defined budget, and retain an investigation queue. Repeated validation failures need a repair decision, not a tight retry loop.

The example disables transport retries so the worker owns the retry budget. Viem's raw submission action also sets retryCount: 0. Do not assume one SDK call will rebroadcast until inclusion.

Ethers' broadcast implementation verifies the returned hash against the signed transaction. Its JSON-RPC adapter maps nonce errors to NONCE_EXPIRED. Those library errors still require chain reconciliation.

Separate inclusion, execution, and settlement

A matching receipt identifies inclusion in the node's current chain view. status: 0x0 means execution reverted. That transaction still consumes its nonce and pays execution fees. status: 0x1 means execution succeeded, but application checks can still be necessary. For token payments, verify the expected contract, recipient, amount, and relevant logs.

The example checks the receipt's block hash against the block at that height. This detects a mismatch at that moment. The reads are not atomic, and the chain can reorganize afterward. Recheck canonicality while waiting for your confirmation or finality policy before settling the withdrawal.

Use new-block notifications to trigger receipt checks, with HTTP polling as a recovery path. Resume from persisted outstanding records after a disconnect or restart. Neither a notification nor an elapsed timeout proves transaction success.

Viem's receipt waiter demonstrates replacement handling. It looks for a transaction with the same sender and nonce and reports whether it was repriced, cancelled, or replaced. A receipt for a cancellation must not settle the original withdrawal as paid.

Treat synchronous submission as an endpoint contract

EIP-7966 remains a Draft Interface proposal. It defines eth_sendRawTransactionSync, which submits signed bytes and waits for a receipt within a timeout. A returned receipt still needs execution and finality checks.

The draft distinguishes three outcomes:

CodeDraft meaningRecovery
4Added to the pool, but the wait expired. Error data contains the transaction hash.Continue tracking the saved hash.
5Not added because the transaction is not ready. Error data contains its hash.Investigate readiness before another attempt.
6Not added because of a nonce gap. Error data contains the expected nonce.Reconcile outstanding nonces without creating another payment.

These are proposed semantics, not proof that an endpoint implements them. Verify your route's documentation and behavior before depending on its timeout units or error data. A malformed-byte rejection establishes neither valid submission nor successful receipt waiting.

A network timeout remains inconclusive, even when the method name includes Sync. Preserve the same durable record and continue receipt tracking.

For a Dwellir integration, use the Ethereum submission reference alongside the receipt reference. Connect both operations to the same saved withdrawal record.

Start free.1 response = 1 credit.

100,000 responses a day at 20 per second, on 150+ networks. Upgrade when you need more.