feat(python): add unified pay_kit surface with x402 exact#149
Merged
lgalabru merged 45 commits intoJun 1, 2026
Conversation
8695dbc to
d49fb41
Compare
lgalabru
reviewed
May 29, 2026
| │ ├── 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) |
…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.
9ea3718 to
e2e51d9
Compare
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.
e2e51d9 to
b1d6c94
Compare
lgalabru
reviewed
May 29, 2026
| @@ -0,0 +1,472 @@ | |||
| """Cross-language harness adapter for the Python PayKit umbrella surface. | |||
lgalabru
reviewed
May 29, 2026
| @@ -0,0 +1,84 @@ | |||
| # examples/flask-paykit/app.py | |||
lgalabru
reviewed
May 29, 2026
| @@ -3,6 +3,7 @@ | |||
| from __future__ import annotations | |||
Collaborator
There was a problem hiding this comment.
this file should move under protocols/mpp ?
lgalabru
reviewed
May 29, 2026
| from typing import TypedDict | ||
|
|
||
|
|
||
| class X402ExtraRequired(TypedDict): |
Collaborator
There was a problem hiding this comment.
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.
bb8c555 to
96798b8
Compare
lgalabru
reviewed
May 29, 2026
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 |
Collaborator
There was a problem hiding this comment.
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).
…narios" This reverts commit 90b442f.
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.
936ead2 to
a44e7a8
Compare
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.
a44e7a8 to
02811a3
Compare
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
approved these changes
Jun 1, 2026
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.
Summary
Adds the unified
pay_kitsurface to the Python SDK, mirroring the PHP reference (#145) and the Ruby idioms. One package, onerequire_payment, two protocols underneath (x402 and MPP). It ships alongside the existingsolana_mpppackage 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)withOperator(recipient + signer + fee_payer),X402Config,MppConfig.Signerfactories:demo,bytes,json,base58,hex,file,from_env,generate;pay_kit.kmsreserved for future remote signers.Gate,Price,Fee.usd(...)is Decimal-only and rejects float.Gatecarriesamount,pay_to,fee_within/fee_on_top,accept; exposestotalandpayout(to=...). x402 is auto-disabled on any gate with fees.require_payment(raises 402),is_paid(bool),get_payment(Payment | None).PayKitErrorbase) converted to framework-native 402 responses.Protocols
Caveats acceptance bar
ConfigurationErrorelsewhere, RPC failures logged not raised, opt-out viaconfigure(preflight=False)orPAY_KIT_DISABLE_PREFLIGHT=1.preflight.pyis omitted from the coverage gate../.envread, then a generated value persisted at mode 0600, with an in-memory fallback when the file is unwritable. No new dependency.HTTPException, Flaskabort, DjangoJsonResponse).Verification
ruff checkandruff format --checkclean,pyright0 errors.solana_mpp.pay-kit-pythonharness server runs green against the Rust reference for bothcharge(5 scenarios) andx402-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).GET /reportreturns 402 advertising both protocols inacceptorder, and that the MPPrequestblob 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 remotepay_kit.kmssigners 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 underpay_kit/protocols/mpp/andpay_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)
python/src/pay_kit/__init__.pythenpython/src/pay_kit/config.py— the public surface and howconfigurebuilds a frozenConfig.python/src/pay_kit/gate.py,price.py,pricing.py,fee.py— the value objects every adapter consumes.python/src/pay_kit/_middleware.py— the host-neutralPayCore: gate resolution, adapter detection, 402 assembly. The framework shims (fastapi.py,flask.py,django.py) only translate its outcome.python/src/pay_kit/protocols/mpp/__init__.py— the MPP adapter that bridges aGateto the charge flow.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— theMppclass wiring HMAC checks, broadcast, consume, and confirmation in L8 order. It re-exports the helpers above so the import path is unchanged.python/src/pay_kit/protocols/x402/__init__.pythenpython/src/pay_kit/protocols/x402/exact/verify.py— the self-hosted x402 adapter and the 11-rule structural verifier.Skim (mechanical, no behavior change)
solana_mpp/deletions and the moved files underpay_kit/_paycore/andpay_kit/protocols/mpp/core/(base64url,challenge,expires,headers,json,types,store,rpc,solana,network_check) — content carried over fromsolana_mppwith import-path updates only.refactor(python): split mpp server charge into ...is a pure extraction:charge.py(1612 -> 612 lines) split into_tx_decode.pyand_verify.py, with the helpers re-exported. No logic edited; the test suite count is unchanged.test_*->test_pk_*and the import-path churn across the existingsolana_mpptests.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_slotenforcing slot 0). This is the security boundary for the sponsored cosign path.pay_kit/protocols/x402/__init__.py:_co_signslot-splice and theacceptedvs 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_transactionand the push-mode verify-before-consume ordering in_verify_signature.pay_kit/_middleware.py:_CORE_CACHEper-Configreuse 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
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.python/tests/test_pk_x402_verifier.py,python/tests/test_pk_x402_settle.py,python/tests/test_pk_x402_client.py.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.cd python && uv run pytest -q(761 tests) anduv run ruff check src tests.