Skip to content

fix(ack-pay): validate receipt payment option#88

Open
EfeDurmaz16 wants to merge 1 commit into
agentcommercekit:mainfrom
EfeDurmaz16:codex/validate-receipt-payment-option
Open

fix(ack-pay): validate receipt payment option#88
EfeDurmaz16 wants to merge 1 commit into
agentcommercekit:mainfrom
EfeDurmaz16:codex/validate-receipt-payment-option

Conversation

@EfeDurmaz16

@EfeDurmaz16 EfeDurmaz16 commented May 15, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Validate that a PaymentReceipt's paymentOptionId matches an option offered by the verified Payment Request token
  • Add a dedicated InvalidPaymentReceiptError for receipt-level semantic failures
  • Cover the mismatched-option case with a regression test
  • Add a patch changeset for @agentcommercekit/ack-pay

Why

A receipt proves a paymentRequestToken and a selected paymentOptionId, but verification did not bind that selection back to an option actually offered by the request. This keeps receipt proof semantics aligned with the payment options the request offered.

The binding check reads from the proof-decoded credential (verifiedReceipt, returned by verifyParsedCredential), so it cannot be bypassed by mutating the outer object on the parsed-credential input path. This builds on the trust-boundary work in #108 / the proof-binding changeset already on main.

Verification

  • pnpm install --frozen-lockfile
  • pnpm run build
  • pnpm run check (build + format + types + lint + test) — all green, lint 0 warnings/0 errors
  • pnpm --filter @agentcommercekit/ack-pay test — 35 passing

AI Usage Disclosure

This contribution was AI-assisted (Codex CLI/app and Claude Opus). AI assistance was used for repository/docs navigation, understanding ACK context, and small edits/validation. The branch was rebased onto current main and the feature was rebuilt on top of main's proof-verified-receipt mechanism. I reviewed the final diff and take responsibility for the submitted changes.

Summary by CodeRabbit

  • Bug Fixes
    • Receipt verification now rejects receipts when the receipt’s paymentOptionId doesn’t match an option in the verified payment request.
    • Receipt parsing from JWT proof now derives receipt fields from the signed proof to prevent bypass via mutated input.
  • Tests
    • Added a negative test ensuring verifyPaymentReceipt(...) throws when the payment option is missing from the payment request.
  • Chores
    • Patch release update, including a new exported InvalidPaymentReceiptError for callers to catch receipt validation failures.

@coderabbitai

coderabbitai Bot commented May 15, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR adds paymentOptionId validation to receipt verification, introduces InvalidPaymentReceiptError, adds rejection coverage, and records the change in a changeset.

Changes

Payment Option Validation

Layer / File(s) Summary
InvalidPaymentReceiptError type definition
packages/ack-pay/src/errors.ts
New exported error class extends Error with a default message and explicit name property.
Receipt option validation
packages/ack-pay/src/verify-payment-receipt.ts
verifyPaymentReceipt imports InvalidPaymentReceiptError and throws it when verifiedReceipt.credentialSubject.paymentOptionId does not match any paymentRequest.paymentOptions[*].id.
Receipt validation tests
packages/ack-pay/src/verify-payment-receipt.test.ts
Imports InvalidPaymentReceiptError and adds a rejection test for a receipt whose paymentOptionId is missing from the payment request options.
Release documentation
.changeset/validate-receipt-payment-option.md
Changeset file records a patch release for @agentcommercekit/ack-pay and documents the receipt validation rule and exported error.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • agentcommercekit/ack#113: Both PRs modify packages/ack-pay/src/verify-payment-receipt.ts to bind receipt trust checks to proof-verified credential data.
  • agentcommercekit/ack#95: Both PRs touch ack-pay receipt verification and proof-bound credential handling.

Suggested reviewers

  • venables
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding validation that a receipt’s payment option is allowed by the verified payment request.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
.changeset/validate-receipt-payment-option.md (1)

5-5: ⚡ Quick win

Consider mentioning the new error type.

The changeset describes the validation behavior but doesn't mention that a new InvalidPaymentReceiptError is exported. Users reading the changelog may want to know about new error types they can catch.

📝 Suggested enhancement
-Validate that a PaymentReceipt paymentOptionId matches an option from the verified Payment Request token.
+Validate that a PaymentReceipt paymentOptionId matches an option from the verified Payment Request token. Introduces `InvalidPaymentReceiptError` for receipt validation failures.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/validate-receipt-payment-option.md at line 5, Update the
changeset text to explicitly mention the newly exported error type so consumers
know to catch it: add a line stating that the validation will throw/export
InvalidPaymentReceiptError when a PaymentReceipt paymentOptionId does not match
the verified Payment Request token; reference the exported symbol
InvalidPaymentReceiptError and briefly note that it can be imported and used for
error handling in client code.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In @.changeset/validate-receipt-payment-option.md:
- Line 5: Update the changeset text to explicitly mention the newly exported
error type so consumers know to catch it: add a line stating that the validation
will throw/export InvalidPaymentReceiptError when a PaymentReceipt
paymentOptionId does not match the verified Payment Request token; reference the
exported symbol InvalidPaymentReceiptError and briefly note that it can be
imported and used for error handling in client code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 099e3e98-0b72-400c-bc66-1a1df2aa2922

