Skip to content

fix(ack-pay): bind payment receipt issuer to the selected payment option receiptService #192

Description

@Kewe63

Summary

verifyPaymentReceipt currently verifies that:

  1. the receipt is a valid PaymentReceiptCredential,
  2. the receipt issuer is in trustedReceiptIssuers,
  3. the embedded paymentRequestToken is valid,
  4. the receipt paymentOptionId exists in the verified Payment Request.

However, it does not bind the receipt issuer to the receiptService declared by the selected PaymentOption.

As a result, if a verifier trusts multiple receipt issuers globally, a receipt issued by one trusted receipt service can be accepted for a payment option that named a different receiptService.

Affected Code

  • packages/ack-pay/src/verify-payment-receipt.ts
  • packages/ack-pay/src/schemas/valibot.ts

Relevant Code Path

verifyPaymentReceipt verifies the receipt against trustedReceiptIssuers in packages/ack-pay/src/verify-payment-receipt.ts.

Then it verifies the embedded payment request token and checks that the receipt's paymentOptionId exists in the verified payment request.

The check confirms that the option exists, but it does not compare the selected option's receiptService to verifiedReceipt.issuer.id.

Why This Matters

PaymentOption.receiptService appears to identify the service responsible for issuing receipts for that payment option.

In multi-rail or multi-processor deployments, different payment options may delegate receipt issuance to different receipt services. For example:

  • card option → receiptService A
  • USDC/Base option → receiptService B
  • Solana option → receiptService C

If the verifier uses a global trustedReceiptIssuers list, any trusted issuer can issue a receipt for any payment option, even when the selected option named a different receiptService.

This weakens per-option trust separation.

Minimal Reproduction

Test setup:

  1. Create a signed Payment Request with one payment option:
    • id: card-option
    • receiptService: did:key:<legit-receipt-service>
  2. Create a PaymentReceiptCredential for card-option.
  3. Sign the receipt with a different DID:
    • did:key:<other-trusted-receipt-service>
  4. Verify with:
    • trustedReceiptIssuers: [legitReceiptDid, otherTrustedReceiptDid]
    • paymentRequestIssuer: paymentIssuerDid

Observed Result

verifyPaymentReceipt accepts the receipt even though:

  • accepted receipt issuer: did:key:<other-trusted-receipt-service>
  • selected payment option receiptService: did:key:<legit-receipt-service>

Expected Behavior

If the selected PaymentOption.receiptService is a DID, verifyPaymentReceipt should reject receipts whose verified issuer does not match that DID.

Suggested behavior:

  • Find the selected payment option by paymentOptionId.
  • If selectedPaymentOption.receiptService is a DID:
    • require selectedPaymentOption.receiptService === verifiedReceipt.issuer.id
  • If selectedPaymentOption.receiptService is a URL:
    • either leave enforcement to a documented application-level policy callback, or add an explicit option such as validateReceiptIssuerForPaymentOption.

Possible API shape:

validateReceiptIssuerForPaymentOption?: (params: {
  receiptIssuer: string
  paymentOption: PaymentOption
  paymentRequest: PaymentRequest
}) => boolean | Promise<boolean>

Relation to Existing Issues/PRs

I checked the existing issue/PR history and did not find this exact root cause.

Related but different:

Suggested Fix

Change the current existence check into a selected-option lookup:

const selectedPaymentOption = paymentRequest.paymentOptions.find(
  (paymentOption) =>
    paymentOption.id === verifiedReceipt.credentialSubject.paymentOptionId,
)

Then, after confirming the option exists, enforce receiptService binding when receiptService is a DID.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions