Skip to content

fix: implement JCS cart-to-payment mandate binding per RFC 8785 (closes #211)#241

Closed
chopmob-cloud wants to merge 1 commit into
google-agentic-commerce:mainfrom
chopmob-cloud:fix/cart-payment-mandate-jcs-binding
Closed

fix: implement JCS cart-to-payment mandate binding per RFC 8785 (closes #211)#241
chopmob-cloud wants to merge 1 commit into
google-agentic-commerce:mainfrom
chopmob-cloud:fix/cart-payment-mandate-jcs-binding

Conversation

@chopmob-cloud
Copy link
Copy Markdown

@chopmob-cloud chopmob-cloud commented Apr 29, 2026

Summary

Addresses the CartMandate ↔ PaymentMandate binding and verification gap raised in #211, and the canonicalization gap raised in the follow-up discussion (see comments by @srotzin on 2026-04-29 and our earlier response).

The core problem this fixes

The spec says PaymentMandate is "bound to Cart/Intent mandate" but:

  1. PaymentMandateContents had no explicit cart_mandate_id or cart_mandate_hash field
  2. The hash implementations in the sample code were placeholders returning "fake_cart_mandate_hash_" + id
  3. Cross-language implementations would produce different SHA-256 digests for the same logical cart because JSON float serialisation is not standardised (120.0 in Python, 120 in Go/TypeScript)
  4. No verifier gate existed to enforce binding before credential release or payment initiation

Changes

Spec (docs/specification.md)

Added section 4.1.3.1 "Cart-to-Payment Mandate Binding" with three normative requirements:

  1. PaymentMandateContents MUST include cart_mandate_id and cart_mandate_hash
  2. cart_mandate_hash MUST be hex(sha256(JCS(CartMandate))) per RFC 8785 — eliminates float serialisation divergence across implementations
  3. Credential Provider, Merchant, and Merchant Payment Processor MUST recompute and compare before credential release; mismatch MUST cause rejection

Types (src/ap2/types/mandate.py)

Added two Optional fields to PaymentMandateContents (Optional for backward compatibility; new mandates SHOULD always populate both):

  • cart_mandate_id — reference to the bound CartMandate
  • cart_mandate_hashsha256(JCS(CartMandate))

Sample implementation (samples/python/)

shopping_agent/tools.py:

  • Replaced fake _generate_cart_mandate_hash() and _generate_payment_mandate_hash() with real sha256(rfc8785.dumps(model.model_dump(mode="json")))
  • create_payment_mandate() now populates cart_mandate_id and cart_mandate_hash at mandate creation time
  • sign_mandates_on_user_device() reads the hash from the already-committed mandate field, so user_authorization signs the same bytes

common/validation.py:

  • Added validate_cart_mandate_hash(payment_mandate, cart_mandate) — recomputes JCS hash and raises ValueError on mismatch; logs a warning and skips gracefully when cart_mandate_hash is absent (backward compat)
  • validate_payment_mandate_signature() signature unchanged

pyproject.toml (samples): Added rfc8785>=0.1.2

Relation to other issues

Implementation note: chain-specific on-chain anchors

For the Solana scenario (PR #228), the on-chain binding anchor is a Solana Pay reference pubkey (not a memo field), but cart_mandate_hash in PaymentMandateContents uses the same JCS algorithm defined here — the hash canonicalization is uniform regardless of which chain settles the payment. The spec addition in §4.1.3.1 is intentionally chain-agnostic.

google-agentic-commerce#211)

Addresses the CartMandate <> PaymentMandate binding and verification gap
raised in google-agentic-commerce#211 and the canonicalization gap raised in the follow-up
discussion (see issue comments 2026-04-13 and 2026-04-29).

## Spec changes (docs/specification.md)

Added section 4.1.3.1 "Cart-to-Payment Mandate Binding" under section 4.1.3
with three normative requirements:

1. PaymentMandateContents MUST include `cart_mandate_id` and
   `cart_mandate_hash`.
2. `cart_mandate_hash` MUST be hex(sha256(JCS(CartMandate))) per RFC 8785.
   JCS eliminates the cross-language float serialisation ambiguity (Python
   produces 120.0, Go/TypeScript produce 120 for the same value -- three
   distinct byte sequences without canonicalisation).
3. Verifiers (CP, Merchant, MPP) MUST recompute the hash and MUST reject
   the transaction on mismatch before releasing credentials or initiating
   payment.

## Type changes (src/ap2/types/mandate.py)

Added two Optional fields to PaymentMandateContents:
- `cart_mandate_id`: reference to the bound CartMandate
- `cart_mandate_hash`: sha256(RFC 8785 canonical form of CartMandate)

Optional for backward compatibility; new mandates SHOULD always populate both.

## Sample implementation (samples/python/...)

shopping_agent/tools.py:
- Replaced fake _generate_cart_mandate_hash() placeholder with real
  sha256(rfc8785.dumps(cart_mandate.model_dump(mode="json"))).
- Replaced fake _generate_payment_mandate_hash() placeholder similarly.
- create_payment_mandate() now populates cart_mandate_id and
  cart_mandate_hash in PaymentMandateContents at mandate creation time.
- sign_mandates_on_user_device() reads cart_mandate_hash from the mandate
  (which was already computed and stored) so user_authorization signs the
  same bytes that were committed.

common/validation.py:
- Added validate_cart_mandate_hash(payment_mandate, cart_mandate) that
  recomputes and compares the JCS hash. Verifiers should call this before
  credential release or payment initiation.
- Existing validate_payment_mandate_signature() signature is unchanged
  (backward compatible).

pyproject.toml (samples):
- Added rfc8785>=0.1.2 dependency.

Relates to: google-agentic-commerce#192 (PaymentCurrencyAmount.value float vs string -- JCS
normalises floats so the hashing issue is resolved; fixing the field type
to string remains separately desirable for schema validation).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@chopmob-cloud chopmob-cloud requested a review from a team as a code owner April 29, 2026 15:57
@google-cla
Copy link
Copy Markdown

google-cla Bot commented Apr 29, 2026

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements the Cart-to-Payment Mandate Binding as defined in the updated specification (section 4.1.3.1). It introduces deterministic SHA-256 hashing using the JSON Canonicalization Scheme (RFC 8785) to link PaymentMandates to their corresponding CartMandates. The changes include documentation updates, the addition of the rfc8785 library to dependencies, and the implementation of hash generation and validation logic in the Python sample code. Feedback was provided to ensure cross-language hash consistency by excluding null values during model serialization and to use f-strings for better readability.

)
return

cart_dict = cart_mandate.model_dump(mode="json")
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.

high

To ensure cross-language consistency with implementations that use omitempty (like the Go implementation in this repository), you should exclude None values when dumping the model to a JSON-compatible dictionary. Otherwise, Python will include optional fields as null, while Go will omit them entirely, leading to different JCS outputs and hash mismatches.

Suggested change
cart_dict = cart_mandate.model_dump(mode="json")
cart_dict = cart_mandate.model_dump(mode="json", exclude_none=True)

Lowercase hex SHA-256 digest string.
"""
return "fake_cart_mandate_hash_" + cart_mandate.contents.id
cart_dict = cart_mandate.model_dump(mode="json")
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.

high

When generating a canonical hash intended for cross-language verification, it is critical to handle optional fields consistently. Using exclude_none=True ensures that fields with None values are omitted from the dictionary, matching the behavior of Go's omitempty tag used in the corresponding types.

Suggested change
cart_dict = cart_mandate.model_dump(mode="json")
cart_dict = cart_mandate.model_dump(mode="json", exclude_none=True)

return (
"fake_payment_mandate_hash_" + payment_mandate_contents.payment_mandate_id
)
contents_dict = payment_mandate_contents.model_dump(mode="json")
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.

high

Similar to the cart mandate hash, the payment mandate contents hash must be deterministic across languages. Ensure None values are excluded to align with implementations that omit empty optional fields.

Suggested change
contents_dict = payment_mandate_contents.model_dump(mode="json")
contents_dict = payment_mandate_contents.model_dump(mode="json", exclude_none=True)

Comment on lines +87 to +91
raise ValueError(
"CartMandate hash mismatch: mandate carries %r but recomputed %r. "
"PaymentMandate does not match the merchant-authorised CartMandate."
% (expected, actual)
)
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.

low

Modern Python (3.6+) prefers f-strings for string formatting as they are more readable and efficient than the old-style % operator.

Suggested change
raise ValueError(
"CartMandate hash mismatch: mandate carries %r but recomputed %r. "
"PaymentMandate does not match the merchant-authorised CartMandate."
% (expected, actual)
)
if expected != actual:
raise ValueError(
f"CartMandate hash mismatch: mandate carries {expected!r} but recomputed {actual!r}. "
"PaymentMandate does not match the merchant-authorised CartMandate."
)
References
  1. Modern Python string formatting should prefer f-strings over % formatting. (link)

@chopmob-cloud
Copy link
Copy Markdown
Author

Closing this in favour of the v0.2 architecture.

The v0.2 release (#233) restructured the spec and type system:

  • spec split into per-mandate pages under docs/ap2/
  • Pydantic types replaced by JSON Schemas in code/sdk/schemas/ap2/
  • samples/ moved to code/samples/

More importantly, v0.2 addresses the binding gap at the schema level via checkout_hash in checkout_mandate.json (sha256 of the signed checkout JWT) and transaction_id in payment_mandate.json (same hash, linking payment back to checkout). Since the JWT is already canonical bytes, there is no cross-language serialisation ambiguity.

One thing still worth a targeted follow-up: the verifier obligation (recompute + reject on mismatch before credential release) is not yet normative text in the new docs/ap2/ spec pages. That is a small, focused addition against the new structure.

chopmob-cloud added a commit to chopmob-cloud/AP2 that referenced this pull request May 1, 2026
Addresses the CartMandate <> PaymentMandate binding gap raised in google-agentic-commerce#211.

## Spec (docs/ap2/specification.md)

Added section "Cart-to-Payment Mandate Binding" under Payment Mandate
with three normative requirements:

1. PaymentMandateContents MUST include cart_mandate_id and
   cart_mandate_hash = hex(sha256(JCS(CartMandate))) per RFC 8785.
   JCS eliminates cross-language float-serialisation ambiguity
   (Python: 120.0, Go: 120 — different bytes without canonicalisation).
2. cart_mandate_hash MUST be computed with null/None optional fields
   excluded so Python and Go (omitempty) produce the same canonical form.
3. Verifiers MUST recompute the hash and MUST reject on mismatch before
   releasing credentials or initiating payment.

## Types (code/sdk/python/ap2/models/mandate.py)

Added two Optional fields to PaymentMandateContents:
- cart_mandate_id — reference to the bound CartMandate.
- cart_mandate_hash — sha256(RFC 8785 canonical form of CartMandate).

Both Optional for backward compatibility; new mandates SHOULD populate both.

## Sample validation (code/samples/python/src/common/validation.py)

New helper module with:
- validate_payment_mandate_signature() — placeholder for sd-jwt-vc
  key-binding verification (unchanged from prior design).
- validate_cart_mandate_hash() — recomputes and compares the JCS hash.
  Uses model_dump(exclude_none=True) so None-valued optional fields are
  omitted, matching Go omitempty and ensuring cross-language consistency
  (high-priority Gemini feedback on the earlier closed PR google-agentic-commerce#241).
  Uses f-strings throughout (low-priority Gemini feedback).

## Dependency (code/samples/python/pyproject.toml)

Added rfc8785>=0.1.2 for RFC 8785 JSON canonicalisation.
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.

[Security Hardening Suggestion]AP2 Human-Present: CartMandate ↔ PaymentMandate Binding and Verification Gap

1 participant