Skip to content

feat(python): add unified pay_kit surface with x402 exact#149

Merged
lgalabru merged 45 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/python-pay-kit
Jun 1, 2026
Merged

feat(python): add unified pay_kit surface with x402 exact#149
lgalabru merged 45 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/python-pay-kit

Conversation

@EfeDurmaz16

@EfeDurmaz16 EfeDurmaz16 commented May 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the unified pay_kit surface to the Python SDK, mirroring the PHP reference (#145) and the Ruby idioms. One package, one require_payment, two protocols underneath (x402 and MPP). It ships alongside the existing solana_mpp package and reuses its wire internals rather than duplicating them.

Scope this pass: reshape MPP behind the unified surface, add x402 exact (self-hosted verify and settle), keep the multi-recipient fee model, and add FastAPI, Flask, and Django shims.

Surface

  • pay_kit.configure(...) / configure_from(Settings) with Operator (recipient + signer + fee_payer), X402Config, MppConfig.
  • Signer factories: demo, bytes, json, base58, hex, file, from_env, generate; pay_kit.kms reserved for future remote signers.
  • Frozen Pydantic v2 value objects: Gate, Price, Fee. usd(...) is Decimal-only and rejects float.
  • Gate carries amount, pay_to, fee_within / fee_on_top, accept; exposes total and payout(to=...). x402 is auto-disabled on any gate with fees.
  • Request trio across all frameworks: require_payment (raises 402), is_paid (bool), get_payment (Payment | None).
  • Typed exception hierarchy (PayKitError base) converted to framework-native 402 responses.

Protocols

Cell Status
x402 / exact implemented (self-hosted verify + settle, matches the Rust spine)
mpp / charge / pull implemented (reuses solana_mpp)
mpp / charge / push implemented (reuses solana_mpp)
fees (fee_within / fee_on_top) implemented (MPP-only, multi-recipient)

Caveats acceptance bar

  1. localnet falls back to the mainnet mint row when no explicit localnet row exists.
  2. localnet RPC defaults to the hosted Surfpool endpoint.
  3. Boot-time preflight (fee-payer SOL + recipient ATA), Surfnet cheatcode auto-bootstrap on localnet with the demo signer, ConfigurationError elsewhere, RPC failures logged not raised, opt-out via configure(preflight=False) or PAY_KIT_DISABLE_PREFLIGHT=1. preflight.py is omitted from the coverage gate.
  4. MPP challenge-binding-secret auto-resolution: env, then a tolerant ./.env read, then a generated value persisted at mode 0600, with an in-memory fallback when the file is unwritable. No new dependency.
  5. x402 challenge embeds the server's recent blockhash via an injectable provider so unit tests stay offline.
  6. Framework-host quirks landed for each shim (FastAPI HTTPException, Flask abort, Django JsonResponse).
  7. Coverage gates: preflight omitted, both preflight kill-switches tested via a stubbed run, branch-critical paths (x402 verifier, MPP cross-route replay, cosign) covered. No live RPC in the unit suite.

Verification

  • ruff check and ruff format --check clean, pyright 0 errors.
  • 700 tests pass; pay_kit line coverage 94.8%, total 93.5%, no regressions in solana_mpp.
  • Interop: a new pay-kit-python harness server runs green against the Rust reference for both charge (5 scenarios) and x402-exact, including real on-chain settlement through surfpool. Registered opt-in (enabled: false), matching the sibling servers. Two real library bugs were found and fixed during interop (MPP RPC injection, x402 v0 cosign detection).
  • Manual DX: booted the FastAPI example on solana_localnet, confirmed GET /report returns 402 advertising both protocols in accept order, and that the MPP request blob decodes to canonical RFC 8785 JSON with the pinned replay fields.

Not in this pass

Delegated x402 facilitator mode (raises NotImplementedError), MPP sessions/subscriptions, x402 upto/batch, and remote pay_kit.kms signers are reserved for follow-ups.

Reviewing this PR

The diff is large because it is a full package restructure: the old solana_mpp/ tree is deleted and its wire internals are re-homed under pay_kit/protocols/mpp/ and pay_kit/_paycore/, with the new unified surface (config, gate, _middleware, the two protocol adapters) layered on top. Correctness is verified (761 tests, interop green); this guide is about reading order, not re-verifying behavior.

Suggested reading order (core logic)

  1. python/src/pay_kit/__init__.py then python/src/pay_kit/config.py — the public surface and how configure builds a frozen Config.
  2. python/src/pay_kit/gate.py, price.py, pricing.py, fee.py — the value objects every adapter consumes.
  3. python/src/pay_kit/_middleware.py — the host-neutral PayCore: gate resolution, adapter detection, 402 assembly. The framework shims (fastapi.py, flask.py, django.py) only translate its outcome.
  4. python/src/pay_kit/protocols/mpp/__init__.py — the MPP adapter that bridges a Gate to the charge flow.
  5. MPP server charge (decode then verify then orchestrate):
    • python/src/pay_kit/protocols/mpp/server/_tx_decode.py — pure transaction decoders and parsed-instruction verifiers, plus the Rust-mirrored constants.
    • python/src/pay_kit/protocols/mpp/server/_verify.py — fee-payer cosign, ATA-creation policy, and the no-leftovers instruction allowlist (the drain-protection logic).
    • python/src/pay_kit/protocols/mpp/server/charge.py — the Mpp class wiring HMAC checks, broadcast, consume, and confirmation in L8 order. It re-exports the helpers above so the import path is unchanged.
  6. python/src/pay_kit/protocols/x402/__init__.py then python/src/pay_kit/protocols/x402/exact/verify.py — the self-hosted x402 adapter and the 11-rule structural verifier.

Skim (mechanical, no behavior change)

  • The solana_mpp/ deletions and the moved files under pay_kit/_paycore/ and pay_kit/protocols/mpp/core/ (base64url, challenge, expires, headers, json, types, store, rpc, solana, network_check) — content carried over from solana_mpp with import-path updates only.
  • The most recent commit refactor(python): split mpp server charge into ... is a pure extraction: charge.py (1612 -> 612 lines) split into _tx_decode.py and _verify.py, with the helpers re-exported. No logic edited; the test suite count is unchanged.
  • Test renames test_* -> test_pk_* and the import-path churn across the existing solana_mpp tests.
  • uv.lock, pyproject.toml, and the CI workflow updates.

Risk areas (focus review here)

  • pay_kit/protocols/mpp/server/_verify.py: the instruction allowlist and the fee-payer drain guards (System transfer source check, SPL authority/source-ATA check, _assert_signature_slot enforcing slot 0). This is the security boundary for the sponsored cosign path.
  • pay_kit/protocols/x402/__init__.py: _co_sign slot-splice and the accepted vs server-offer equality gate (charge_request_mismatch), plus the replay-reserve / await-confirmation / rollback ordering.
  • pay_kit/protocols/mpp/server/charge.py: the L8 broadcast -> consume -> await ordering in _verify_transaction and the push-mode verify-before-consume ordering in _verify_signature.
  • pay_kit/_middleware.py: _CORE_CACHE per-Config reuse keeps the in-memory replay store alive across requests; a regression here would reset replay protection per request.

Note: _co_sign_with_fee_payer (MPP) and _co_sign (x402), and the several request header / path readers (_middleware._request_path, x402._request_path, MppAdapter._header) are structurally similar but intentionally not merged: they use different signer interfaces, slot policies, and error taxonomies, so a shared helper would couple the two protocol layers. Left as-is.

Test entry points

  • MPP server flow + security guards: python/tests/test_server.py, python/tests/test_pk_mpp_adapter.py, python/tests/test_mpp_helpers.py, python/tests/test_server_v0_transactions.py, python/tests/test_cross_route_replay.py.
  • x402: python/tests/test_pk_x402_verifier.py, python/tests/test_pk_x402_settle.py, python/tests/test_pk_x402_client.py.
  • Unified surface + frameworks: python/tests/test_pk_config.py, python/tests/test_pk_value_objects.py, python/tests/test_pk_middleware.py, python/tests/test_pk_frameworks.py.
  • Run: cd python && uv run pytest -q (761 tests) and uv run ruff check src tests.

@EfeDurmaz16 EfeDurmaz16 force-pushed the feat/python-pay-kit branch from 8695dbc to d49fb41 Compare May 29, 2026 08:58
Comment thread python/README.md Outdated
│ ├── kms.py reserved remote-enclave signer namespace
│ ├── _paycore/ Currency / Network / Protocol / Stablecoin enums
│ └── protocols/{x402,mpp}/ protocol adapters over the solana_mpp wire
├── src/solana_mpp/ lower-level MPP wire library (reused, not reimplemented)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

outdated?

…solana_mpp

Replaces float-based amount math in the payment page with Decimal (money
must never go through float) and tightens the protocol dataclass to_dict/
from_dict signatures to dict[str, Any]. Prepares these wire helpers for
reuse by the pay_kit MPP adapter.
Network/RPC-default table (localnet defaults to the hosted Surfpool
endpoint), currency and stablecoin types, the protocol enum, and the
stablecoin mint resolver with the localnet->mainnet mint fallback.
Pydantic v2 frozen Price/Fee value objects (Decimal-only, float rejected),
the typed error hierarchy, the Signer factory protocol (demo/bytes/json/
base58/hex/file/from_env/generate) with the reserved kms namespace, and the
Operator identity bundle with None-as-default resolution.
Gate value object with total/payout math, fee_within/fee_on_top validators,
and x402 auto-disable on gates with fees; the Pricing registry helper; and
configure/configure_from with X402/Mpp subconfigs, deprecation shims, and the
demo-signer-on-mainnet refusal.
Payment value object plus the two scheme adapters. The MPP adapter wraps the
existing solana_mpp wire internals and exposes verify_credential_with_expected
(cross-route replay protection pinning amount/currency/recipient) and HMAC
challenge-binding-secret auto-resolution (env -> ./.env -> generated). The x402
adapter implements the self-hosted exact verifier matching the Rust spine and
embeds the server's recent blockhash via an injectable provider.
Boot-time preflight (fee-payer SOL + recipient ATA checks, Surfnet cheatcode
auto-bootstrap on localnet+demo, ConfigurationError elsewhere, RPC failures
logged not raised, opt-out via flag/env) and the protocol-agnostic resolver
backing the require_payment/is_paid/get_payment trio and 402 assembly.
FastAPI (Depends injection), Flask (decorator + g.payment), and Django
(decorator + middleware) shims over the shared resolver, each converting
PayKitError to the framework-native 402 response, plus the umbrella public API.
Add pydantic + pydantic-settings, optional fastapi/flask/django extras, ship
both solana_mpp and pay_kit packages, extend coverage to pay_kit, and omit
preflight.py from the gate (live RPC + cheatcode paths).
Covers value objects, signer/operator, config and deprecation shims, the x402
11-rule verifier and settle path, the MPP adapter cross-route replay and secret
resolution, preflight knobs, middleware, and the three framework shims. pay_kit
line coverage 94.8%.
Dual-protocol server adapter (x402 exact via the pay_kit adapter, MPP charge via
the solana_mpp handler) verified green against the Rust reference for both
intents. Registered opt-in (enabled: false), matching the sibling servers.
Rewrite the README around the unified pay_kit surface with the protocol matrix,
add runnable FastAPI/Flask/Django examples, and add the test-python CI job
(ruff format-check + lint, pyright, pytest at the 90% gate).
Scope pyright strict to src/pay_kit (solana_mpp stays standard) and clear every
finding with real types: TypedDict wire shapes for the x402 offer/payload and MPP
request/methodDetails in _wire.py (serialized bytes unchanged), explicit
annotations, and narrowed input guards. Pydantic value objects gain
extra=forbid alongside the existing frozen config, and MppConfig.expires_in is
Strict-typed. A handful of rule-specific, documented pyright ignores cover the
load-bearing runtime guards and the host-shadowed framework shims.
Bare pyright on the runner does not reliably auto-detect the env the extras
were installed into, so the fastapi/flask/django shim-test imports failed to
resolve. Pass --pythonpath explicitly, matching local invocation.
protocols/ holds x402.py and mpp.py files (not {x402,mpp}/ dirs), and add the
new _wire.py typed wire-shapes module. Addresses review feedback.
…licate

The Python CI lives in python.yml (per-language, like go.yml/php.yml). The
earlier pass added a duplicate test-python job to ci.yml, causing two Python
tests jobs and a python-coverage artifact-name collision; the python.yml job
was the one failing because it installed only .[dev] (no framework extras), so
the fastapi/flask/django shim-test imports could not resolve under pyright.

Update python.yml to install the framework extras, point pyright at the active
interpreter, and extend coverage to pay_kit; remove the ci.yml duplicate.
@EfeDurmaz16 EfeDurmaz16 force-pushed the feat/python-pay-kit branch from 9ea3718 to e2e51d9 Compare May 29, 2026 12:08
Rewrite the repo-layout tree in the compact, grouped per-purpose style the
PHP/Go READMEs use (instead of one verbose line per file, which drifts), and
use the uppercase ## MPP heading like the other SDKs. Addresses lgalabru's
review.
@EfeDurmaz16 EfeDurmaz16 force-pushed the feat/python-pay-kit branch from e2e51d9 to b1d6c94 Compare May 29, 2026 12:09
@@ -0,0 +1,472 @@
"""Cross-language harness adapter for the Python PayKit umbrella surface.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

just python-server ?

Comment thread python/examples/flask-paykit/app.py Outdated
@@ -0,0 +1,84 @@
# examples/flask-paykit/app.py

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

examples/flask/app.py

?

@@ -3,6 +3,7 @@
from __future__ import annotations

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this file should move under protocols/mpp ?

Comment thread python/src/pay_kit/_wire.py Outdated
from typing import TypedDict


class X402ExtraRequired(TypedDict):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

belongs to protocols/x402

Address lgalabru's review: x402 and MPP code now live in their expected
locations with rust-like nesting (intent/server/client/core) instead of a flat
tree. Dissolves the separate solana_mpp package into pay_kit; the distribution
is now solana-pay-kit (import pay_kit).

- protocols/x402/: __init__ (X402Adapter) + verify.py (ExactVerifier + x402 wire shapes)
- protocols/mpp/: __init__ (MppAdapter + SecretResolver) + core/ + intents/charge + server/ + client/
- shared Solana wire primitives -> _paycore/solana.py (used by x402 and mpp)
- harness: pay-kit-python-server is now the canonical dual-protocol python-server
- examples: flask-paykit -> flask (replaces the old solana_mpp example)
- pyproject/CI/html-asset paths updated; all imports rewritten

No wire/behavior changes: 700 tests pass, pyright clean, ruff clean, and the
rust interop matrix stays green for both charge and x402-exact.
@EfeDurmaz16 EfeDurmaz16 force-pushed the feat/python-pay-kit branch from bb8c555 to 96798b8 Compare May 29, 2026 15:38
Comment on lines +30 to +32
from pay_kit.protocols.mpp.core.rpc import SolanaRpc
from pay_kit.protocols.mpp.core.store import MemoryStore, Store
from pay_kit.protocols.mpp.server.network_check import check_network_blockhash

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

protocols should not depend on each other.

…depend on each other

Address lgalabru: 'protocols should not depend on each other.' x402 was
importing SolanaRpc, the replay Store, the network-blockhash check, and the
v0-wire detector from protocols/mpp. Move that shared infrastructure into
_paycore (the analog of the rust 'core' crate): errors, rpc, store,
network_check, and a new transaction.py (is_v0_wire_bytes). Both x402 and mpp
now depend only on _paycore; neither imports the other (verified: zero
protocols.x402<->protocols.mpp references).

No wire/behavior change: 700 tests pass, pyright/ruff clean, rust interop stays
green for charge and x402-exact.
The allowlisted Lighthouse assertion program id was L1TEV... (a wrong value
also present in the PHP port); the canonical id used by the rust spine, Go, and
Ruby is L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95. A real Lighthouse-carrying
x402 transaction would otherwise be rejected. Also soften the verifier docstring
('byte-for-byte' -> 'rule-for-rule, plus strictly-stronger defensive rejects')
to match reality.
The client's build_charge_transaction raised NotImplementedError for SPL tokens
(only SOL worked). Implement it mirroring the Go client and the server verifier:
a TransferChecked (disc 12, amount u64 LE + decimals u8) per recipient to their
derived ATA, with an idempotent create-ATA prepended for splits that flag it.
Covered by new structural tests (the old 'raises NotImplementedError' edge test
is repointed to assert the real transfer). Also drop a stale 'pipeline is still
a stub' comment in the server charge handler (the pipeline is fully implemented).
PHP and Lua still carried the wrong L1TEV... id (the rust spine, Go, Ruby, and
now Python use L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95). A real
Lighthouse-carrying x402 transaction would otherwise be rejected. Constant-only
change; php -l and lua syntax checks pass, no test referenced the old value.
Move ExactVerifier + constants into protocols/x402/exact/verify.py and the
X402* wire TypedDicts into exact/types.py. X402Adapter (server entry) stays in
protocols/x402/__init__.py and re-exports ExactVerifier + X402_VERSION. Add
empty client/ and client/exact/ packages for the upcoming exact client. Repoint
_middleware and the x402 verifier/settle tests to the new module paths. No
behavior change.
…ansport)

Mirror the rust spine client (crates/x402/src/client/exact/payment.rs) and the
go client byte-for-behavior, operating on the X402AcceptsEntry wire shape the
pay_kit x402 server emits and ExactVerifier validates.

- parse_x402_challenge: decode the base64 payment-required header or the JSON
  accepts[] body, filter to x402/exact on the preferred Solana network, pick by
  currency-preference order then cheapest amount.
- build_payment / build_payment_header: compile a v0 VersionedTransaction with
  ComputeBudget(limit 200000 + price) + transferChecked to derive_ata(payTo,
  asset, tokenProgram) for SPL (or System transfer for native SOL) + memo, fee
  payer = extra.feePayer, signed by the client over the v0 message. Blockhash
  from extra.recentBlockhash, else an injectable provider, else rpc.
- PaymentTransport / X402Client: httpx auto-pay (402 -> build PAYMENT-SIGNATURE
  -> retry once). Header name matches the server reader.

Reuses canonical base64/JSON, solana primitives, derive_ata, RPC, Signer.
Round-trips through ExactVerifier and cosigns cleanly (verified offline).
Pyright strict scope extended to the new modules.
Cover parse_x402_challenge (header vs body, header-preferred, network filter,
currency-preference order + fallback + none-match + mint-address keys, cheapest,
garbage), build_payment (SPL round-trip through ExactVerifier, instruction
layout + amount/decimals bytes, fee-payer slot, blockhash from extra/provider/
rpc, native SOL, error paths), build_payment_header envelope shape, and the
PaymentTransport 402 -> pay -> 200 flow against a stub ASGI app backed by a real
X402Adapter (RPC stubbed) including the PAYMENT-SIGNATURE retry header. New
client modules at 95% line coverage; overall gate 93.84%.
Add harness/python-x402-client/main.py mirroring the rust interop client: read
the X402_INTEROP_* env contract, GET the target, parse the challenge with the
network + currency-preference selection, build the PAYMENT-SIGNATURE header, GET
again, then print one result JSON line (incl the Payment-Signature-sent echo and
the x-fixture-settlement value). Inserts python/src on sys.path like the python
server adapter; stdout carries only the result line, diagnostics to stderr.

Register python-x402 in implementations.ts (role client, intent x402-exact,
opt-in via X402_INTEROP_CLIENTS) and allow it against the rust-x402 and python
x402 servers in the e2e matrix (both full-settling; ts-x402's stub server is
excluded for the same reason rust-x402 is). Verified end-to-end offline against
a stub X402Adapter server (challenge -> parse -> signed v0 tx -> 200).
Extend the x402-exact scenario set with two full-settlement happy paths gated to
the full-settling client+server pairs (rust-x402/python-x402 x rust-x402/python):
- x402-exact-token2022: PYUSD under the Token-2022 program (verifier Rule 11
  Token-2022 branch + Token-2022 ATA derivation).
- x402-exact-ata-precreated: recipient ATA pre-created with a zero balance so
  the bare transferChecked lands in an existing destination ATA.
The existing x402-exact-basic already exercises the memo + recentBlockhash-present
bindings (the pay_kit rust/python servers stamp both into the offer). Update the
intent-selection test's expected id list. Existing scenarios unchanged.
Build the rust solana-x402 interop adapters and add a focused interop step that
drives the python-x402 client against the full-settling rust-x402 and python
x402 servers via test/e2e.test.ts (self-hosted surfnet, x402-exact intent). The
basic + token-2022 + ATA-precreated happy paths settle end-to-end; the negative
x402 scenarios gate their clientIds away from python-x402 so they skip. ts-x402
is excluded (stub server, no real broadcast).
Set the MPP_INTEROP_* selectors alongside X402_INTEROP_* so the default
charge-enabled adapters (typescript, php, go) stay out of the x402 run, and pin
MPP_INTEROP_SCENARIOS=x402-exact-basic. The token-2022 + ATA-precreated variants
were dropped (reverted) because neither x402 interop server advertises a
configurable token-2022 mint or a non-default resource path: the rust x402
server hardcodes TOKEN_PROGRAM + /protected, and the python x402 harness server
resolves its asset through the Stablecoin/USDC path. The basic scenario already
exercises the memo + recentBlockhash bindings end-to-end and is the proven
oracle (python-x402 pays rust-x402 and python green against surfpool).
Keeps the 'protocols must not depend on each other' invariant grep-clean
(it was only a doc cross-reference, not an import).
…lockhash fallback

Manual DX (high-level transport against a live server) surfaced that the x402
client's blockhash fallback called rpc.get_latest_blockhash(), which the shared
_paycore SolanaRpc never implemented — unit tests injected a provider/stub and
interop passed because the pay_kit server stamps recentBlockhash into the offer,
so the fallback path was never exercised. Add the method (returns
resp.value.blockhash like solana-py) + regression tests.
Add a Client column to the x402 matrix, a '### Client' section showing the
auto-pay transport (x402_async_client), and examples/x402-client/.
…20_000)

The x402 exact client emitted SetComputeUnitLimit(200_000), the MPP-charge value;
the rust spine client and the Go client use 20_000. The server verifier only
checks the instruction shape + the price cap, so interop passes either way, but
the SDKs should emit one canonical limit. Lock it with a layout assertion.
…nonce memo

Align the x402 exact client/verifier with the coinbase/x402 scheme_exact_svm
spec and the rust/go reference verifiers:
- Await on-chain confirmation (getSignatureStatuses) before returning
  settlement success; roll back the replay reservation and raise on a
  confirmation timeout or on-chain failure (149-2).
- Remove ATA-create from the exact verifier optional allowlist; optional slots
  are Lighthouse + Memo only and the destination ATA must pre-exist (149-3).
- Always append a Memo (extra.memo, else a random >=16-byte hex nonce) per the
  spec; the nonce source is injectable.
- Reject one-sided amount/maxAmountRequired drift in the accepted echo (149-1).
- Fix the README quickstart to construct the SDK with a store.
…wire expiry

Address review follow-ups:
- Cache one PayCore (and its replay store) per Config via a weakref map; the
  fastapi/flask/django shims no longer build a fresh adapter+MemoryStore per
  request, so a settled MPP signature can no longer be replayed.
- MPP fee-on-top gates: issue and expect gate.total() (base + on-top) instead
  of the base amount, so the verifier cannot accept an underpayment.
- Derive the issued challenge expiry from MppConfig.expires_in.
- Resolve a registry-returned DynamicGate against the current request.
Address review follow-ups:
- _enforce_demo_signer_on_mainnet now also rejects the x402 effective signer
  (cfg.effective_x402_signer) when Protocol.X402 is accepted on mainnet, so a
  demo signer set only on X402Config can no longer cosign mainnet payments.
- x402 accepts_entry converts the amount with the exact micro-unit parser
  (parse_units) instead of int() truncation, so a sub-microunit price raises
  rather than advertising a zero-amount transfer.
- Price._to_decimal validates the coerced Decimal is non-negative for int and
  Decimal inputs, not just string inputs.
@EfeDurmaz16 EfeDurmaz16 force-pushed the feat/python-pay-kit branch from 936ead2 to a44e7a8 Compare May 30, 2026 17:39
The x402 exact verifier restricted Lighthouse to the first two optional
instruction slots, so a wallet that injects a Lighthouse guard in the 5th or
6th instruction was rejected. The rust and Go verifiers accept Lighthouse or
Memo in any optional slot (instructions 3..n); match them so legitimate
wallet-built payments verify and the SDKs stay interoperable.
@EfeDurmaz16 EfeDurmaz16 force-pushed the feat/python-pay-kit branch from a44e7a8 to 02811a3 Compare May 30, 2026 17:39
The server charge module had grown to 1612 lines, mixing three concerns:
pure transaction decoders, the fee-payer cosign plus no-leftovers
instruction allowlist, and the Mpp orchestration class. Extract the
decoders into _tx_decode and the cosign/allowlist into _verify, leaving
charge.py at 612 lines holding only the Mpp flow. The moved helpers are
re-exported from charge so the existing import surface and the tests that
reach into the private helpers stay unchanged. No behavior change.
…ggle

Bring the python x402 exact client to byte-parity with the rust spine
(rust/crates/x402/src/client/exact/payment.rs + protocol/schemes/exact/
types.rs) on offer-field precedence:

- fee-payer toggle: source the key from top-level feePayerKey first, then
  extra.feePayer; honor the explicit feePayer bool so feePayer:false opts
  out even when a key is present (types.rs:350-353, payment.rs:43-51)
- read tokenProgram/decimals/recentBlockhash top-level first, then extra
  (types.rs:344-349); read currency/recipient before asset/payTo
  (types.rs:334-342)
- default the SPL token program via default_token_program_for_currency when
  the offer omits it instead of raising (payment.rs:445-452)
- reject an amount outside unsigned u64 at parse time, matching
  amount.parse::<u64>() (payment.rs:33-36)
- echo the offer resource info at the envelope top level and attach the
  envelope-level v2 resource onto parsed accepts (payment.rs:131-138,
  types.rs:463-476)
Bring the python mpp charge client to parity with the rust spine
(rust/crates/mpp/src/client/charge.rs):

- prepend the ComputeBudget prelude SetComputeUnitPrice(1) then
  SetComputeUnitLimit(200_000) so instruction order matches an identical
  rust challenge byte-for-byte (charge.rs:108-110)
- sponsored charge uses the server fee payer as the message fee payer
  (account[0]) and partial-signs only the client slot, leaving slot 0 for
  the server cosign; previously the client sat at slot 0 and the credential
  was unsettleable cross-impl (charge.rs:96-104,162-163)
- the create-ATA-idempotent payer is the fee payer when sponsored, else the
  signer (charge.rs:368)
- enforce the 8-split cap client-side (charge.rs:76-78)
- require an SPL mint-address currency when any split flags
  ataCreationRequired (charge.rs:113-128)
- resolve the token program via the mint account owner over RPC when
  methodDetails.tokenProgram is absent and reject any program outside the
  {Token, Token-2022} allowlist (charge.rs:442-466)
The charge instruction allowlist computed required_ata_owners then discarded
it, so a sponsored credential that omitted a demanded create-ATA for an
ataCreationRequired split recipient was accepted and the server cosigned and
broadcast, under-creating the recipient ATA. Collect the validated ATA owner
from each create-idempotent instruction and, after the instruction loop,
reject when any required-ATA-owner has no matching create, mirroring rust
validate_instruction_allowlist (server/charge.rs:1362-1368).
pyright flagged partially-unknown types on resource.get(...) because the
isinstance(dict) narrowing yields dict[Unknown, Unknown]. Cast the narrowed
value to dict[str, object] so url/description resolve as object.
The x402 client parity wave merged the envelope-level v2 resource into each
parsed offer's wire fields (resource/description) via _attach_envelope_resource,
and then echoed that mutated offer back as the credential's accepted body. The
rust x402 server (server/exact.rs verify_envelope_payload) round-trips the
echoed accepted through PaymentRequirements and runs a structural deepEqual
against its own freshly built requirements, which carry no top-level
resource/description. The extra fields broke the compare and the server
returned HTTP 402 payment_invalid, regressing the python-x402 -> rust-x402
interop.

Mirror rust exactly: the rust client echoes the offer verbatim through
to_accepted_value while resource_info rides only the envelope-level resource.
Stash the resolved resource info under a private non-wire key instead of
mutating the offer, strip it before echoing accepted, and derive the envelope
resource from the stash (per-offer resource/description still win). All other
parity fixes (top-level precedence, tokenProgram default, fee-payer toggle,
amount parse) are untouched.

Regression test asserts parse leaves the offer's wire fields clean and that
build_payment echoes resource at the envelope level with an accepted body equal
to the received offer.
…build overrides

The pre-broadcast verifier matched SPL transferChecked instructions on
mint, amount, and destination ATA but never checked the inline decimals
byte, so a transfer encoding decimals=9 satisfied a decimals=6 challenge.
Surface the decimals from the wire bytes (data[9]) and the jsonParsed RPC
shape, and reject a present decimals that disagrees with the challenge,
mirroring the TS reference verifier and the Rust spine. A missing decimals
(older confirmed-transaction fixtures) is left unconstrained so push-mode
matching is unchanged.

The client build path hardcoded SetComputeUnitPrice(1) and
SetComputeUnitLimit(200_000) with no override, so a caller could not build
a transaction carrying values the server cap rejects. Add optional
compute_unit_limit / compute_unit_price parameters, defaulting to the
existing values, matching the Go BuildOptions and the TS compute overrides.
@lgalabru lgalabru merged commit e0bcef1 into solana-foundation:main Jun 1, 2026
26 checks passed
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.

2 participants