Menu ▾ ▴

#12 [Bug] Settlement is recorded as failed when the facilitator returns no tx hash

closed
nobody
bug (2)
2026-09-21
2026-09-17
Anonymous
No

Originally created by: SergeevDmitry

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 (github.com) 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`.

Related

Tickets: #6

Discussion

  • Anonymous

    Anonymous - 2026-09-21

    Originally posted by: Revinand

    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?

    No, the SDK cannot separate them; its own binding comment says it throws a bare Error for a 401 and a FacilitatorResponseError for a bad body. So a verdict is only ever a returned value, every throw is uncertain, and the gateway over-retains. failed is now written by nothing; it stays in the union because existing databases hold rows under the old reading.

     
  • Anonymous

    Anonymous - 2026-09-21

    Ticket changed by: SergeevDmitry

    • status: open --> closed
     

Log in to post a comment.