fix: implement JCS cart-to-payment mandate binding per RFC 8785 (closes #211)#241
Conversation
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>
|
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. |
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
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.
| 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") |
There was a problem hiding this comment.
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.
| 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") |
There was a problem hiding this comment.
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.
| contents_dict = payment_mandate_contents.model_dump(mode="json") | |
| contents_dict = payment_mandate_contents.model_dump(mode="json", exclude_none=True) |
| raise ValueError( | ||
| "CartMandate hash mismatch: mandate carries %r but recomputed %r. " | ||
| "PaymentMandate does not match the merchant-authorised CartMandate." | ||
| % (expected, actual) | ||
| ) |
There was a problem hiding this comment.
Modern Python (3.6+) prefers f-strings for string formatting as they are more readable and efficient than the old-style % operator.
| 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
- Modern Python string formatting should prefer f-strings over % formatting. (link)
|
Closing this in favour of the v0.2 architecture. The v0.2 release (#233) restructured the spec and type system:
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. |
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.
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:
PaymentMandateContentshad no explicitcart_mandate_idorcart_mandate_hashfield"fake_cart_mandate_hash_" + id120.0in Python,120in Go/TypeScript)Changes
Spec (
docs/specification.md)Added section 4.1.3.1 "Cart-to-Payment Mandate Binding" with three normative requirements:
PaymentMandateContentsMUST includecart_mandate_idandcart_mandate_hashcart_mandate_hashMUST behex(sha256(JCS(CartMandate)))per RFC 8785 — eliminates float serialisation divergence across implementationsTypes (
src/ap2/types/mandate.py)Added two
Optionalfields toPaymentMandateContents(Optional for backward compatibility; new mandates SHOULD always populate both):cart_mandate_id— reference to the bound CartMandatecart_mandate_hash—sha256(JCS(CartMandate))Sample implementation (
samples/python/)shopping_agent/tools.py:
_generate_cart_mandate_hash()and_generate_payment_mandate_hash()with realsha256(rfc8785.dumps(model.model_dump(mode="json")))create_payment_mandate()now populatescart_mandate_idandcart_mandate_hashat mandate creation timesign_mandates_on_user_device()reads the hash from the already-committed mandate field, souser_authorizationsigns the same bytescommon/validation.py:
validate_cart_mandate_hash(payment_mandate, cart_mandate)— recomputes JCS hash and raisesValueErroron mismatch; logs a warning and skips gracefully whencart_mandate_hashis absent (backward compat)validate_payment_mandate_signature()signature unchangedpyproject.toml (samples): Added
rfc8785>=0.1.2Relation to other issues
PaymentCurrencyAmount.value) — JCS resolves the hashing dimension of that issue; fixing the field type tostringremains separately desirable for JSON Schema validationImplementation note: chain-specific on-chain anchors
For the Solana scenario (PR #228), the on-chain binding anchor is a Solana Pay
referencepubkey (not a memo field), butcart_mandate_hashinPaymentMandateContentsuses 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.