Skip to content

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

Description

@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 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions