Repository navigation
fix(ack-pay): bind payment receipt issuer to the selected payment option receiptService #192
Description
Summary
verifyPaymentReceipt currently verifies that:
- the receipt is a valid
PaymentReceiptCredential, - the receipt issuer is in
trustedReceiptIssuers, - the embedded
paymentRequestTokenis valid, - the receipt
paymentOptionIdexists 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.tspackages/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-optionreceiptService:did:key:<legit-receipt-service>
- Create a
PaymentReceiptCredentialforcard-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.receiptServiceis a DID:- require
selectedPaymentOption.receiptService === verifiedReceipt.issuer.id
- require
- If
selectedPaymentOption.receiptServiceis a URL:- either leave enforcement to a documented application-level policy callback, or add an explicit option such as
validateReceiptIssuerForPaymentOption.
- either leave enforcement to a documented application-level policy callback, or add an explicit option such as
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:
- fix(ack-pay): validate receipt payment option #88 validates that
paymentOptionIdexists in the verified Payment Request.- This issue is about binding that selected option's
receiptServiceto the receipt issuer.
- This issue is about binding that selected option's
- fix(vc,ack-pay): bind credential verification to the verified proof (credential forgery) #113 / fix(vc): bind credential verification to the signed proof (#105, #108) #110 / vc: verifyParsedCredential trusts caller-supplied fields on parsed-credential input #108 bind credential verification to the signed proof.
- This issue assumes proof verification works and concerns semantic receipt-service authorization.
- fix(vc): fail closed when credential revocation cannot be verified #135 hardens revocation and issuer/signature binding.
- This issue concerns per-payment-option receipt issuer authorization.
- docs(ack-pay): align receipt API examples #89 aligns docs around
trustedReceiptIssuers.- This issue concerns enforcement behavior.
- RFC: a scheme model for ACK-Pay (up-to, sessions/batch, subscriptions, streaming, refunds) #111 discusses a broader ACK-Pay scheme model.
- This issue can be fixed independently.
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