📥 Commits

Reviewing files that changed from the base of the PR and between 6f65936 and c28c1e3.

📒 Files selected for processing (4)
  • .changeset/validate-receipt-payment-option.md
  • packages/ack-pay/src/errors.ts
  • packages/ack-pay/src/verify-payment-receipt.test.ts
  • packages/ack-pay/src/verify-payment-receipt.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c28c1e307d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +120 to +124
const receiptPaymentOptionId =
parsedCredential.credentialSubject.paymentOptionId
const paymentOptionExists = paymentRequest.paymentOptions.some(
(paymentOption) => paymentOption.id === receiptPaymentOptionId,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive receipt option from the signed proof

For parsed-credential inputs, this new binding check reads credentialSubject.paymentOptionId from the caller-supplied object, but verifyParsedCredential() only validates proof.jwt and then runs claim checks against the current object. If a service accepts a parsed VC, an attacker can supply a valid receipt proof whose signed payload names one option, mutate this field to an offered option before verification, and this check passes even though the signed receipt did not bind that option. Reparse or derive the receipt fields from proof.jwt (or otherwise reject mutated parsed credentials) before enforcing the payment-option match.

Useful? React with 👍 / 👎.

@EfeDurmaz16

Copy link
Copy Markdown
Contributor Author

Addressed the changeset nit in 5a4cb46 by mentioning the exported InvalidPaymentReceiptError for explicit receipt validation handling.\n\nVerification:\n- git diff --check\n- pnpm exec oxfmt --check .changeset/validate-receipt-payment-option.md

@ak68a

ak68a commented May 24, 2026

Copy link
Copy Markdown
Contributor

Hey @EfeDurmaz16, thanks for the burst of contributions here.

Before we dig into individual PRs, one thing: our AI Usage Policy requires disclosure of AI tooling used in contributions, including the tool and the extent of assistance. Your PRs and issues don't include any disclosure, but we'd like to confirm: were any of these written or developed with AI assistance? If so, please update the PR descriptions accordingly. We hold all contributors to this standard, ourselves included.

Appreciate the initiative and will be reviewing each PR individually.

@EfeDurmaz16

Copy link
Copy Markdown
Contributor Author

Hey @ak68a !

Yes, I used AI assistance, mainly Codex CLI and the Codex app. The assistance was mostly for understanding ACK/Catena context, navigating docs/issues, identifying possible contribution areas, and helping draft or validate small changes. I reviewed the final diffs myself and can explain the changes and why they were made.

I should have disclosed this in the PR descriptions from the start. I’ll update the open PRs with the tooling and extent of AI assistance so they comply with the policy.

Appreciate the clarification, and thanks for taking the time to review them individually.

},
)

const receiptPaymentOptionId =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. One note for future reference: this check runs against parsedCredential.credentialSubject, which is trustworthy when the input is a JWT string (decoded from the signed payload) but is the caller-supplied object when the input is a pre-parsed credential.

The proof verification validates the JWT inside proof, but doesn't currently enforce that the outer object matches the signed payload. Not a blocker for this PR tho.

@ak68a

ak68a commented May 25, 2026

Copy link
Copy Markdown
Contributor

Good fix, @EfeDurmaz16. A receipt could claim any paymentOptionId and verification wouldn't catch it. The implementation is clean and minimal.

One thing I want to flag. I dug into the security question re: parsed-credential path:

For JWT inputs (the primary path), parseJwtCredential() decodes from the signed payload, so parsedCredential.credentialSubject.paymentOptionId reflects the signed data.

For pre-parsed credential inputs, verifyParsedCredential() validates proof.jwt, but the new binding check reads from the outer parsedCredential object and not from the signed JWT payload directly.

In theory, a caller could supply a valid receipt proof whose signed payload names one option but mutate the outer object to name another.

That said, this is a pre-existing trust assumption in the VC verification pipeline, not something introduced by your PR. Your change is strictly better than having no check at all. We can open a separate issue to track tightening the parsed-credential binding in verifyParsedCredential itself.

@EfeDurmaz16

Copy link
Copy Markdown
Contributor Author

Thanks for digging into that path.

