Skip to content

[Bug]: Payment receipt Success can be issued without payment-rail success evidence #327

Description

@mh-yu

What happened?

Description

AP2 PaymentReceipt(status="Success") should represent payment success, not only successful mandate verification.

The Python SDK helper creates a Success Payment Receipt directly and fills psp_confirmation_id / network_confirmation_id with a generated UUID. The Python MPP sample verifies the mandate chain, then signs this receipt without requiring payment-rail success, settlement, or finality evidence.

Impact

A downstream party may treat a signed PaymentReceipt(status="Success") as proof that payment succeeded, even though the sample path has not shown PSP/network acceptance, settlement, or finality.

Checkout completion, delivery, or entitlement release may then depend on a receipt that proves mandate verification but not payment success.

Steps to Reproduce

  1. Review code/sdk/schemas/ap2/payment_receipt.json:36-63. A success receipt requires PSP/network confirmation IDs, but the schema does not require evidence behind them.

  2. Review code/sdk/python/ap2/sdk/receipt_wrapper.py:44-75. create_payment_receipt() always creates status='Success' and uses a generated UUID as both confirmation IDs.

  3. Review code/samples/python/src/roles/merchant_payment_processor_mcp/server.py:249-286. After mandate verification, the MPP sample creates the success receipt without a payment-rail success/finality check.

  4. Review code/sdk/python/ap2/tests/receipt_wrapper_tests.py:29-49. The test confirms that the helper returns status='Success' and derives the PSP confirmation ID from the generated payment ID.

Relevant log output

Code of Conduct

  • I agree to follow this project's Code of Conduct

Activity

  1. added theissue type on Aug 10, 2026
  2. giskard09 commented on Aug 10, 2026

    @giskard09

    Sharp diagnosis, and it's a specific instance of a general class of bug: a receipt's status field asserting an outcome that was never independently checked, only self-declared by the party with an incentive to declare success. Requiring psp_confirmation_id/network_confirmation_id to be present doesn't close this — a generated UUID satisfies presence without ever touching the payment rail, exactly as shown here.

    The structural fix direction, for what it's worth: bind the receipt's success status to something the issuer can't self-supply — e.g. require psp_confirmation_id to resolve against the PSP's own settlement record (a lookup, not a field the sample constructs locally), or require a content-addressed commitment to the settlement event that a third party can independently recompute/verify rather than merely read. The failure mode this catches: an issuer that has no incentive to lie today can still ship a sample/SDK that silently degrades to self-report under refactor, exactly as happened here — the schema allowed it, so nothing caught it until someone read the code.

    For a concrete existing precedent that this is solvable without trusting the issuer: our own action_ref is a content-addressed identifier over the four preimage fields of an action, independently recomputable by any party holding the same inputs — it doesn't fix AP2's schema, just shows the general pattern ("prove it's reconstructible from data you don't control" vs. "trust the field is populated correctly") holds up in a comparable payment-accountability context.

    Not proposing a specific schema change here — you know the AP2 mandate/receipt model far better than I do — just flagging that the fix probably needs to close the reconstructibility gap, not just tighten a presence check.

  3. babyblueviper1 commented on Aug 16, 2026

    @babyblueviper1

    The reconstructibility framing is right, and worth sharpening one level further: recomputability closes tampering, not honesty. A content-addressed commitment to a settlement event stops a receipt from being silently forged or drifted after the fact -- anyone can recompute the same hash from the same inputs and confirm it matches. It does nothing if the PSP itself signs a settlement event that never happened. The action_ref pattern (and our own signed-verdict path, /verify-proof) both give a third party "this exact claim is unaltered and traceable to this specific key" -- not "the party holding that key had no incentive to lie."

    For AP2's specific case that split matters because the two failure modes need different fixes:

    • Tamper-after-issuance (the receipt gets edited, or a UUID gets reused across a different settlement) — closed by recomputability alone, no live lookup needed.
    • Fabrication-at-issuance (the sample mints status='Success' with no rail contact at all, as filed here) — recomputability alone can't touch this, because a fabricated claim recomputes just as cleanly as a real one. This needs the claim to originate from a party the receiver has an independent reason to trust for this specific fact — which is what points back toward giskard09's first option (resolve against the PSP's own record), not the second, for this particular failure.

    Concretely: the fix probably wants both, in this order — (1) don't let anything downstream of mandate-verification mint a Success receipt without a real rail-side confirmation object existing (closes fabrication), then (2) make that confirmation content-addressed/recomputable so it can't silently drift after issuance (closes tampering). Doing (2) without (1) would make a fabricated receipt more convincing, not less -- exactly the "solved problem that isn't actually solved" trap self-attested systems fall into when they add cryptography without changing who gets to make the initial claim.

  4. giskard09 commented on Aug 16, 2026

    @giskard09

    Good sharpening — that's exactly the distinction our own spec had to make explicit (invariant 3 in negotiation-ref.md: a field being present is not the same claim as the referenced fact being verified). Recomputability proves "this claim is unaltered and traceable to this key," never "the key-holder had no incentive to lie" — conflating the two is the same self-attestation trap wearing cryptography.

    Agreed on the ordering too: (1) before (2) matters specifically because reversing it (recomputable-but-unfabricated-only-by-assumption) makes a fabricated receipt look more trustworthy than an honest self-report would, which is worse than doing nothing.

  5. babyblueviper1 commented on Aug 16, 2026

    @babyblueviper1

    Worth naming explicitly since it's now converged three separate times, not two: your negotiation_ref spec's invariant 5 already generalizes this exact shape -- a verifier MUST surface an explicit absent | present | malformed | unreached status, where present means "the field carries a claim," never "the claim was checked." That's the same distinction both AP2 bugs in this repo need: #319's aud/nonce currently collapses "claim absent" into "no expectation supplied, pass," and this issue's receipt collapses "settlement claim present" into "settlement happened."

    Concretely, if AP2 adopted a status field of that shape for its own token/receipt verification outputs -- binding_status: absent | present_unverified | present_verified | malformed on the JWT claims, and something equivalent on the receipt's rail-confirmation object -- both bugs collapse to one pattern instead of two separate fixes: never let "present but unchecked" render the same as either "absent" or "checked and true." Same fix shape as verify_expected_claims on #319, generalized past the specific aud/nonce case.

    Not proposing AP2 adopt negotiation_ref itself -- different payload shape, different trust model -- just the status-partition pattern underneath it, since it's now proven out independently in three places (your spec, our own intended_verifier_authenticated split on /verify-proof, and whatever AP2 ends up shipping here). Happy to sketch a concrete proposal for AP2's specific fields if there's interest -- know both of these repos well enough at this point to make it a real PR, not a suggestion someone else has to translate.

  6. giskard09 commented on Aug 17, 2026

    @giskard09

    Agreed on the generalization — invariant 5's shape holds because it's the same asymmetry each time (recomputable closes tampering, not honesty; checked ≠ present). Go ahead and draft the concrete AP2 proposal if you want to — you know both repos better than we do at this point, and a real PR carries more weight than us sketching field names for a spec we don't maintain.

  7. babyblueviper1 commented on Aug 17, 2026

    @babyblueviper1

    Draft PR up: #335 — additive rail_confirmation_verified field, backward-compatible create_payment_receipt() signature change, tests confirming both the default-unverified and explicit-verified paths (10/10 pass, plus the full 188-test suite green modulo the two pre-existing #319 failures). Full reasoning + honest scope notes (what it deliberately does NOT try to fix) in the PR description.

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

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions