fix(sdk): preserve type-specific PaymentInstrument extension fields through signing - #329
Open
vishkaty wants to merge 1 commit into
Conversation
…hrough signing ## Observed vs expected The AP2 specification permits a Payment Instrument `type` to define additional properties, but the generated `PaymentInstrument` model silently discards every property beyond `id`, `type`, and `description`. Because signed claims are built with `model_dump()`, those extension fields are absent from the signed Payment Mandate, and a downstream verifier that reads them observes a missing value. Expected: fields a `type` defines (for x402: `payee_address`, `facilitator`) survive parse -> model_dump -> sign -> verify, so a verifier acts on the values the user actually signed. ## Runtime reproduction (main @ e1ea56d) PaymentInstrument( id="x402-usdc-1", type="x402", payee_address="0xAbCd...0001", facilitator="https://facilitator.example", ).model_dump() # -> {'id': 'x402-usdc-1', 'type': 'x402', 'description': None} # payee_address and facilitator are dropped before signing. Driving the actual x402 Credential Provider sample end to end with a genuinely signed mandate chain (destination 0xAbCd...0001, amount 199c), the CP authorizes an EIP-3009 transfer to 0x7099...79C8 (DEFAULT_MERCHANT_ADDRESS) for 12500000 USDC units (the hard-coded 1250c fallback) rather than the verified destination and amount. This is a fail-open on a payment path: a valid, signed mandate is replaced by fabricated fallback values. ## Root cause and exact sites - Model drops the fields: code/sdk/python/ap2/sdk/generated/types/payment_instrument.py#L10-L22 - Signed via model_dump: code/sdk/python/ap2/sdk/sdjwt/common.py#L225-L229 - CP fail-open on the missing field: code/samples/python/src/roles/x402_credentials_provider_mcp/server.py#L148-L170 The generated model carries no `extra` policy, so Pydantic's default silently ignores unknown properties. `code/sdk/schemas/ap2/types/payment_instrument.json` declares no `additionalProperties`, and `generate.py` runs datamodel-codegen which, given no `additionalProperties`, emits a model with the default (ignore) posture. ## Fix Declare the open extension surface in the schema: `payment_instrument.json` gains `"additionalProperties": true`. datamodel-codegen already maps a schema's `additionalProperties` to a Pydantic `extra` policy (`jwk.json` -> `extra='forbid'`; `ucp/types/buyer.json` and `ucp/types/checkout.json` -> `extra='allow'`), so regenerating emits `model_config = ConfigDict(extra='allow')` on `PaymentInstrument`. Pydantic v2 then preserves the extra properties through `model_dump` (hence through signing, parsing, and verification) and exposes them for attribute access. This is schema-driven, not per-type code: the durable invariant lives in the schema (data), it opens the extension surface rather than closing it (so it does not constrain what a `type` may define), and it reuses the same convention the repo already applies to buyer/checkout. It is the AP2 analogue of the extension-preservation fixes made in UCP python-sdk#66 and js-sdk#40. ## Spec grounding - AP2 specification.md, Payment Instrument: "additional properties MAY be defined for that specific `type`." - UCP models preserve extension (`extra`) data through their round trip; this keeps AP2 consistent with that. ## Dedup google-agentic-commerce#299 item 1 is unaddressed by any open PR. google-agentic-commerce#301 (item 2, allowed-instrument matching) explicitly left item 1 to maintainers: "Preserving type-specific instrument fields through parsing and signing (google-agentic-commerce#299 item 1) is a schema and generated-model design decision ... which I have left for maintainers." google-agentic-commerce#300 hardens the sample amount fallback (fail closed) and is complementary: it does not restore the dropped destination field, which this change does at the source. ## Class sweep (schema drops a field that is passed and must travel) | Model | Schema additionalProperties | Passed non-schema fields? | Disposition | |-------|-----------------------------|---------------------------|-------------| | PaymentInstrument | absent -> now `true` | yes: payee_address, facilitator (x402) | CONVERTED | | types/buyer, types/checkout | already `true` | n/a | already `extra='allow'`, no change | | types/jwk | `false` (intentionally closed) | no | out of scope, must stay closed | | PaymentReceipt, CheckoutReceipt | oneOf variants | fields are schema-declared, preserved | out of scope, no drop | | all other generated models | absent (default ignore) | no construction passes non-schema fields | out of scope, no observed extension use | ## Tests Adds code/sdk/python/ap2/tests/payment_instrument_extension_tests.py: - model_dump / delegate-claims preserve x402 extension fields - fields survive the full sign -> verify -> typed-parse round trip - kill-test mirroring the CP extraction: verified destination + amount are sourced, never the default address or the 1250 fallback Full SDK suite: 190 passed. The two failing kb_sd_jwt aud/nonce tests are pre-existing on main and unrelated to this change.
vishkaty
force-pushed
the
fix/preserve-payment-instrument-extensions
branch
from
August 10, 2026 22:18
a2b8d7d to
901f1e2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(sdk): preserve type-specific PaymentInstrument extension fields through signing
Addresses #299 item 1: the Python SDK drops
PaymentInstrumentextensionproperties before a mandate is signed. Items 2 (allowed-instrument matching,
#301/#324) and the sample amount fallback (#300) are handled separately and are
not touched here.
Observed vs expected
The specification permits a Payment Instrument
typeto define additionalproperties, but the generated
PaymentInstrumentmodel keeps onlyid,type,and
descriptionand silently discards the rest. Because signed claims are builtwith
model_dump(), the discarded properties never reach the signed PaymentMandate, so a verifier that reads them sees a missing value.
Expected: properties a
typedefines (for x402,payee_addressandfacilitator) surviveparse -> model_dump -> sign -> verify, so the verifieracts on the values that were actually signed.
Runtime reproduction (main @
e1ea56d)Driving the actual x402 Credential Provider sample end to end with a genuinely
signed mandate chain (verified destination
0xAbCd...0001, verified amount199c): the CP reads the now-missingpayee_address, hitsAttributeError, andauthorizes an EIP-3009 transfer to
0x7099...79C8(DEFAULT_MERCHANT_ADDRESS)for
12500000USDC units (the hard-coded1250cfallback), instead of theverified destination and amount. A valid signed mandate is replaced with
fabricated fallback values on a payment path.
Exact sites
code/sdk/python/ap2/sdk/generated/types/payment_instrument.py#L10-L22model_dump:code/sdk/python/ap2/sdk/sdjwt/common.py#L225-L229code/samples/python/src/roles/x402_credentials_provider_mcp/server.py#L148-L170Root cause
payment_instrument.jsondeclares noadditionalProperties, andgenerate.pyruns datamodel-codegen, which then emits a model with Pydantic's default posture
(unknown properties ignored).
--field-extra-keysonly whitelists the twoselective-disclosure markers, so nothing else is preserved.
Fix
Declare the open extension surface in the schema, and let codegen carry it
through:
code/sdk/schemas/ap2/types/payment_instrument.json: add"additionalProperties": true.payment_instrument.pynow carriesmodel_config = ConfigDict(extra='allow').datamodel-codegen already maps a schema's
additionalPropertiesonto a Pydanticextrapolicy in this repo:jwk.json(additionalProperties: false) generatesextra='forbid', anducp/types/buyer.json/ucp/types/checkout.json(
additionalProperties: true) already generateextra='allow'. This change justapplies the same, existing convention to
PaymentInstrument. Pydantic v2 thenpreserves the extra properties through
model_dump(hence through signing,parsing, and verification) and exposes them for attribute access, so the x402 CP
reads the verified
payee_addressandpayment_amountinstead of falling back.Why schema-driven rather than a per-type model or a global codegen flag:
typecode,so new instrument types need no SDK changes.
constrain what a
typemay define (additionalProperties: falsewould).jwk's intentionalextra='forbid'and every other model are untouched.python-sdk#66andjs-sdk#40.Spec grounding
specification.md, Payment Instrument: "additional properties MAY bedefined for that specific
type."extra) data through its model round trip; this keepsAP2 consistent with that behavior.
Class sweep
The class: a generated model that drops a property which is passed in practice
and must travel through signing. Swept every schema and every construction in the
SDK and samples.
additionalPropertiesPaymentInstrumenttruepayee_address,facilitator(x402)types/buyer,types/checkouttrueextra='allow'; no change neededtypes/jwkfalse(intentionally closed)PaymentReceipt,CheckoutReceiptoneOfvariantsOnly
PaymentInstrumentis a genuine instance.Side benefit (no behavior anyone relies on changes)
Preserving extension fields also strengthens the existing "Pre-set
payment_instrument mismatch" equality check (
constraints.py): with extrasretained,
PaymentInstrument(id, type)no longer compares equal toPaymentInstrument(id, type, payee_address="0xATTACKER"), so apayee_addressswap that an id/type-only comparison silently accepted is now caught. Extensions
are only ever carried inside the SD-JWT-signed payload and are read by name where
used; instrument matching keys on declared fields only, so no allow-list or
matching decision is loosened by this change.
Tests
Adds
code/sdk/python/ap2/tests/payment_instrument_extension_tests.py:model_dumpand delegate-claims preserve the x402 extension fields.sign -> verify -> typed-parseround trip.server.py#L148-L160): the verifieddestination and amount are sourced, never the default address or the
1250fallback.
Each test fails on
mainfor the right reason (fields dropped) and passes withthe fix; removing the regenerated
model_configline reddens all four.Verification
190 passed. The two failingkb_sd_jwtaud/nonce tests arepre-existing on
main(the KB-hop area of fix(sdjwt): honor expected aud/nonce on every KB hop #313/fix(sdjwt): require expected aud/nonce when a terminal KB hop carries them #326) and unrelated to thischange.
generate.pyagainst the updated schemareproduces the committed model body byte for byte. The only regen churn is the
pre-existing header-timestamp non-determinism, so the committed file keeps the
existing timestamp to avoid unrelated diff.
authorizes the verified payee and
199c; without it,DEFAULT_MERCHANT_ADDRESSand
1250c.ruff-clean under the repo
.ruff.toml. Spellcheck is addressed below.Spellcheck
The spellcheck workflow (
cspell-action,incremental_files_only) re-scans eachchanged file in full, so it surfaced domain terms not yet in the dictionary
(
datamodel/codegenin the generated model header;sdjwt/SECPin the newtest). Added those four to
.cspell/custom-words.txt. No prose words introduced.