Blockchain Field Guide

Field notes · Blockchain education

Preventing duplicate blockchain payments when requests retry

Design retries around one durable payment intent, reconcile ambiguous broadcasts, and test an offline example that prevents duplicate simulated effects.

By · Updated and technically reviewed

Application design with Bitcoin and Ethereum distinctions. The runnable example is an in-memory teaching model, not a payment service.

The dangerous retry happens after an unknown outcome

Imagine an order refund. A worker broadcasts its transaction and then loses the response. A second attempt interprets the timeout as No payment happened, creates a new transaction, and sends another refund. Both transactions may be valid. Consensus can prevent spending the same input twice without knowing that two different valid transfers were intended to refund the same order only once.

This is a gap between transport, ledger, and business semantics. Exactly once is not something an HTTP retry loop can promise across those boundaries. The design goal is one authorized payment intent, with repeated attempts that either refer to that intent or explicitly reject conflicting instructions.

Choose the identity before doing the work

Give each logical operation a stable identifier, such as order-1024-refund-1, and reuse it on retries. Scope it to the authenticated customer or merchant. Associate it with a normalized payload containing network, asset identity, destination, integer amount in base units, and business purpose. Reject the same identifier with different parameters; do not quietly return success for a different payment.

Do not deduplicate only by amount and recipient: two legitimate purchases can share both. Conversely, an idempotency identifier alone is insufficient if a client can mint a new one for every retry. Enforce the business rule with a durable unique constraint on the operation being paid, such as the authorized refund record.

Persist the intent and coordinate workers

In one database transaction, reserve the operation and create a durable work item. A unique constraint arbitrates concurrent requests; a read-then-insert check does not. PostgreSQL’s ON CONFLICT can support that reservation, but the application must still verify that an existing row belongs to the caller and has the same normalized payload.

Use a durable state machine and worker coordination to prevent two workers from independently preparing different transfers. Persist the prepared transaction’s identity and the exact broadcast artifact before external submission. The database cannot atomically commit with an arbitrary blockchain node, so recovery must cover both crash-before-send and crash-after-send-before-response. Never delete an uncertain record simply to unlock a retry.

Retry observation and delivery, not payment creation

After an ambiguous submission, reconcile the known transaction against node observations and recorded attempts. If rebroadcast is appropriate for the protocol, resend the same prepared transaction rather than constructing a second payment. Repeated broadcasts can produce different RPC responses, such as Already known; that is not a second ledger effect and is not by itself settlement evidence.

Keep status polling separate from signing or creating another transfer. Apply bounded backoff and jitter to transient failures. Classify invalid-transaction errors separately from transport failures. Escalate unresolved outcomes instead of retrying forever or inventing a new intent. A crash recovery job should resume from durable state and include every known replacement in reconciliation.

Protocol duplicate protection has a different scope

Bitcoin prevents an accepted transaction from spending an already-spent output. Two payments funded from different outputs can still both be accepted. Rebroadcasting identical serialized bytes refers to the same transaction; rebuilding a payment can change its identifier and spending choices. Replacement and fee-bumping flows need their own tracked relationships.

An Ethereum account’s transaction nonce orders its transactions. At most one transaction with a given sender nonce can execute in a particular canonical history, but a retry created with the next nonce can execute as another payment. The account nonce is not the proof-of-work search counter shown in this guide. Fee replacements using the same account nonce remain part of the same operation and require reconciliation after reorganizations.

For a contract you control, an operation identifier recorded and checked atomically with the intended effect can provide another layer of deduplication. Its authorization, namespace, and replay scope must be designed carefully. It cannot retroactively protect transfers sent through unrelated contracts or with newly invented operation identifiers.

Run the timeout exercise without a network

Download the example below and run it with Node.js 20 or later. It uses a Map as the intent store and a Set as a fake ledger. The first send applies an effect and deliberately loses its response. The next request reuses the recorded intent and observes the existing effect. Concurrent retries return that same intent; a changed amount with the same operation identifier is rejected.

Expected output includes simulated effects: 1 and conflicting retry: rejected. Change the retry’s operation identifier to represent a second operation and observe why an API-level identifier cannot enforce a business rule on its own. The assertions deliberately fail if the same-intent expectation is no longer met.

The fake ledger models duplicate recognition only. It has no balances, signatures, RPC endpoint, or funds. The Map loses state at process exit and is not safe across service replicas. Production requires durable uniqueness, authorization, crash recovery, network-specific replacement handling, and settlement/reorganization tracking. Use this example to reason about the failure boundary, not as deployable payment code.

Deduplicate the business effect too

A once-submitted transaction can generate repeated webhook or polling observations. Protect fulfillment with its own durable uniqueness rule, tied to the business operation, and apply it only after verifying the intended payment and settlement policy. An idempotent refund API paired with a non-idempotent fulfillment handler still leaves a duplicate-effect path.

Test concurrent requests, changed parameters, process restarts, the broadcast response being lost, replacement transactions, duplicate notifications, and an inclusion that disappears. The offline example covers only the first, second, and ambiguous-response cases. The submitted-versus-confirmed article explains the remaining observation states.

Runnable offline example

Save retry-payments.mjs and run node retry-payments.mjs with Node.js 20 or later. No dependencies, credentials, network access, or funds are needed.

// Offline, single-process teaching model. NOT a payment service.
// Map/Set state is lost on restart. No network, signing, or funds.
import assert from 'node:assert/strict';
const intents = new Map();
const fakeLedger = new Set();
let loseFirstResponse = true;

function reserve(operationId, payment) {
  // Fixed field order; integer amount as a string, never floating point.
  const payload = JSON.stringify([
    payment.network, payment.asset, payment.to, payment.amount,
  ]);
  const existing = intents.get(operationId);
  if (existing) {
    if (existing.payload !== payload) throw new Error('conflicting retry');
    return existing;
  }
  // No await between lookup and insertion: atomic only in this toy process.
  // Production: durable unique constraint + authorized business operation.
  const intent = {
    id: `classroom-${intents.size + 1}`, payload, state: 'prepared',
  };
  intents.set(operationId, intent);
  return intent;
}

async function requestPayment(operationId, payment) {
  const intent = reserve(operationId, payment);
  // Reconcile first. This fake lookup is definitive; real node views aren't.
  if (fakeLedger.has(intent.id)) {
    intent.state = 'observed'; // NOT confirmed or finalized
    return intent;
  }
  fakeLedger.add(intent.id); // fake delivery of the SAME prepared intent
  if (loseFirstResponse) {
    loseFirstResponse = false;
    intent.state = 'unknown';
    throw new Error('response lost after simulated delivery');
  }
  intent.state = 'observed';
  return intent;
}

const payment = {
  network: 'classroom', asset: 'pretend-units', to: 'Alice', amount: '25',
};
const operation = 'order-1024-refund-1';
await assert.rejects(requestPayment(operation, payment), /response lost/);
assert.equal(intents.get(operation).state, 'unknown');
const [a, b] = await Promise.all([
  requestPayment(operation, payment), requestPayment(operation, payment),
]);
assert.equal(a.id, b.id);
assert.equal(a.state, 'observed');
assert.equal(fakeLedger.size, 1);
await assert.rejects(
  requestPayment(operation, { ...payment, amount: '250' }), /conflicting retry/
);
console.log('simulated effects:', fakeLedger.size);
console.log('same intent on concurrent retries:', a.id === b.id);
console.log('conflicting retry: rejected');

Practice and vocabulary