Skip to content

Harden 402 payment verification: check recipient, amount, and add replay prevention #8

Description

@wd7zfpysvs-ui

Summary

The x402 middleware now does on-chain confirmation of the tx hash via Circle (server.js:19-27, server.js:67-74), which closes the "any 64-char hex passes" bypass — a real improvement. But it still doesn't verify who was paid, how much, or whether the same proof has been used before. A sharp judge (Raymond from Crosby was named in the feedback) will catch this immediately: "what stops me from sending you any confirmed tx and getting free data?"

Problem

server.js:19-27:

async function verifyCirclePayment(txHash) {
  const client = initiateDeveloperControlledWalletsClient({...});
  const res = await client.listTransactions({ txHash });
  const tx = res.data?.transactions?.[0];
  return tx?.state === 'CONFIRMED' || tx?.state === 'COMPLETE';
}

Three gaps:

  1. No recipient check. A confirmed tx that sent USDC to the attacker's own wallet, to charity, or to anyone — still passes.
  2. No amount check. A confirmed tx of 0.000001 USDC passes the same as the required PAYMENT_PRICE.
  3. No replay protection. The same x-payment-proof: <txHash> header can be reused for unlimited searches.

Combined, an attacker submits one tiny transfer to themselves, then replays that single tx hash forever to drain the operator's Nimble API quota.

Proposed Implementation

server.js — extend verifyCirclePayment (~40 lines)

Change the helper to return a structured verdict, not a boolean. Each check is independently logged so a failed proof surfaces a useful 402 body:

async function verifyCirclePayment(txHash) {
  const tx = ...listTransactions({ txHash }).data?.transactions?.[0];
  if (!tx) return { ok: false, reason: 'tx_not_found' };
  if (tx.state !== 'CONFIRMED' && tx.state !== 'COMPLETE') return { ok: false, reason: 'not_confirmed' };
  if (tx.destinationAddress?.toLowerCase() !== PAYMENT_PAYTO.toLowerCase()) return { ok: false, reason: 'wrong_recipient' };
  // Compare amount as a decimal — Circle returns amounts as strings
  const paid = parseFloat(tx.amounts?.[0] ?? '0');
  if (!(paid >= parseFloat(PAYMENT_PRICE))) return { ok: false, reason: 'amount_too_low', paid, required: PAYMENT_PRICE };
  return { ok: true, tx };
}

Confirm the exact field names against Circle's getTransaction response shape — destinationAddress, amounts[0], and tokenAddress are what the SDK returns today, but verify by logging one real Circle response during development.

server.js — add replay prevention (~20 lines)

In-memory Set<string> of used tx hashes, scoped to the server process. Reject any proof whose hash is already in the set; insert on successful verification.

const usedTxHashes = new Set();
// in the handler, after verifyCirclePayment succeeds:
if (usedTxHashes.has(paymentProof)) return res.status(402).json({ error: 'payment proof already used' });
usedTxHashes.add(paymentProof);

For the demo scope this is enough. Persistence isn't needed — server restart resets the demo anyway.

server.js — token + chain assertions (~10 lines)

Also assert the tx's tokenAddress matches the configured USDC contract on ARC-TESTNET, and the blockchain field matches PAYMENT_CHAIN. A confirmed Circle tx on a different chain or for a different token shouldn't pass.

Out of scope (explicit non-goals)

  • Persisting the used-tx-hash set to ClickHouse or Redis. In-memory is fine for the demo; mention the limitation in the README.
  • Rate limiting per IP. Separate concern.
  • Validating the payer address (i.e., that the agent's own wallet sent the tx). Open question — see below.

Risks / open questions

  • Payer identity: should the server also verify the tx originated from the agent's wallet, or is "anyone who pays the right amount to the right address" sufficient? The latter matches the x402 spec better (machine-to-machine micropayments don't require identity) — recommend leaving payer unchecked but documenting the decision.
  • Circle response shape: confirm destinationAddress, amounts[0], tokenAddress, and blockchain are the actual field names by logging a real getTransaction response. Field names in the docs vs. SDK responses sometimes drift.
  • Amount comparison: Circle returns amounts as decimal strings. parseFloat is fine for $0.001 demo amounts but would lose precision at scale. Use a BigInt-via-viem/parseUnits comparison if we ever raise the price.

Test Cases

Unit

  • verifyCirclePayment returns {ok: false, reason: 'tx_not_found'} when Circle returns no transactions.
  • Returns {ok: false, reason: 'wrong_recipient'} when destination is not PAYMENT_PAYTO (case-insensitive comparison).
  • Returns {ok: false, reason: 'amount_too_low'} when paid amount is less than PAYMENT_PRICE.
  • Returns {ok: true} when state, recipient, amount, token, and chain all match.

Integration

  • Submit a real Circle tx of the correct amount to PAYMENT_PAYTO, use its hash — /search returns results.
  • Submit a real Circle tx of the correct amount but to a different address, use that hash — /search returns 402 with reason: wrong_recipient.
  • Submit a real Circle tx of $0.0001 (below PAYMENT_PRICE) — /search returns 402 with reason: amount_too_low.

End-to-end

  • Agent runs end-to-end against the middleware. Search succeeds. The same tx hash sent to /search a second time returns 402 with error: 'payment proof already used'.

Negative / security

  • An attacker who submits one $0.001 tx to themselves cannot use that hash to query /search (wrong recipient).
  • An attacker who finds a real successful payment from a legitimate Shop3 run cannot reuse that hash (replay set rejects it).
  • An attacker who submits a confirmed tx on Base Sepolia (wrong chain) cannot use it on the ARC-TESTNET-gated middleware.

Acceptance criteria

  • verifyCirclePayment checks state, recipient, amount, token, and chain — five independent assertions, each logged on failure.
  • Replay set rejects a re-used tx hash.
  • The 402 response body distinguishes the four failure reasons (tx_not_found, not_confirmed, wrong_recipient, amount_too_low, already_used).
  • A short comment in the code says "in-memory replay set; resets on server restart" so future readers don't assume persistence.

References

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions