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:
- No recipient check. A confirmed tx that sent USDC to the attacker's own wallet, to charity, or to anyone — still passes.
- No amount check. A confirmed tx of 0.000001 USDC passes the same as the required
PAYMENT_PRICE.
- 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
Integration
End-to-end
Negative / security
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
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:
Three gaps:
PAYMENT_PRICE.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— extendverifyCirclePayment(~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:
Confirm the exact field names against Circle's
getTransactionresponse shape —destinationAddress,amounts[0], andtokenAddressare 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.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
tokenAddressmatches the configured USDC contract on ARC-TESTNET, and theblockchainfield matchesPAYMENT_CHAIN. A confirmed Circle tx on a different chain or for a different token shouldn't pass.Out of scope (explicit non-goals)
Risks / open questions
destinationAddress,amounts[0],tokenAddress, andblockchainare the actual field names by logging a realgetTransactionresponse. Field names in the docs vs. SDK responses sometimes drift.parseFloatis fine for $0.001 demo amounts but would lose precision at scale. Use a BigInt-via-viem/parseUnitscomparison if we ever raise the price.Test Cases
Unit
verifyCirclePaymentreturns{ok: false, reason: 'tx_not_found'}when Circle returns no transactions.{ok: false, reason: 'wrong_recipient'}when destination is notPAYMENT_PAYTO(case-insensitive comparison).{ok: false, reason: 'amount_too_low'}when paid amount is less thanPAYMENT_PRICE.{ok: true}when state, recipient, amount, token, and chain all match.Integration
PAYMENT_PAYTO, use its hash —/searchreturns results./searchreturns 402 withreason: wrong_recipient.PAYMENT_PRICE) —/searchreturns 402 withreason: amount_too_low.End-to-end
/searcha second time returns 402 witherror: 'payment proof already used'.Negative / security
/search(wrong recipient).Acceptance criteria
verifyCirclePaymentchecks state, recipient, amount, token, and chain — five independent assertions, each logged on failure.tx_not_found,not_confirmed,wrong_recipient,amount_too_low,already_used).References