aws-samples / aws-samples/sample-agentcore-cloudfront-x402-payments
A settle timeout is reported as 'settlement failed' and answered with 402, so a conforming payer re-signs and pays twice
- Dominant language
- Python
- Stars
- 25
- Forks
- 11
- PR merge metrics
- No merged PRs in 30d
Description
The intent in this file is right and rarer than it looks — `// Fail properly - don't fake settlements` (`payment-verifier.ts:447`) is the correct instinct, and most implementations do not have it. The report is that the code collapses two different outcomes into that one, and the x402 flow turns the collapse into a second real payment.
## The path, three links
**1. An unknown outcome is recorded as a failure with no anchor.** `settlePaymentWithFacilitator` (`:397`) has two exits that report the same thing:
```ts
if (!response.ok) { // :420
return { success: false, transaction: '', errorReason: 'settlement_failed' };
}
...
} catch (error) { // :440 network error, socket reset, timeout
return { success: false, transaction: '', errorReason: 'facilitator_unavailable' };
}
```
A `fetch` that throws, or a 5xx/504 arriving after the facilitator already broadcast, is **not** evidence the payment did not happen. It is evidence that nothing is known. Both exits return `transaction: ''`, which discards the only field that could reconcile it later.
**2. Any unsuccessful settlement becomes a fresh 402.** `:801`:
```ts
if (!settlement.success) {
return createErrorResponse('402', 'Payment Required', 'Settlement Failed', ...);
}
```
**3. A conforming payer answers a 402 by signing a new authorization.** The reference client mints a fresh nonce on every `createPaymentPayload` call, and its own fetch wrapper calls that once per 402 it sees — asserted in the upstream test suite (`typescript/packages/http/fetch/src/index.test.ts`: `expect(mockClient.createPaymentPayload).toHaveBeenCalledTimes(2)` in the recovery case). A new nonce is a different replay identity, so the facilitator correctly admits it as a distinct payment.
So: the transfer lands, the response is lost, the edge says `402`, the agent signs again, and the payer pays twice. Every guard behaved correctly; at no point did anything see a duplicate, because at the protocol boundary there was not one. An EIP-3009 authorization stays presentable until `validBefore`, so both are live simultaneously.
## Why this is worth changing in a sample rather than only in production code
This is a reference implementation on `aws-samples`, running at the CloudFront edge. The shape gets copied. And Lambda@Edge makes the ambiguous case *more* likely than usual, not less — a short edge timeout in front of a settle that waits on chain confirmation is exactly the geometry that produces "broadcast, then no answer."
## What the fix looks like
Three changes, none large:
1. **Separate "unknown" from "failed."** The `catch` path and a 5xx are unresolved; only a facilitator response that positively says the payment did not happen is a failure.
2. **Never answer an unresolved settlement with `402`.** A 402 is an instruction to pay, and a payer that obeys it double-pays. `503` with an explicit instruction — *retry with the same `X-PAYMENT` payload, do not create a new authorization* — is the answer that keeps a conforming client safe. A production operator arrived at exactly this independently last week and wrote it well: [x402-foundation/x402#3438](https://github.com/x402-foundation/x402/issues/3438) — *"a receiver that cannot determine a settlement outcome has no business naming a price for it."*
3. **Keep the hash when there is one.** If the facilitator returns `settlement_pending` it carries the broadcast `transaction`; propagating `transaction: ''` throws away the only thing that makes reconciliation possible. Related: a settlement ledger keyed on the serialized payload lets a replay of the same authorization be served free with the original hash rather than re-challenged.
This is the receiver half of a rule currently proposed for the specification as §5.3.5 ([x402-foundation/x402#3437](https://github.com/x402-foundation/x402/pull/3437)). The client half — that the payer's intent to retry is not expressible in the bytes, so the receiver has to make re-signing harmless — is [#3438](https://github.com/x402-foundation/x402/issues/3438).
## Checking it rather than taking my word
The battery I use is MIT and ships with a mutation control, so you can watch it fail on known-broken input before trusting a pass: https://github.com/aurumflux20/hostile-facilitator/releases/tag/v0.1.2 — it drives accept-then-timeout, 5xx-after-settle and re-challenge cases and counts **distinct settlements at the facilitator** rather than comparing response bodies, which is the only way this class is visible at all.
Nothing to buy and nothing needed from me. If the position is that a sample should stay simple and the ambiguous case is out of scope, that is a legitimate answer — I would only suggest saying so in the README, since a reader evaluating this as a reference for a payment path will assume the settle handling is the part to copy.
Contributor guide
Research direction
Start in payment-verifier.ts at settlePaymentWithFacilitator (around lines 397-447) and the unsuccessful-settlement response around line 801. Trace the non-OK, catch, and pending responses, then review the referenced fetch recovery test and hostile-facilitator battery. Done means unresolved outcomes are not reported as 402, retry guidance preserves the same payload, and any available transaction hash remains usable for reconciliation.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- aws, typescript
- Domain
- backend, cloud, payments
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 52/100