Summary
verifyPaymentReceipt currently verifies that:
- the receipt is a valid
PaymentReceiptCredential,
- the receipt issuer is in
trustedReceiptIssuers,
- the embedded
paymentRequestToken is valid,
- 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:
- Create a signed Payment Request with one payment option:
id: card-option
receiptService: did:key:<legit-receipt-service>
- Create a
PaymentReceiptCredential for card-option.
- Sign the receipt with a different DID:
did:key:<other-trusted-receipt-service>
- 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.
Summary
verifyPaymentReceiptcurrently verifies that:PaymentReceiptCredential,trustedReceiptIssuers,paymentRequestTokenis valid,paymentOptionIdexists in the verified Payment Request.However, it does not bind the receipt issuer to the
receiptServicedeclared by the selectedPaymentOption.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.tspackages/ack-pay/src/schemas/valibot.tsRelevant Code Path
verifyPaymentReceiptverifies the receipt againsttrustedReceiptIssuersinpackages/ack-pay/src/verify-payment-receipt.ts.Then it verifies the embedded payment request token and checks that the receipt's
paymentOptionIdexists in the verified payment request.The check confirms that the option exists, but it does not compare the selected option's
receiptServicetoverifiedReceipt.issuer.id.Why This Matters
PaymentOption.receiptServiceappears 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:
If the verifier uses a global
trustedReceiptIssuerslist, any trusted issuer can issue a receipt for any payment option, even when the selected option named a differentreceiptService.This weakens per-option trust separation.
Minimal Reproduction
Test setup:
id:card-optionreceiptService:did:key:<legit-receipt-service>PaymentReceiptCredentialforcard-option.did:key:<other-trusted-receipt-service>trustedReceiptIssuers:[legitReceiptDid, otherTrustedReceiptDid]paymentRequestIssuer:paymentIssuerDidObserved Result
verifyPaymentReceiptaccepts the receipt even though:did:key:<other-trusted-receipt-service>receiptService:did:key:<legit-receipt-service>Expected Behavior
If the selected
PaymentOption.receiptServiceis a DID,verifyPaymentReceiptshould reject receipts whose verified issuer does not match that DID.Suggested behavior:
paymentOptionId.selectedPaymentOption.receiptServiceis a DID:selectedPaymentOption.receiptService === verifiedReceipt.issuer.idselectedPaymentOption.receiptServiceis a URL:validateReceiptIssuerForPaymentOption.Possible API shape:
Relation to Existing Issues/PRs
I checked the existing issue/PR history and did not find this exact root cause.
Related but different:
paymentOptionIdexists in the verified Payment Request.receiptServiceto the receipt issuer.trustedReceiptIssuers.Suggested Fix
Change the current existence check into a selected-option lookup:
Then, after confirming the option exists, enforce
receiptServicebinding whenreceiptServiceis a DID.