Agreed, this sounds like a separate pre-existing trust boundary in the parsed credential verification flow rather than something this PR should expand into. I will keep this PR scoped to binding the receipt paymentOptionId against the verified payment request options.

I opened a focused follow-up issue for tightening verifyParsedCredential() so the outer parsed credential fields are bound back to the signed JWT payload: #105

@venables

venables commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

@EfeDurmaz16 — there are two open inline review threads on packages/ack-pay/src/verify-payment-receipt.ts (lines 120 and 124), both raising the same point: the new paymentOptionId binding check reads from parsedCredential.credentialSubject, which is trustworthy when the input is a JWT string (decoded from the signed payload) but is caller-supplied when the input is a pre-parsed credential. The same applies to the existing paymentRequestToken read on that object, so it may be pre-existing rather than something this PR introduces.

What are your thoughts on these two comments? Do you see it as in-scope to address here (e.g. deriving the option from the signed proof), or better tracked as separate follow-up? The comments have no replies, so I'm curious what your take is.

@EfeDurmaz16

Copy link
Copy Markdown
Contributor Author

Thanks @venables — I went through the full path and I agree with both threads.

Mechanism: on the JWT-string input path, parseJwtCredential returns the credential reconstructed from the signed payload (verifyCredential().verifiableCredential), so paymentOptionId / paymentRequestToken / issuer are all trustworthy. On the parsed-credential path, verifyParsedCredentialverifyProof only verifies that proof.jwt is a validly-signed credential and then discards the decoded payload — every later check (claims, expiry, revocation, trusted-issuer) runs against the caller-supplied outer object. So a caller can attach any valid proof.jwt and set credentialSubject.paymentOptionId freely, and the binding check reads the spoofable field.

Scope: this is pre-existing rather than introduced here — the paymentRequestToken read just above has the identical exposure, and the trustedReceiptIssuers check compares the equally-caller-supplied outer issuer.id on this path. The new option-binding check inherits that trust model; on the JWT-string path (the documented usage) it does exactly what it should.

What I changed here: rather than leave the parsed path bypassable, I added a localized hardening — when the input is a parsed credential with a JWT proof, the receipt is now re-derived from proof.jwt (via the already-exported parseJwtCredential) before the paymentOptionId / paymentRequestToken reads, so they come from the signed payload. Added a regression test that a parsed credential whose outer credentialSubject.paymentOptionId is mutated to a valid option — while its signed proof names an option absent from the request — is now rejected (it fails without the change, passes with it). No public API change.

Still recommend a follow-up: the general fix belongs in @agentcommercekit/vc — after verifyProof validates proof.jwt, the credential fields should be derived from / reconciled against the decoded payload for every credential type (the trustedIssuers / claim checks share the same gap). That changes verification semantics library-wide, so I'd keep it as its own PR with dedicated tests rather than expand this one. Happy to open that issue and take it on.

Verification:

  • pnpm --filter @agentcommercekit/ack-pay test -- --run (34 passing, incl. the new regression test)
  • pnpm --filter @agentcommercekit/ack-pay check:types
  • pnpm exec oxfmt --check / oxlint clean

@EfeDurmaz16

Copy link
Copy Markdown
Contributor Author

General vc fix tracked as a follow-up in #108.

// paymentOptionId / paymentRequestToken reads below come from the signed
// payload rather than a (potentially mutated) caller-supplied object.
parsedCredential = isJwtProof(receipt.proof)
? await parseJwtCredential(receipt.proof.jwt, resolver)

@venables venables Jun 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, when a Verifiable<W3CCredential> was passed in, parsedCredential = receipt and the proof was only validated later by verifyParsedCredential(), which throws InvalidProofError on a bad proof. Now parseJwtCredential(receipt.proof.jwt, resolver) runs first, so an invalid/malformed proof.jwt surfaces a raw did-jwt-vc error instead of InvalidProofError — a silent contract change for callers catching InvalidProofError.

Non-blocking. If you want to preserve the prior contract, re-throw as InvalidProofError around this call; otherwise it may be fine to leave as-is given the general vc trust-boundary work is already deferred to #108. Flagging so the choice is deliberate.

venables
venables previously approved these changes Jun 30, 2026
@venables

Copy link
Copy Markdown
Contributor

Thanks @EfeDurmaz16 , this looks good to merge once the check/tests are passing

Bind a PaymentReceipt's selected paymentOptionId back to an option offered
by the verified Payment Request token. The check reads from the proof-decoded
credential returned by verifyParsedCredential, so it cannot be bypassed by
mutating the outer object on the parsed-credential input path.

Adds InvalidPaymentReceiptError for receipt-level validation failures and a
regression test for the mismatched-option case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@EfeDurmaz16 EfeDurmaz16 force-pushed the codex/validate-receipt-payment-option branch from 7d3eb3f to 4147b31 Compare June 30, 2026 09:18

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
packages/ack-pay/src/verify-payment-receipt.ts (1)

