Context & Rationale
A facilitator can accept a settlement and then lose the response (timeout, connection reset, proxy 502) before we ever see a transaction hash. "No hash" does not mean "no transfer".
Today the pipeline treats a settlement as uncertain only when a hash is present. settle() throws PAYMENT_PROVIDER_UNAVAILABLE without one, uncertainSettlementTxHash() returns nothing for that case, and the pipeline records the attempt as failed and releases the AP2 hold. Payment replay protection still holds, but the released mandate can be presented again with a different payment authorization, which is exactly what the hold prevents. On the one rail we ship today, it also breaks a rule #6 already states: provider outages must stay distinguishable from rejected payments.
This is a deliberate decision, not an oversight: pipeline-authorization.test.ts asserts "releases the reservation when settlement throws without moving funds". The premise that a throw without a hash means no funds moved is what this issue disputes, so that test flips. The docs/security.md matrix already calls a facilitator timeout uncertain without qualifying it by hash, so the documentation describes the behaviour this issue asks for.
Version: 1.4.0, commit a05a08f.
Steps to reproduce (planned): point x402 at a stub facilitator that answers /verify normally; make its /settle perform the transfer and then close the socket without a body; invoke a paid resource with AP2 enabled and a valid mandate; inspect the payment attempt and the AP2 replay store.
Expected: attempt unresolved, AP2 hold retained, backend not executed, buyer told "outcome unknown, do not pay again".
Actual (from source): attempt failed, hold released, retryable provider error returned.
Proposed Changes
- Model "settlement outcome unknown" as its own state, independent of whether a hash or any external reference exists.
- Keep the attempt unresolved and the mandate held until evidence resolves it.
- Return a typed error carrying a correlation ID and whatever reference exists, so the client can follow up instead of paying twice.
- Distinguish a definite pre-transfer rejection (hold may be released) from an inconclusive failure (hold must not be released).
Non-goals / Invariants Preserved
- Uncertainty never triggers a second charge, a rail fallback, or a "pay again" hint.
- Definite rejections and uncertainty-with-hash behave as they do today.
- Refunds are out of scope.
Impact & Value
Done when a settlement whose result never arrives is recorded as unresolved, not failed, and the purchase authority behind it cannot be reused until someone proves the outcome.
Open question
Does the facilitator SDK expose enough to classify a thrown error as pre- or post-transfer, or must every transport error after /settle was sent count as uncertain?
Test plan
Facilitator accepts then drops the response: no hash, attempt unresolved, hold retained, and that state survives a gateway restart. Mandate re-presented with a different payment authorization: rejected. Backend never executed on unresolved settlement. Existing definite-rejection and hash-present paths unchanged. Rewrite the "releases the reservation when settlement throws without moving funds" unit test: a bare throw is now uncertain, only a returned rejected result releases. Align the facilitator-timeout row in docs/security.md.
Context & Rationale
A facilitator can accept a settlement and then lose the response (timeout, connection reset, proxy 502) before we ever see a transaction hash. "No hash" does not mean "no transfer".
Today the pipeline treats a settlement as uncertain only when a hash is present.
settle()throwsPAYMENT_PROVIDER_UNAVAILABLEwithout one,uncertainSettlementTxHash()returns nothing for that case, and the pipeline records the attempt asfailedand releases the AP2 hold. Payment replay protection still holds, but the released mandate can be presented again with a different payment authorization, which is exactly what the hold prevents. On the one rail we ship today, it also breaks a rule #6 already states: provider outages must stay distinguishable from rejected payments.This is a deliberate decision, not an oversight:
pipeline-authorization.test.tsasserts "releases the reservation when settlement throws without moving funds". The premise that a throw without a hash means no funds moved is what this issue disputes, so that test flips. Thedocs/security.mdmatrix already calls a facilitator timeout uncertain without qualifying it by hash, so the documentation describes the behaviour this issue asks for.Version: 1.4.0, commit
a05a08f.Steps to reproduce (planned): point x402 at a stub facilitator that answers
/verifynormally; make its/settleperform the transfer and then close the socket without a body; invoke a paid resource with AP2 enabled and a valid mandate; inspect the payment attempt and the AP2 replay store.Expected: attempt unresolved, AP2 hold retained, backend not executed, buyer told "outcome unknown, do not pay again".
Actual (from source): attempt
failed, hold released, retryable provider error returned.Proposed Changes
Non-goals / Invariants Preserved
Impact & Value
Done when a settlement whose result never arrives is recorded as unresolved, not failed, and the purchase authority behind it cannot be reused until someone proves the outcome.
Open question
Does the facilitator SDK expose enough to classify a thrown error as pre- or post-transfer, or must every transport error after
/settlewas sent count as uncertain?Test plan
Facilitator accepts then drops the response: no hash, attempt unresolved, hold retained, and that state survives a gateway restart. Mandate re-presented with a different payment authorization: rejected. Backend never executed on unresolved settlement. Existing definite-rejection and hash-present paths unchanged. Rewrite the "releases the reservation when settlement throws without moving funds" unit test: a bare throw is now uncertain, only a returned
rejectedresult releases. Align the facilitator-timeout row indocs/security.md.