77-89: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Skip the pre-verification receipt-schema reject on object inputs.

Lines 77-83 still let the caller-supplied outer object fail verification before verifyParsedCredential() can recover the proof-decoded receipt. A valid Verifiable<W3CCredential> with a tampered outer credentialSubject will now false-negative here, even though the signed JWT inside proof is still valid. If you want to keep this fast-reject, gate it to the JWT-string path only; otherwise rely on the verified check at Lines 95-99.

Suggested fix
-  if (!isPaymentReceiptCredential(parsedCredential)) {
+  if (isJwtString(receipt) && !isPaymentReceiptCredential(parsedCredential)) {
     throw new InvalidCredentialError(
       "Credential is not a PaymentReceiptCredential",
     )
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ack-pay/src/verify-payment-receipt.ts` around lines 77 - 89, The
early isPaymentReceiptCredential fast-reject in verify-payment-receipt.ts is
incorrectly blocking valid object-based inputs before the proof-verified
credential is recovered. Update the precheck around parsedCredential so it only
runs for the JWT-string path, or remove it for object inputs and rely on
verifyParsedCredential() plus the verifiedReceipt validation instead. Keep the
fix scoped to the verifyPaymentReceipt flow and the isPaymentReceiptCredential /
verifyParsedCredential call sites.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@packages/ack-pay/src/verify-payment-receipt.ts`:
- Around line 77-89: The early isPaymentReceiptCredential fast-reject in
verify-payment-receipt.ts is incorrectly blocking valid object-based inputs
before the proof-verified credential is recovered. Update the precheck around
parsedCredential so it only runs for the JWT-string path, or remove it for
object inputs and rely on verifyParsedCredential() plus the verifiedReceipt
validation instead. Keep the fix scoped to the verifyPaymentReceipt flow and the
isPaymentReceiptCredential / verifyParsedCredential call sites.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 40788aaa-cc39-4fc4-909b-35667bcf234b

📥 Commits

Reviewing files that changed from the base of the PR and between 7d3eb3f and 4147b31.

📒 Files selected for processing (4)
  • .changeset/validate-receipt-payment-option.md
  • packages/ack-pay/src/errors.ts
  • packages/ack-pay/src/verify-payment-receipt.test.ts
  • packages/ack-pay/src/verify-payment-receipt.ts
✅ Files skipped from review due to trivial changes (1)
  • .changeset/validate-receipt-payment-option.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/ack-pay/src/errors.ts

@EfeDurmaz16

Copy link
Copy Markdown
Contributor Author

@coderabbitai Thanks for the review. On the isPaymentReceiptCredential(parsedCredential) fast-reject (verify-payment-receipt.ts:77-83) — skipping this one, for the record:

  • Not introduced by this PR. That precheck is existing main code. It only shows in this diff because the branch was rebased onto main, so CodeRabbit is diffing from the original PR base (7d3eb3f) and sees main's intervening changes as new.
  • Intentional by design. The maintainers added it with an explicit comment: "Cheap structural fast-reject only — NOT a trust boundary. The authoritative receipt check runs on the proof-verified credential below." The authoritative check is isPaymentReceiptCredential(verifiedReceipt) after verifyParsedCredential().
  • Not a security issue. The flagged behavior is a false-negative (rejecting a valid receipt whose outer object was mutated to not look like a receipt), not a false-accept. The trust boundary is intact.

This PR is scoped to binding the receipt's paymentOptionId back to an option offered by the verified Payment Request, on top of main's proof-verified verifiedReceipt. Changing main's deliberate fast-reject is out of scope here; happy to defer that to the maintainers if they want it gated to the JWT-string path.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@EfeDurmaz16 Thanks for the clear breakdown — agreed. Given that context, this concern doesn’t apply to this PR, so I’m withdrawing that comment.

The key point is that isPaymentReceiptCredential(parsedCredential) in packages/ack-pay/src/verify-payment-receipt.ts is an existing, intentionally non-authoritative fast-reject, while the real trust boundary is the proof-verified verifiedReceipt path below. Framed that way, this is out of scope for the paymentOptionId binding change here.


✏️ Learnings added
Learnt from: EfeDurmaz16
URL: https://github.com/agentcommercekit/ack/pull/88

Timestamp: 2026-06-30T16:37:10.718Z
Learning: In `packages/ack-pay/src/verify-payment-receipt.ts`, the `isPaymentReceiptCredential(parsedCredential)` check near the early fast-reject path is intentionally a cheap structural filter and not a trust boundary; the authoritative receipt validation is performed later against the proof-verified `verifiedReceipt` after `verifyParsedCredential()`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants