From 705fc1e894eadfb4793ab972d2cf47ec8a28ed1b Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 10:42:42 +0300 Subject: [PATCH 01/45] style(python): ruff-format existing solana_mpp tests --- python/tests/test_client_charge_edge.py | 12 ++------ python/tests/test_middleware.py | 4 +-- python/tests/test_server_html.py | 39 ++++++++++++++++++------- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/python/tests/test_client_charge_edge.py b/python/tests/test_client_charge_edge.py index 6c73f9f15..857656dcc 100644 --- a/python/tests/test_client_charge_edge.py +++ b/python/tests/test_client_charge_edge.py @@ -122,9 +122,7 @@ async def test_build_credential_header_wraps_charge_transaction(): "methodDetails": {"recentBlockhash": BLOCKHASH}, } ) - challenge = PaymentChallenge( - id="c1", realm="api", method="solana", intent="charge", request=request - ) + challenge = PaymentChallenge(id="c1", realm="api", method="solana", intent="charge", request=request) header = await build_credential_header( challenge=challenge, signer=signer, @@ -140,12 +138,8 @@ async def test_build_credential_header_wraps_charge_transaction(): async def test_build_credential_header_without_method_details(): signer = Keypair() recipient = str(Keypair().pubkey()) - request = encode_json( - {"amount": "100", "currency": "sol", "recipient": recipient} - ) - challenge = PaymentChallenge( - id="c1", realm="api", method="solana", intent="charge", request=request - ) + request = encode_json({"amount": "100", "currency": "sol", "recipient": recipient}) + challenge = PaymentChallenge(id="c1", realm="api", method="solana", intent="charge", request=request) rpc = _FakeRpcClient() header = await build_credential_header( challenge=challenge, diff --git a/python/tests/test_middleware.py b/python/tests/test_middleware.py index f7a832595..d1e7d8815 100644 --- a/python/tests/test_middleware.py +++ b/python/tests/test_middleware.py @@ -162,9 +162,7 @@ async def wrapped(request, credential, receipt): # Build a valid credential that matches the route's expected charge # ("1.00" USDC, recipient = TEST_RECIPIENT, devnet). challenge = handler_mpp.charge("1.00") - transaction = _build_spl_transfer_checked_transaction( - TEST_RECIPIENT, USDC_DEVNET, 1_000_000 - ) + transaction = _build_spl_transfer_checked_transaction(TEST_RECIPIENT, USDC_DEVNET, 1_000_000) credential = PaymentCredential( challenge=challenge.to_echo(), payload={"type": "transaction", "transaction": transaction}, diff --git a/python/tests/test_server_html.py b/python/tests/test_server_html.py index b5ef05d8a..196e172ad 100644 --- a/python/tests/test_server_html.py +++ b/python/tests/test_server_html.py @@ -108,7 +108,10 @@ def test_includes_expires(self): def test_network_devnet(self): challenge = PaymentChallenge( - id="t", realm="api", method="solana", intent="charge", + id="t", + realm="api", + method="solana", + intent="charge", request=encode_json({"amount": "1000"}), ) html = challenge_to_html(challenge, "https://api.devnet.solana.com", "devnet") @@ -117,7 +120,10 @@ def test_network_devnet(self): def test_network_mainnet(self): challenge = PaymentChallenge( - id="t", realm="api", method="solana", intent="charge", + id="t", + realm="api", + method="solana", + intent="charge", request=encode_json({"amount": "1000"}), ) html = challenge_to_html(challenge, "https://api.mainnet-beta.solana.com", "mainnet-beta") @@ -125,7 +131,10 @@ def test_network_mainnet(self): def test_amount_display_sol(self): challenge = PaymentChallenge( - id="t", realm="api", method="solana", intent="charge", + id="t", + realm="api", + method="solana", + intent="charge", request=encode_json({"amount": str(2 * 10**9), "currency": "SOL"}), ) html = challenge_to_html(challenge, "http://localhost:8899", "localnet") @@ -133,7 +142,10 @@ def test_amount_display_sol(self): def test_amount_display_usdc_symbol(self): challenge = PaymentChallenge( - id="t", realm="api", method="solana", intent="charge", + id="t", + realm="api", + method="solana", + intent="charge", request=encode_json({"amount": "1500000", "currency": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"}), ) html = challenge_to_html(challenge, "http://localhost:8899", "localnet") @@ -142,7 +154,10 @@ def test_amount_display_usdc_symbol(self): def test_amount_display_unknown_token(self): challenge = PaymentChallenge( - id="t", realm="api", method="solana", intent="charge", + id="t", + realm="api", + method="solana", + intent="charge", request=encode_json({"amount": "12345678", "currency": "ABCDEFGHIJKLMNOP"}), ) html = challenge_to_html(challenge, "http://localhost:8899", "localnet") @@ -151,7 +166,10 @@ def test_amount_display_unknown_token(self): def test_amount_display_uses_methoddetails_decimals(self): challenge = PaymentChallenge( - id="t", realm="api", method="solana", intent="charge", + id="t", + realm="api", + method="solana", + intent="charge", request=encode_json({"amount": "100000000", "currency": "FOO", "methodDetails": {"decimals": 8}}), ) html = challenge_to_html(challenge, "http://localhost:8899", "localnet") @@ -160,15 +178,16 @@ def test_amount_display_uses_methoddetails_decimals(self): def test_malformed_request_falls_back(self): # Non base64url request body must not raise; renders 0 default amount. - challenge = PaymentChallenge( - id="t", realm="api", method="solana", intent="charge", request="not_base64_!!!" - ) + challenge = PaymentChallenge(id="t", realm="api", method="solana", intent="charge", request="not_base64_!!!") html = challenge_to_html(challenge, "http://localhost:8899", "localnet") assert "" in html def test_embedded_data_contains_required_fields(self): challenge = PaymentChallenge( - id="abc", realm="api", method="solana", intent="charge", + id="abc", + realm="api", + method="solana", + intent="charge", request=encode_json({"amount": "1"}), ) html = challenge_to_html(challenge, "http://localhost:8899", "localnet") From 0c86702609f1869d710a1a95e4f05d6886cef56d Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 10:42:42 +0300 Subject: [PATCH 02/45] refactor(python): use Decimal money math and explicit dict typing in 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. --- python/src/solana_mpp/protocol/solana.py | 19 ++++++++++--------- python/src/solana_mpp/server/payment_page.py | 8 ++++++-- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/python/src/solana_mpp/protocol/solana.py b/python/src/solana_mpp/protocol/solana.py index b2e212abf..d15297e2c 100644 --- a/python/src/solana_mpp/protocol/solana.py +++ b/python/src/solana_mpp/protocol/solana.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from typing import Any SYSTEM_PROGRAM = "11111111111111111111111111111111" TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" @@ -124,9 +125,9 @@ class MethodDetails: recent_blockhash: str = "" splits: list[Split] = field(default_factory=list) - def to_dict(self) -> dict: + def to_dict(self) -> dict[str, Any]: """Serialize to a JSON-compatible dict, omitting empty fields.""" - d: dict = {} + d: dict[str, Any] = {} if self.network: d["network"] = self.network if self.decimals is not None: @@ -144,7 +145,7 @@ def to_dict(self) -> dict: return d @classmethod - def from_dict(cls, data: dict) -> MethodDetails: + def from_dict(cls, data: dict[str, Any]) -> MethodDetails: """Deserialize from a JSON-compatible dict.""" splits = [Split.from_dict(s) for s in data.get("splits", [])] return cls( @@ -168,8 +169,8 @@ class Split: memo: str = "" ata_creation_required: bool = False - def to_dict(self) -> dict: - d: dict = {"recipient": self.recipient, "amount": self.amount} + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"recipient": self.recipient, "amount": self.amount} if self.ata_creation_required: d["ataCreationRequired"] = self.ata_creation_required if self.label: @@ -179,7 +180,7 @@ def to_dict(self) -> dict: return d @classmethod - def from_dict(cls, data: dict) -> Split: + def from_dict(cls, data: dict[str, Any]) -> Split: return cls( recipient=data["recipient"], amount=data["amount"], @@ -197,8 +198,8 @@ class CredentialPayload: transaction: str = "" signature: str = "" - def to_dict(self) -> dict: - d: dict = {"type": self.type} + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"type": self.type} if self.transaction: d["transaction"] = self.transaction if self.signature: @@ -206,7 +207,7 @@ def to_dict(self) -> dict: return d @classmethod - def from_dict(cls, data: dict) -> CredentialPayload: + def from_dict(cls, data: dict[str, Any]) -> CredentialPayload: return cls( type=data.get("type", ""), transaction=data.get("transaction", ""), diff --git a/python/src/solana_mpp/server/payment_page.py b/python/src/solana_mpp/server/payment_page.py index 5e9e10a21..63cd01753 100644 --- a/python/src/solana_mpp/server/payment_page.py +++ b/python/src/solana_mpp/server/payment_page.py @@ -10,6 +10,7 @@ import html as html_mod import importlib.resources import json +from decimal import Decimal, InvalidOperation from typing import Any from urllib.parse import parse_qs, urlparse @@ -66,8 +67,11 @@ def challenge_to_html(challenge: PaymentChallenge, rpc_url: str, network: str) - md = request_data.get("methodDetails", {}) decimals = md.get("decimals", 9 if currency.lower() == "sol" else 6) amount_raw = request_data.get("amount", "0") - amount_f = float(amount_raw) / (10**decimals) - display_amount = str(int(amount_f)) if amount_f == int(amount_f) else f"{amount_f:.2f}" + try: + amount_dec = Decimal(str(amount_raw)) / Decimal(10**decimals) + except (InvalidOperation, ValueError): + amount_dec = Decimal(0) + display_amount = str(int(amount_dec)) if amount_dec == amount_dec.to_integral_value() else f"{amount_dec:.2f}" sym = _KNOWN_SYMBOLS.get(currency) if currency.lower() == "sol": From 2f3575cf80d56aef27949c8c5b3b7e84ac2c653e Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 10:42:42 +0300 Subject: [PATCH 03/45] feat(python): add pay_kit PayCore foundation 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. --- python/src/pay_kit/_paycore/__init__.py | 43 +++++++++++++ python/src/pay_kit/_paycore/currency.py | 15 +++++ python/src/pay_kit/_paycore/mints.py | 78 +++++++++++++++++++++++ python/src/pay_kit/_paycore/network.py | 67 +++++++++++++++++++ python/src/pay_kit/_paycore/protocol.py | 19 ++++++ python/src/pay_kit/_paycore/stablecoin.py | 17 +++++ 6 files changed, 239 insertions(+) create mode 100644 python/src/pay_kit/_paycore/__init__.py create mode 100644 python/src/pay_kit/_paycore/currency.py create mode 100644 python/src/pay_kit/_paycore/mints.py create mode 100644 python/src/pay_kit/_paycore/network.py create mode 100644 python/src/pay_kit/_paycore/protocol.py create mode 100644 python/src/pay_kit/_paycore/stablecoin.py diff --git a/python/src/pay_kit/_paycore/__init__.py b/python/src/pay_kit/_paycore/__init__.py new file mode 100644 index 000000000..56a923476 --- /dev/null +++ b/python/src/pay_kit/_paycore/__init__.py @@ -0,0 +1,43 @@ +"""Layer A: paycore primitives (enums, mints) plus solana_mpp re-exports.""" + +from __future__ import annotations + +from pay_kit._paycore.currency import Currency +from pay_kit._paycore.mints import ( + ASSOCIATED_TOKEN_PROGRAM, + TOKEN_2022_PROGRAM, + TOKEN_PROGRAM, + derive_ata, + resolve, + resolve_stablecoin_mint, + symbol_for, + token_program_for, +) +from pay_kit._paycore.network import ( + AUTOFUND_LAMPORTS, + MIN_FEE_PAYER_LAMPORTS, + PUBLIC_RPC_URLS, + SOLANA_DEVNET_CAIP2, + SOLANA_MAINNET_CAIP2, + Network, +) +from pay_kit._paycore.stablecoin import Stablecoin + +__all__ = [ + "Currency", + "Stablecoin", + "Network", + "PUBLIC_RPC_URLS", + "SOLANA_MAINNET_CAIP2", + "SOLANA_DEVNET_CAIP2", + "MIN_FEE_PAYER_LAMPORTS", + "AUTOFUND_LAMPORTS", + "resolve_stablecoin_mint", + "resolve", + "token_program_for", + "symbol_for", + "derive_ata", + "ASSOCIATED_TOKEN_PROGRAM", + "TOKEN_PROGRAM", + "TOKEN_2022_PROGRAM", +] diff --git a/python/src/pay_kit/_paycore/currency.py b/python/src/pay_kit/_paycore/currency.py new file mode 100644 index 000000000..d7bf537ff --- /dev/null +++ b/python/src/pay_kit/_paycore/currency.py @@ -0,0 +1,15 @@ +"""Fiat currency denominations supported by Price value objects.""" + +from __future__ import annotations + +from enum import StrEnum + +__all__ = ["Currency"] + + +class Currency(StrEnum): + """ISO 4217 fiat currency used to denominate a gate amount.""" + + USD = "USD" + EUR = "EUR" + GBP = "GBP" diff --git a/python/src/pay_kit/_paycore/mints.py b/python/src/pay_kit/_paycore/mints.py new file mode 100644 index 000000000..cb8bba187 --- /dev/null +++ b/python/src/pay_kit/_paycore/mints.py @@ -0,0 +1,78 @@ +"""Stablecoin mint resolution and ATA derivation over solana_mpp data. + +Mirrors PHP ``PayCore/Solana/Mints.php``. All mint/program tables live in +``solana_mpp.protocol.solana`` and are reused here rather than duplicated, so +pay_kit and the legacy surface always agree on wire values. +""" + +from __future__ import annotations + +from solders.pubkey import Pubkey + +from solana_mpp.protocol.solana import ( + ASSOCIATED_TOKEN_PROGRAM, + TOKEN_2022_PROGRAM, + TOKEN_PROGRAM, + default_token_program_for_currency, + resolve_mint, + stablecoin_symbol, +) + +__all__ = [ + "ASSOCIATED_TOKEN_PROGRAM", + "TOKEN_PROGRAM", + "TOKEN_2022_PROGRAM", + "resolve_stablecoin_mint", + "resolve", + "token_program_for", + "symbol_for", + "derive_ata", +] + + +def resolve_stablecoin_mint(currency: str, network: str = "mainnet") -> str | None: + """Resolve a stablecoin symbol or raw mint to a concrete mint pubkey. + + Native ``SOL`` returns ``None`` (no mint). Unknown networks fall back to the + mainnet row, so ``localnet`` resolves to the mainnet mint (caveat #1: + Surfpool clones mainnet state). + """ + if currency.upper() == "SOL": + return None + mint = resolve_mint(currency, network) + return mint or None + + +# Alias matching the blueprint's `resolve` contract for sibling modules. +def resolve(currency: str, network: str = "mainnet") -> str | None: + """Alias for :func:`resolve_stablecoin_mint`.""" + return resolve_stablecoin_mint(currency, network) + + +def token_program_for(currency: str, network: str = "mainnet") -> str: + """Return the SPL token program that owns the currency's mint.""" + return default_token_program_for_currency(currency, network) + + +def symbol_for(currency: str, network: str = "mainnet") -> str | None: + """Reverse lookup: symbol for a stablecoin symbol or known mint, else None.""" + symbol = stablecoin_symbol(currency) + if symbol is not None: + return symbol + resolved = resolve_stablecoin_mint(currency, network) + if resolved is None or resolved == currency: + return None + return stablecoin_symbol(resolved) + + +def derive_ata(owner: str, mint: str, token_program: str) -> str: + """Derive the Associated Token Account address for (owner, mint, program).""" + ata, _ = Pubkey.find_program_address( + [ + bytes(Pubkey.from_string(owner)), + bytes(Pubkey.from_string(token_program)), + bytes(Pubkey.from_string(mint)), + ], + Pubkey.from_string(ASSOCIATED_TOKEN_PROGRAM), + ) + return str(ata) diff --git a/python/src/pay_kit/_paycore/network.py b/python/src/pay_kit/_paycore/network.py new file mode 100644 index 000000000..9133187da --- /dev/null +++ b/python/src/pay_kit/_paycore/network.py @@ -0,0 +1,67 @@ +"""Solana network slugs plus default RPC endpoints and CAIP-2 identifiers.""" + +from __future__ import annotations + +from enum import StrEnum + +__all__ = [ + "Network", + "PUBLIC_RPC_URLS", + "SOLANA_MAINNET_CAIP2", + "SOLANA_DEVNET_CAIP2", + "MIN_FEE_PAYER_LAMPORTS", + "AUTOFUND_LAMPORTS", +] + +# CAIP-2 chain identifiers advertised in x402 + MPP accepts entries. These must +# byte-match the Rust spine (PHP PayCore/Network.php caip2()): Surfpool-localnet +# clones mainnet state but reuses the devnet genesis hash by convention, so +# localnet shares the devnet CAIP-2. +SOLANA_MAINNET_CAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" +SOLANA_DEVNET_CAIP2 = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + +# Boot-time preflight thresholds (caveat #3). Defined here so config + preflight +# share a single source of truth without a cross-layer import. +MIN_FEE_PAYER_LAMPORTS = 1_000_000 +AUTOFUND_LAMPORTS = 10_000_000_000 + + +class Network(StrEnum): + """Solana network slug; backing values match the Rust spine's wire form.""" + + SOLANA_MAINNET = "solana_mainnet" + SOLANA_DEVNET = "solana_devnet" + SOLANA_LOCALNET = "solana_localnet" + + def default_rpc_url(self) -> str: + """Default public RPC endpoint for this network (caveat #2).""" + return PUBLIC_RPC_URLS[self] + + def mints_label(self) -> str: + """Bare network slug consumed by the mints registry (mainnet/devnet/localnet).""" + return _MINTS_LABELS[self] + + def caip2(self) -> str: + """CAIP-2 chain identifier advertised in accepts entries.""" + return _CAIP2[self] + + +# Localnet defaults to the hosted Surfpool endpoint (mainnet-state fork) so a +# zero-config localnet boot is reachable (caveat #2), not http://localhost:8899. +PUBLIC_RPC_URLS: dict[Network, str] = { + Network.SOLANA_MAINNET: "https://api.mainnet-beta.solana.com", + Network.SOLANA_DEVNET: "https://api.devnet.solana.com", + Network.SOLANA_LOCALNET: "https://402.surfnet.dev:8899", +} + +_MINTS_LABELS: dict[Network, str] = { + Network.SOLANA_MAINNET: "mainnet", + Network.SOLANA_DEVNET: "devnet", + Network.SOLANA_LOCALNET: "localnet", +} + +_CAIP2: dict[Network, str] = { + Network.SOLANA_MAINNET: SOLANA_MAINNET_CAIP2, + Network.SOLANA_DEVNET: SOLANA_DEVNET_CAIP2, + Network.SOLANA_LOCALNET: SOLANA_DEVNET_CAIP2, +} diff --git a/python/src/pay_kit/_paycore/protocol.py b/python/src/pay_kit/_paycore/protocol.py new file mode 100644 index 000000000..781f2127b --- /dev/null +++ b/python/src/pay_kit/_paycore/protocol.py @@ -0,0 +1,19 @@ +"""Wire-level payment protocol a credential proves. + +The backing string is what crosses the wire (lowercase, matching the Rust +spine and the cross-SDK matrix tables). Mirrors PHP ``PayKit\\Protocol`` and +Ruby ``PayKit::Protocol``. +""" + +from __future__ import annotations + +from enum import StrEnum + +__all__ = ["Protocol"] + + +class Protocol(StrEnum): + """Payment protocol advertised in an accepts entry and proven by a proof.""" + + X402 = "x402" + MPP = "mpp" diff --git a/python/src/pay_kit/_paycore/stablecoin.py b/python/src/pay_kit/_paycore/stablecoin.py new file mode 100644 index 000000000..65ba112f6 --- /dev/null +++ b/python/src/pay_kit/_paycore/stablecoin.py @@ -0,0 +1,17 @@ +"""Stablecoin symbols MPP charge and x402 exact can settle in.""" + +from __future__ import annotations + +from enum import StrEnum + +__all__ = ["Stablecoin"] + + +class Stablecoin(StrEnum): + """SPL stablecoin symbol used as a settlement asset.""" + + USDC = "USDC" + USDT = "USDT" + USDG = "USDG" + PYUSD = "PYUSD" + CASH = "CASH" From 0270a30b84e7dae56872d72e86b56cb39b28f365 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 10:42:42 +0300 Subject: [PATCH 04/45] feat(python): add pay_kit value objects, signer, and operator 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. --- python/src/pay_kit/errors.py | 94 +++++++++ python/src/pay_kit/fee.py | 43 ++++ python/src/pay_kit/kms.py | 35 ++++ python/src/pay_kit/operator.py | 81 ++++++++ python/src/pay_kit/price.py | 113 +++++++++++ python/src/pay_kit/signer.py | 349 +++++++++++++++++++++++++++++++++ 6 files changed, 715 insertions(+) create mode 100644 python/src/pay_kit/errors.py create mode 100644 python/src/pay_kit/fee.py create mode 100644 python/src/pay_kit/kms.py create mode 100644 python/src/pay_kit/operator.py create mode 100644 python/src/pay_kit/price.py create mode 100644 python/src/pay_kit/signer.py diff --git a/python/src/pay_kit/errors.py b/python/src/pay_kit/errors.py new file mode 100644 index 000000000..8870646f5 --- /dev/null +++ b/python/src/pay_kit/errors.py @@ -0,0 +1,94 @@ +"""Exception hierarchy for pay_kit. + +Two families share the :class:`PayKitError` root: + +* Boot-time configuration errors (:class:`ConfigurationError` and friends) + surface invalid gate registries, fee math, signer secrets, or network + config before any request is served. +* Request-time errors (:class:`PaymentRequiredError`, :class:`InvalidProofError`, + :class:`ProtocolNotSupportedError`) carry an :attr:`http_status` so framework + adapters can render the right HTTP response (402 for missing/invalid proof, + 406 for an unsupported protocol). + +``InvalidProofError.code`` carries the canonical cross-SDK L6 error string +(e.g. ``charge_request_mismatch``, ``signature_consumed``). Adapters map the +underlying ``solana_mpp`` ``PaymentError.code`` to these at the boundary via +``solana_mpp._errors.canonical_code``. +""" + +from __future__ import annotations + +__all__ = [ + "PayKitError", + "ConfigurationError", + "DemoSignerOnMainnetError", + "InvalidKeyError", + "MixedCurrenciesError", + "ProtocolIncompatibleError", + "InvalidProofError", + "ChallengeExpiredError", + "PaymentRequiredError", + "ProtocolNotSupportedError", +] + + +class PayKitError(Exception): + """Root of every pay_kit exception; catch this for a generic handler.""" + + +class ConfigurationError(PayKitError): + """Boot-time misconfiguration the operator must resolve before serving.""" + + +class DemoSignerOnMainnetError(ConfigurationError): + """The package-shipped demo signer was paired with solana_mainnet.""" + + +class InvalidKeyError(PayKitError): + """A signer secret could not be parsed (bad JSON/byte length/base58/hex).""" + + +class MixedCurrenciesError(ConfigurationError): + """A gate or price sum mixed amounts denominated in different currencies.""" + + +class ProtocolIncompatibleError(ConfigurationError): + """A gate explicitly accepts a protocol that cannot settle its shape.""" + + +class InvalidProofError(PayKitError): + """A submitted payment proof is structurally valid but failed verification.""" + + def __init__(self, message: str, code: str = "payment_invalid") -> None: + super().__init__(message) + self.code = code + + @property + def http_status(self) -> int: + """HTTP status framework adapters render for an invalid proof.""" + return 402 + + +class ChallengeExpiredError(InvalidProofError): + """A credential's challenge aged past its expiry; re-issue a fresh one.""" + + def __init__(self, message: str = "challenge expired", code: str = "challenge_expired") -> None: + super().__init__(message, code=code) + + +class PaymentRequiredError(PayKitError): + """A request reached a gated route without a valid payment.""" + + @property + def http_status(self) -> int: + """HTTP status framework adapters render when payment is required.""" + return 402 + + +class ProtocolNotSupportedError(PayKitError): + """The client requested a protocol the server's config does not accept.""" + + @property + def http_status(self) -> int: + """HTTP status framework adapters render for an unsupported protocol.""" + return 406 diff --git a/python/src/pay_kit/fee.py b/python/src/pay_kit/fee.py new file mode 100644 index 000000000..709a6b35d --- /dev/null +++ b/python/src/pay_kit/fee.py @@ -0,0 +1,43 @@ +"""A single recipient line on a Gate. + +Either taken ``within`` the gate amount (the payTo recipient nets less) or +``on_top`` of it (the customer pays more, payTo nets the full amount). Frozen +at construction; Gate builds these from its ``fee_within`` / ``fee_on_top`` +arguments. +""" + +from __future__ import annotations + +from typing import Literal + +import pydantic + +from pay_kit.errors import ConfigurationError +from pay_kit.price import Price + +__all__ = ["Fee"] + + +class Fee(pydantic.BaseModel): + """A recipient address and the price they receive, within or on top.""" + + model_config = pydantic.ConfigDict(frozen=True) + + recipient: str + price: Price + kind: Literal["within", "on_top"] + + @pydantic.field_validator("recipient") + @classmethod + def _non_empty_recipient(cls, value: str) -> str: + if not value: + raise ConfigurationError("pay_kit: Fee recipient must be a non-empty string") + return value + + def is_within(self) -> bool: + """Whether this fee is taken out of the gate's amount.""" + return self.kind == "within" + + def is_on_top(self) -> bool: + """Whether this fee is added on top of the gate's amount.""" + return self.kind == "on_top" diff --git a/python/src/pay_kit/kms.py b/python/src/pay_kit/kms.py new file mode 100644 index 000000000..2ca13e4eb --- /dev/null +++ b/python/src/pay_kit/kms.py @@ -0,0 +1,35 @@ +"""Reserved namespace for remote enclave signers (GCP/AWS KMS, HashiCorp Vault). + +The shape is locked so consumers can build against ``pay_kit.kms.gcp(...)`` today +without renaming when the real implementations ship in a follow-up release. Every +factory currently raises :class:`NotImplementedError`. Loud failure is on purpose: +silent fallback to a local in-process signer would mask a production +misconfiguration (a merchant intending to sign through a managed KMS service must +not silently get a local signer instead). + +When implemented, KMS signers will satisfy the same duck-type contract as +:class:`pay_kit.signer.LocalSigner` (``pubkey()``, ``sign(message)``, +``is_fee_payer()``) with explicit ``pubkey=`` configuration so boot does not probe +the enclave. Mirrors Ruby ``PayKit::Kms`` and the PHP reserved ``Kms`` namespace. +""" + +from __future__ import annotations + +__all__ = ["aws", "gcp", "vault"] + +_FOLLOW_UP = "is reserved for a follow-up release; use pay_kit.Signer.file or pay_kit.Signer.env in the meantime" + + +def gcp(*, key_name: str, pubkey: str) -> object: + """Reserved: a Google Cloud KMS signer. Raises until the backend ships.""" + raise NotImplementedError(f"pay_kit.kms.gcp(key_name={key_name!r}, pubkey={pubkey!r}) {_FOLLOW_UP}") + + +def aws(*, key_id: str, region: str, pubkey: str) -> object: + """Reserved: an AWS KMS signer. Raises until the backend ships.""" + raise NotImplementedError(f"pay_kit.kms.aws(key_id={key_id!r}, region={region!r}, pubkey={pubkey!r}) {_FOLLOW_UP}") + + +def vault(*, addr: str, path: str, pubkey: str) -> object: + """Reserved: a HashiCorp Vault transit signer. Raises until the backend ships.""" + raise NotImplementedError(f"pay_kit.kms.vault(addr={addr!r}, path={path!r}, pubkey={pubkey!r}) {_FOLLOW_UP}") diff --git a/python/src/pay_kit/operator.py b/python/src/pay_kit/operator.py new file mode 100644 index 000000000..f7e2de0f0 --- /dev/null +++ b/python/src/pay_kit/operator.py @@ -0,0 +1,81 @@ +"""Merchant identity bundle: recipient, signer, and fee-payer flag.""" + +from __future__ import annotations + +import pydantic + +from pay_kit.errors import ConfigurationError +from pay_kit.signer import LocalSigner, Signer + +__all__ = ["Operator"] + + +class Operator(pydantic.BaseModel): + """Where settled funds land, who signs, and whether the signer pays fees. + + Mirrors the Ruby/PHP ``nil``-as-default-marker convention: a freshly + constructed ``Operator()`` carries ``recipient=None`` / ``signer=None``, + and :meth:`with_defaults` resolves those into the demo signer and the + signer's own pubkey. ``recipient`` then falls back to ``signer.pubkey()`` + via :meth:`effective_recipient`, so a zero-config boot still has a + settlement destination. ``Config`` layers the mainnet-refusal rule on top. + """ + + model_config = pydantic.ConfigDict(frozen=True, arbitrary_types_allowed=True) + + recipient: str | None = None + signer: LocalSigner | None = None + fee_payer: bool = True + + @pydantic.field_validator("recipient", mode="before") + @classmethod + def _validate_recipient(cls, value: object) -> object: + """Reject non-string recipients (``None`` stays as the default marker).""" + if value is None: + return None + if not isinstance(value, str): + raise ConfigurationError(f"operator.recipient must be a str, got {type(value).__name__}") + return value + + @pydantic.field_validator("fee_payer", mode="before") + @classmethod + def _validate_fee_payer(cls, value: object) -> bool: + """Require an exact ``bool``; truthy coercion would mask config bugs.""" + if not isinstance(value, bool): + raise ConfigurationError(f"operator.fee_payer must be true or false, got {value!r}") + return value + + def with_defaults(self) -> Operator: + """Resolve ``None`` markers into shipped defaults. + + ``signer`` defaults to :meth:`Signer.demo`; ``recipient`` defaults to + the resolved signer's pubkey. Returns a new frozen instance. + """ + signer = self.signer if self.signer is not None else Signer.demo() + recipient = self.recipient if self.recipient is not None else signer.pubkey() + return Operator(recipient=recipient, signer=signer, fee_payer=self.fee_payer) + + def effective_recipient(self) -> str: + """The settlement address: explicit ``recipient`` or the signer's pubkey.""" + if self.recipient is not None: + return self.recipient + signer = self.signer if self.signer is not None else Signer.demo() + return signer.pubkey() + + def __eq__(self, other: object) -> bool: + """Equal when resolved recipient, signer pubkey, and fee_payer all match.""" + if not isinstance(other, Operator): + return NotImplemented + return ( + self.effective_recipient() == other.effective_recipient() + and self._signer_pubkey() == other._signer_pubkey() + and self.fee_payer == other.fee_payer + ) + + def __hash__(self) -> int: + """Hash over the resolved identity tuple (matches :meth:`__eq__`).""" + return hash((Operator, self.effective_recipient(), self._signer_pubkey(), self.fee_payer)) + + def _signer_pubkey(self) -> str: + signer = self.signer if self.signer is not None else Signer.demo() + return signer.pubkey() diff --git a/python/src/pay_kit/price.py b/python/src/pay_kit/price.py new file mode 100644 index 000000000..d84802e95 --- /dev/null +++ b/python/src/pay_kit/price.py @@ -0,0 +1,113 @@ +"""Denominated amount plus an ordered settlement-preference list. + +``Price.usd("0.10")`` reads "ten cents USD, settle in whatever the config +prefers". ``Price.usd("0.10", Stablecoin.USDC)`` narrows settlement to USDC. +The variadic stablecoin order is preference; the resolver picks the first coin +it can settle. + +Amounts are :class:`decimal.Decimal`. The factories reject ``float`` at the +signature level (accept ``str | int | Decimal`` only) so binary-float rounding +never touches money. Build via :meth:`Price.usd` / :meth:`Price.eur` / +:meth:`Price.gbp`, never the raw constructor. +""" + +from __future__ import annotations + +import re +from decimal import Decimal, InvalidOperation + +import pydantic + +from pay_kit._paycore.currency import Currency +from pay_kit._paycore.stablecoin import Stablecoin +from pay_kit.errors import ConfigurationError, MixedCurrenciesError + +__all__ = ["Price", "Settlement"] + +_AMOUNT_RE = re.compile(r"^\d+(\.\d+)?$") + + +def _to_decimal(amount: str | int | Decimal) -> Decimal: + """Coerce a money input to Decimal, rejecting float and bad formats.""" + if isinstance(amount, bool): # bool is an int subclass; reject explicitly. + raise ConfigurationError("pay_kit: Price amount must be str | int | Decimal, not bool") + if isinstance(amount, float): + raise ConfigurationError("pay_kit: Price amount must be str | int | Decimal, not float") + if isinstance(amount, Decimal): + return amount + if isinstance(amount, int): + return Decimal(amount) + if isinstance(amount, str): + if not _AMOUNT_RE.match(amount): + raise ConfigurationError(f"pay_kit: invalid Price amount: {amount!r}") + try: + return Decimal(amount) + except InvalidOperation as exc: + raise ConfigurationError(f"pay_kit: invalid Price amount: {amount!r}") from exc + raise ConfigurationError("pay_kit: Price amount must be str | int | Decimal") + + +class Settlement(pydantic.BaseModel): + """A single settlement preference: pay ``amount`` denominated in ``coin``.""" + + model_config = pydantic.ConfigDict(frozen=True) + + coin: Stablecoin + amount: str + + def __str__(self) -> str: + return f"{self.amount} {self.coin.value}" + + +class Price(pydantic.BaseModel): + """Currency-denominated amount with an ordered settlement-coin preference.""" + + model_config = pydantic.ConfigDict(frozen=True) + + amount: Decimal + currency: Currency + settlements: tuple[Stablecoin, ...] = () + + @pydantic.field_validator("amount", mode="before") + @classmethod + def _coerce_amount(cls, value: object) -> Decimal: + return _to_decimal(value) # type: ignore[arg-type] + + @classmethod + def usd(cls, amount: str | int | Decimal, *settlements: Stablecoin) -> Price: + """Build a USD-denominated price.""" + return cls(amount=_to_decimal(amount), currency=Currency.USD, settlements=settlements) + + @classmethod + def eur(cls, amount: str | int | Decimal, *settlements: Stablecoin) -> Price: + """Build a EUR-denominated price.""" + return cls(amount=_to_decimal(amount), currency=Currency.EUR, settlements=settlements) + + @classmethod + def gbp(cls, amount: str | int | Decimal, *settlements: Stablecoin) -> Price: + """Build a GBP-denominated price.""" + return cls(amount=_to_decimal(amount), currency=Currency.GBP, settlements=settlements) + + def with_amount(self, amount: str | int | Decimal) -> Price: + """Return a copy with a new amount, same currency and settlements.""" + return Price(amount=_to_decimal(amount), currency=self.currency, settlements=self.settlements) + + def plus(self, other: Price) -> Price: + """Sum two same-currency prices; raise on a currency mismatch.""" + if self.currency != other.currency: + raise MixedCurrenciesError( + f"pay_kit: cannot sum prices of different currencies ({self.currency.value} vs {other.currency.value})" + ) + return Price( + amount=self.amount + other.amount, + currency=self.currency, + settlements=self.settlements, + ) + + def amount_string(self) -> str: + """The wire-form decimal string, preserving trailing zeros.""" + return format(self.amount, "f") + + def primary_coin(self) -> Stablecoin | None: + """The most-preferred settlement coin, or None to defer to config.""" + return self.settlements[0] if self.settlements else None diff --git a/python/src/pay_kit/signer.py b/python/src/pay_kit/signer.py new file mode 100644 index 000000000..b4ee8763e --- /dev/null +++ b/python/src/pay_kit/signer.py @@ -0,0 +1,349 @@ +"""Local Ed25519 signer family and the ``Signer`` factory namespace. + +Every factory returns a :class:`LocalSigner` that satisfies the pay_kit signer +duck-type contract used by the protocol adapters: + +* ``pubkey()`` -> base58 ``str`` (the 32-byte public key) +* ``sign(message)`` -> 64-byte signature ``bytes`` +* ``is_fee_payer()``-> ``bool`` (``True`` for in-process local signers) +* ``is_demo()`` -> ``bool`` (only ``True`` for :meth:`Signer.demo`) + +Mirrors Ruby ``PayKit::Signer`` and PHP ``PayKit\\Signer`` exactly, including the +auto-detecting :meth:`Signer.env` loader (returns ``None`` when unset/empty so +the Operator null-as-default contract composes, raises on malformed input). + +Remote enclave signers (GCP/AWS KMS, HashiCorp Vault) are reserved under +:mod:`pay_kit.kms` and are not part of this release. +""" + +from __future__ import annotations + +import json +import logging +import os +import warnings +from typing import TYPE_CHECKING + +from solders.keypair import Keypair + +from .errors import InvalidKeyError + +if TYPE_CHECKING: + from collections.abc import Sequence + +__all__ = ["DEMO_PUBKEY", "InvalidKeyError", "LocalSigner", "Signer"] + +logger = logging.getLogger("pay_kit") + +# The package-shipped demo keypair. Same identity across every pay_kit SDK +# (Ruby PayKit::Signer::Demo, PHP PayKit\Signer\Demo, Lua pay_kit.signer.demo) +# so a process running one SDK can exchange traffic with another during local +# dev. Verified: base58(pubkey) of _DEMO_SECRET_BYTES below. +DEMO_PUBKEY = "ALtYSsZuYyKrNSe6GnVCzxj1T2RPMTPzXMe51xhbmXEq" + +# 64-byte secret matching the Ruby / PHP / Lua demo signer byte-for-byte. +_DEMO_SECRET_BYTES: tuple[int, ...] = ( + 26, + 61, + 117, + 192, + 9, + 232, + 24, + 51, + 89, + 135, + 105, + 182, + 47, + 9, + 83, + 244, + 11, + 214, + 85, + 170, + 227, + 83, + 170, + 26, + 55, + 129, + 58, + 114, + 89, + 160, + 195, + 51, + 138, + 209, + 127, + 35, + 54, + 41, + 202, + 166, + 199, + 166, + 97, + 238, + 181, + 63, + 254, + 185, + 45, + 16, + 174, + 102, + 250, + 198, + 30, + 191, + 232, + 236, + 147, + 167, + 41, + 178, + 151, + 26, +) + +_HEX_DIGITS = frozenset("0123456789abcdefABCDEF") + +# Cached demo singleton + one-time-warning guard. +_demo_instance: LocalSigner | None = None +_demo_warned = False + + +class LocalSigner: + """In-process Ed25519 signer over a solders ``Keypair``; no I/O on sign().""" + + __slots__ = ("_is_demo", "_is_fee_payer", "_keypair") + + def __init__( + self, + keypair: Keypair, + *, + is_demo: bool = False, + is_fee_payer: bool = True, + ) -> None: + """Wrap a solders ``Keypair`` with demo / fee-payer flags.""" + self._keypair = keypair + self._is_demo = is_demo + self._is_fee_payer = is_fee_payer + + @property + def keypair(self) -> Keypair: + """The underlying solders ``Keypair`` (used by cosign paths).""" + return self._keypair + + def pubkey(self) -> str: + """Base58-encoded 32-byte public key.""" + return str(self._keypair.pubkey()) + + def sign(self, message: bytes) -> bytes: + """Return the 64-byte Ed25519 signature over ``message``.""" + return bytes(self._keypair.sign_message(message)) + + def is_fee_payer(self) -> bool: + """Whether this signer acts as the transaction fee payer.""" + return self._is_fee_payer + + def is_demo(self) -> bool: + """Whether this is the shipped demo keypair.""" + return self._is_demo + + def secret_key(self) -> bytes: + """Raw 64-byte secret. Reserved for internal cosign paths. + + @internal + """ + return bytes(self._keypair) + + @classmethod + def from_keypair(cls, kp: Keypair, *, is_demo: bool = False) -> LocalSigner: + """Build a signer from an existing solders ``Keypair``.""" + return cls(kp, is_demo=is_demo) + + @classmethod + def from_bytes(cls, secret: bytes | Sequence[int]) -> LocalSigner: + """Build a signer from a 64-byte secret (``bytes`` or 64 ints in [0,255]).""" + raw = _coerce_secret_bytes(secret) + return cls.from_keypair(_keypair_from_bytes(raw)) + + @classmethod + def from_base58(cls, s: str) -> LocalSigner: + """Build a signer from a base58-encoded 64-byte secret (Phantom/Solflare).""" + if not isinstance(s, str) or s == "": + raise InvalidKeyError("pay_kit: Signer.base58 expects a non-empty string") + try: + kp = Keypair.from_base58_string(s) + except (KeyboardInterrupt, SystemExit): + raise + except BaseException as exc: # noqa: BLE001 - solders raises a non-Exception base on bad input + raise InvalidKeyError(f"pay_kit: Signer.base58 invalid base58: {exc}") from ( + exc if isinstance(exc, Exception) else None + ) + return cls.from_keypair(kp) + + @classmethod + def from_hex(cls, s: str) -> LocalSigner: + """Build a signer from a 128-character hex string (64 bytes hex-encoded).""" + if not isinstance(s, str) or len(s) != 128: + length = len(s) if isinstance(s, str) else 0 + raise InvalidKeyError(f"pay_kit: Signer.hex expects 128 chars, got {length}") + if any(ch not in _HEX_DIGITS for ch in s): + raise InvalidKeyError("pay_kit: Signer.hex contains non-hex characters") + try: + raw = bytes.fromhex(s) + except ValueError as exc: + raise InvalidKeyError("pay_kit: Signer.hex decode failed") from exc + return cls.from_keypair(_keypair_from_bytes(raw)) + + @classmethod + def generate(cls) -> LocalSigner: + """Generate a fresh ephemeral keypair (test-only utility).""" + return cls.from_keypair(Keypair()) + + +class Signer: + """Factory namespace for local Ed25519 signers (static methods only).""" + + def __init__(self) -> None: # pragma: no cover - factory is not instantiated + raise TypeError("Signer is a factory namespace and cannot be instantiated") + + @staticmethod + def demo() -> LocalSigner: + """Return the cached shipped demo signer; warns once per process.""" + global _demo_instance, _demo_warned + if _demo_instance is None: + _demo_instance = LocalSigner( + _keypair_from_bytes(bytes(_DEMO_SECRET_BYTES)), + is_demo=True, + ) + if not _demo_warned: + _demo_warned = True + warnings.warn( + f"pay_kit: using the shipped demo signer ({DEMO_PUBKEY}); never use it on solana_mainnet", + stacklevel=2, + ) + logger.warning( + "pay_kit: using the shipped demo signer (%s); never use it on solana_mainnet", + DEMO_PUBKEY, + ) + return _demo_instance + + @staticmethod + def bytes(secret: bytes | Sequence[int]) -> LocalSigner: + """Build a signer from a 64-byte secret (``bytes`` or 64 ints).""" + return LocalSigner.from_bytes(secret) + + @staticmethod + def json(json_array: str) -> LocalSigner: + """Build a signer from a Solana-CLI JSON-array string ``"[1,2,...,64]"``.""" + if not isinstance(json_array, str): + raise InvalidKeyError("pay_kit: Signer.json expects a string") + trimmed = json_array.strip() + if trimmed == "": + raise InvalidKeyError("pay_kit: Signer.json received empty input") + try: + decoded = json.loads(trimmed) + except (json.JSONDecodeError, ValueError) as exc: + raise InvalidKeyError(f"pay_kit: malformed Solana CLI JSON-array keypair: {exc}") from exc + if not isinstance(decoded, list): + raise InvalidKeyError("pay_kit: Signer.json expected a JSON array") + return LocalSigner.from_bytes(decoded) + + @staticmethod + def base58(s: str) -> LocalSigner: + """Build a signer from a base58-encoded 64-byte secret.""" + return LocalSigner.from_base58(s) + + @staticmethod + def hex(s: str) -> LocalSigner: + """Build a signer from a 128-character hex string.""" + return LocalSigner.from_hex(s) + + @staticmethod + def file(path: str) -> LocalSigner: + """Read a Solana-CLI JSON-array keypair file and build a signer.""" + if not isinstance(path, str) or path == "": + raise InvalidKeyError("pay_kit: Signer.file expects a non-empty path") + try: + with open(path, encoding="utf-8") as handle: + raw = handle.read() + except (OSError, ValueError) as exc: + raise InvalidKeyError(f"pay_kit: Signer.file cannot read {path}: {exc}") from exc + return Signer.json(raw) + + @staticmethod + def env(name: str) -> LocalSigner | None: + """Auto-detect a keypair from env var ``name``. + + Returns ``None`` when the variable is unset or empty so the caller's + default (typically :meth:`Signer.demo`) survives the assignment chain. + Raises :class:`InvalidKeyError` when the variable IS set but cannot be + parsed as JSON-array / hex / base58, because silent fallback would mask + a real misconfiguration. + """ + if not isinstance(name, str) or name == "": + raise InvalidKeyError("pay_kit: Signer.env expects a non-empty name") + raw = os.environ.get(name) + if raw is None or raw == "": + return None + trimmed = raw.strip() + if trimmed == "": + return None + if trimmed.startswith("["): + return Signer.json(trimmed) + if len(trimmed) == 128 and all(ch in _HEX_DIGITS for ch in trimmed): + return Signer.hex(trimmed) + return Signer.base58(trimmed) + + @staticmethod + def generate() -> LocalSigner: + """Generate a fresh ephemeral keypair (test-only utility).""" + return LocalSigner.generate() + + +def _coerce_secret_bytes(secret: bytes | Sequence[int]) -> bytes: + """Validate and coerce a secret into exactly 64 raw bytes.""" + if isinstance(secret, bytes | bytearray): + if len(secret) != 64: + raise InvalidKeyError(f"pay_kit: Signer.bytes expects a 64-byte secret, got {len(secret)} bytes") + return bytes(secret) + if isinstance(secret, str): + raise InvalidKeyError("pay_kit: Signer.bytes expects bytes or a sequence of ints, not str") + try: + items = list(secret) + except TypeError as exc: + raise InvalidKeyError("pay_kit: Signer.bytes expects bytes or a sequence of ints") from exc + if len(items) != 64: + raise InvalidKeyError(f"pay_kit: Signer.bytes expects 64 integers, got {len(items)}") + for i, value in enumerate(items): + if not isinstance(value, int) or isinstance(value, bool) or value < 0 or value > 255: + raise InvalidKeyError(f"pay_kit: Signer.bytes[{i}] must be an int in [0,255]") + return bytes(items) + + +def _keypair_from_bytes(raw: bytes) -> Keypair: + """Build a solders ``Keypair`` from 64 raw secret bytes.""" + try: + return Keypair.from_bytes(raw) + except (KeyboardInterrupt, SystemExit): + raise + except BaseException as exc: # noqa: BLE001 - solders raises non-Exception on bad bytes + raise InvalidKeyError(f"pay_kit: invalid 64-byte Ed25519 keypair: {exc}") from ( + exc if isinstance(exc, Exception) else None + ) + + +def _reset_demo_for_tests() -> None: + """Reset the cached demo singleton + warning guard so the next call rebuilds. + + @internal + """ + global _demo_instance, _demo_warned + _demo_instance = None + _demo_warned = False From dd318a72524c37bf8834d53e52c5018fdaf5c6fe Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 10:42:42 +0300 Subject: [PATCH 05/45] feat(python): add pay_kit gate, pricing, and configuration 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. --- python/src/pay_kit/config.py | 411 ++++++++++++++++++++++++++++++++++ python/src/pay_kit/gate.py | 315 ++++++++++++++++++++++++++ python/src/pay_kit/pricing.py | 121 ++++++++++ 3 files changed, 847 insertions(+) create mode 100644 python/src/pay_kit/config.py create mode 100644 python/src/pay_kit/gate.py create mode 100644 python/src/pay_kit/pricing.py diff --git a/python/src/pay_kit/config.py b/python/src/pay_kit/config.py new file mode 100644 index 000000000..e04f1b0a5 --- /dev/null +++ b/python/src/pay_kit/config.py @@ -0,0 +1,411 @@ +"""Boot-time configuration: ``configure`` builder, sub-configs, and singleton. + +The public ``configure(**kwargs)`` entry point builds a frozen :class:`Config`, +runs the operator default-resolution, applies the boot-time safety rules +(demo-signer-on-mainnet refusal, public-mainnet-RPC warning), auto-resolves the +MPP HMAC challenge-binding secret when unset, and runs the live-RPC preflight +unless disabled. It then stores the result in a module-level singleton readable +through :func:`config`. ``configure_from`` drives the same builder from +environment variables via pydantic-settings. + +Mirrors Ruby ``PayKit::Config`` (``VALID_NETWORKS``, ``PUBLIC_RPC_URLS``, +``deprecation_warning_for`` warn-once) and PHP ``PayKit\\Config``. Deprecated +field/env names route to the new surface and emit a one-time ``DeprecationWarning`` +plus a ``logging`` record per key. +""" + +from __future__ import annotations + +import logging +import warnings +from typing import Any, Literal + +import pydantic +import pydantic_settings + +from pay_kit._paycore.network import Network +from pay_kit._paycore.protocol import Protocol +from pay_kit._paycore.stablecoin import Stablecoin +from pay_kit.errors import ConfigurationError, DemoSignerOnMainnetError +from pay_kit.operator import Operator +from pay_kit.signer import LocalSigner + +__all__ = [ + "Config", + "X402Config", + "MppConfig", + "configure", + "configure_from", + "config", + "reset", +] + +logger = logging.getLogger("pay_kit") + +# Module-level singleton. ``None`` until the first ``configure``/``config`` call. +_config: Config | None = None + +# Warn-once memo for deprecated kwarg/field/env names. Keyed by the canonical +# deprecation key so a setter used in a loop logs at most one warning per process +# (mirrors Ruby ``Config.deprecation_warning_for``). +_warned_deprecations: set[str] = set() + +# Old kwarg name -> (canonical key, human-readable migration suggestion). Routed +# by the builder before constructing the immutable Config. +_DEPRECATED_KWARGS: dict[str, tuple[str, str]] = { + "pay_to": ( + "pay_to", + "use operator=Operator(recipient=...)", + ), + "facilitator": ( + "x402.facilitator", + "this field historically held the Solana RPC URL; use rpc_url instead. " + "The new x402.facilitator_url is for facilitator delegation only.", + ), + "facilitator_secret_key": ( + "x402.facilitator_secret_key", + "use operator=Operator(signer=Signer.json(...)) or x402=X402Config(signer=...)", + ), + "secret": ( + "mpp.secret", + "use mpp=MppConfig(challenge_binding_secret=...) (matches draft-httpauth-payment-00 spec vocabulary)", + ), +} + + +def _deprecation_warning_for(key: str, suggestion: str) -> None: + """Emit a one-time ``DeprecationWarning`` + log record for a deprecated key.""" + if key in _warned_deprecations: + return + _warned_deprecations.add(key) + message = f"pay_kit: configure({key}=...) is deprecated; {suggestion}" + warnings.warn(message, DeprecationWarning, stacklevel=3) + logger.warning(message) + + +class X402Config(pydantic.BaseModel): + """x402-protocol knobs: facilitator delegation, scheme, and signer override.""" + + model_config = pydantic.ConfigDict(frozen=True, arbitrary_types_allowed=True) + + facilitator_url: str | None = None + scheme: Literal["exact"] = "exact" + signer: LocalSigner | None = None + + def is_delegated(self) -> bool: + """``True`` when a non-empty facilitator URL routes verify/settle off-host.""" + return self.facilitator_url is not None and self.facilitator_url != "" + + def effective_signer(self, operator: Operator) -> LocalSigner | None: + """The x402 cosigner: the explicit override or the operator's signer.""" + return self.signer if self.signer is not None else operator.signer + + +class MppConfig(pydantic.BaseModel): + """MPP-protocol knobs: realm label, challenge-binding secret, expiry window.""" + + model_config = pydantic.ConfigDict(frozen=True) + + realm: str = "App" + challenge_binding_secret: str | None = None + expires_in: int = 120 + + @pydantic.field_validator("expires_in") + @classmethod + def _validate_expires_in(cls, value: int) -> int: + """Reject a non-positive expiry window (the challenge would never be valid).""" + if value <= 0: + raise ConfigurationError(f"mpp.expires_in must be a positive number of seconds, got {value}") + return value + + def with_challenge_binding_secret(self, secret: str) -> MppConfig: + """Return a copy carrying the resolved HMAC challenge-binding secret.""" + return self.model_copy(update={"challenge_binding_secret": secret}) + + +class Config(pydantic.BaseModel): + """Immutable boot-time configuration; build via :func:`configure`.""" + + model_config = pydantic.ConfigDict(frozen=True, arbitrary_types_allowed=True) + + network: Network = Network.SOLANA_LOCALNET + accept: tuple[Protocol, ...] = (Protocol.X402, Protocol.MPP) + stablecoins: tuple[Stablecoin, ...] = (Stablecoin.USDC,) + rpc_url: str | None = None + operator: Operator = Operator() + x402: X402Config = X402Config() + mpp: MppConfig = MppConfig() + preflight: bool = True + + @pydantic.field_validator("accept", mode="before") + @classmethod + def _coerce_accept(cls, value: object) -> object: + """Accept a single ``Protocol`` or any iterable; normalise to a tuple.""" + if value is None: + return value + if isinstance(value, Protocol | str): + return (value,) + return tuple(value) # type: ignore[arg-type] + + @pydantic.field_validator("stablecoins", mode="before") + @classmethod + def _coerce_stablecoins(cls, value: object) -> object: + """Accept a single ``Stablecoin`` or any iterable; normalise to a tuple.""" + if value is None: + return value + if isinstance(value, Stablecoin | str): + return (value,) + return tuple(value) # type: ignore[arg-type] + + @pydantic.field_validator("accept") + @classmethod + def _validate_accept(cls, value: tuple[Protocol, ...]) -> tuple[Protocol, ...]: + """Require a non-empty, de-duplicated accept preference list.""" + if not value: + raise ConfigurationError("pay_kit: accept must not be empty") + seen: list[Protocol] = [] + for protocol in value: + if protocol not in seen: + seen.append(protocol) + return tuple(seen) + + @pydantic.field_validator("stablecoins") + @classmethod + def _validate_stablecoins(cls, value: tuple[Stablecoin, ...]) -> tuple[Stablecoin, ...]: + """Require a non-empty, de-duplicated settlement preference list.""" + if not value: + raise ConfigurationError("pay_kit: stablecoins must not be empty") + seen: list[Stablecoin] = [] + for coin in value: + if coin not in seen: + seen.append(coin) + return tuple(seen) + + def effective_rpc_url(self) -> str: + """The active Solana RPC URL: explicit override or the network default.""" + if self.rpc_url is not None and self.rpc_url != "": + return self.rpc_url + return self.network.default_rpc_url() + + def effective_recipient(self) -> str: + """The operator's settlement address, post default-resolution.""" + return self.operator.effective_recipient() + + def effective_x402_signer(self) -> LocalSigner | None: + """The x402 cosigner: x402 override falling back to the operator signer.""" + return self.x402.effective_signer(self.operator) + + def using_public_rpc_default(self) -> bool: + """``True`` when no explicit ``rpc_url`` was set (public RPC in use).""" + return self.rpc_url is None or self.rpc_url == "" + + +def _resolve_mpp_secret_if_needed(cfg: Config) -> Config: + """Auto-resolve the MPP challenge-binding secret when the caller left it unset. + + Mirrors PHP ``Config::__construct`` caveat #4: env -> ./.env -> generate + + persist. Skipped when preflight is off (tests / read-only deploys) so the + suite does not leak a generated ``.env`` file. The resolution chain itself + lives in ``protocols.mpp.SecretResolver``; imported lazily so this layer-C + module does not hard-depend on the layer-D adapter at import time. + """ + secret = cfg.mpp.challenge_binding_secret + if secret is not None and secret != "": + return cfg + if not cfg.preflight: + return cfg + + from pay_kit import preflight # noqa: I001 + from pay_kit.protocols.mpp import SecretResolver # noqa: I001 + + if preflight.is_disabled_by_env(): + return cfg + + resolved, _source, _persisted = SecretResolver.resolve_mpp_secret() + return cfg.model_copy(update={"mpp": cfg.mpp.with_challenge_binding_secret(resolved)}) + + +def _enforce_demo_signer_on_mainnet(cfg: Config) -> None: + """Refuse to boot the shipped demo signer against solana_mainnet.""" + if cfg.network is not Network.SOLANA_MAINNET: + return + signer = cfg.operator.signer + if signer is not None and signer.is_demo(): + raise DemoSignerOnMainnetError( + "pay_kit: the package-shipped demo signer " + f"({signer.pubkey()}) refuses to start on solana_mainnet. " + "Load a real keypair via Signer.file() or Signer.env()." + ) + + +def _warn_about_public_mainnet_rpc(cfg: Config) -> None: + """Warn when mainnet silently falls back to the rate-limited public RPC.""" + if cfg.network is not Network.SOLANA_MAINNET: + return + if not cfg.using_public_rpc_default(): + return + logger.warning( + "pay_kit: network=solana_mainnet uses the public Solana RPC by default. " + "Public mainnet RPC is rate-limited and unsuitable for production traffic. " + "Set rpc_url to a dedicated endpoint (Helius, QuickNode, your own validator)." + ) + + +def _run_preflight(cfg: Config) -> None: + """Run the live-RPC boot preflight unless disabled by kwarg or env (caveat #3).""" + if not cfg.preflight: + return + from pay_kit import preflight + + if preflight.is_disabled_by_env(): + return + preflight.run(cfg) + + +def _apply_deprecated_kwargs(kwargs: dict[str, Any]) -> None: + """Route deprecated kwarg names onto the new surface, warning once per key. + + Mutates ``kwargs`` in place: pops each legacy key, emits its deprecation + warning, and folds the value into the modern ``operator`` / ``rpc_url`` / + ``mpp`` / ``x402`` shape without clobbering an explicit modern value. + """ + if "pay_to" in kwargs: + key, suggestion = _DEPRECATED_KWARGS["pay_to"] + _deprecation_warning_for(key, suggestion) + pay_to = kwargs.pop("pay_to") + if "operator" not in kwargs and pay_to is not None and pay_to != "": + kwargs["operator"] = Operator(recipient=pay_to) + + if "facilitator" in kwargs: + key, suggestion = _DEPRECATED_KWARGS["facilitator"] + _deprecation_warning_for(key, suggestion) + facilitator = kwargs.pop("facilitator") + if "rpc_url" not in kwargs and facilitator is not None and facilitator != "": + kwargs["rpc_url"] = facilitator + + if "facilitator_secret_key" in kwargs: + key, suggestion = _DEPRECATED_KWARGS["facilitator_secret_key"] + _deprecation_warning_for(key, suggestion) + raw = kwargs.pop("facilitator_secret_key") + # The legacy field used "[]" / "" as a "boot without a real signer" + # sentinel (mpp-only demos). The modern operator default is the demo + # signer, so an empty literal is a no-op rather than a parse failure. + stripped = raw.strip() if isinstance(raw, str) else raw + if "operator" not in kwargs and raw is not None and stripped not in ("", "[]"): + from pay_kit.signer import Signer + + kwargs["operator"] = Operator(signer=Signer.json(raw)) + + if "secret" in kwargs: + key, suggestion = _DEPRECATED_KWARGS["secret"] + _deprecation_warning_for(key, suggestion) + secret = kwargs.pop("secret") + if "mpp" not in kwargs and secret is not None and secret != "": + kwargs["mpp"] = MppConfig(challenge_binding_secret=secret) + + +def _build_config(**kwargs: Any) -> Config: + """Construct, default-resolve, validate, and finalize an immutable Config.""" + _apply_deprecated_kwargs(kwargs) + + operator = kwargs.pop("operator", None) + if operator is None: + operator = Operator() + elif not isinstance(operator, Operator): + raise ConfigurationError(f"pay_kit: operator must be a pay_kit.Operator, got {type(operator).__name__}") + resolved_operator = operator.with_defaults() + + cfg = Config(operator=resolved_operator, **kwargs) + + _enforce_demo_signer_on_mainnet(cfg) + _warn_about_public_mainnet_rpc(cfg) + cfg = _resolve_mpp_secret_if_needed(cfg) + _run_preflight(cfg) + return cfg + + +def configure(**kwargs: Any) -> Config: + """Build the global config, run boot safety checks, and store the singleton. + + Accepts the modern surface (``network``, ``accept``, ``stablecoins``, + ``rpc_url``, ``operator``, ``x402``, ``mpp``, ``preflight``) plus one-release + deprecation shims (``pay_to``, ``facilitator``, ``facilitator_secret_key``, + ``secret``) that warn once and route onto the modern fields. Returns the + frozen :class:`Config`, also readable via :func:`config`. + """ + global _config + cfg = _build_config(**kwargs) + _config = cfg + return cfg + + +class _Settings(pydantic_settings.BaseSettings): + """Environment-driven view of the modern Config scalar knobs.""" + + model_config = pydantic_settings.SettingsConfigDict( + env_prefix="PAY_KIT_", + extra="ignore", + case_sensitive=False, + ) + + network: Network | None = None + rpc_url: str | None = None + accept: tuple[Protocol, ...] | None = None + stablecoins: tuple[Stablecoin, ...] | None = None + preflight: bool | None = None + mpp_realm: str | None = None + mpp_challenge_binding_secret: str | None = None + mpp_expires_in: int | None = None + x402_facilitator_url: str | None = None + + +def configure_from(env_prefix: str = "PAY_KIT_") -> Config: + """Build and store the global config from ``{env_prefix}``-prefixed env vars. + + Reads scalar knobs (network, rpc_url, accept, stablecoins, preflight) and the + nested ``MPP_*`` / ``X402_*`` overrides via pydantic-settings, then funnels + them through :func:`configure` so the same validation and boot checks apply. + """ + settings = _Settings(_env_prefix=env_prefix) # type: ignore[call-arg] + + kwargs: dict[str, Any] = {} + if settings.network is not None: + kwargs["network"] = settings.network + if settings.rpc_url is not None: + kwargs["rpc_url"] = settings.rpc_url + if settings.accept is not None: + kwargs["accept"] = settings.accept + if settings.stablecoins is not None: + kwargs["stablecoins"] = settings.stablecoins + if settings.preflight is not None: + kwargs["preflight"] = settings.preflight + + mpp_updates: dict[str, Any] = {} + if settings.mpp_realm is not None: + mpp_updates["realm"] = settings.mpp_realm + if settings.mpp_challenge_binding_secret is not None: + mpp_updates["challenge_binding_secret"] = settings.mpp_challenge_binding_secret + if settings.mpp_expires_in is not None: + mpp_updates["expires_in"] = settings.mpp_expires_in + if mpp_updates: + kwargs["mpp"] = MppConfig(**mpp_updates) + + if settings.x402_facilitator_url is not None: + kwargs["x402"] = X402Config(facilitator_url=settings.x402_facilitator_url) + + return configure(**kwargs) + + +def config() -> Config: + """Return the global config, lazily constructing the zero-config default once.""" + global _config + if _config is None: + _config = _build_config() + return _config + + +def reset() -> None: + """Drop the global config and the deprecation warn-once memo (test hook).""" + global _config + _config = None + _warned_deprecations.clear() diff --git a/python/src/pay_kit/gate.py b/python/src/pay_kit/gate.py new file mode 100644 index 000000000..1981e8b41 --- /dev/null +++ b/python/src/pay_kit/gate.py @@ -0,0 +1,315 @@ +"""A single protected unit: amount, optional fees, and accepted protocols. + +A :class:`Gate` is a frozen value object built once at boot. It carries the +base :class:`~pay_kit.price.Price`, an optional ``pay_to`` override (falling +back to the configured operator recipient), an ordered list of accepted +protocols, and zero or more named fees (``within`` / ``on_top``). + +All validation runs in :meth:`Gate.build`; misconfigured gates die at boot, +not at request time. The rules mirror the Ruby/PHP reference: + +1. Fixed :class:`Price` amounts only (Decimal under the hood; no floats). +2. One main recipient via ``pay_to`` (defaults to the operator recipient). +3. All fee prices share the gate amount's currency. +4. ``sum(fee_within) <= amount``. +5. No fee recipient may equal ``pay_to`` (fold it into the amount instead), + and fee recipients must be unique. +6. x402 is auto-disabled when fees are present (stock x402 facilitators + settle to a single address); an explicit ``accept=(Protocol.X402,)`` on a + fee-bearing gate raises :class:`~pay_kit.errors.ProtocolIncompatibleError`, + as does a fee-bearing gate whose accept list collapses to empty. + +:func:`dynamic` wraps a request-evaluated builder in a :class:`DynamicGate`, +which resolves to a fully validated :class:`Gate` per request. +""" + +from __future__ import annotations + +from collections.abc import Callable +from decimal import Decimal +from typing import Any + +import pydantic + +from pay_kit._paycore.protocol import Protocol +from pay_kit.errors import ( + ConfigurationError, + MixedCurrenciesError, + ProtocolIncompatibleError, +) +from pay_kit.fee import Fee +from pay_kit.price import Price + +__all__ = ["Gate", "DynamicGate", "dynamic"] + + +def _build_fees( + fee_within: dict[str, Price] | None, + fee_on_top: dict[str, Price] | None, +) -> tuple[Fee, ...]: + """Coerce the ``{recipient: Price}`` fee maps into an ordered Fee tuple.""" + fees: list[Fee] = [] + for kind, mapping in (("within", fee_within), ("on_top", fee_on_top)): + if mapping is None: + continue + if not isinstance(mapping, dict): + raise ConfigurationError(f"pay_kit: fee_{kind} must be a dict of {{recipient: Price}}") + for recipient, price in mapping.items(): + if not isinstance(recipient, str) or not recipient: + raise ConfigurationError(f"pay_kit: fee_{kind} recipient must be a non-empty string, got {recipient!r}") + if not isinstance(price, Price): + raise ConfigurationError( + f"pay_kit: fee_{kind} price for {recipient!r} must be a Price (use usd/eur/gbp)" + ) + fees.append(Fee(recipient=recipient, price=price, kind=kind)) # type: ignore[arg-type] + return tuple(fees) + + +def _resolve_accept( + name: str, + accept: tuple[Protocol, ...] | None, + has_fees: bool, +) -> tuple[Protocol, ...] | None: + """Apply the x402-vs-fees rule and return the effective accept tuple. + + ``None`` means "inherit from Config". When fees are present, x402 is + stripped silently from an inherited list; an explicit x402 in the accept + list raises, and an accept list that collapses to empty raises. + """ + if not has_fees: + return accept + + if accept is None: + # Inherited accept; the resolver/middleware strips x402 on a + # fee-bearing gate. Leave None so Config's list is honored there. + return None + + if Protocol.X402 in accept: + raise ProtocolIncompatibleError( + f"pay_kit: gate {name!r}: x402 cannot be combined with fees " + f"(stock x402 facilitators settle to a single address). " + f"Drop Protocol.X402 from accept or remove the fees." + ) + if not accept: + raise ProtocolIncompatibleError( + f"pay_kit: gate {name!r}: fees present and x402 auto-disabled, " + f"no remaining accepted protocols (add Protocol.MPP to accept)" + ) + return accept + + +class Gate(pydantic.BaseModel): + """A frozen, fully validated protected unit built via :meth:`Gate.build`.""" + + model_config = pydantic.ConfigDict(frozen=True) + + name: str + amount: Price + pay_to: str | None = None + accept: tuple[Protocol, ...] | None = None + description: str | None = None + external_id: str | None = None + fees: tuple[Fee, ...] = () + + @classmethod + def build( + cls, + *, + name: str, + amount: Price, + pay_to: str | None = None, + accept: tuple[Protocol, ...] | None = None, + description: str | None = None, + external_id: str | None = None, + fee_within: dict[str, Price] | None = None, + fee_on_top: dict[str, Price] | None = None, + default_pay_to: str | None = None, + accept_default: tuple[Protocol, ...] | None = None, + ) -> Gate: + """Build a Gate with full boot validation. + + ``default_pay_to`` and ``accept_default`` carry the resolved Config + defaults the DSL omits; ``pay_to`` and ``accept`` override them. + Raises :class:`~pay_kit.errors.ConfigurationError` (and subclasses) on + any rule violation so misconfiguration fails at boot. + """ + if not isinstance(name, str) or not name: + raise ConfigurationError(f"pay_kit: gate name must be a non-empty string, got {name!r}") + if not isinstance(amount, Price): + raise ConfigurationError(f"pay_kit: gate {name!r}: amount must be a Price (use usd/eur/gbp)") + + resolved_pay_to = pay_to if pay_to is not None else default_pay_to + if not isinstance(resolved_pay_to, str) or not resolved_pay_to: + raise ConfigurationError( + f"pay_kit: gate {name!r}: pay_to is required (set it on the gate or configure an operator recipient)" + ) + + fees = _build_fees(fee_within, fee_on_top) + + cls._validate_fee_recipients(name, resolved_pay_to, fees) + cls._validate_denominations(name, amount, fees) + cls._validate_within_sum(name, amount, fees) + + requested = accept if accept is not None else accept_default + if accept is None and accept_default is not None and not accept_default: + raise ConfigurationError(f"pay_kit: gate {name!r}: accept resolved to an empty list") + # When an explicit accept is given for a fee gate it is validated by + # _resolve_accept; an inherited list is left as-is (None or default). + resolved_accept = _resolve_accept(name, requested, bool(fees)) + + return cls( + name=name, + amount=amount, + pay_to=resolved_pay_to, + accept=resolved_accept, + description=description, + external_id=external_id, + fees=fees, + ) + + def total(self) -> Price: + """The amount the customer pays: base amount plus all on-top fees.""" + total: Decimal = self.amount.amount + for fee in self.fees: + if fee.is_on_top(): + total += fee.price.amount + return self.amount.with_amount(total) + + def payout(self, address: str) -> Price | None: + """What ``address`` nets, or ``None`` if this gate does not address it. + + ``pay_to`` nets the amount minus all ``within`` fees; a fee recipient + nets their fee price; any other address returns ``None``. + """ + if self.pay_to == address: + net: Decimal = self.amount.amount + for fee in self.fees: + if fee.is_within(): + net -= fee.price.amount + return self.amount.with_amount(net) + for fee in self.fees: + if fee.recipient == address: + return fee.price + return None + + def has_fees(self) -> bool: + """Whether this gate carries any fees.""" + return len(self.fees) > 0 + + def x402_accepted(self) -> bool: + """Whether x402 is in the resolved accept list.""" + return self.accept is not None and Protocol.X402 in self.accept + + def mpp_accepted(self) -> bool: + """Whether MPP is in the resolved accept list.""" + return self.accept is not None and Protocol.MPP in self.accept + + @staticmethod + def _validate_fee_recipients(name: str, pay_to: str, fees: tuple[Fee, ...]) -> None: + """Rule 5: no fee recipient may equal pay_to and recipients are unique.""" + seen: set[str] = set() + for fee in fees: + if fee.recipient == pay_to: + raise ConfigurationError( + f"pay_kit: gate {name!r}: fee recipient {pay_to!r} duplicates " + f"pay_to; fold the fee into the amount instead" + ) + if fee.recipient in seen: + raise ConfigurationError(f"pay_kit: gate {name!r}: duplicate fee recipient {fee.recipient!r}") + seen.add(fee.recipient) + + @staticmethod + def _validate_denominations(name: str, amount: Price, fees: tuple[Fee, ...]) -> None: + """Rule 3: every fee price shares the gate amount's currency.""" + for fee in fees: + if fee.price.currency != amount.currency: + raise MixedCurrenciesError( + f"pay_kit: gate {name!r}: fee for {fee.recipient!r} is " + f"{fee.price.currency.value}, gate amount is {amount.currency.value}; " + f"all prices on a gate must share one denomination" + ) + + @staticmethod + def _validate_within_sum(name: str, amount: Price, fees: tuple[Fee, ...]) -> None: + """Rule 4: sum of within fees must not exceed the gate amount.""" + within_sum = sum( + (fee.price.amount for fee in fees if fee.is_within()), + start=Decimal(0), + ) + if within_sum > amount.amount: + raise ConfigurationError( + f"pay_kit: gate {name!r}: sum(fee_within) = {within_sum} exceeds amount {amount.amount_string()}" + ) + + +class DynamicGate: + """A request-evaluated gate: a builder callable resolves to a Gate per request. + + Deliberately exposes no ``has_fees`` method: a dynamic gate cannot answer + "do I have fees?" without a request to evaluate the builder against. + Callers must :meth:`resolve` first. + """ + + __slots__ = ("name", "accept", "description", "_builder", "_defaults") + + def __init__( + self, + *, + name: str, + accept: tuple[Protocol, ...] | None, + description: str | None, + builder: Callable[[Any], Gate], + defaults: dict[str, Any] | None = None, + ) -> None: + self.name = name + self.accept = accept + self.description = description + self._builder = builder + self._defaults = defaults or {} + + def resolve(self, request: Any) -> Gate: + """Run the builder against ``request`` and return a validated Gate. + + The builder may return a :class:`Gate` directly or a :class:`Price`, + in which case a Gate is constructed from it with the dynamic gate's + accept/description and the resolved Config defaults. + """ + result = self._builder(request) + if isinstance(result, Gate): + return result + if isinstance(result, Price): + return Gate.build( + name=self.name, + amount=result, + accept=self.accept, + description=self.description, + default_pay_to=self._defaults.get("pay_to"), + accept_default=self._defaults.get("accept"), + ) + raise ConfigurationError( + f"pay_kit: dynamic gate {self.name!r}: builder must return a Gate or a Price, got {type(result).__name__}" + ) + + +def dynamic( + name: str, + *, + accept: tuple[Protocol, ...] | None = None, + description: str | None = None, +) -> Callable[[Callable[[Any], Gate]], DynamicGate]: + """Decorator turning a request-builder callable into a :class:`DynamicGate`. + + The decorated function receives the request and returns a :class:`Gate` + (or a :class:`Price` to be wrapped). Config defaults are applied lazily at + :meth:`DynamicGate.resolve` time by the middleware that owns the request. + """ + + def _wrap(builder: Callable[[Any], Gate]) -> DynamicGate: + return DynamicGate( + name=name, + accept=accept, + description=description, + builder=builder, + ) + + return _wrap diff --git a/python/src/pay_kit/pricing.py b/python/src/pay_kit/pricing.py new file mode 100644 index 000000000..de3d2d7a5 --- /dev/null +++ b/python/src/pay_kit/pricing.py @@ -0,0 +1,121 @@ +"""Catalogue-style gate registry plus a coercion helper. + +:class:`Pricing` is an optional base class for declaring a named catalogue of +gates. Two equally supported shapes: + +1. Subclass :class:`Pricing` and assign :class:`~pay_kit.gate.Gate` (or + :class:`~pay_kit.gate.DynamicGate`) instances as attributes in + ``__init__``. Container- and IDE-friendly:: + + class Catalog(Pricing): + def __init__(self) -> None: + self.report = Gate.build(name="report", amount=Price.usd("0.10"), ...) + + then ``catalog.gate("report")`` resolves by string handle (used by the + framework middleware alias forms that are string-only). + +2. Build gates inline and pass them straight to the middleware without ever + touching this class. + +:func:`coerce` funnels the assorted middleware argument shapes (a registered +name, an inline :class:`~pay_kit.gate.Gate` / :class:`~pay_kit.gate.DynamicGate`, +or a bare :class:`~pay_kit.price.Price`) through one resolution path, applying +Config defaults to inline prices. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import TYPE_CHECKING + +from pay_kit._paycore.protocol import Protocol +from pay_kit.errors import ConfigurationError +from pay_kit.gate import DynamicGate, Gate +from pay_kit.price import Price + +if TYPE_CHECKING: + from pay_kit.config import Config + +__all__ = ["Pricing", "coerce"] + +#: Argument shapes the middleware accepts and :func:`coerce` resolves. +GateRef = "Gate | DynamicGate | Price | str" + + +class Pricing: + """A named registry of gates resolvable by string handle. + + The default :meth:`gate` introspects public attributes: subclass and + assign :class:`~pay_kit.gate.Gate` / :class:`~pay_kit.gate.DynamicGate` + instances in ``__init__``. Iteration and membership operate over those + declared gate attributes. + """ + + def gate(self, name: str) -> Gate | DynamicGate: + """Resolve a gate by attribute name; raise on an unknown or non-gate name.""" + if not hasattr(self, name): + raise ConfigurationError(f"pay_kit: Pricing has no gate {name!r}") + value = getattr(self, name) + if not isinstance(value, (Gate, DynamicGate)): + raise ConfigurationError(f"pay_kit: Pricing attribute {name!r} is not a Gate") + return value + + def _gate_attrs(self) -> dict[str, Gate | DynamicGate]: + """The public attributes that hold gates, keyed by attribute name.""" + attrs: dict[str, Gate | DynamicGate] = {} + for key, value in vars(self).items(): + if key.startswith("_"): + continue + if isinstance(value, (Gate, DynamicGate)): + attrs[key] = value + return attrs + + def __contains__(self, name: object) -> bool: + """Whether ``name`` resolves to a declared gate attribute.""" + if not isinstance(name, str): + return False + return isinstance(getattr(self, name, None), (Gate, DynamicGate)) + + def __iter__(self) -> Iterator[Gate | DynamicGate]: + """Iterate over the declared gate instances.""" + return iter(self._gate_attrs().values()) + + +def coerce( + arg: Gate | DynamicGate | Price | str, + *, + registry: Pricing | None = None, + config: Config | None = None, +) -> Gate | DynamicGate: + """Coerce a middleware gate reference into a Gate or DynamicGate. + + A ``str`` is looked up in ``registry`` (raising if none is configured); a + :class:`~pay_kit.gate.Gate` / :class:`~pay_kit.gate.DynamicGate` passes + through; a bare :class:`~pay_kit.price.Price` is wrapped into an inline + Gate using the Config-resolved default recipient and accept list. + """ + if isinstance(arg, (Gate, DynamicGate)): + return arg + if isinstance(arg, str): + if registry is None: + raise ConfigurationError(f"pay_kit: no Pricing registry configured to resolve gate {arg!r}") + return registry.gate(arg) + if isinstance(arg, Price): + default_pay_to, accept_default = _config_defaults(config) + return Gate.build( + name="_inline", + amount=arg, + default_pay_to=default_pay_to, + accept_default=accept_default, + ) + raise ConfigurationError(f"pay_kit: cannot coerce {arg!r} to a Gate") + + +def _config_defaults(config: Config | None) -> tuple[str | None, tuple[Protocol, ...] | None]: + """Pull the default recipient and accept list off Config, lazily if absent.""" + resolved = config + if resolved is None: + from pay_kit import config as config_accessor # local import: avoids cycle + + resolved = config_accessor() + return resolved.effective_recipient(), resolved.accept From 53ff1c9368aa4798e1e10ea105fa2c2dc376c447 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 10:43:01 +0300 Subject: [PATCH 06/45] feat(python): add pay_kit MPP and x402-exact protocol adapters 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. --- python/src/pay_kit/payment.py | 28 + python/src/pay_kit/protocols/__init__.py | 7 + python/src/pay_kit/protocols/mpp.py | 357 ++++++++++++ python/src/pay_kit/protocols/x402.py | 700 +++++++++++++++++++++++ 4 files changed, 1092 insertions(+) create mode 100644 python/src/pay_kit/payment.py create mode 100644 python/src/pay_kit/protocols/__init__.py create mode 100644 python/src/pay_kit/protocols/mpp.py create mode 100644 python/src/pay_kit/protocols/x402.py diff --git a/python/src/pay_kit/payment.py b/python/src/pay_kit/payment.py new file mode 100644 index 000000000..75c033205 --- /dev/null +++ b/python/src/pay_kit/payment.py @@ -0,0 +1,28 @@ +"""Request-scoped proof returned after a successful payment verification.""" + +from __future__ import annotations + +import pydantic + +from pay_kit._paycore.protocol import Protocol + +__all__ = ["Payment"] + + +class Payment(pydantic.BaseModel): + """Immutable record of a settled payment attached to the request scope. + + Built by an adapter (:class:`pay_kit.protocols.mpp.MppAdapter` or + :class:`pay_kit.protocols.x402.X402Adapter`) after on-chain settlement. + ``transaction`` is the settled signature/reference, ``settlement_headers`` + are echoed onto the framework response, and ``raw`` keeps the original + proof string (Authorization / Payment-Signature) for auditing. + """ + + model_config = pydantic.ConfigDict(frozen=True) + + protocol: Protocol + transaction: str + gate_name: str | None = None + settlement_headers: dict[str, str] = pydantic.Field(default_factory=dict) + raw: str | None = None diff --git a/python/src/pay_kit/protocols/__init__.py b/python/src/pay_kit/protocols/__init__.py new file mode 100644 index 000000000..be084d4f7 --- /dev/null +++ b/python/src/pay_kit/protocols/__init__.py @@ -0,0 +1,7 @@ +"""Protocol adapters that bridge gates to the solana_mpp wire layer.""" + +from __future__ import annotations + +from pay_kit.protocols.mpp import MppAdapter, SecretResolver + +__all__ = ["MppAdapter", "SecretResolver"] diff --git a/python/src/pay_kit/protocols/mpp.py b/python/src/pay_kit/protocols/mpp.py new file mode 100644 index 000000000..cd68b3fd0 --- /dev/null +++ b/python/src/pay_kit/protocols/mpp.py @@ -0,0 +1,357 @@ +"""MPP charge adapter wrapping the solana_mpp server wire layer. + +Mirrors PHP ``Protocols/Mpp/{Adapter,SecretResolver}`` and the Ruby +reference. The adapter never reimplements canonical JSON, header parsing, +challenge HMAC binding, or the on-chain Solana verifier; those all live in +:mod:`solana_mpp` and are reused per the blueprint reuse map. This module +only translates a unified :class:`pay_kit.gate.Gate` into the wire request, +builds the 402 challenge, and runs cross-route-safe verification through +``solana_mpp.server.mpp.Mpp.verify_credential_with_expected``. +""" + +from __future__ import annotations + +import contextlib +import logging +import os +import secrets +from collections.abc import Callable +from decimal import Decimal +from typing import TYPE_CHECKING, Any + +from pay_kit._paycore.protocol import Protocol +from pay_kit.errors import InvalidProofError +from pay_kit.payment import Payment +from solana_mpp._errors import PaymentError, canonical_code +from solana_mpp._headers import format_www_authenticate, parse_authorization +from solana_mpp._rpc import SolanaRpc +from solana_mpp.protocol.intents import ChargeRequest +from solana_mpp.server.mpp import ChargeOptions, Mpp +from solana_mpp.server.mpp import Config as MppServerConfig +from solana_mpp.store import MemoryStore, Store + +if TYPE_CHECKING: + from pay_kit.config import Config + from pay_kit.gate import Gate + from pay_kit.price import Price + +__all__ = ["MppAdapter", "SecretResolver"] + +logger = logging.getLogger(__name__) + +# USDC/USDT/USDG/PYUSD/CASH are all 6-decimal mints; base units = amount * 1e6. +# Matches the PHP adapter's ``multipliedBy(1_000_000)`` conversion. +_BASE_UNIT_SCALE = 1_000_000 + +_DEFAULT_MPP_SECRET_ENV = "PAY_KIT_MPP_CHALLENGE_BINDING_SECRET" + + +class SecretResolver: + """Auto-resolves the MPP HMAC challenge-binding secret (caveat #4). + + Mirrors PHP ``SecretResolver`` and Ruby PR #142. Resolution chain so the + example apps boot without the operator setting anything: + + 1. ``ENV[env_var]`` -- production pattern (orchestrator-supplied). + 2. ``./.env`` parsed for the same key -- sticky across restarts. + 3. Generate ``secrets.token_hex(32)`` and append to ``./.env`` (mode + 0600 when the file is created) so later boots reuse the value. + + If ``./.env`` is unwritable the in-memory generated value is kept and + ``persisted`` is ``False``; the caller is expected to warn because the + secret rotates per process and invalidates in-flight challenges on + restart. The dotenv parser is intentionally a tolerant ~10-line reader so + no dotenv dependency is pulled in for this one feature. + """ + + @staticmethod + def resolve_mpp_secret( + env_var: str = _DEFAULT_MPP_SECRET_ENV, + dotenv_path: str | None = None, + ) -> tuple[str, str, bool]: + """Return ``(secret, source, persisted)`` for the binding secret.""" + path = dotenv_path if dotenv_path is not None else os.path.join(os.getcwd(), ".env") + + from_env = os.environ.get(env_var) + if from_env: + return (from_env, "env", True) + + from_dotenv = SecretResolver._read_dotenv(path, env_var) + if from_dotenv is not None: + return (from_dotenv, "dotenv", True) + + generated = secrets.token_hex(32) + persisted = SecretResolver._append_to_dotenv(path, env_var, generated) + if not persisted: + logger.warning( + "pay_kit: could not persist MPP challenge-binding secret to %s; " + "using an in-memory value that rotates per process and invalidates " + "in-flight challenges on restart", + path, + ) + return (generated, "generated+persisted" if persisted else "generated", persisted) + + @staticmethod + def _read_dotenv(path: str, key: str) -> str | None: + """Tolerant dotenv reader: blanks, ``#`` comments, KEY=value, quotes.""" + try: + with open(path, encoding="utf-8") as handle: + for line in handle: + trimmed = line.strip() + if not trimmed or trimmed.startswith("#"): + continue + eq = trimmed.find("=") + if eq == -1: + continue + name = trimmed[:eq].strip() + if name != key: + continue + value = trimmed[eq + 1 :].strip() + if len(value) >= 2 and ( + (value[0] == '"' and value[-1] == '"') or (value[0] == "'" and value[-1] == "'") + ): + value = value[1:-1] + return value or None + except OSError: + return None + return None + + @staticmethod + def _append_to_dotenv(path: str, key: str, value: str) -> bool: + """Append ``KEY=value``; create at 0600 if absent. Returns success.""" + existed = os.path.isfile(path) + try: + with open(path, "a", encoding="utf-8") as handle: + if not existed: + with contextlib.suppress(OSError): + os.chmod(path, 0o600) + handle.write(f"{key}={value}\n") + return True + except OSError: + return False + + +class MppAdapter: + """Bridges a unified gate to ``solana_mpp.server.mpp.Mpp`` charge flow.""" + + def __init__( + self, + config: Config, + replay_store: Store | None = None, + recent_blockhash_provider: Callable[[], str | None] | None = None, + ) -> None: + self._config = config + self._replay_store: Store = replay_store if replay_store is not None else MemoryStore() + self._recent_blockhash_provider = recent_blockhash_provider + # Cache one solana_mpp.Mpp per (payTo|coin) key, like the PHP + # handlerCache, so the HMAC secret and RPC client are reused. + self._handler_cache: dict[str, Mpp] = {} + self._secret = self._resolve_secret() + + def _resolve_secret(self) -> str: + """Resolve the HMAC binding secret: config override else caveat #4.""" + configured = self._config.mpp.challenge_binding_secret + if configured: + return configured + secret, _source, _persisted = SecretResolver.resolve_mpp_secret() + return secret + + # -- offer / challenge -------------------------------------------------- + + def accepts_entry(self, gate: Gate, request: Any) -> dict[str, Any]: + """Build one ``accepts[]`` entry advertising the MPP charge offer.""" + coin = self._settlement_coin(gate) + pay_to = gate.pay_to or self._config.effective_recipient() + entry: dict[str, Any] = { + "protocol": "mpp", + "scheme": "charge", + "network": self._config.network.caip2(), + "amount": str(self._price_units(gate.total())), + "currency": coin, + "payTo": pay_to, + "realm": self._config.mpp.realm, + } + if gate.has_fees(): + entry["splits"] = [ + {"recipient": fee.recipient, "amount": str(self._price_units(fee.price))} for fee in gate.fees + ] + return entry + + def challenge_headers(self, gate: Gate, request: Any) -> dict[str, str]: + """Return the WWW-Authenticate header for the 402 MPP challenge.""" + mpp = self._server_for(gate) + challenge = mpp.charge_with_options(self._human_amount(gate), self._charge_options(gate)) + return {"www-authenticate": format_www_authenticate(challenge)} + + # -- verify + settle ---------------------------------------------------- + + async def verify_and_settle(self, gate: Gate, request: Any) -> Payment: + """Verify the MPP credential and settle, pinning amount/currency/recipient. + + Cross-route replay protection: the route's expected + :class:`ChargeRequest` is rebuilt here and passed to + ``verify_credential_with_expected`` so a credential issued for a + cheaper route fails on this route with a canonical mismatch code. + """ + authorization = self._header(request, "authorization") + if not authorization or not authorization.strip(): + raise InvalidProofError("pay_kit: payment required", code=canonical_code("")) + + try: + credential = parse_authorization(authorization) + except Exception as exc: # noqa: BLE001 - parse failures are 402s + raise InvalidProofError( + f"pay_kit: could not parse Authorization: {exc}", + code="payment_invalid", + ) from exc + + mpp = self._server_for(gate) + expected = self._charge_request_for(gate) + + # The cached ``solana_mpp.Mpp`` is built with ``rpc=None`` (the + # adapter is constructed at boot, before any event loop exists). + # Transaction verification + broadcast need a live RPC, so scope a + # request-lifetime ``SolanaRpc`` to this verify via ``using_rpc`` + # and close it immediately afterwards. Mirrors the X402Adapter's + # own-RPC pattern and the standalone harness python-server. The + # ``using_rpc`` lock serialises the swap on this event loop. + rpc = SolanaRpc(self._config.effective_rpc_url()) + try: + async with mpp.using_rpc(rpc): + receipt = await mpp.verify_credential_with_expected(credential, expected) + except PaymentError as err: + raise InvalidProofError( + str(err) or "verification failed", + code=canonical_code(err.code), + ) from err + finally: + await rpc.aclose() + + settlement_headers = { + "payment-receipt": receipt.reference, + "x-payment-settlement-signature": receipt.reference, + } + return Payment( + protocol=Protocol.MPP, + transaction=receipt.reference, + gate_name=gate.name, + settlement_headers=settlement_headers, + raw=authorization, + ) + + # -- internals ---------------------------------------------------------- + + def _charge_request_for(self, gate: Gate) -> ChargeRequest: + """Build the route's expected charge request from the gate.""" + coin = self._settlement_coin(gate) + pay_to = gate.pay_to or self._config.effective_recipient() + amount = str(self._price_units(gate.amount)) + # Pay's MPP client filters challenges by the short network slug + # ("mainnet"/"devnet"/"localnet") in request.methodDetails.network + # (rust/crates/core/src/client/mpp.rs). Advertise the same slug + # Mints::resolve uses so `pay --sandbox --mpp curl` matches. + method_details: dict[str, Any] = {"network": self._config.network.mints_label()} + if gate.has_fees(): + method_details["splits"] = [ + {"recipient": fee.recipient, "amount": str(self._price_units(fee.price))} for fee in gate.fees + ] + signer = self._config.operator.signer + if self._config.operator.fee_payer and signer is not None: + method_details["feePayer"] = True + method_details["feePayerKey"] = signer.pubkey() + # Embed the server's recent blockhash when a provider is wired + # (caveat #5). Injected via kwarg so unit tests stay offline; the + # pull-mode MPP verifier ignores an unused blockhash on real nets. + if self._recent_blockhash_provider is not None: + blockhash = self._recent_blockhash_provider() + if blockhash: + method_details["recentBlockhash"] = blockhash + return ChargeRequest( + amount=amount, + currency=coin, + recipient=pay_to, + description=gate.description or "", + external_id=gate.external_id or "", + method_details=method_details or None, + ) + + def _charge_options(self, gate: Gate) -> ChargeOptions: + """Build ChargeOptions mirroring the route's charge request.""" + options = ChargeOptions( + description=gate.description or "", + external_id=gate.external_id or "", + ) + if gate.has_fees(): + options.splits = [ + {"recipient": fee.recipient, "amount": str(self._price_units(fee.price))} for fee in gate.fees + ] + signer = self._config.operator.signer + if self._config.operator.fee_payer and signer is not None: + options.fee_payer = True + return options + + def _server_for(self, gate: Gate) -> Mpp: + """Return a cached ``solana_mpp.Mpp`` keyed on (payTo|coin).""" + coin = self._settlement_coin(gate) + pay_to = gate.pay_to or self._config.effective_recipient() + key = f"{pay_to}|{coin}" + cached = self._handler_cache.get(key) + if cached is not None: + return cached + + fee_payer_signer = self._fee_payer_keypair() + server_config = MppServerConfig( + recipient=pay_to, + currency=coin, + decimals=6, + network=self._config.network.mints_label(), + rpc_url=self._config.effective_rpc_url(), + secret_key=self._secret, + realm=self._config.mpp.realm, + fee_payer_signer=fee_payer_signer, + store=self._replay_store, + rpc=None, + ) + mpp = Mpp(server_config) + self._handler_cache[key] = mpp + return mpp + + def _fee_payer_keypair(self) -> Any: + """Materialize a solders Keypair fee payer when the operator sponsors.""" + signer = self._config.operator.signer + if not self._config.operator.fee_payer or signer is None: + return None + from solders.keypair import Keypair + + return Keypair.from_bytes(signer.secret_key()) + + def _settlement_coin(self, gate: Gate) -> str: + """Pick the settlement coin: gate primary coin else config default.""" + primary = gate.amount.primary_coin() + if primary is not None: + return primary.value + return self._config.stablecoins[0].value + + def _human_amount(self, gate: Gate) -> str: + """Charge amount as a human decimal string the wire re-parses.""" + return gate.amount.amount_string() + + def _price_units(self, price: Price) -> int: + """Convert a Decimal price to 6-decimal base units (no float).""" + return int((Decimal(price.amount) * _BASE_UNIT_SCALE).to_integral_value()) + + @staticmethod + def _header(request: Any, name: str) -> str: + """Read a header off a generic request bag (dict-like or .headers).""" + headers = getattr(request, "headers", None) + if headers is None and isinstance(request, dict): + headers = request.get("headers", request) + if headers is None: + return "" + getter = getattr(headers, "get", None) + if callable(getter): + value: Any = getter(name) + if value is None: + value = getter(name.title()) + return str(value) if value else "" + return "" diff --git a/python/src/pay_kit/protocols/x402.py b/python/src/pay_kit/protocols/x402.py new file mode 100644 index 000000000..bebd159d7 --- /dev/null +++ b/python/src/pay_kit/protocols/x402.py @@ -0,0 +1,700 @@ +"""x402 ``exact`` (Solana) adapter and self-hosted 11-rule verifier. + +Self-hosted x402 ``exact`` scheme for the Solana SVM. ``X402Adapter`` issues +402 challenges, runs the structural 11-rule verifier on submitted credentials, +cosigns as the facilitator fee payer, broadcasts via the configured RPC, and +namespaces the consumed signature in the replay store. ``ExactVerifier`` +mirrors the Rust spine at +``rust/crates/x402/src/protocol/schemes/exact/verify.rs`` and the server +backstops at ``rust/crates/x402/src/server/exact.rs`` byte-for-byte, and the +PHP port at ``php/src/Protocols/X402/{Adapter,Exact/Verifier}.php``. + +Delegated mode (``X402Config.facilitator_url`` set) is reserved in the config +schema but not yet wired; the adapter raises ``NotImplementedError`` when a +facilitator URL is configured. Self-hosted is the only x402 path that ships. +""" + +from __future__ import annotations + +import base64 +import json +import struct +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from pay_kit._paycore.mints import derive_ata, resolve, token_program_for +from pay_kit._paycore.protocol import Protocol +from pay_kit.errors import InvalidProofError +from pay_kit.payment import Payment +from solana_mpp._rpc import SolanaRpc +from solana_mpp.protocol.solana import ASSOCIATED_TOKEN_PROGRAM +from solana_mpp.server.network_check import check_network_blockhash +from solana_mpp.store import MemoryStore, Store + +if TYPE_CHECKING: + from pay_kit.config import Config + from pay_kit.gate import Gate + +__all__ = ["X402Adapter", "ExactVerifier", "X402_VERSION"] + +#: x402 protocol version emitted in challenges and required on credentials. +X402_VERSION = 2 + +#: ComputeBudget program id (instruction[0]/[1] guard). +COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111" +#: SPL Memo program id (allowlisted optional instruction + memo binding). +MEMO_PROGRAM = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" +#: Lighthouse assertion program id (allowlisted optional instruction). +LIGHTHOUSE_PROGRAM = "L1TEVtgA75k273wWz1s6XMmDhQY5i3MwcvKb4VbZzfK" +#: Token-2022 program id (accepted transfer program alongside the route's). +TOKEN_2022_PROGRAM = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" +#: Maximum SetComputeUnitPrice in microlamports. Matches the Rust spine +#: constant ``MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS`` in verify.rs. +MAX_COMPUTE_UNIT_PRICE = 5_000_000 + +_SETTLEMENT_HEADER = "x-payment-settlement-signature" +_RESPONSE_HEADER = "payment-response" +_REPLAY_PREFIX = "x402-svm-exact:consumed:" + + +def _u64_le(data: bytes, offset: int) -> int: + """Read a little-endian u64 at ``offset``; reject on a short buffer.""" + if len(data) < offset + 8: + raise InvalidProofError( + "invalid_exact_svm_payload_no_transfer_instruction", + code="invalid_exact_svm_payload_no_transfer_instruction", + ) + return struct.unpack_from(" dict[str, Any]: + """Verify a base64 transaction against the route's x402 requirement. + + ``requirement`` is one ``accepts[]`` entry (the server offer). + ``managed_signers`` lists server-managed pubkeys (typically the + facilitator fee payer) that must never be the transfer authority. + Returns a dict describing the matched transfer on success. + """ + from solders.transaction import VersionedTransaction + + try: + raw = base64.b64decode(transaction_base64, validate=True) + except Exception as exc: # noqa: BLE001 - any decode failure is a reject + raise InvalidProofError( + "invalid_exact_svm_payload_base64", + code="invalid_exact_svm_payload_base64", + ) from exc + if not raw: + raise InvalidProofError( + "invalid_exact_svm_payload_base64", + code="invalid_exact_svm_payload_base64", + ) + + try: + tx = VersionedTransaction.from_bytes(raw) + except Exception as exc: # noqa: BLE001 + raise InvalidProofError( + "invalid_exact_svm_payload_transaction_parse", + code="invalid_exact_svm_payload_transaction_parse", + ) from exc + + message = tx.message + instructions = list(message.instructions) + account_keys = [str(key) for key in message.account_keys] + + # Rule 1: instruction count 3..=6. + n = len(instructions) + if n < 3 or n > 6: + raise InvalidProofError( + "invalid_exact_svm_payload_transaction_instructions_length", + code="invalid_exact_svm_payload_transaction_instructions_length", + ) + + # Rule 2: ix[0] = ComputeBudget SetComputeUnitLimit (disc 2, 5 bytes). + ExactVerifier._verify_compute_limit(instructions[0], account_keys) + # Rule 3: ix[1] = ComputeBudget SetComputeUnitPrice (disc 3, 9 bytes, <= MAX). + ExactVerifier._verify_compute_price(instructions[1], account_keys) + # Rules 4 + 5 + 6 + 7 + 8 + 11: transferChecked. + transfer = ExactVerifier._verify_transfer(instructions[2], account_keys, requirement, managed_signers) + + # Rule 9: ix[3:] allowlist (memo, lighthouse(<2 slots), ata-create(<2 slots)). + destination_create_ata = False + reasons = ( + "invalid_exact_svm_payload_unknown_fourth_instruction", + "invalid_exact_svm_payload_unknown_fifth_instruction", + "invalid_exact_svm_payload_unknown_sixth_instruction", + ) + for i in range(3, n): + ix = instructions[i] + program = ExactVerifier._program_of(account_keys, ix) + slot_index = i - 3 + allowed = program == MEMO_PROGRAM or (slot_index < 2 and program == LIGHTHOUSE_PROGRAM) + if ( + not allowed + and slot_index < 2 + and ExactVerifier._valid_ata_create(ix, account_keys, requirement, transfer) + ): + destination_create_ata = True + allowed = True + if not allowed: + reason = ( + reasons[slot_index] + if slot_index < len(reasons) + else "invalid_exact_svm_payload_unknown_optional_instruction" + ) + raise InvalidProofError(reason, code=reason) + + # Rule 10: memo binding (exactly one Memo == extra.memo if set). + expected_memo = ExactVerifier._string_extra(requirement, "memo", required=False) + if expected_memo: + ExactVerifier._find_memo_match(account_keys, instructions, expected_memo) + + transfer["destinationCreateAta"] = destination_create_ata + return transfer + + @staticmethod + def _verify_compute_limit(ix: Any, account_keys: list[str]) -> None: + program = ExactVerifier._program_of(account_keys, ix) + data = bytes(ix.data) + if program != COMPUTE_BUDGET_PROGRAM or len(data) != 5 or data[0] != 2: + raise InvalidProofError( + "invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction", + code="invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction", + ) + + @staticmethod + def _verify_compute_price(ix: Any, account_keys: list[str]) -> None: + program = ExactVerifier._program_of(account_keys, ix) + data = bytes(ix.data) + if program != COMPUTE_BUDGET_PROGRAM or len(data) != 9 or data[0] != 3: + raise InvalidProofError( + "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction", + code="invalid_exact_svm_payload_transaction_instructions_compute_price_instruction", + ) + micro = _u64_le(data, 1) + if micro > MAX_COMPUTE_UNIT_PRICE: + reason = "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high" + raise InvalidProofError(reason, code=reason) + + @staticmethod + def _verify_transfer( + ix: Any, + account_keys: list[str], + requirement: dict[str, Any], + managed_signers: list[str], + ) -> dict[str, Any]: + program = ExactVerifier._program_of(account_keys, ix) + # Rule 11: token program strict bind to extra.tokenProgram. + token_program_extra = ExactVerifier._string_extra(requirement, "tokenProgram", required=True) + if program != token_program_extra and program != TOKEN_2022_PROGRAM: + raise InvalidProofError( + "invalid_exact_svm_payload_no_transfer_instruction", + code="invalid_exact_svm_payload_no_transfer_instruction", + ) + data = bytes(ix.data) + accounts = list(ix.accounts) + # Rule 4: transferChecked shape (disc 12, 10-byte data, >= 4 accounts). + if len(accounts) < 4 or len(data) != 10 or data[0] != 12: + raise InvalidProofError( + "invalid_exact_svm_payload_no_transfer_instruction", + code="invalid_exact_svm_payload_no_transfer_instruction", + ) + + source = ExactVerifier._account_at(account_keys, ix, 0) + mint = ExactVerifier._account_at(account_keys, ix, 1) + destination = ExactVerifier._account_at(account_keys, ix, 2) + authority = ExactVerifier._account_at(account_keys, ix, 3) + + # Rule 5: authority guard (no managed signer as authority/source/account). + for managed in managed_signers: + if managed in (authority, source): + raise InvalidProofError( + "invalid_exact_svm_payload_transaction_fee_payer_transferring_funds", + code="invalid_exact_svm_payload_transaction_fee_payer_transferring_funds", + ) + for idx in accounts: + key = account_keys[idx] if 0 <= idx < len(account_keys) else None + if key is None: + continue + for managed in managed_signers: + if managed == key: + raise InvalidProofError( + "invalid_exact_svm_payload_transaction_fee_payer_in_instruction_accounts", + code="invalid_exact_svm_payload_transaction_fee_payer_in_instruction_accounts", + ) + + # Rule 6: mint match (offer carries the resolved on-chain mint on `asset`). + expected_mint = ExactVerifier._b58_field(requirement, "asset") + if mint != expected_mint: + raise InvalidProofError( + "invalid_exact_svm_payload_mint_mismatch", + code="invalid_exact_svm_payload_mint_mismatch", + ) + + # Rule 7: destination ATA match (re-derive owner+mint+token_program). + pay_to = ExactVerifier._b58_field(requirement, "payTo") + expected_destination = derive_ata(pay_to, expected_mint, program) + if destination != expected_destination: + raise InvalidProofError( + "invalid_exact_svm_payload_recipient_mismatch", + code="invalid_exact_svm_payload_recipient_mismatch", + ) + + # Rule 8: amount match (u64 LE at data[1:9]). + amount = _u64_le(data, 1) + expected_amount = ExactVerifier._amount_field(requirement) + if amount != expected_amount: + raise InvalidProofError( + "invalid_exact_svm_payload_amount_mismatch", + code="invalid_exact_svm_payload_amount_mismatch", + ) + + return { + "program": program, + "source": source, + "mint": mint, + "destination": destination, + "authority": authority, + "amount": amount, + } + + @staticmethod + def _valid_ata_create( + ix: Any, + account_keys: list[str], + requirement: dict[str, Any], + transfer: dict[str, Any], + ) -> bool: + if ExactVerifier._program_of(account_keys, ix) != ASSOCIATED_TOKEN_PROGRAM: + return False + data = bytes(ix.data) + if len(data) < 1 or (data[0] != 0 and data[0] != 1): + return False + if len(list(ix.accounts)) < 6: + return False + ata = ExactVerifier._account_at(account_keys, ix, 1) + owner = ExactVerifier._account_at(account_keys, ix, 2) + mint = ExactVerifier._account_at(account_keys, ix, 3) + if owner != requirement.get("payTo"): + return False + if mint != transfer["mint"]: + return False + return ata == transfer["destination"] + + @staticmethod + def _find_memo_match(account_keys: list[str], instructions: list[Any], expected_memo: str) -> None: + count = 0 + last_data: bytes | None = None + for i in range(3, len(instructions)): + ix = instructions[i] + if ExactVerifier._program_of(account_keys, ix) == MEMO_PROGRAM: + count += 1 + last_data = bytes(ix.data) + if count != 1: + raise InvalidProofError( + "invalid_exact_svm_payload_memo_count", + code="invalid_exact_svm_payload_memo_count", + ) + if last_data is None or last_data.decode("utf-8", "replace") != expected_memo: + raise InvalidProofError( + "invalid_exact_svm_payload_memo_mismatch", + code="invalid_exact_svm_payload_memo_mismatch", + ) + + @staticmethod + def _program_of(account_keys: list[str], ix: Any) -> str: + idx = int(ix.program_id_index) + return account_keys[idx] if 0 <= idx < len(account_keys) else "" + + @staticmethod + def _account_at(account_keys: list[str], ix: Any, slot: int) -> str: + accounts = list(ix.accounts) + if slot >= len(accounts): + raise InvalidProofError( + "invalid_exact_svm_payload_no_transfer_instruction", + code="invalid_exact_svm_payload_no_transfer_instruction", + ) + idx = int(accounts[slot]) + return account_keys[idx] if 0 <= idx < len(account_keys) else "" + + @staticmethod + def _b58_field(requirement: dict[str, Any], key: str) -> str: + value = requirement.get(key) + if not isinstance(value, str) or value == "": + raise InvalidProofError( + f"invalid_exact_svm_payload_missing_field_{key}", + code=f"invalid_exact_svm_payload_missing_field_{key}", + ) + return value + + @staticmethod + def _string_extra(requirement: dict[str, Any], key: str, *, required: bool) -> str | None: + extra = requirement.get("extra") + value = extra.get(key) if isinstance(extra, dict) else None + if (value is None or value == "") and required: + raise InvalidProofError( + f"invalid_exact_svm_payload_missing_extra_{key}", + code=f"invalid_exact_svm_payload_missing_extra_{key}", + ) + return value if isinstance(value, str) else None + + @staticmethod + def _amount_field(requirement: dict[str, Any]) -> int: + value = requirement.get("amount") + if value is None: + value = requirement.get("maxAmountRequired") + if not isinstance(value, (str, int)): + raise InvalidProofError( + "invalid_exact_svm_payload_missing_field_amount", + code="invalid_exact_svm_payload_missing_field_amount", + ) + return int(value) + + +class X402Adapter: + """Self-hosted server adapter for the x402 ``exact`` Solana scheme.""" + + def __init__( + self, + config: Config, + replay_store: Store | None = None, + recent_blockhash_provider: Callable[[], str | None] | None = None, + ) -> None: + """Build an adapter bound to ``config``; raise for delegated mode.""" + if config.x402.is_delegated(): + raise NotImplementedError( + "pay_kit: x402 delegated mode is not yet implemented; " + "leave X402Config.facilitator_url None for self-hosted" + ) + self._config = config + self._store: Store = replay_store if replay_store is not None else MemoryStore() + self._recent_blockhash_provider = recent_blockhash_provider + + def accepts_entry(self, gate: Gate, request: Any) -> dict[str, Any]: + """Build one ``accepts[]`` entry (the server x402 offer for ``gate``).""" + coin = gate.amount.primary_coin() + coin_value = coin.value if coin is not None else self._config.stablecoins[0].value + label = self._config.network.mints_label() + # x402 puts the on-chain mint pubkey on `asset`, not the ticker. + # resolve() falls back to the mainnet row when the network row is + # absent (caveat #1). + asset = resolve(coin_value, label) or coin_value + token_program = token_program_for(coin_value, label) + pay_to = gate.pay_to or self._config.effective_recipient() + amount = str(int(gate.total().amount * 1_000_000)) + signer = self._config.x402.effective_signer(self._config.operator) + extra: dict[str, Any] = { + "feePayer": signer.pubkey() if signer is not None else "", + "decimals": 6, + "tokenProgram": token_program, + "memo": _request_path(request), + } + # caveat #5: stamp the server's recent blockhash into accepted.extra + # so pay-kit Rust clients sign against the same chain state the server + # broadcasts to. Canonical TS/Go clients ignore it; harmless on real + # networks. The provider keeps unit tests offline. + blockhash = self._fetch_recent_blockhash() + if blockhash is not None: + extra["recentBlockhash"] = blockhash + return { + "protocol": "x402", + "scheme": "exact", + "network": self._caip2(), + "asset": asset, + "amount": amount, + "maxAmountRequired": amount, + "payTo": pay_to, + "maxTimeoutSeconds": 60, + "extra": extra, + } + + def challenge_headers(self, gate: Gate, request: Any) -> dict[str, str]: + """Build the ``payment-required`` header (base64 JSON challenge).""" + challenge = { + "x402Version": X402_VERSION, + "resource": {"type": "http", "url": _request_path(request)}, + "accepts": [self.accepts_entry(gate, request)], + } + payload = json.dumps(challenge, separators=(",", ":")).encode("utf-8") + return {"payment-required": base64.b64encode(payload).decode("ascii")} + + async def verify_and_settle(self, gate: Gate, request: Any) -> Payment: + """Verify the submitted x402 credential, cosign, broadcast, settle.""" + signer = self._config.x402.effective_signer(self._config.operator) + if signer is None: + raise InvalidProofError("pay_kit: x402 requires operator.signer", code="payment_invalid") + + header = _payment_signature_header(request) + if not header: + raise InvalidProofError("pay_kit: payment required", code="payment_required") + + try: + decoded = base64.b64decode(header, validate=True) + except Exception as exc: # noqa: BLE001 + raise InvalidProofError( + "invalid_exact_svm_payload_signature_base64", + code="invalid_exact_svm_payload_signature_base64", + ) from exc + try: + envelope = json.loads(decoded) + except Exception as exc: # noqa: BLE001 + raise InvalidProofError( + "invalid_exact_svm_payload_signature_json", + code="invalid_exact_svm_payload_signature_json", + ) from exc + + if not isinstance(envelope, dict) or envelope.get("x402Version") != X402_VERSION: + raise InvalidProofError("unsupported_x402_version", code="unsupported_x402_version") + accepted = envelope.get("accepted") + payload = envelope.get("payload") + if not isinstance(accepted, dict) or not isinstance(payload, dict): + raise InvalidProofError( + "invalid_exact_svm_payload_envelope", + code="invalid_exact_svm_payload_envelope", + ) + + # Tier-2 identity-key match: the credential's accepted requirement must + # match the server's freshly built offer for this route. x402 has no + # HMAC-bound challenge id, so the offer is the source of truth and the + # credential's `accepted` is never trusted for the route's parameters + # (mirrors rust verify_pinned_fields + the targeted deepEqual gate). + offer = self.accepts_entry(gate, request) + for key in ("scheme", "network", "asset", "payTo"): + if accepted.get(key) != offer.get(key): + raise InvalidProofError( + "pay_kit: charge_request_mismatch: accepted payment requirement does not match server challenge", + code="charge_request_mismatch", + ) + if accepted.get("amount") != offer.get("amount") and accepted.get("maxAmountRequired") != offer.get( + "maxAmountRequired" + ): + raise InvalidProofError( + "pay_kit: charge_request_mismatch (amount)", + code="charge_request_mismatch", + ) + offer_extra = offer.get("extra") or {} + accepted_extra = accepted.get("extra") or {} + for key in ("feePayer", "tokenProgram", "memo"): + if key in offer_extra and accepted_extra.get(key) != offer_extra[key]: + raise InvalidProofError( + f"pay_kit: charge_request_mismatch (extra.{key})", + code="charge_request_mismatch", + ) + + tx_base64 = payload.get("transaction") + if not isinstance(tx_base64, str) or tx_base64 == "": + raise InvalidProofError( + "invalid_exact_svm_payload_missing_transaction", + code="invalid_exact_svm_payload_missing_transaction", + ) + + # Structural shape (11 rules) against the server offer. + ExactVerifier.verify(tx_base64, offer, [signer.pubkey()]) + + # Reject up-front if the client signed against the wrong cluster. + # Skip on a loopback RPC where a Surfpool blockhash is expected. + rpc_url = self._config.effective_rpc_url() + if not _is_loopback_rpc(rpc_url): + blockhash = _recent_blockhash_of(tx_base64) + if blockhash is not None: + check_network_blockhash(self._config.network.mints_label(), blockhash) + + # Cosign as the facilitator fee payer (slot-splice, version aware). + cosigned_wire = _co_sign(tx_base64, signer) + + rpc = SolanaRpc(rpc_url) + try: + response = await rpc.send_raw_transaction(cosigned_wire) + signature = str(response.value if hasattr(response, "value") else response) + except Exception as exc: # noqa: BLE001 + raise InvalidProofError(f"pay_kit: invalid proof: broadcast failed: {exc}", code="payment_invalid") from exc + finally: + await rpc.aclose() + if not signature: + raise InvalidProofError("pay_kit: empty broadcast result", code="payment_invalid") + + # Replay reservation. Namespace is distinct from the MPP charge key so + # an x402 signature can never satisfy an MPP route and vice versa. + if not await self._store.put_if_absent(_REPLAY_PREFIX + signature, True): + raise InvalidProofError("pay_kit: signature_consumed", code="signature_consumed") + + response_envelope = base64.b64encode( + json.dumps( + { + "success": True, + "transaction": signature, + "network": accepted.get("network") or self._caip2(), + "payer": payload.get("transactionHash", ""), + }, + separators=(",", ":"), + ).encode("utf-8") + ).decode("ascii") + + return Payment( + protocol=Protocol.X402, + transaction=signature, + gate_name=gate.name, + settlement_headers={ + _RESPONSE_HEADER: response_envelope, + _SETTLEMENT_HEADER: signature, + }, + raw=header, + ) + + def _fetch_recent_blockhash(self) -> str | None: + if self._recent_blockhash_provider is not None: + try: + value = self._recent_blockhash_provider() + except Exception: # noqa: BLE001 - provider failures are non-fatal + return None + return value if isinstance(value, str) and value != "" else None + return None + + def _caip2(self) -> str: + return self._config.network.caip2() + + +def _co_sign(transaction_b64: str, signer: Any) -> bytes: + """Splice the facilitator signature into the fee-payer slot, return wire. + + Mirrors ``solana_mpp.server.mpp._co_sign_with_fee_payer``: legacy messages + are signed over ``bytes(msg)``, v0 over ``to_bytes_versioned(msg)`` (0x80 + prefix). The fee payer must occupy a signature slot. + """ + from solders.message import to_bytes_versioned + from solders.pubkey import Pubkey + from solders.transaction import Transaction, VersionedTransaction + + from solana_mpp.server.mpp import _is_v0_wire_bytes + + raw = base64.b64decode(transaction_b64) + fee_payer_pubkey = Pubkey.from_string(signer.pubkey()) + + # SECURITY: ``solders.transaction.Transaction.from_bytes`` is lenient and + # silently MIS-PARSES v0 ``VersionedTransaction`` wire bytes as a legacy + # transaction (it does not raise), yielding a bogus header and garbage + # account keys. The rust x402 client (and the canonical PaymentProof + # builder) emit v0 messages, so we must route on the message-version + # prefix byte rather than trusting a legacy parse to fail. Mirrors + # ``solana_mpp.server.mpp._co_sign_with_fee_payer`` and reuses its + # ``_is_v0_wire_bytes`` guard (no parallel detection logic). + if _is_v0_wire_bytes(raw): + try: + vtx = VersionedTransaction.from_bytes(raw) + except Exception as exc: # noqa: BLE001 + raise InvalidProofError( + "invalid_exact_svm_payload_transaction_parse", + code="invalid_exact_svm_payload_transaction_parse", + ) from exc + account_keys = list(vtx.message.account_keys) + message_bytes = bytes(to_bytes_versioned(vtx.message)) + num_required = int(vtx.message.header.num_required_signatures) + else: + try: + tx = Transaction.from_bytes(raw) + except Exception: # noqa: BLE001 - fall back to versioned + try: + vtx = VersionedTransaction.from_bytes(raw) + except Exception as exc: # noqa: BLE001 + raise InvalidProofError( + "invalid_exact_svm_payload_transaction_parse", + code="invalid_exact_svm_payload_transaction_parse", + ) from exc + account_keys = list(vtx.message.account_keys) + message_bytes = bytes(to_bytes_versioned(vtx.message)) + num_required = int(vtx.message.header.num_required_signatures) + else: + account_keys = list(tx.message.account_keys) + message_bytes = bytes(tx.message) + num_required = int(tx.message.header.num_required_signatures) + + try: + idx = account_keys.index(fee_payer_pubkey) + except ValueError as exc: + raise InvalidProofError( + "pay_kit: fee payer pubkey not present in transaction accounts", + code="payment_invalid", + ) from exc + if idx >= num_required: + raise InvalidProofError("pay_kit: fee payer is not a required signer", code="payment_invalid") + + sig_bytes = bytes(signer.sign(message_bytes)) + serialized = bytearray(raw) + sig_start = 1 + idx * 64 + serialized[sig_start : sig_start + 64] = sig_bytes + return bytes(serialized) + + +def _recent_blockhash_of(transaction_b64: str) -> str | None: + """Best-effort extract of the recent blockhash for the network check.""" + from solders.transaction import VersionedTransaction + + try: + raw = base64.b64decode(transaction_b64) + tx = VersionedTransaction.from_bytes(raw) + return str(tx.message.recent_blockhash) + except Exception: # noqa: BLE001 - the verifier already validated shape + return None + + +def _is_loopback_rpc(rpc_url: str) -> bool: + """True if ``rpc_url`` points at a loopback host (mirror rust).""" + stripped = rpc_url.strip() + for prefix in ("http://", "https://", "ws://", "wss://"): + if stripped.startswith(prefix): + stripped = stripped[len(prefix) :] + break + host_and_rest = stripped.split("/", 1)[0] + host = host_and_rest[1:].split("]", 1)[0] if host_and_rest.startswith("[") else host_and_rest.split(":", 1)[0] + return host in {"127.0.0.1", "localhost", "::1", "0.0.0.0"} + + +def _request_path(request: Any) -> str: + """Resolve the request path across framework request shapes.""" + path = getattr(request, "path", None) + if isinstance(path, str): + return path + url = getattr(request, "url", None) + if url is not None: + url_path = getattr(url, "path", None) + if isinstance(url_path, str): + return url_path + if isinstance(request, dict): + candidate = request.get("path") + if isinstance(candidate, str): + return candidate + return "/" + + +def _payment_signature_header(request: Any) -> str: + """Read the ``Payment-Signature`` header across framework request shapes.""" + headers = getattr(request, "headers", None) + if headers is not None: + getter = getattr(headers, "get", None) + if callable(getter): + for name in ("payment-signature", "Payment-Signature", "PAYMENT-SIGNATURE"): + value = getter(name) + if value: + return str(value) + if isinstance(request, dict): + raw_headers = request.get("headers") + if isinstance(raw_headers, dict): + for key, value in raw_headers.items(): + if key.lower() == "payment-signature" and value: + return str(value) + return "" From a2c9b8e4d4b6dd8d7a67a2a6c5b942e804666f5b Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 10:43:01 +0300 Subject: [PATCH 07/45] feat(python): add pay_kit preflight and middleware core 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. --- python/src/pay_kit/_middleware.py | 377 ++++++++++++++++++++++++++++++ python/src/pay_kit/preflight.py | 226 ++++++++++++++++++ 2 files changed, 603 insertions(+) create mode 100644 python/src/pay_kit/_middleware.py create mode 100644 python/src/pay_kit/preflight.py diff --git a/python/src/pay_kit/_middleware.py b/python/src/pay_kit/_middleware.py new file mode 100644 index 000000000..6921ceccc --- /dev/null +++ b/python/src/pay_kit/_middleware.py @@ -0,0 +1,377 @@ +"""Framework-agnostic payment-gating core plus the request-scoped trio. + +The framework shims (``pay_kit.fastapi`` / ``pay_kit.flask`` / ``pay_kit.django``) +all delegate to :class:`PayCore`. The split keeps every protocol/scheme decision, +402-challenge assembly, and adapter dispatch in one host-neutral place; the shims +only translate ``PayCore``'s outcome into their framework's response idioms +(caveat #6). + +:class:`PayCore` mirrors the PHP ``Middleware\\RequirePayment`` and the Ruby Rack +middleware: + +* :meth:`PayCore.resolve_gate` coerces the assorted gate-reference shapes + (inline :class:`~pay_kit.gate.Gate`, registered name, request builder, bare + :class:`~pay_kit.price.Price`) into a concrete validated gate. +* :meth:`PayCore.detect_adapter` walks the gate's accept list in order and picks + the scheme adapter whose proof header is present. x402 wins when both proofs + arrive; a fee-bearing gate disables x402 entirely (stock x402 facilitators + settle to a single address). +* :meth:`PayCore.process` runs verification and returns a settled + :class:`~pay_kit.payment.Payment`, or raises :class:`PaymentRequiredError` + (carrying the 402 challenge headers + JSON body on the exception) / + :class:`InvalidProofError` / :class:`ProtocolNotSupportedError`. + +The request-scoped trio (:func:`require_payment`, :func:`is_paid`, +:func:`is_paid_for`, :func:`payment`) read the verified +:class:`~pay_kit.payment.Payment` the shims attach to the request under the +``paykit_payment`` attribute, matching the cross-SDK ``payment`` / ``paid?`` / +``require_payment!`` shape. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import TYPE_CHECKING, Any + +from pay_kit._paycore.protocol import Protocol +from pay_kit.errors import ( + InvalidProofError, + PaymentRequiredError, + ProtocolNotSupportedError, +) +from pay_kit.gate import DynamicGate, Gate +from pay_kit.payment import Payment +from pay_kit.price import Price +from pay_kit.pricing import Pricing, coerce +from pay_kit.protocols.mpp import MppAdapter +from pay_kit.protocols.x402 import X402Adapter + +if TYPE_CHECKING: + from pay_kit.config import Config + +__all__ = [ + "PayCore", + "require_payment", + "is_paid", + "is_paid_for", + "payment", + "PAYMENT_ATTR", +] + +#: Request attribute the framework shims write the verified payment under. +PAYMENT_ATTR = "paykit_payment" + +#: Gate reference shapes accepted by the middleware. +GateRef = "Gate | DynamicGate | Price | str | Callable[[Any], Gate]" + + +class PayCore: + """Host-neutral payment-gating core shared by every framework shim. + + One instance wraps a frozen :class:`~pay_kit.config.Config` and lazily + constructs the MPP and (when x402 is accepted) x402 adapters. Callers can + inject pre-built adapters to override defaults, e.g. with an offline + ``recent_blockhash_provider`` for tests. + """ + + def __init__( + self, + config: Config, + *, + mpp: MppAdapter | None = None, + x402: X402Adapter | None = None, + ) -> None: + """Bind to ``config`` and resolve (or inject) the scheme adapters.""" + self._config = config + self._mpp = mpp if mpp is not None else MppAdapter(config) + # Auto-wire the x402 adapter only when the config accept list includes + # it; mirrors the PHP constructor. An explicit adapter always wins. + if x402 is not None: + self._x402: X402Adapter | None = x402 + elif Protocol.X402 in config.accept: + self._x402 = X402Adapter(config) + else: + self._x402 = None + + @property + def config(self) -> Config: + """The frozen configuration this core gates against.""" + return self._config + + def resolve_gate( + self, + gate_ref: Gate | DynamicGate | Price | str | Callable[[Any], Gate], + pricing: Pricing | None, + request: Any, + ) -> Gate: + """Coerce any gate-reference shape into a concrete validated Gate. + + A plain callable (not a :class:`DynamicGate`) is invoked with the + request and may return a :class:`Gate` or a :class:`~pay_kit.price.Price` + (wrapped with Config defaults). A :class:`DynamicGate` is resolved with + the Config defaults the DSL omitted. Everything else funnels through + :func:`pay_kit.pricing.coerce`. + """ + if isinstance(gate_ref, DynamicGate): + self._inject_dynamic_defaults(gate_ref) + return gate_ref.resolve(request) + if not isinstance(gate_ref, (Gate, Price, str)) and callable(gate_ref): + return self._resolve_callable(gate_ref, request) + return self._coerce_static(gate_ref, pricing) + + def detect_adapter( + self, + gate: Gate, + headers: Mapping[str, str], + ) -> X402Adapter | MppAdapter | None: + """Pick the scheme adapter whose proof header is present, in accept order. + + x402 requires a non-empty ``Payment-Signature`` header, the gate to + accept x402, no fees on the gate, and a wired x402 adapter. MPP requires + an ``Authorization`` header whose scheme is ``payment`` (case-insensitive). + When both proofs are present x402 wins, matching the PHP/Ruby reference. + """ + accept = gate.accept if gate.accept is not None else self._config.accept + authorization = _read_header(headers, "authorization") + signature = _read_header(headers, "payment-signature") + + for scheme in accept: + if scheme == Protocol.X402 and signature and self._x402 is not None and not gate.has_fees(): + return self._x402 + if scheme == Protocol.MPP and authorization and authorization.strip().lower().startswith("payment "): + return self._mpp + return None + + async def process( + self, + gate_ref: Gate | DynamicGate | Price | str | Callable[[Any], Gate], + pricing: Pricing | None, + request: Any, + ) -> Payment: + """Resolve, dispatch, verify, and return a settled :class:`Payment`. + + Raises :class:`~pay_kit.errors.PaymentRequiredError` (with + ``challenge_headers`` and ``body`` attributes set for the shim to render + a 402) when no usable proof is present, and re-raises + :class:`~pay_kit.errors.InvalidProofError` / + :class:`~pay_kit.errors.ProtocolNotSupportedError` on verification or + protocol-mismatch failures. + """ + gate = self.resolve_gate(gate_ref, pricing, request) + headers = _request_headers(request) + adapter = self.detect_adapter(gate, headers) + + if adapter is None: + raise self._payment_required(gate, request) + + try: + return await adapter.verify_and_settle(gate, request) + except (InvalidProofError, ProtocolNotSupportedError): + raise + except PaymentRequiredError as exc: + raise self._payment_required(gate, request) from exc + + def build_402(self, gate: Gate, request: Any) -> tuple[dict[str, str], dict[str, Any]]: + """Assemble the 402 challenge headers and JSON body for ``gate``. + + Returns ``(headers, body)`` where ``body`` is + ``{"error", "resource", "accepts"}``. x402 is offered first when the + gate accepts it and carries no fees; MPP is offered whenever accepted. + """ + accept = gate.accept if gate.accept is not None else self._config.accept + accepts: list[dict[str, Any]] = [] + headers: dict[str, str] = {} + + if self._x402 is not None and Protocol.X402 in accept and not gate.has_fees(): + accepts.append(self._x402.accepts_entry(gate, request)) + headers.update(self._x402.challenge_headers(gate, request)) + if Protocol.MPP in accept: + accepts.append(self._mpp.accepts_entry(gate, request)) + headers.update(self._mpp.challenge_headers(gate, request)) + + headers.setdefault("content-type", "application/json") + body = { + "error": "payment_required", + "resource": _request_path(request), + "accepts": accepts, + } + return headers, body + + # -- internals ---------------------------------------------------------- + + def _payment_required(self, gate: Gate, request: Any) -> PaymentRequiredError: + """Build a PaymentRequiredError carrying the 402 challenge for the shim.""" + headers, body = self.build_402(gate, request) + error = PaymentRequiredError("pay_kit: payment required") + # Stash the rendered challenge on the exception so framework shims can + # emit a 402 without re-deriving it; mirrors PHP's build402 short-circuit. + error.challenge_headers = headers # type: ignore[attr-defined] + error.body = body # type: ignore[attr-defined] + return error + + def _resolve_callable(self, builder: Callable[[Any], Gate], request: Any) -> Gate: + """Invoke a bare request-builder and coerce its Gate/Price result.""" + result = builder(request) + if isinstance(result, Gate): + return result + if isinstance(result, Price): + return self._coerce_static(result, None) + raise ProtocolNotSupportedError( + f"pay_kit: gate builder returned {type(result).__name__}, expected Gate or Price" + ) + + def _coerce_static( + self, + gate_ref: Gate | DynamicGate | Price | str, + pricing: Pricing | None, + ) -> Gate: + """Coerce a non-callable reference; resolve a DynamicGate against defaults.""" + coerced = coerce(gate_ref, registry=pricing, config=self._config) + if isinstance(coerced, DynamicGate): + self._inject_dynamic_defaults(coerced) + # A DynamicGate from a registry still needs a request to resolve; + # callers must pass it through process() (which has the request). + raise ProtocolNotSupportedError(f"pay_kit: dynamic gate {coerced.name!r} requires a request to resolve") + return coerced + + def _inject_dynamic_defaults(self, gate: DynamicGate) -> None: + """Seed a DynamicGate's lazy Config defaults (pay_to + accept list).""" + defaults = getattr(gate, "_defaults", None) + if isinstance(defaults, dict) and not defaults: + defaults["pay_to"] = self._config.effective_recipient() + defaults["accept"] = self._config.accept + + +# -- request-scoped trio ---------------------------------------------------- + + +def payment(request: Any) -> Payment | None: + """The verified payment attached to ``request``, or ``None`` if unpaid. + + Reads the ``paykit_payment`` attribute the framework shims write after a + successful :meth:`PayCore.process`. Tolerates an attribute bag, a mapping, + or a framework request exposing ``.state`` (FastAPI/Starlette). + """ + value = _read_attr(request, PAYMENT_ATTR) + return value if isinstance(value, Payment) else None + + +def is_paid(request: Any) -> bool: + """Whether a verified payment is attached to ``request``.""" + return payment(request) is not None + + +def is_paid_for(request: Any, gate: Gate | str) -> bool: + """Whether ``request`` carries a verified payment for ``gate``. + + A :class:`~pay_kit.gate.Gate` instance trusts the middleware that wrote the + attribute (Payment does not carry gate identity beyond its name); a string + is matched against the payment's ``gate_name``. + """ + settled = payment(request) + if settled is None: + return False + if isinstance(gate, Gate): + return True + return settled.gate_name == gate + + +def require_payment(request: Any) -> Payment: + """Return the attached payment or raise :class:`PaymentRequiredError`. + + Imperative gating from inside a handler that did not run the middleware + decorator/dependency. Mirrors the cross-SDK ``require_payment!``. + """ + settled = payment(request) + if settled is None: + raise PaymentRequiredError("pay_kit: payment required") + return settled + + +# -- header / attribute helpers --------------------------------------------- + + +def _request_headers(request: Any) -> Mapping[str, str]: + """Extract a case-tolerant header mapping from a generic request bag.""" + headers = getattr(request, "headers", None) + if headers is None and isinstance(request, Mapping): + candidate = request.get("headers", request) + headers = candidate + if headers is None: + return {} + if isinstance(headers, Mapping): + return headers + # Header objects exposing .get (e.g. Starlette Headers, WSGI EnvironHeaders). + if callable(getattr(headers, "get", None)): + return _HeaderProxy(headers) + return {} + + +def _read_header(headers: Mapping[str, str], name: str) -> str: + """Read a header case-insensitively from a mapping or proxy; "" if absent.""" + getter = getattr(headers, "get", None) + if not callable(getter): + return "" + value = getter(name) + if value is None: + value = getter(name.title()) + if value is None: + value = getter(name.upper()) + return str(value) if value else "" + + +def _read_attr(request: Any, name: str) -> Any: + """Read an attribute off a request bag, mapping, or ``.state`` namespace.""" + state = getattr(request, "state", None) + if state is not None and hasattr(state, name): + return getattr(state, name) + if hasattr(request, name): + return getattr(request, name) + if isinstance(request, Mapping): + return request.get(name) + return None + + +def _request_path(request: Any) -> str: + """Best-effort request path for the 402 body ``resource`` field.""" + url = getattr(request, "url", None) + if url is not None: + path = getattr(url, "path", None) + if isinstance(path, str): + return path + path = getattr(request, "path", None) + if isinstance(path, str): + return path + if isinstance(request, Mapping): + candidate = request.get("path") or request.get("PATH_INFO") + if isinstance(candidate, str): + return candidate + return "/" + + +class _HeaderProxy(Mapping[str, str]): + """Adapts a ``.get``-bearing header object to a read-only Mapping.""" + + __slots__ = ("_headers",) + + def __init__(self, headers: Any) -> None: + self._headers = headers + + def __getitem__(self, key: str) -> str: + value = self._headers.get(key) + if value is None: + raise KeyError(key) + return str(value) + + def get(self, key: str, default: Any = None) -> Any: # type: ignore[override] + value = self._headers.get(key) + return value if value is not None else default + + def __iter__(self) -> Any: + return iter(getattr(self._headers, "keys", lambda: ())()) + + def __len__(self) -> int: + try: + return len(self._headers) + except TypeError: + return 0 diff --git a/python/src/pay_kit/preflight.py b/python/src/pay_kit/preflight.py new file mode 100644 index 000000000..ceb9ec029 --- /dev/null +++ b/python/src/pay_kit/preflight.py @@ -0,0 +1,226 @@ +"""Boot-time soundness checks for the operator wallet (caveat #3). + +Two checks run at ``configure`` time, mirroring Ruby ``pay_kit/preflight.rb`` +and PHP ``Preflight.php``: + +1. The fee payer (``operator.signer``) holds enough SOL to settle + (``>= MIN_FEE_PAYER_LAMPORTS``). +2. Every stablecoin in ``config.stablecoins`` has an associated token account + owned by the operator's effective recipient. + +On ``solana_localnet`` with the demo signer, missing accounts are +auto-provisioned via the Surfnet cheatcodes (``surfnet_setAccount``, +``surfnet_setTokenAccount``) so the example apps boot reachable against +``https://402.surfnet.dev:8899`` with zero manual setup. Anywhere else, a +missing account raises :class:`~pay_kit.errors.ConfigurationError` at boot so +the operator is told immediately rather than at the first 402 retry. + +RPC transport failures during preflight are LOGGED, never raised: an +unreachable endpoint must not block boot (the runtime surfaces it on the first +request anyway). Opt-out: ``configure(preflight=False)`` or +``PAY_KIT_DISABLE_PREFLIGHT=1``. + +NOTE: This module is EXCLUDED from the coverage gate (see the ``omit`` entry in +``pyproject.toml``) because every meaningful path wraps a live Solana RPC call +plus Surfnet cheatcodes that cannot run inside the offline unit suite. Unit +tests instead exercise the two opt-out knobs against a stubbed +``pay_kit.preflight.run`` and inject a fake RPC callable via +:func:`set_rpc_callable_for_tests`. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +import httpx + +from pay_kit._paycore import mints +from pay_kit.errors import ConfigurationError + +if TYPE_CHECKING: + from pay_kit.config import Config + +__all__ = [ + "run", + "is_disabled_by_env", + "set_rpc_callable_for_tests", + "MIN_FEE_PAYER_LAMPORTS", + "AUTOFUND_LAMPORTS", +] + +_LOG = logging.getLogger("pay_kit.preflight") + +# 0.001 SOL: enough for ~200 settlement txs at 5000 lamports/tx. +MIN_FEE_PAYER_LAMPORTS = 1_000_000 + +# 10 SOL: a generous local sandbox budget so a developer can poke the example +# for hours without re-funding. +AUTOFUND_LAMPORTS = 10_000_000_000 + +SYSTEM_PROGRAM_ID = "11111111111111111111111111111111" + +# Synchronous JSON-RPC callable signature ``(method, params) -> result``. +RpcCallable = Callable[[str, list[Any]], Any] + +# Injected by tests via ``set_rpc_callable_for_tests`` so the unit suite never +# touches a live endpoint. +_rpc_callable_override: RpcCallable | None = None + + +def is_disabled_by_env() -> bool: + """Return True when ``PAY_KIT_DISABLE_PREFLIGHT`` is set to ``1``/``true``.""" + raw = os.environ.get("PAY_KIT_DISABLE_PREFLIGHT") + return raw in {"1", "true"} + + +def set_rpc_callable_for_tests(override: RpcCallable | None) -> None: + """Install (or clear) a synchronous RPC callable used in place of httpx. + + @internal: test hook only. Pass ``None`` to restore live behaviour. + """ + global _rpc_callable_override + _rpc_callable_override = override + + +def run(config: Config) -> None: # pragma: no cover - live RPC + Surfnet cheatcodes + """Run the fee-payer and recipient-ATA preflight checks for ``config``. + + Configuration problems raise :class:`ConfigurationError`; RPC transport + failures are logged and swallowed so an unreachable endpoint never blocks + boot. + """ + autofix = _autofix_enabled(config) + + try: + _check_fee_payer_sol(config, autofix) + except ConfigurationError: + raise + except Exception as exc: # noqa: BLE001 - transient RPC failure must not block boot + _LOG.warning("[pay_kit preflight] skipped fee-payer balance check: %s", exc) + + for coin in config.stablecoins: + try: + _check_recipient_ata(config, str(coin), autofix) + except ConfigurationError: + raise + except Exception as exc: # noqa: BLE001 - transient RPC failure must not block boot + _LOG.warning("[pay_kit preflight] skipped %s ATA check: %s", coin, exc) + + +def _autofix_enabled(config: Config) -> bool: # pragma: no cover - exercised live only + """Localnet + demo signer is the only combination that mutates on-chain state.""" + network = str(config.network) + if network != "solana_localnet": + return False + signer = config.operator.signer + return signer is not None and signer.is_demo() + + +def _check_fee_payer_sol(config: Config, autofix: bool) -> None: # pragma: no cover - live RPC + """Ensure the fee payer holds at least ``MIN_FEE_PAYER_LAMPORTS``.""" + if not config.operator.fee_payer: + return + signer = config.operator.signer + if signer is None: + return + + pubkey = signer.pubkey() + result = _rpc_call(config, "getBalance", [pubkey, {"commitment": "confirmed"}]) + lamports = int(result["value"]) if isinstance(result, dict) and "value" in result else 0 + if lamports >= MIN_FEE_PAYER_LAMPORTS: + return + + if autofix: + _LOG.info( + "[pay_kit preflight] funding demo fee-payer %s with %d lamports via surfnet_setAccount", + pubkey, + AUTOFUND_LAMPORTS, + ) + _rpc_call( + config, + "surfnet_setAccount", + [ + pubkey, + { + "lamports": AUTOFUND_LAMPORTS, + "data": "", + "executable": False, + "owner": SYSTEM_PROGRAM_ID, + "rentEpoch": 0, + }, + ], + ) + return + + raise ConfigurationError( + f"pay_kit preflight: fee-payer {pubkey} has {lamports} lamports on " + f"{config.network} (need >= {MIN_FEE_PAYER_LAMPORTS}). " + "Fund the account before booting." + ) + + +def _check_recipient_ata(config: Config, coin: str, autofix: bool) -> None: # pragma: no cover - live RPC + """Ensure the effective recipient has an ATA for ``coin``.""" + label = config.network.mints_label() + mint = mints.resolve(coin, label) + if not mint: + return # native SOL has no ATA to check + + token_program = mints.token_program_for(coin, label) + recipient = config.effective_recipient() + ata = mints.derive_ata(recipient, mint, token_program) + + info = _rpc_call( + config, + "getAccountInfo", + [ata, {"encoding": "base64", "commitment": "confirmed"}], + ) + value = info["value"] if isinstance(info, dict) and "value" in info else None + if value is not None: + return + + if autofix: + _LOG.info( + "[pay_kit preflight] provisioning %s ATA for %s (mint=%s) via surfnet_setTokenAccount", + coin, + recipient, + mint, + ) + _rpc_call( + config, + "surfnet_setTokenAccount", + [recipient, mint, {"amount": 0, "state": "initialized"}, token_program], + ) + return + + raise ConfigurationError( + f"pay_kit preflight: recipient {recipient} has no {coin} ATA on " + f"{config.network} (expected {ata}). Create the ATA before booting " + f"(e.g. `spl-token create-account {mint} --owner {recipient}`)." + ) + + +def _rpc_call(config: Config, method: str, params: list[Any]) -> Any: # pragma: no cover - live RPC + """Issue a synchronous JSON-RPC call, honoring the test override. + + Mirrors the PHP transport: returns the ``result`` field, raises on + transport/decode failure so :func:`run` can log-and-skip. + """ + override = _rpc_callable_override + if override is not None: + return override(method, params) + + endpoint = config.effective_rpc_url() + response = httpx.post( + endpoint, + json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, + timeout=5.0, + ) + response.raise_for_status() + decoded = response.json() + if not isinstance(decoded, dict): + raise RuntimeError(f"rpc returned non-JSON from {endpoint}") + return decoded.get("result") From 06e17fcc3c856b3a6958a9124f76f6dcc9fb661e Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 10:43:01 +0300 Subject: [PATCH 08/45] feat(python): add pay_kit framework shims and public API 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. --- python/src/pay_kit/__init__.py | 132 +++++++++++++++++++++ python/src/pay_kit/django.py | 203 +++++++++++++++++++++++++++++++++ python/src/pay_kit/fastapi.py | 147 ++++++++++++++++++++++++ python/src/pay_kit/flask.py | 155 +++++++++++++++++++++++++ 4 files changed, 637 insertions(+) create mode 100644 python/src/pay_kit/__init__.py create mode 100644 python/src/pay_kit/django.py create mode 100644 python/src/pay_kit/fastapi.py create mode 100644 python/src/pay_kit/flask.py diff --git a/python/src/pay_kit/__init__.py b/python/src/pay_kit/__init__.py new file mode 100644 index 000000000..1e14d0ddd --- /dev/null +++ b/python/src/pay_kit/__init__.py @@ -0,0 +1,132 @@ +"""pay_kit: unified payment surface over x402 and MPP on Solana. + +Public entry point. Configure once with :func:`configure`, declare priced +routes with :class:`Gate` (or the :func:`gate` dynamic factory), and guard +handlers with the framework-agnostic trio :func:`require_payment`, +:func:`is_paid`, and :func:`payment`. Framework shims live in optional +submodules (``pay_kit.fastapi``, ``pay_kit.flask``, ``pay_kit.django``) and are +imported on demand so the base install carries no web-framework dependency. + +This package ships alongside :mod:`solana_mpp`, whose wire internals it reuses +rather than reimplements. +""" + +from __future__ import annotations + +from decimal import Decimal + +from pay_kit import errors, kms +from pay_kit._middleware import ( + is_paid, + is_paid_for, + payment, + require_payment, +) +from pay_kit._paycore.currency import Currency +from pay_kit._paycore.network import Network +from pay_kit._paycore.protocol import Protocol +from pay_kit._paycore.stablecoin import Stablecoin +from pay_kit.config import ( + Config, + MppConfig, + X402Config, + config, + configure, + configure_from, + reset, +) +from pay_kit.errors import ( + ChallengeExpiredError, + ConfigurationError, + DemoSignerOnMainnetError, + InvalidKeyError, + InvalidProofError, + MixedCurrenciesError, + PayKitError, + PaymentRequiredError, + ProtocolIncompatibleError, + ProtocolNotSupportedError, +) +from pay_kit.fee import Fee +from pay_kit.gate import Gate +from pay_kit.gate import dynamic as gate +from pay_kit.operator import Operator +from pay_kit.payment import Payment +from pay_kit.price import Price +from pay_kit.pricing import Pricing +from pay_kit.signer import LocalSigner, Signer +from solana_mpp._expires import days, hours, minutes, seconds, weeks +from solana_mpp.store import FileReplayStore, MemoryStore, Store + +__all__ = [ + # enums / paycore + "Protocol", + "Currency", + "Network", + "Stablecoin", + # value objects + "Price", + "Fee", + "Gate", + "gate", + "Operator", + "Pricing", + "usd", + "eur", + "gbp", + # signer + "Signer", + "LocalSigner", + "InvalidKeyError", + "kms", + # config + "Config", + "X402Config", + "MppConfig", + "configure", + "configure_from", + "config", + "reset", + # payment + store + "Payment", + "Store", + "MemoryStore", + "FileReplayStore", + # middleware trio (framework-agnostic) + "require_payment", + "is_paid", + "is_paid_for", + "payment", + # errors + "errors", + "PayKitError", + "ConfigurationError", + "DemoSignerOnMainnetError", + "MixedCurrenciesError", + "ProtocolIncompatibleError", + "InvalidProofError", + "ChallengeExpiredError", + "PaymentRequiredError", + "ProtocolNotSupportedError", + # expiry helpers (re-exported from solana_mpp) + "seconds", + "minutes", + "hours", + "days", + "weeks", +] + + +def usd(amount: str | int | Decimal, *settlements: Stablecoin) -> Price: + """Build a USD-denominated :class:`Price` (top-level shorthand).""" + return Price.usd(amount, *settlements) + + +def eur(amount: str | int | Decimal, *settlements: Stablecoin) -> Price: + """Build a EUR-denominated :class:`Price` (top-level shorthand).""" + return Price.eur(amount, *settlements) + + +def gbp(amount: str | int | Decimal, *settlements: Stablecoin) -> Price: + """Build a GBP-denominated :class:`Price` (top-level shorthand).""" + return Price.gbp(amount, *settlements) diff --git a/python/src/pay_kit/django.py b/python/src/pay_kit/django.py new file mode 100644 index 000000000..30292433f --- /dev/null +++ b/python/src/pay_kit/django.py @@ -0,0 +1,203 @@ +"""Django integration for pay_kit (caveat #6 host quirks). + +Two entry points, both delegating to the host-neutral +:class:`pay_kit._middleware.PayCore`: + +* :func:`require_payment` decorates a view with a gate reference. On a missing + or unusable proof it returns a ``402`` :class:`~django.http.JsonResponse` + carrying the challenge headers and the ``{"error","resource","accepts"}`` + body; on success it sets ``request.payment`` (and the canonical + ``paykit_payment`` attribute the trio reads) before calling the view, then + echoes the settlement headers onto the response. +* :class:`PaymentMiddleware` is the optional MIDDLEWARE-stack form: it attaches + ``request.payment`` for routes whose ``paykit_gate`` attribute was set (e.g. + by a URLconf wrapper) and translates any escaping :class:`PayKitError` into + the matching JSON response via :attr:`PayKitError.http_status`. + +``PayCore.process`` is async; Django request handling is synchronous by +default, so both forms drive the coroutine with :func:`asyncio.run` (or a +fresh loop when one is already running, e.g. under ASGI). Wire-level header +constants stay canonical-cased; Django lowercases response header names at the +WSGI/ASGI boundary on its own. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from functools import wraps +from typing import TYPE_CHECKING, Any + +from pay_kit._middleware import PAYMENT_ATTR, PayCore, is_paid +from pay_kit._middleware import payment as _core_payment +from pay_kit.config import config as _config +from pay_kit.errors import PayKitError, PaymentRequiredError +from pay_kit.payment import Payment + +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponse, JsonResponse + + from pay_kit.config import Config + from pay_kit.gate import DynamicGate, Gate + from pay_kit.price import Price + from pay_kit.pricing import Pricing + + GateRef = Gate | DynamicGate | Price | str | Callable[[HttpRequest], Gate] + +__all__ = ["require_payment", "PaymentMiddleware", "is_paid", "payment"] + +#: Request attribute a URLconf wrapper or middleware may set to bind a gate to +#: a view when the :class:`PaymentMiddleware` stack form is used. +GATE_ATTR = "paykit_gate" + + +def require_payment( + gate_ref: GateRef, + *, + pricing: Pricing | None = None, + config: Config | None = None, +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Decorate a Django view to require payment for ``gate_ref``. + + On success attaches the verified :class:`~pay_kit.payment.Payment` to + ``request.payment`` (and the canonical ``paykit_payment`` attribute), calls + the view, then merges the settlement headers onto the returned response. On + a missing/invalid proof returns a ``402`` :class:`~django.http.JsonResponse` + built from the challenge; any other :class:`~pay_kit.errors.PayKitError` + renders its :attr:`~pay_kit.errors.PayKitError.http_status`. + """ + + def decorator(view: Callable[..., Any]) -> Callable[..., Any]: + @wraps(view) + def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + core = PayCore(config if config is not None else _config()) + try: + payment = _run(core.process(gate_ref, pricing, request)) + except PayKitError as exc: + return _error_response(exc) + _attach(request, payment) + response = view(request, *args, **kwargs) + return _merge_settlement_headers(response, payment) + + return wrapper + + return decorator + + +class PaymentMiddleware: + """Django MIDDLEWARE-stack form gating views that declare a gate. + + A view becomes gated by exposing a gate reference on the request under the + ``paykit_gate`` attribute (e.g. via a thin URLconf wrapper that sets it + before dispatch). For such requests the middleware verifies the proof, + attaches ``request.payment``, and echoes the settlement headers; otherwise + it passes the request through untouched. Any :class:`PayKitError` raised by + a downstream view (e.g. an imperative :func:`require_payment` from the + trio) is converted to the matching JSON response. + """ + + def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: + """Store the next handler in the Django middleware chain.""" + self._get_response = get_response + + def __call__(self, request: HttpRequest) -> HttpResponse: + """Gate the request when it declares a gate, else pass it through.""" + gate_ref = getattr(request, GATE_ATTR, None) + if gate_ref is None: + return self._passthrough(request) + + core = PayCore(_config()) + try: + payment = _run(core.process(gate_ref, _request_pricing(request), request)) + except PayKitError as exc: + return _error_response(exc) + _attach(request, payment) + response = self._passthrough(request) + return _merge_settlement_headers(response, payment) + + def _passthrough(self, request: HttpRequest) -> HttpResponse: + """Call the next handler, translating any escaping PayKitError.""" + try: + return self._get_response(request) + except PayKitError as exc: + return _error_response(exc) + + +def payment(request: HttpRequest) -> Payment | None: + """Return the verified payment attached to ``request``, or ``None``.""" + return _core_payment(request) + + +# -- internals -------------------------------------------------------------- + + +def _attach(request: HttpRequest, payment: Payment) -> None: + """Bind the verified payment to the request for views and the trio.""" + setattr(request, PAYMENT_ATTR, payment) + # Friendly Django-idiomatic alias mirroring the cross-SDK request.payment. + request.payment = payment # type: ignore[attr-defined] + + +def _merge_settlement_headers(response: HttpResponse, payment: Payment) -> HttpResponse: + """Echo the payment's settlement headers onto the framework response.""" + for key, value in payment.settlement_headers.items(): + response[key] = value + return response + + +def _error_response(exc: PayKitError) -> JsonResponse: + """Render a PayKitError as a JsonResponse using its HTTP status. + + A :class:`~pay_kit.errors.PaymentRequiredError` carries the rendered 402 + challenge (``challenge_headers`` + ``body``) from + :meth:`PayCore.build_402`; everything else falls back to a minimal error + body keyed on the exception's canonical code (if any). + """ + from django.http import JsonResponse + + status = getattr(exc, "http_status", 500) + body = getattr(exc, "body", None) + if not isinstance(body, dict): + body = {"error": getattr(exc, "code", "payment_error"), "message": str(exc)} + + response = JsonResponse(body, status=status) + if isinstance(exc, PaymentRequiredError): + for key, value in getattr(exc, "challenge_headers", {}).items(): + response[key] = value + return response + + +def _request_pricing(request: HttpRequest) -> Pricing | None: + """Pull an optional Pricing registry a wrapper attached to the request.""" + pricing = getattr(request, "paykit_pricing", None) + return pricing + + +def _run(coro: Any) -> Payment: + """Drive an async coroutine to completion from sync Django request code. + + Uses :func:`asyncio.run` when no loop is running; spins a dedicated loop in + a fresh thread when called from within a running loop (ASGI handlers). + """ + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + + import threading + + result: dict[str, Any] = {} + + def _runner() -> None: + try: + result["value"] = asyncio.run(coro) + except BaseException as exc: # re-raised on the calling thread below + result["error"] = exc + + thread = threading.Thread(target=_runner) + thread.start() + thread.join() + error = result.get("error") + if error is not None: + raise error + return result["value"] diff --git a/python/src/pay_kit/fastapi.py b/python/src/pay_kit/fastapi.py new file mode 100644 index 000000000..34f02b293 --- /dev/null +++ b/python/src/pay_kit/fastapi.py @@ -0,0 +1,147 @@ +"""FastAPI shim: a ``Depends``-compatible payment gate plus error mapping. + +Optional dependency: install with ``pay_kit[fastapi]``. Importing this module +without FastAPI present raises a clear :class:`ImportError`. + +Usage:: + + from fastapi import FastAPI, Depends + import pay_kit + from pay_kit.fastapi import RequirePayment, install_exception_handler, payment + + pay_kit.configure(network="solana_localnet") + app = FastAPI() + install_exception_handler(app) + + @app.get("/report") + async def report(payment=Depends(RequirePayment("report", pricing=pricing))): + return {"ok": True, "tx": payment.transaction} + +:func:`RequirePayment` returns a FastAPI dependency. On a missing/invalid proof +it raises ``fastapi.HTTPException`` carrying the 402 challenge headers and JSON +body; on success it attaches the verified :class:`~pay_kit.payment.Payment` to +``request.state`` (so :func:`payment` / the trio can read it) and schedules +the settlement headers to be merged onto the response (caveat #6: FastAPI/ +Starlette lowercase header names at the boundary, so canonical casing is safe). +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +try: + from fastapi import HTTPException, Request, Response +except ImportError as exc: # pragma: no cover - exercised only without the extra + raise ImportError("pay_kit.fastapi requires FastAPI; install with 'pay_kit[fastapi]'") from exc + +from pay_kit._middleware import PAYMENT_ATTR, PayCore, payment +from pay_kit.config import config as _config +from pay_kit.errors import PayKitError, PaymentRequiredError +from pay_kit.payment import Payment + +if TYPE_CHECKING: + from pay_kit.config import Config + from pay_kit.gate import DynamicGate, Gate + from pay_kit.price import Price + from pay_kit.pricing import Pricing + +__all__ = ["RequirePayment", "install_exception_handler", "payment", "Payment"] + +#: Header that carries each settlement header's name through the response hook. +_SETTLEMENT_STATE_ATTR = "paykit_settlement_headers" + +GateRef = "Gate | DynamicGate | Price | str | Callable[[Request], Gate]" + + +def RequirePayment( # noqa: N802 - factory reads as a dependency constructor + gate_ref: Gate | DynamicGate | Price | str | Callable[[Request], Gate], + *, + pricing: Pricing | None = None, + config: Config | None = None, +) -> Callable[..., Any]: + """Build a FastAPI dependency that gates a route behind ``gate_ref``. + + The returned coroutine resolves and verifies payment on every request. + Pass ``config`` to gate against a specific :class:`~pay_kit.config.Config`; + otherwise the process-wide configured instance is used lazily at request + time. On success the verified :class:`Payment` is returned (so the handler + can ``Depends`` on it) and stashed on ``request.state`` for the trio. + """ + + async def dependency(request: Request) -> Payment: + core = PayCore(config if config is not None else _config()) + try: + payment = await core.process(gate_ref, pricing, request) + except PaymentRequiredError as exc: + raise _http_exception(exc) from exc + except PayKitError as exc: + raise _http_exception(exc) from exc + + setattr(request.state, PAYMENT_ATTR, payment) + if payment.settlement_headers: + setattr( + request.state, + _SETTLEMENT_STATE_ATTR, + dict(payment.settlement_headers), + ) + return payment + + return dependency + + +def install_exception_handler(app: Any) -> None: + """Register handlers mapping :class:`PayKitError` to its HTTP status. + + Routes that gate imperatively (calling :func:`pay_kit.require_payment` + inside the handler rather than via :func:`RequirePayment`) raise + :class:`~pay_kit.errors.PayKitError` subclasses directly; this handler + renders them with the correct status and (for a 402) challenge headers. + Also installs a middleware that echoes settlement headers onto successful + responses for gated routes. + """ + + @app.exception_handler(PayKitError) + async def _paykit_error_handler(_request: Request, exc: PayKitError) -> Response: + http_exc = _http_exception(exc) + from fastapi.responses import JSONResponse + + return JSONResponse( + status_code=http_exc.status_code, + content=http_exc.detail, + headers=http_exc.headers, + ) + + @app.middleware("http") + async def _paykit_settlement_headers(request: Request, call_next: Any) -> Response: + response = await call_next(request) + settlement = getattr(request.state, _SETTLEMENT_STATE_ATTR, None) + if isinstance(settlement, dict): + for name, value in settlement.items(): + response.headers[name] = value + return response + + +def _http_exception(exc: PayKitError) -> HTTPException: + """Translate a :class:`PayKitError` into a FastAPI ``HTTPException``. + + A 402 carries the challenge headers and JSON body the core stashed on the + :class:`~pay_kit.errors.PaymentRequiredError`; other errors render a compact + ``{"error": ...}`` detail keyed by the error's canonical code when present. + """ + status = getattr(exc, "http_status", 500) + headers = getattr(exc, "challenge_headers", None) + body = getattr(exc, "body", None) + + detail: Any + if isinstance(body, dict): + detail = body + else: + code = getattr(exc, "code", None) + detail = {"error": code or "payment_error", "message": str(exc)} + + return HTTPException( + status_code=status, + detail=detail, + headers=headers if isinstance(headers, dict) else None, + ) diff --git a/python/src/pay_kit/flask.py b/python/src/pay_kit/flask.py new file mode 100644 index 000000000..f31bd8037 --- /dev/null +++ b/python/src/pay_kit/flask.py @@ -0,0 +1,155 @@ +"""Flask shim for pay_kit (optional dependency, caveat #6). + +Exposes a :func:`require_payment` view decorator that gates a Flask route on a +verified payment, plus :func:`is_paid` / :func:`payment` request accessors. The +decorator delegates every protocol/scheme decision to +:class:`pay_kit._middleware.PayCore`; this module only translates ``PayCore``'s +outcome into Flask idioms (caveat #6): + +* a settled :class:`~pay_kit.payment.Payment` is stashed on ``flask.g`` (under + the same ``paykit_payment`` attribute the host-neutral trio reads) and its + settlement headers are merged onto the response; +* a :class:`~pay_kit.errors.PaymentRequiredError` becomes ``flask.abort`` with a + JSON 402 carrying the challenge headers + body; +* any other :class:`~pay_kit.errors.PayKitError` becomes ``flask.abort`` at its + declared :attr:`http_status` (402 for invalid proof, 406 for unsupported + protocol). + +Header constants stay canonical casing here; Flask/Werkzeug normalise them at +the response boundary. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from functools import wraps +from typing import TYPE_CHECKING, Any, NoReturn, TypeVar + +import flask +from flask import abort, g, make_response + +from pay_kit._middleware import PAYMENT_ATTR, PayCore +from pay_kit.config import config as _global_config +from pay_kit.errors import PayKitError, PaymentRequiredError +from pay_kit.payment import Payment + +if TYPE_CHECKING: + from pay_kit.config import Config + from pay_kit.gate import DynamicGate, Gate + from pay_kit.price import Price + from pay_kit.pricing import Pricing + +__all__ = ["require_payment", "is_paid", "payment"] + +_F = TypeVar("_F", bound="Callable[..., Any]") + +#: Gate reference shapes the decorator accepts (forwarded verbatim to PayCore). +GateRef = "Gate | DynamicGate | Price | str | Callable[[Any], Gate]" + + +def require_payment( + gate_ref: Gate | DynamicGate | Price | str | Callable[[Any], Gate], + *, + pricing: Pricing | None = None, + config: Config | None = None, +) -> Callable[[_F], _F]: + """Decorate a Flask view so it serves only after a verified payment. + + On a successful verify the settled :class:`~pay_kit.payment.Payment` is + attached to ``flask.g`` and its settlement headers are merged onto the + response. A missing/invalid proof aborts with the right HTTP status: 402 with + the challenge headers + JSON body for :class:`PaymentRequiredError`, otherwise + the error's :attr:`~pay_kit.errors.PayKitError.http_status`. + """ + + def decorator(view: _F) -> _F: + @wraps(view) + def wrapper(*args: Any, **kwargs: Any) -> Any: + request = flask.request + core = PayCore(config if config is not None else _global_config()) + try: + payment_obj = _run(core.process(gate_ref, pricing, request)) + except PaymentRequiredError as exc: + _abort_payment_required(exc) + except PayKitError as exc: + _abort_pay_kit_error(exc) + + setattr(g, PAYMENT_ATTR, payment_obj) + response = make_response(view(*args, **kwargs)) + for header, value in payment_obj.settlement_headers.items(): + response.headers[header] = value + return response + + return wrapper # type: ignore[return-value] + + return decorator + + +def payment() -> Payment | None: + """The verified payment attached to the current request, or ``None``.""" + value = getattr(g, PAYMENT_ATTR, None) + return value if isinstance(value, Payment) else None + + +def is_paid( + gate_ref: Gate | DynamicGate | Price | str | Callable[[Any], Gate] | None = None, +) -> bool: + """Whether the current request carries a verified payment. + + With no argument, reports whether any payment is attached. Given a gate (or + gate name) it additionally checks the payment was settled for that gate; + other gate-reference shapes only confirm presence (Payment carries no gate + identity beyond its name). + """ + current = payment() + if current is None: + return False + if gate_ref is None: + return True + if isinstance(gate_ref, str): + return current.gate_name == gate_ref + name = getattr(gate_ref, "name", None) + if isinstance(name, str): + return current.gate_name == name + return True + + +# -- internals -------------------------------------------------------------- + + +def _abort_payment_required(exc: PaymentRequiredError) -> NoReturn: + """Render a 402 from a PaymentRequiredError's stashed challenge.""" + headers: dict[str, str] = getattr(exc, "challenge_headers", {}) or {} + body: dict[str, Any] = getattr(exc, "body", None) or {"error": "payment_required"} + response = make_response(flask.jsonify(body), exc.http_status) + for header, value in headers.items(): + response.headers[header] = value + abort(response) + + +def _abort_pay_kit_error(exc: PayKitError) -> NoReturn: + """Render a non-402-challenge PayKitError at its declared http_status.""" + status = getattr(exc, "http_status", 402) + code = getattr(exc, "code", None) + body: dict[str, Any] = {"error": str(exc)} + if isinstance(code, str): + body["code"] = code + response = make_response(flask.jsonify(body), status) + abort(response) + + +def _run(coro: Any) -> Payment: + """Drive PayCore's async pipeline from Flask's synchronous view context. + + Uses :func:`asyncio.run` when no loop is running; falls back to a dedicated + short-lived loop if one is somehow already active on this thread. + """ + try: + return asyncio.run(coro) + except RuntimeError: + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() From 776351168f7421140e2239161bc7b31bc181fd6a Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 10:43:01 +0300 Subject: [PATCH 09/45] chore(python): wire pay_kit into pyproject 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). --- python/pyproject.toml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 8b07a8a6a..0b59d1e8c 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -12,9 +12,14 @@ dependencies = [ "httpx>=0.27", "solders>=0.22", "solana>=0.35", + "pydantic>=2", + "pydantic-settings>=2", ] [project.optional-dependencies] +fastapi = ["fastapi>=0.110"] +flask = ["flask>=3"] +django = ["django>=4.2"] dev = [ "pytest>=8", "pytest-asyncio>=0.24", @@ -24,7 +29,7 @@ dev = [ ] [tool.hatch.build.targets.wheel] -packages = ["src/solana_mpp"] +packages = ["src/solana_mpp", "src/pay_kit"] [tool.ruff] target-version = "py311" @@ -37,16 +42,21 @@ select = ["E", "F", "W", "I", "UP", "B", "SIM"] pythonVersion = "3.11" typeCheckingMode = "standard" include = ["src", "tests"] +exclude = ["**/__pycache__"] [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] [tool.coverage.run] -source = ["solana_mpp"] +source = ["solana_mpp", "pay_kit"] # Line coverage gate is 90%. Branch coverage is follow-up work tracked in # issue #108. branch = false +# pay_kit/preflight.py wraps live Solana RPC + Surfnet cheatcodes that cannot +# run inside the offline unit suite; every meaningful path is a network call. +# Its two opt-out knobs are covered separately against a stubbed run/RPC. +omit = ["src/pay_kit/preflight.py"] [tool.coverage.report] fail_under = 90 From b3eb1c44e3ca6829181a4a2c59288f10415318f4 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 10:43:01 +0300 Subject: [PATCH 10/45] test(python): add pay_kit test suite 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%. --- python/tests/test_pk_config.py | 318 +++++++++++++++ python/tests/test_pk_frameworks.py | 333 ++++++++++++++++ python/tests/test_pk_middleware.py | 389 ++++++++++++++++++ python/tests/test_pk_mpp_adapter.py | 270 +++++++++++++ python/tests/test_pk_pricing_helpers.py | 227 +++++++++++ python/tests/test_pk_signer_operator.py | 291 ++++++++++++++ python/tests/test_pk_value_objects.py | 366 +++++++++++++++++ python/tests/test_pk_x402_settle.py | 321 +++++++++++++++ python/tests/test_pk_x402_verifier.py | 505 ++++++++++++++++++++++++ 9 files changed, 3020 insertions(+) create mode 100644 python/tests/test_pk_config.py create mode 100644 python/tests/test_pk_frameworks.py create mode 100644 python/tests/test_pk_middleware.py create mode 100644 python/tests/test_pk_mpp_adapter.py create mode 100644 python/tests/test_pk_pricing_helpers.py create mode 100644 python/tests/test_pk_signer_operator.py create mode 100644 python/tests/test_pk_value_objects.py create mode 100644 python/tests/test_pk_x402_settle.py create mode 100644 python/tests/test_pk_x402_verifier.py diff --git a/python/tests/test_pk_config.py b/python/tests/test_pk_config.py new file mode 100644 index 000000000..22af42ed7 --- /dev/null +++ b/python/tests/test_pk_config.py @@ -0,0 +1,318 @@ +"""Config builder, env loader, deprecation shims, and preflight-knob coverage. + +Covers: ``configure`` / ``configure_from`` happy paths, the warn-once +deprecation shims (``pay_to`` / ``facilitator`` / ``facilitator_secret_key`` / +``secret``), the demo-signer-on-mainnet refusal, rpc_url defaults per network +(caveat #2), the localnet->mainnet mint fallback (caveat #1), and BOTH preflight +opt-out knobs (caveat #7): ``configure(preflight=False)`` and +``PAY_KIT_DISABLE_PREFLIGHT=1``, each asserted against a stubbed +``pay_kit.preflight.run`` so no live RPC runs. +""" + +from __future__ import annotations + +import warnings + +import pytest + +import pay_kit.preflight as preflight_mod +from pay_kit import ( + Config, + MppConfig, + Network, + Operator, + Protocol, + Signer, + Stablecoin, + X402Config, + configure, + configure_from, +) +from pay_kit._paycore import mints +from pay_kit.config import config as get_config +from pay_kit.config import reset +from pay_kit.errors import ConfigurationError, DemoSignerOnMainnetError +from pay_kit.signer import DEMO_PUBKEY + + +@pytest.fixture(autouse=True) +def _clean_config(monkeypatch): + """Reset the singleton + deprecation memo and disable real preflight/RPC.""" + reset() + monkeypatch.setenv("PAY_KIT_DISABLE_PREFLIGHT", "1") + # Belt-and-braces: also stub run so nothing can hit the network. + monkeypatch.setattr(preflight_mod, "run", lambda cfg: None) + yield + reset() + + +# -- configure happy paths --------------------------------------------------- + + +def test_configure_zero_config_defaults(): + cfg = configure() + assert cfg.network is Network.SOLANA_LOCALNET + assert cfg.accept == (Protocol.X402, Protocol.MPP) + assert cfg.stablecoins == (Stablecoin.USDC,) + assert get_config() is cfg + + +def test_configure_stores_singleton(): + cfg = configure(network="solana_devnet") + assert get_config() is cfg + + +def test_config_accessor_lazily_builds_default(): + reset() + cfg = get_config() + assert isinstance(cfg, Config) + + +def test_configure_accept_single_protocol_coerced(): + cfg = configure(accept=Protocol.MPP) + assert cfg.accept == (Protocol.MPP,) + + +def test_configure_stablecoins_single_coerced_and_deduped(): + cfg = configure(stablecoins=[Stablecoin.USDC, Stablecoin.USDC, Stablecoin.USDT]) + assert cfg.stablecoins == (Stablecoin.USDC, Stablecoin.USDT) + + +def test_configure_empty_accept_raises(): + with pytest.raises(ConfigurationError, match="accept must not be empty"): + configure(accept=()) + + +def test_configure_empty_stablecoins_raises(): + with pytest.raises(ConfigurationError, match="stablecoins must not be empty"): + configure(stablecoins=()) + + +def test_configure_rejects_non_operator(): + with pytest.raises(ConfigurationError, match="operator must be"): + configure(operator={"recipient": "x"}) + + +# -- rpc_url defaults (caveat #2) -------------------------------------------- + + +def test_localnet_default_rpc_is_hosted_surfnet(): + cfg = configure(network="solana_localnet") + assert cfg.effective_rpc_url() == "https://402.surfnet.dev:8899" + assert cfg.using_public_rpc_default() is True + + +def test_devnet_default_rpc(): + cfg = configure(network="solana_devnet") + assert cfg.effective_rpc_url() == "https://api.devnet.solana.com" + + +def test_explicit_rpc_url_overrides_default(): + cfg = configure(network="solana_devnet", rpc_url="https://my.rpc") + assert cfg.effective_rpc_url() == "https://my.rpc" + assert cfg.using_public_rpc_default() is False + + +# -- demo-signer-on-mainnet refusal ------------------------------------------ + + +def test_demo_signer_on_mainnet_refused(): + with pytest.raises(DemoSignerOnMainnetError, match="refuses to start"): + configure(network="solana_mainnet") # operator defaults to demo signer + + +def test_real_signer_on_mainnet_allowed(): + op = Operator(signer=Signer.generate(), recipient="R1111111111111111111111111111111111111111") + cfg = configure(network="solana_mainnet", operator=op, rpc_url="https://helius") + assert cfg.network is Network.SOLANA_MAINNET + + +def test_public_mainnet_rpc_warns(caplog): + op = Operator(signer=Signer.generate(), recipient="R1111111111111111111111111111111111111111") + with caplog.at_level("WARNING", logger="pay_kit"): + configure(network="solana_mainnet", operator=op) # no rpc_url -> public default + assert any("public Solana RPC" in r.message for r in caplog.records) + + +# -- mint localnet -> mainnet fallback (caveat #1) --------------------------- + + +def test_mint_localnet_falls_back_to_mainnet_row(): + mainnet = mints.resolve("USDC", "mainnet") + localnet = mints.resolve("USDC", "localnet") + assert localnet == mainnet + assert localnet is not None + + +def test_mint_sol_returns_none(): + assert mints.resolve("SOL", "mainnet") is None + + +# -- effective accessors ----------------------------------------------------- + + +def test_effective_recipient_from_operator(): + cfg = configure() + assert cfg.effective_recipient() == DEMO_PUBKEY + + +def test_effective_x402_signer_falls_back_to_operator_signer(): + cfg = configure() + s = cfg.effective_x402_signer() + assert s is not None and s.is_demo() + + +def test_x402_config_override_signer_wins(): + override = Signer.generate() + cfg = configure(x402=X402Config(signer=override)) + signer = cfg.effective_x402_signer() + assert signer is not None + assert signer.pubkey() == override.pubkey() + + +def test_x402_is_delegated_flag(): + assert X402Config(facilitator_url="https://f").is_delegated() is True + assert X402Config().is_delegated() is False + + +def test_mpp_config_expires_in_must_be_positive(): + with pytest.raises(ConfigurationError, match="positive"): + MppConfig(expires_in=0) + + +def test_mpp_config_with_secret_copy(): + base = MppConfig() + updated = base.with_challenge_binding_secret("abc") + assert updated.challenge_binding_secret == "abc" + assert base.challenge_binding_secret is None # original untouched + + +# -- deprecation shims (warn-once) ------------------------------------------- + + +def test_deprecated_pay_to_routes_to_operator(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + cfg = configure(pay_to="LegacyRecipient1111111111111111111111111111") + assert cfg.effective_recipient() == "LegacyRecipient1111111111111111111111111111" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + +def test_deprecated_pay_to_warns_once(): + reset() # clears the warn-once memo + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + configure(pay_to="R1111111111111111111111111111111111111111") + configure(pay_to="R2222222222222222222222222222222222222222") + pay_to_warnings = [w for w in caught if w.category is DeprecationWarning and "pay_to" in str(w.message)] + assert len(pay_to_warnings) == 1 + + +def test_deprecated_facilitator_routes_to_rpc_url(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + cfg = configure(network="solana_devnet", facilitator="https://legacy.rpc") + assert cfg.effective_rpc_url() == "https://legacy.rpc" + + +def test_deprecated_facilitator_secret_key_routes_to_signer(): + import json + + from solders.keypair import Keypair + + kp = Keypair() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + cfg = configure(facilitator_secret_key=json.dumps(list(bytes(kp)))) + assert cfg.operator.signer is not None + assert cfg.operator.signer.pubkey() == str(kp.pubkey()) + + +def test_deprecated_facilitator_secret_key_empty_sentinel_is_noop(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + cfg = configure(facilitator_secret_key="[]") # legacy "boot without signer" + assert cfg.operator.signer is not None and cfg.operator.signer.is_demo() + + +def test_deprecated_secret_routes_to_mpp(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + cfg = configure(secret="legacy-binding-secret") + assert cfg.mpp.challenge_binding_secret == "legacy-binding-secret" + + +# -- configure_from (env) ---------------------------------------------------- + + +def test_configure_from_reads_scalars(monkeypatch): + monkeypatch.setenv("PAY_KIT_NETWORK", "solana_devnet") + monkeypatch.setenv("PAY_KIT_RPC_URL", "https://env.rpc") + monkeypatch.setenv("PAY_KIT_PREFLIGHT", "false") + cfg = configure_from() + assert cfg.network is Network.SOLANA_DEVNET + assert cfg.effective_rpc_url() == "https://env.rpc" + assert cfg.preflight is False + + +def test_configure_from_reads_mpp_and_x402(monkeypatch): + monkeypatch.setenv("PAY_KIT_MPP_REALM", "MyRealm") + monkeypatch.setenv("PAY_KIT_MPP_CHALLENGE_BINDING_SECRET", "envsecret") + monkeypatch.setenv("PAY_KIT_MPP_EXPIRES_IN", "300") + monkeypatch.setenv("PAY_KIT_X402_FACILITATOR_URL", "https://fac") + cfg = configure_from() + assert cfg.mpp.realm == "MyRealm" + assert cfg.mpp.challenge_binding_secret == "envsecret" + assert cfg.mpp.expires_in == 300 + assert cfg.x402.facilitator_url == "https://fac" + + +def test_configure_from_no_env_uses_defaults(monkeypatch): + for key in ("NETWORK", "RPC_URL", "ACCEPT", "STABLECOINS", "MPP_REALM"): + monkeypatch.delenv(f"PAY_KIT_{key}", raising=False) + cfg = configure_from() + assert cfg.network is Network.SOLANA_LOCALNET + + +# -- preflight knobs (caveat #7) --------------------------------------------- + + +def test_preflight_false_skips_run(monkeypatch): + """configure(preflight=False) must not invoke preflight.run at all.""" + calls = [] + monkeypatch.setattr(preflight_mod, "run", lambda cfg: calls.append(cfg)) + # Clear the env kill-switch so only the kwarg governs this path. + monkeypatch.delenv("PAY_KIT_DISABLE_PREFLIGHT", raising=False) + configure(preflight=False) + assert calls == [] + + +def test_preflight_env_kill_switch_skips_run(monkeypatch): + """PAY_KIT_DISABLE_PREFLIGHT=1 short-circuits even when preflight=True.""" + calls = [] + monkeypatch.setattr(preflight_mod, "run", lambda cfg: calls.append(cfg)) + monkeypatch.setenv("PAY_KIT_DISABLE_PREFLIGHT", "1") + configure(preflight=True) + assert calls == [] + + +def test_preflight_fires_when_enabled(monkeypatch): + """With the env switch cleared and preflight=True, run() fires exactly once.""" + calls = [] + monkeypatch.setattr(preflight_mod, "run", lambda cfg: calls.append(cfg)) + monkeypatch.delenv("PAY_KIT_DISABLE_PREFLIGHT", raising=False) + cfg = configure( + preflight=True, + mpp=MppConfig(challenge_binding_secret="set-so-no-dotenv-write"), + ) + assert calls == [cfg] + + +def test_is_disabled_by_env_true_values(monkeypatch): + for value in ("1", "true"): + monkeypatch.setenv("PAY_KIT_DISABLE_PREFLIGHT", value) + assert preflight_mod.is_disabled_by_env() is True + monkeypatch.setenv("PAY_KIT_DISABLE_PREFLIGHT", "0") + assert preflight_mod.is_disabled_by_env() is False + monkeypatch.delenv("PAY_KIT_DISABLE_PREFLIGHT", raising=False) + assert preflight_mod.is_disabled_by_env() is False diff --git a/python/tests/test_pk_frameworks.py b/python/tests/test_pk_frameworks.py new file mode 100644 index 000000000..67613694a --- /dev/null +++ b/python/tests/test_pk_frameworks.py @@ -0,0 +1,333 @@ +"""Framework-shim coverage (caveat #6): FastAPI, Flask, Django. + +Each shim is exercised end to end through its native test client: a missing +proof yields a 402 carrying the challenge headers, and a valid proof attaches +the verified :class:`Payment` and echoes settlement headers. ``PayCore.process`` +is stubbed at the class level so no adapter / RPC runs; the shims own only the +host-quirk translation these tests assert on. +""" + +from __future__ import annotations + +import pytest + +import pay_kit._middleware as mw +from pay_kit import MppConfig, Payment, Price, Protocol, Stablecoin, configure +from pay_kit.config import reset +from pay_kit.errors import PaymentRequiredError, ProtocolNotSupportedError + +SECRET = "challenge-binding-secret-long-enough-for-hmac" + + +@pytest.fixture(autouse=True) +def _clean(monkeypatch): + reset() + monkeypatch.setenv("PAY_KIT_DISABLE_PREFLIGHT", "1") + configure( + network="solana_localnet", + preflight=False, + accept=(Protocol.MPP,), + mpp=MppConfig(challenge_binding_secret=SECRET), + ) + yield + reset() + + +def _valid_payment(): + return Payment( + protocol=Protocol.MPP, + transaction="sig-abc", + gate_name="report", + settlement_headers={"x-payment-settlement-signature": "sig-abc"}, + ) + + +def _stub_402(): + err = PaymentRequiredError("pay_kit: payment required") + err.challenge_headers = {"www-authenticate": "Payment realm=App", "content-type": "application/json"} # type: ignore[attr-defined] + err.body = {"error": "payment_required", "resource": "/report", "accepts": []} # type: ignore[attr-defined] + return err + + +def _patch_process(monkeypatch, *, paid: bool): + async def fake_process(self, gate_ref, pricing, request): + if paid: + return _valid_payment() + raise _stub_402() + + monkeypatch.setattr(mw.PayCore, "process", fake_process) + + +# --------------------------------------------------------------------------- +# FastAPI +# --------------------------------------------------------------------------- + + +def _fastapi_app(): + from fastapi import Depends, FastAPI + + import pay_kit.fastapi as pk_fastapi + + app = FastAPI() + pk_fastapi.install_exception_handler(app) + + dep = Depends(pk_fastapi.RequirePayment(Price.usd("0.10", Stablecoin.USDC))) + + @app.get("/report") + async def report(payment=dep): + return {"ok": True, "tx": payment.transaction} + + return app + + +def test_fastapi_402_on_missing_payment(monkeypatch): + from starlette.testclient import TestClient + + _patch_process(monkeypatch, paid=False) + client = TestClient(_fastapi_app()) + resp = client.get("/report") + assert resp.status_code == 402 + assert resp.headers.get("www-authenticate") == "Payment realm=App" + # FastAPI's HTTPException nests the rendered challenge body under "detail". + assert resp.json()["detail"]["error"] == "payment_required" + + +def test_fastapi_success_attaches_payment_and_settlement(monkeypatch): + from starlette.testclient import TestClient + + _patch_process(monkeypatch, paid=True) + client = TestClient(_fastapi_app()) + resp = client.get("/report") + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "tx": "sig-abc"} + assert resp.headers.get("x-payment-settlement-signature") == "sig-abc" + + +def test_fastapi_exception_handler_renders_pay_kit_error(monkeypatch): + from fastapi import FastAPI + from starlette.testclient import TestClient + + import pay_kit.fastapi as pk_fastapi + + app = FastAPI() + pk_fastapi.install_exception_handler(app) + + @app.get("/imperative") + async def imperative(): + raise ProtocolNotSupportedError("nope") + + resp = TestClient(app, raise_server_exceptions=False).get("/imperative") + assert resp.status_code == 406 + + +def test_fastapi_payment_reexport(): + import pay_kit.fastapi as pk_fastapi + + assert pk_fastapi.payment is not None + assert pk_fastapi.Payment is Payment + + +# --------------------------------------------------------------------------- +# Flask +# --------------------------------------------------------------------------- + + +def _flask_app(): + import flask + + import pay_kit.flask as pk_flask + + app = flask.Flask(__name__) + + @app.get("/report") + @pk_flask.require_payment(Price.usd("0.10", Stablecoin.USDC)) + def report(): + current = pk_flask.payment() + assert current is not None + return {"ok": True, "tx": current.transaction, "paid": pk_flask.is_paid("report")} + + return app + + +def test_flask_402_on_missing_payment(monkeypatch): + _patch_process(monkeypatch, paid=False) + client = _flask_app().test_client() + resp = client.get("/report") + assert resp.status_code == 402 + assert resp.headers.get("www-authenticate") == "Payment realm=App" + assert resp.get_json()["error"] == "payment_required" + + +def test_flask_success_attaches_g_and_settlement(monkeypatch): + _patch_process(monkeypatch, paid=True) + client = _flask_app().test_client() + resp = client.get("/report") + assert resp.status_code == 200 + assert resp.get_json() == {"ok": True, "tx": "sig-abc", "paid": True} + assert resp.headers.get("x-payment-settlement-signature") == "sig-abc" + + +def test_flask_non_402_pay_kit_error(monkeypatch): + import flask + + import pay_kit.flask as pk_flask + + async def boom(self, gate_ref, pricing, request): + raise ProtocolNotSupportedError("unsupported") + + monkeypatch.setattr(mw.PayCore, "process", boom) + + app = flask.Flask(__name__) + + @app.get("/x") + @pk_flask.require_payment(Price.usd("0.10", Stablecoin.USDC)) + def view(): + return {"ok": True} + + resp = app.test_client().get("/x") + assert resp.status_code == 406 + + +def test_flask_is_paid_without_payment(): + import flask + + import pay_kit.flask as pk_flask + + app = flask.Flask(__name__) + + @app.get("/probe") + def probe(): + return {"paid": pk_flask.is_paid(), "payment_none": pk_flask.payment() is None} + + resp = app.test_client().get("/probe") + assert resp.get_json() == {"paid": False, "payment_none": True} + + +# --------------------------------------------------------------------------- +# Django +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module", autouse=True) +def _django_settings(): + import django + from django.conf import settings + + if not settings.configured: + settings.configure( + DEBUG=True, + ALLOWED_HOSTS=["*"], + ROOT_URLCONF=None, + DATABASES={}, + INSTALLED_APPS=[], + ) + django.setup() + yield + + +def test_django_decorator_402_on_missing_payment(monkeypatch): + from django.test import RequestFactory + + import pay_kit.django as pk_django + + _patch_process(monkeypatch, paid=False) + + @pk_django.require_payment(Price.usd("0.10", Stablecoin.USDC)) + def view(request): + from django.http import JsonResponse + + return JsonResponse({"ok": True}) + + resp = view(RequestFactory().get("/report")) + assert resp.status_code == 402 + assert resp["www-authenticate"] == "Payment realm=App" + + +def test_django_decorator_success_attaches_and_settles(monkeypatch): + from django.http import JsonResponse + from django.test import RequestFactory + + import pay_kit.django as pk_django + + _patch_process(monkeypatch, paid=True) + + @pk_django.require_payment(Price.usd("0.10", Stablecoin.USDC)) + def view(request): + assert pk_django.payment(request) is not None + return JsonResponse({"ok": True, "tx": request.payment.transaction}) + + resp = view(RequestFactory().get("/report")) + assert resp.status_code == 200 + assert resp["x-payment-settlement-signature"] == "sig-abc" + + +def test_django_decorator_non_402_error(monkeypatch): + from django.test import RequestFactory + + import pay_kit.django as pk_django + + async def boom(self, gate_ref, pricing, request): + raise ProtocolNotSupportedError("unsupported") + + monkeypatch.setattr(mw.PayCore, "process", boom) + + @pk_django.require_payment(Price.usd("0.10", Stablecoin.USDC)) + def view(request): + from django.http import JsonResponse + + return JsonResponse({"ok": True}) + + resp = view(RequestFactory().get("/x")) + assert resp.status_code == 406 + + +def test_django_middleware_passthrough_when_no_gate(monkeypatch): + from django.http import JsonResponse + from django.test import RequestFactory + + import pay_kit.django as pk_django + + def get_response(request): + return JsonResponse({"passthrough": True}) + + middleware = pk_django.PaymentMiddleware(get_response) + resp = middleware(RequestFactory().get("/open")) + assert resp.status_code == 200 + assert resp.content == b'{"passthrough": true}' + + +def test_django_middleware_gates_when_gate_attribute_set(monkeypatch): + from django.http import JsonResponse + from django.test import RequestFactory + + import pay_kit.django as pk_django + + _patch_process(monkeypatch, paid=True) + + def get_response(request): + return JsonResponse({"ok": True, "tx": request.payment.transaction}) + + middleware = pk_django.PaymentMiddleware(get_response) + request = RequestFactory().get("/report") + request.paykit_gate = Price.usd("0.10", Stablecoin.USDC) # type: ignore[attr-defined] + resp = middleware(request) + assert resp.status_code == 200 + assert resp["x-payment-settlement-signature"] == "sig-abc" + + +def test_django_middleware_402_when_unpaid(monkeypatch): + from django.http import JsonResponse + from django.test import RequestFactory + + import pay_kit.django as pk_django + + _patch_process(monkeypatch, paid=False) + + def get_response(request): + return JsonResponse({"ok": True}) + + middleware = pk_django.PaymentMiddleware(get_response) + request = RequestFactory().get("/report") + request.paykit_gate = Price.usd("0.10", Stablecoin.USDC) # type: ignore[attr-defined] + resp = middleware(request) + assert resp.status_code == 402 diff --git a/python/tests/test_pk_middleware.py b/python/tests/test_pk_middleware.py new file mode 100644 index 000000000..1ea8c8611 --- /dev/null +++ b/python/tests/test_pk_middleware.py @@ -0,0 +1,389 @@ +"""PayCore middleware, Pricing registry, the request-scoped trio, kms, errors. + +Covers gate-reference coercion (inline Gate, name via Pricing, bare Price, +plain callable, DynamicGate), adapter detection in accept order (x402 wins, +fees disable x402, MPP scheme matching), the 402 build path, and the +``require_payment`` / ``is_paid`` / ``is_paid_for`` / ``payment`` trio over +attribute / mapping / ``.state`` request shapes. +""" + +from __future__ import annotations + +import pytest + +from pay_kit import ( + Gate, + MppConfig, + Payment, + Price, + Pricing, + Protocol, + Stablecoin, + configure, + is_paid, + is_paid_for, + kms, + payment, + require_payment, +) +from pay_kit._middleware import PAYMENT_ATTR, PayCore +from pay_kit.config import reset +from pay_kit.errors import ( + ChallengeExpiredError, + ConfigurationError, + InvalidProofError, + PaymentRequiredError, + ProtocolNotSupportedError, +) +from pay_kit.pricing import coerce + +SECRET = "challenge-binding-secret-long-enough-for-hmac" +FEE_A = "9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ" + + +@pytest.fixture(autouse=True) +def _clean(monkeypatch): + reset() + monkeypatch.setenv("PAY_KIT_DISABLE_PREFLIGHT", "1") + yield + reset() + + +def _cfg(accept=(Protocol.X402, Protocol.MPP)): + return configure( + network="solana_localnet", + preflight=False, + accept=accept, + mpp=MppConfig(challenge_binding_secret=SECRET), + ) + + +def _gate(cfg, **kw): + kw.setdefault("name", "report") + kw.setdefault("amount", Price.usd("0.10", Stablecoin.USDC)) + kw.setdefault("default_pay_to", cfg.effective_recipient()) + return Gate.build(**kw) + + +class _Req: + """Minimal request bag: attribute headers + path.""" + + def __init__(self, headers=None, path="/report"): + self.headers = headers or {} + self.path = path + + +# -- gate resolution --------------------------------------------------------- + + +def test_resolve_inline_gate_passthrough(): + cfg = _cfg() + core = PayCore(cfg) + g = _gate(cfg, accept=(Protocol.MPP,)) + assert core.resolve_gate(g, None, _Req()) is g + + +def test_resolve_bare_price_wraps_with_defaults(): + cfg = _cfg() + core = PayCore(cfg) + g = core.resolve_gate(Price.usd("0.10", Stablecoin.USDC), None, _Req()) + assert isinstance(g, Gate) + assert g.pay_to == cfg.effective_recipient() + + +def test_resolve_name_via_pricing_registry(): + cfg = _cfg() + core = PayCore(cfg) + + class Catalog(Pricing): + def __init__(self): + self.report = _gate(cfg, accept=(Protocol.MPP,)) + + g = core.resolve_gate("report", Catalog(), _Req()) + assert g.name == "report" + + +def test_resolve_plain_callable_returning_price(): + cfg = _cfg() + core = PayCore(cfg) + + def builder(request): + return Price.usd("0.20", Stablecoin.USDC) + + g = core.resolve_gate(builder, None, _Req()) # type: ignore[arg-type] + assert g.amount.amount_string() == "0.20" + + +def test_resolve_plain_callable_returning_gate(): + cfg = _cfg() + core = PayCore(cfg) + concrete = _gate(cfg, accept=(Protocol.MPP,)) + g = core.resolve_gate(lambda r: concrete, None, _Req()) + assert g is concrete + + +def test_resolve_callable_bad_return_raises(): + cfg = _cfg() + core = PayCore(cfg) + with pytest.raises(ProtocolNotSupportedError, match="expected Gate or Price"): + core.resolve_gate(lambda r: 5, None, _Req()) # type: ignore[arg-type] + + +def test_resolve_dynamic_gate_injects_defaults(): + from pay_kit import gate as dynamic + + cfg = _cfg() + core = PayCore(cfg) + + @dynamic("by_units", accept=(Protocol.MPP,)) # type: ignore[arg-type] + def builder(request): + return Price.usd("0.10", Stablecoin.USDC) + + g = core.resolve_gate(builder, None, _Req()) + assert g.pay_to == cfg.effective_recipient() + + +def test_coerce_unknown_name_without_registry_raises(): + cfg = _cfg() + with pytest.raises(ConfigurationError, match="no Pricing registry"): + coerce("report", registry=None, config=cfg) + + +def test_coerce_bad_type_raises(): + cfg = _cfg() + with pytest.raises(ConfigurationError, match="cannot coerce"): + coerce(42, config=cfg) # type: ignore[arg-type] + + +# -- adapter detection ------------------------------------------------------- + + +def test_detect_mpp_when_payment_authorization_present(): + cfg = _cfg(accept=(Protocol.MPP,)) + core = PayCore(cfg) + g = _gate(cfg, accept=(Protocol.MPP,)) + adapter = core.detect_adapter(g, {"authorization": "Payment abc"}) + assert adapter is core._mpp + + +def test_detect_x402_wins_when_both_proofs_present(): + cfg = _cfg() + core = PayCore(cfg) + g = _gate(cfg, accept=(Protocol.X402, Protocol.MPP)) + headers = {"authorization": "Payment abc", "payment-signature": "deadbeef"} + assert core.detect_adapter(g, headers) is core._x402 + + +def test_detect_none_when_no_proof(): + cfg = _cfg() + core = PayCore(cfg) + g = _gate(cfg, accept=(Protocol.X402, Protocol.MPP)) + assert core.detect_adapter(g, {}) is None + + +def test_detect_x402_disabled_on_fee_gate(): + cfg = _cfg() + core = PayCore(cfg) + g = _gate(cfg, fee_on_top={FEE_A: Price.usd("0.02", Stablecoin.USDC)}) + # x402 signature present but fees disable x402; no MPP auth -> None. + assert core.detect_adapter(g, {"payment-signature": "deadbeef"}) is None + # MPP still works on the fee gate. + assert core.detect_adapter(g, {"authorization": "Payment x"}) is core._mpp + + +def test_x402_adapter_absent_when_not_accepted(): + cfg = _cfg(accept=(Protocol.MPP,)) + core = PayCore(cfg) + assert core._x402 is None + + +# -- 402 assembly ------------------------------------------------------------ + + +def test_build_402_offers_both_protocols(): + cfg = _cfg() + core = PayCore(cfg) + g = _gate(cfg, accept=(Protocol.X402, Protocol.MPP)) + headers, body = core.build_402(g, _Req()) + protocols = {a["protocol"] for a in body["accepts"]} + assert protocols == {"x402", "mpp"} + assert "payment-required" in headers + assert "www-authenticate" in headers + assert body["error"] == "payment_required" + + +def test_build_402_fee_gate_omits_x402(): + cfg = _cfg() + core = PayCore(cfg) + g = _gate(cfg, fee_on_top={FEE_A: Price.usd("0.02", Stablecoin.USDC)}) + _headers, body = core.build_402(g, _Req()) + protocols = {a["protocol"] for a in body["accepts"]} + assert protocols == {"mpp"} + + +# -- process ----------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_process_no_proof_raises_payment_required_with_challenge(): + cfg = _cfg() + core = PayCore(cfg) + g = _gate(cfg, accept=(Protocol.X402, Protocol.MPP)) + with pytest.raises(PaymentRequiredError) as exc: + await core.process(g, None, _Req()) + assert hasattr(exc.value, "challenge_headers") + assert hasattr(exc.value, "body") + + +@pytest.mark.asyncio +async def test_process_dispatches_to_adapter(monkeypatch): + cfg = _cfg(accept=(Protocol.MPP,)) + core = PayCore(cfg) + g = _gate(cfg, accept=(Protocol.MPP,)) + + sentinel = Payment(protocol=Protocol.MPP, transaction="sig123", gate_name="report") + + async def fake_verify(gate, request): + return sentinel + + monkeypatch.setattr(core._mpp, "verify_and_settle", fake_verify) + out = await core.process(g, None, _Req(headers={"authorization": "Payment abc"})) + assert out is sentinel + + +@pytest.mark.asyncio +async def test_process_inline_dynamic_from_registry_raises(): + from pay_kit import gate as dynamic + + cfg = _cfg() + core = PayCore(cfg) + + @dynamic("by_units") # type: ignore[arg-type] + def builder(request): + return Price.usd("0.10", Stablecoin.USDC) + + class Catalog(Pricing): + def __init__(self): + self.by_units = builder + + # Resolving a DynamicGate through the static coercion path needs a request. + with pytest.raises(ProtocolNotSupportedError, match="requires a request"): + core._coerce_static("by_units", Catalog()) + + +# -- request-scoped trio ----------------------------------------------------- + + +def _paid_request(gate_name="report"): + req = _Req() + setattr(req, PAYMENT_ATTR, Payment(protocol=Protocol.MPP, transaction="sig", gate_name=gate_name)) + return req + + +def test_payment_reads_attribute(): + req = _paid_request() + settled = payment(req) + assert settled is not None and settled.transaction == "sig" + + +def test_payment_none_when_absent(): + assert payment(_Req()) is None + + +def test_payment_from_mapping(): + settled = Payment(protocol=Protocol.MPP, transaction="sig") + assert payment({PAYMENT_ATTR: settled}) is settled + + +def test_payment_from_state_namespace(): + class State: + pass + + class StateReq: + def __init__(self): + self.state = State() + + req = StateReq() + settled = Payment(protocol=Protocol.MPP, transaction="sig") + setattr(req.state, PAYMENT_ATTR, settled) + assert payment(req) is settled + + +def test_is_paid_true_false(): + assert is_paid(_paid_request()) is True + assert is_paid(_Req()) is False + + +def test_is_paid_for_gate_instance_trusts_middleware(): + cfg = _cfg() + g = _gate(cfg, accept=(Protocol.MPP,)) + assert is_paid_for(_paid_request(), g) is True + + +def test_is_paid_for_string_matches_gate_name(): + assert is_paid_for(_paid_request("report"), "report") is True + assert is_paid_for(_paid_request("report"), "other") is False + + +def test_is_paid_for_unpaid_is_false(): + assert is_paid_for(_Req(), "report") is False + + +def test_require_payment_returns_payment(): + assert require_payment(_paid_request()).transaction == "sig" + + +def test_require_payment_raises_when_unpaid(): + with pytest.raises(PaymentRequiredError): + require_payment(_Req()) + + +# -- kms reserved namespace -------------------------------------------------- + + +def test_kms_gcp_not_implemented(): + with pytest.raises(NotImplementedError, match="follow-up"): + kms.gcp(key_name="k", pubkey="p") + + +def test_kms_aws_not_implemented(): + with pytest.raises(NotImplementedError, match="follow-up"): + kms.aws(key_id="k", region="us", pubkey="p") + + +def test_kms_vault_not_implemented(): + with pytest.raises(NotImplementedError, match="follow-up"): + kms.vault(addr="a", path="p", pubkey="k") + + +# -- errors ------------------------------------------------------------------ + + +def test_invalid_proof_error_http_status_and_code(): + err = InvalidProofError("bad", code="signature_consumed") + assert err.http_status == 402 + assert err.code == "signature_consumed" + + +def test_challenge_expired_defaults(): + err = ChallengeExpiredError() + assert err.code == "challenge_expired" + assert err.http_status == 402 + + +def test_protocol_not_supported_http_status(): + assert ProtocolNotSupportedError("x").http_status == 406 + + +def test_payment_required_http_status(): + assert PaymentRequiredError("x").http_status == 402 + + +# -- top-level shorthands ---------------------------------------------------- + + +def test_usd_and_eur_shorthands(): + import pay_kit + + assert pay_kit.usd("1.00", Stablecoin.USDC).currency.value == "USD" + assert pay_kit.eur("2.00").currency.value == "EUR" diff --git a/python/tests/test_pk_mpp_adapter.py b/python/tests/test_pk_mpp_adapter.py new file mode 100644 index 000000000..7158c6167 --- /dev/null +++ b/python/tests/test_pk_mpp_adapter.py @@ -0,0 +1,270 @@ +"""MPP charge adapter coverage: offer/challenge build, cross-route replay, +fee splits, and the caveat #4 HMAC secret auto-resolution chain. + +No live RPC: all verify paths assert on the binding/Tier-2 layer, which rejects +before settlement. The cross-route test reuses ``solana_mpp``'s real challenge +HMAC so the pin actually fires. +""" + +from __future__ import annotations + +import pytest + +from pay_kit import Gate, MppConfig, Price, Protocol, Stablecoin, configure +from pay_kit.config import reset +from pay_kit.errors import InvalidProofError +from pay_kit.protocols.mpp import MppAdapter, SecretResolver +from solana_mpp._headers import format_authorization +from solana_mpp._types import ChallengeEcho, PaymentCredential + +SECRET = "challenge-binding-secret-long-enough-for-hmac" +FEE_A = "9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ" + + +@pytest.fixture(autouse=True) +def _clean(monkeypatch): + reset() + monkeypatch.setenv("PAY_KIT_DISABLE_PREFLIGHT", "1") + yield + reset() + + +def _cfg(**kw): + kw.setdefault("network", "solana_localnet") + kw.setdefault("preflight", False) + kw.setdefault("accept", (Protocol.MPP,)) + kw.setdefault("mpp", MppConfig(challenge_binding_secret=SECRET)) + return configure(**kw) + + +def _gate(cfg, name="report", amount="0.10", **kw): + return Gate.build( + name=name, + amount=Price.usd(amount, Stablecoin.USDC), + default_pay_to=cfg.effective_recipient(), + accept=(Protocol.MPP,), + **kw, + ) + + +def _credential_for(adapter: MppAdapter, gate: Gate) -> str: + """Issue a real HMAC-bound challenge for ``gate`` and wrap it in an + Authorization header with a (bogus) signature payload.""" + mpp = adapter._server_for(gate) + challenge = mpp.charge_with_options(adapter._human_amount(gate), adapter._charge_options(gate)) + echo = ChallengeEcho( + id=challenge.id, + realm=challenge.realm, + method=challenge.method, + intent=challenge.intent, + request=challenge.request, + expires=challenge.expires, + digest=challenge.digest, + opaque=challenge.opaque, + ) + cred = PaymentCredential( + challenge=echo, + payload={"type": "signature", "signature": "5UfDuX6nSqMzMR8W7n6K3b1GKLmaqEisBFCcYPRLjNHrCbVQJF3BVjkE7aQJMQ2Kx"}, + ) + return format_authorization(cred) + + +# -- offer / challenge ------------------------------------------------------- + + +def test_accepts_entry_shape(): + cfg = _cfg() + entry = MppAdapter(cfg).accepts_entry(_gate(cfg), {"path": "/report"}) + assert entry["protocol"] == "mpp" + assert entry["scheme"] == "charge" + assert entry["amount"] == "100000" # 0.10 * 1e6 + assert entry["currency"] == "USDC" + assert entry["payTo"] == cfg.effective_recipient() + assert entry["realm"] == cfg.mpp.realm + + +def test_accepts_entry_includes_splits_when_fees(): + cfg = _cfg() + gate = _gate(cfg, fee_on_top={FEE_A: Price.usd("0.02", Stablecoin.USDC)}) + entry = MppAdapter(cfg).accepts_entry(gate, {"path": "/report"}) + assert entry["splits"] == [{"recipient": FEE_A, "amount": "20000"}] + # on-top fee raises the advertised total to 0.12. + assert entry["amount"] == "120000" + + +def test_settlement_coin_defaults_to_config_when_unset(): + cfg = _cfg(stablecoins=(Stablecoin.USDT,)) + gate = Gate.build( + name="r", + amount=Price.usd("0.10"), # no settlement preference + default_pay_to=cfg.effective_recipient(), + accept=(Protocol.MPP,), + ) + entry = MppAdapter(cfg).accepts_entry(gate, {"path": "/r"}) + assert entry["currency"] == "USDT" + + +def test_challenge_headers_emit_www_authenticate(): + cfg = _cfg() + headers = MppAdapter(cfg).challenge_headers(_gate(cfg), {"path": "/report"}) + assert "www-authenticate" in headers + assert headers["www-authenticate"].lower().startswith("payment") + + +# -- verify: missing / malformed proof --------------------------------------- + + +@pytest.mark.asyncio +async def test_verify_missing_authorization_is_402(): + cfg = _cfg() + with pytest.raises(InvalidProofError): + await MppAdapter(cfg).verify_and_settle(_gate(cfg), {"headers": {}}) + + +@pytest.mark.asyncio +async def test_verify_unparseable_authorization_is_402(): + cfg = _cfg() + with pytest.raises(InvalidProofError, match="could not parse"): + await MppAdapter(cfg).verify_and_settle(_gate(cfg), {"headers": {"authorization": "Payment garbage"}}) + + +# -- cross-route replay (verify_credential_with_expected pins amount) -------- + + +@pytest.mark.asyncio +async def test_cross_route_replay_amount_mismatch_rejected(): + cfg = _cfg() + adapter = MppAdapter(cfg) + cheap = _gate(cfg, name="cheap", amount="0.001") + expensive = _gate(cfg, name="expensive", amount="1.0") + + auth = _credential_for(adapter, cheap) + with pytest.raises(InvalidProofError) as exc: + await adapter.verify_and_settle(expensive, {"headers": {"authorization": auth}}) + assert exc.value.code == "charge_request_mismatch" + assert "amount" in str(exc.value).lower() + + +@pytest.mark.asyncio +async def test_matching_route_passes_binding_then_fails_at_settlement(): + """A credential matching its own route must clear the Tier-2 pin and fail + only later (settlement can't run offline with a bogus signature).""" + cfg = _cfg() + adapter = MppAdapter(cfg) + gate = _gate(cfg, name="report", amount="0.10") + auth = _credential_for(adapter, gate) + with pytest.raises(InvalidProofError) as exc: + await adapter.verify_and_settle(gate, {"headers": {"authorization": auth}}) + # Must NOT be a cross-route mismatch: the route lined up, settlement failed. + assert exc.value.code != "charge_request_mismatch" + + +# -- recent blockhash injection (caveat #5) ---------------------------------- + + +def test_charge_request_embeds_recent_blockhash_when_provider_set(): + cfg = _cfg() + adapter = MppAdapter(cfg, recent_blockhash_provider=lambda: "SomeBlockhash1111111111111111111111111111111") + req = adapter._charge_request_for(_gate(cfg)) + assert req.method_details is not None + assert req.method_details["recentBlockhash"] == "SomeBlockhash1111111111111111111111111111111" + + +def test_charge_request_network_slug_in_method_details(): + cfg = _cfg() + req = MppAdapter(cfg)._charge_request_for(_gate(cfg)) + assert req.method_details is not None + assert req.method_details["network"] == "localnet" + + +# -- handler cache ----------------------------------------------------------- + + +def test_server_for_caches_by_pay_to_and_coin(): + cfg = _cfg() + adapter = MppAdapter(cfg) + gate = _gate(cfg) + first = adapter._server_for(gate) + second = adapter._server_for(gate) + assert first is second + + +# -- SecretResolver (caveat #4) ---------------------------------------------- + + +def test_secret_resolver_prefers_env(monkeypatch, tmp_path): + monkeypatch.setenv("PAY_KIT_MPP_CHALLENGE_BINDING_SECRET", "from-env") + secret, source, persisted = SecretResolver.resolve_mpp_secret(dotenv_path=str(tmp_path / ".env")) + assert (secret, source, persisted) == ("from-env", "env", True) + + +def test_secret_resolver_reads_dotenv(monkeypatch, tmp_path): + monkeypatch.delenv("PAY_KIT_MPP_CHALLENGE_BINDING_SECRET", raising=False) + env_file = tmp_path / ".env" + env_file.write_text('# a comment\n\nOTHER_KEY=ignored\nPAY_KIT_MPP_CHALLENGE_BINDING_SECRET="quoted-secret"\n') + secret, source, persisted = SecretResolver.resolve_mpp_secret(dotenv_path=str(env_file)) + assert secret == "quoted-secret" + assert source == "dotenv" + assert persisted is True + + +def test_secret_resolver_single_quoted_value(monkeypatch, tmp_path): + monkeypatch.delenv("PAY_KIT_MPP_CHALLENGE_BINDING_SECRET", raising=False) + env_file = tmp_path / ".env" + env_file.write_text("PAY_KIT_MPP_CHALLENGE_BINDING_SECRET='single'\n") + secret, source, _ = SecretResolver.resolve_mpp_secret(dotenv_path=str(env_file)) + assert (secret, source) == ("single", "dotenv") + + +def test_secret_resolver_generates_and_persists(monkeypatch, tmp_path): + monkeypatch.delenv("PAY_KIT_MPP_CHALLENGE_BINDING_SECRET", raising=False) + env_file = tmp_path / ".env" # does not exist yet + secret, source, persisted = SecretResolver.resolve_mpp_secret(dotenv_path=str(env_file)) + assert len(secret) == 64 # token_hex(32) + assert source == "generated+persisted" + assert persisted is True + # New file is mode 0600 and contains the key. + assert env_file.exists() + assert "PAY_KIT_MPP_CHALLENGE_BINDING_SECRET=" in env_file.read_text() + assert (env_file.stat().st_mode & 0o777) == 0o600 + + +def test_secret_resolver_generated_is_sticky_across_calls(monkeypatch, tmp_path): + monkeypatch.delenv("PAY_KIT_MPP_CHALLENGE_BINDING_SECRET", raising=False) + env_file = tmp_path / ".env" + first, _, _ = SecretResolver.resolve_mpp_secret(dotenv_path=str(env_file)) + second, source, _ = SecretResolver.resolve_mpp_secret(dotenv_path=str(env_file)) + assert first == second # second read comes back from the persisted dotenv + assert source == "dotenv" + + +def test_secret_resolver_unwritable_dotenv_keeps_in_memory(monkeypatch, tmp_path): + monkeypatch.delenv("PAY_KIT_MPP_CHALLENGE_BINDING_SECRET", raising=False) + # Point at a path inside a non-existent directory so the append fails. + bad_path = str(tmp_path / "nope" / "deeper" / ".env") + secret, source, persisted = SecretResolver.resolve_mpp_secret(dotenv_path=bad_path) + assert len(secret) == 64 + assert persisted is False + assert source == "generated" + + +def test_secret_resolver_missing_dotenv_returns_generated(monkeypatch, tmp_path): + monkeypatch.delenv("PAY_KIT_MPP_CHALLENGE_BINDING_SECRET", raising=False) + # _read_dotenv on a missing file returns None, then generation kicks in. + env_file = tmp_path / "absent.env" + assert SecretResolver._read_dotenv(str(env_file), "PAY_KIT_MPP_CHALLENGE_BINDING_SECRET") is None + + +def test_adapter_resolves_secret_from_resolver_when_unconfigured(monkeypatch, tmp_path): + """When mpp.challenge_binding_secret is unset, the adapter falls back to the + SecretResolver chain rather than crashing.""" + monkeypatch.setenv("PAY_KIT_MPP_CHALLENGE_BINDING_SECRET", "adapter-env-secret") + monkeypatch.chdir(tmp_path) + cfg = configure( + network="solana_localnet", + preflight=False, + accept=(Protocol.MPP,), + mpp=MppConfig(), # no secret set + ) + adapter = MppAdapter(cfg) + assert adapter._secret == "adapter-env-secret" diff --git a/python/tests/test_pk_pricing_helpers.py b/python/tests/test_pk_pricing_helpers.py new file mode 100644 index 000000000..28be4b01c --- /dev/null +++ b/python/tests/test_pk_pricing_helpers.py @@ -0,0 +1,227 @@ +"""Pricing registry, header/attr helpers, mint reverse-lookup, flask is_paid. + +Fills the remaining branch gaps the larger suites do not reach: Pricing's +attribute introspection (gate/contains/iter and the error paths), the +middleware header proxy + path/attr readers over odd request shapes, the mints +``symbol_for`` reverse lookup, and the flask ``is_paid`` gate-object branch. +""" + +from __future__ import annotations + +import pytest + +from pay_kit import Gate, MppConfig, Price, Pricing, Protocol, Stablecoin, configure +from pay_kit._middleware import _HeaderProxy, _read_attr, _read_header, _request_headers, _request_path +from pay_kit._paycore import mints +from pay_kit.config import reset +from pay_kit.errors import ConfigurationError + +SECRET = "challenge-binding-secret-long-enough-for-hmac" + + +@pytest.fixture(autouse=True) +def _clean(monkeypatch): + reset() + monkeypatch.setenv("PAY_KIT_DISABLE_PREFLIGHT", "1") + yield + reset() + + +def _cfg(): + return configure( + network="solana_localnet", + preflight=False, + accept=(Protocol.MPP,), + mpp=MppConfig(challenge_binding_secret=SECRET), + ) + + +# -- Pricing ----------------------------------------------------------------- + + +class _Catalog(Pricing): + def __init__(self, cfg): + self.report = Gate.build( + name="report", + amount=Price.usd("0.10", Stablecoin.USDC), + default_pay_to=cfg.effective_recipient(), + accept=(Protocol.MPP,), + ) + self._private = "ignored" + + +def test_pricing_gate_lookup(): + cat = _Catalog(_cfg()) + assert cat.gate("report").name == "report" + + +def test_pricing_gate_unknown_raises(): + cat = _Catalog(_cfg()) + with pytest.raises(ConfigurationError, match="has no gate"): + cat.gate("missing") + + +def test_pricing_gate_non_gate_attribute_raises(): + cfg = _cfg() + cat = _Catalog(cfg) + cat.not_a_gate = 123 # type: ignore[attr-defined] + with pytest.raises(ConfigurationError, match="is not a Gate"): + cat.gate("not_a_gate") + + +def test_pricing_contains_and_iter(): + cat = _Catalog(_cfg()) + assert "report" in cat + assert "missing" not in cat + assert 5 not in cat # non-string short-circuits + names = [g.name for g in cat] + assert names == ["report"] + + +# -- middleware helpers ------------------------------------------------------ + + +def test_read_header_case_insensitive(): + headers = {"Authorization": "Payment x"} + assert _read_header(headers, "authorization") == "Payment x" + + +def test_read_header_upper_fallback(): + headers = {"PAYMENT-SIGNATURE": "sig"} + assert _read_header(headers, "payment-signature") == "sig" + + +def test_read_header_absent_returns_empty(): + assert _read_header({}, "authorization") == "" + + +def test_read_header_no_getter_returns_empty(): + assert _read_header(object(), "authorization") == "" # type: ignore[arg-type] + + +def test_request_headers_from_attribute(): + class Req: + headers = {"a": "b"} + + assert _request_headers(Req())["a"] == "b" + + +def test_request_headers_from_mapping(): + out = _request_headers({"headers": {"a": "b"}}) + assert out["a"] == "b" + + +def test_request_headers_proxy_over_get_object(): + class Headers: + def __init__(self): + self._d = {"authorization": "Payment x"} + + def get(self, k, default=None): + return self._d.get(k, default) + + def keys(self): + return self._d.keys() + + def __len__(self): + return len(self._d) + + class Req: + headers = Headers() + + proxy = _request_headers(Req()) + assert isinstance(proxy, _HeaderProxy) + assert proxy.get("authorization") == "Payment x" + assert proxy["authorization"] == "Payment x" + assert proxy.get("missing", "d") == "d" + assert "authorization" in list(iter(proxy)) + assert len(proxy) == 1 + + +def test_header_proxy_keyerror_on_missing(): + class H: + def get(self, k, default=None): + return None + + proxy = _HeaderProxy(H()) + with pytest.raises(KeyError): + proxy["nope"] + + +def test_header_proxy_len_typeerror_returns_zero(): + class H: + def get(self, k, default=None): + return None + + proxy = _HeaderProxy(H()) # no __len__ on H + assert len(proxy) == 0 + + +def test_request_headers_none_returns_empty(): + assert _request_headers(object()) == {} + + +def test_read_attr_from_mapping(): + assert _read_attr({"k": "v"}, "k") == "v" + + +def test_read_attr_missing_returns_none(): + assert _read_attr(object(), "k") is None + + +def test_request_path_mapping_path_info(): + assert _request_path({"PATH_INFO": "/wsgi"}) == "/wsgi" + + +# -- mints reverse lookup ---------------------------------------------------- + + +def test_symbol_for_known_symbol(): + assert mints.symbol_for("USDC", "mainnet") == "USDC" + + +def test_symbol_for_known_mint(): + mint = mints.resolve("USDC", "mainnet") + assert mint is not None + assert mints.symbol_for(mint, "mainnet") == "USDC" + + +def test_symbol_for_unknown_returns_none(): + assert mints.symbol_for("DEFINITELYNOTACOIN", "mainnet") is None + + +# -- flask is_paid gate-object branch ---------------------------------------- + + +def test_flask_is_paid_with_gate_object(monkeypatch): + import flask + + import pay_kit._middleware as mw + import pay_kit.flask as pk_flask + from pay_kit import Payment + + cfg = _cfg() + gate = Gate.build( + name="report", + amount=Price.usd("0.10", Stablecoin.USDC), + default_pay_to=cfg.effective_recipient(), + accept=(Protocol.MPP,), + ) + + async def fake(self, gate_ref, pricing, request): + return Payment(protocol=Protocol.MPP, transaction="sig", gate_name="report") + + monkeypatch.setattr(mw.PayCore, "process", fake) + + app = flask.Flask(__name__) + + @app.get("/report") + @pk_flask.require_payment(gate) + def view(): + return { + "by_gate": pk_flask.is_paid(gate), + "by_name": pk_flask.is_paid("report"), + "wrong_name": pk_flask.is_paid("other"), + } + + resp = app.test_client().get("/report") + assert resp.get_json() == {"by_gate": True, "by_name": True, "wrong_name": False} diff --git a/python/tests/test_pk_signer_operator.py b/python/tests/test_pk_signer_operator.py new file mode 100644 index 000000000..bc1ed7833 --- /dev/null +++ b/python/tests/test_pk_signer_operator.py @@ -0,0 +1,291 @@ +"""Signer factory family and Operator default-resolution coverage. + +Signer: every factory (demo/bytes/json/base58/hex/file/generate/env) including +``from_env`` None/malformed handling and the demo warn-once behaviour. Operator: +``None``-as-default resolution, ``effective_recipient`` fallback, equality/hash +over the resolved identity, and field validators. +""" + +from __future__ import annotations + +import json +import warnings + +import pytest +from solders.keypair import Keypair + +import pay_kit.signer as signer_mod +from pay_kit import LocalSigner, Operator, Signer +from pay_kit.errors import ConfigurationError, InvalidKeyError +from pay_kit.signer import DEMO_PUBKEY + + +@pytest.fixture(autouse=True) +def _reset_demo_warn(): + """Reset the demo warn-once guard so each test sees a clean process state.""" + signer_mod._reset_demo_for_tests() + yield + signer_mod._reset_demo_for_tests() + + +# -- Signer factories -------------------------------------------------------- + + +def test_signer_demo_pubkey_is_fixed_and_flagged(): + s = Signer.demo() + assert s.pubkey() == DEMO_PUBKEY + assert s.is_demo() is True + assert s.is_fee_payer() is True + + +def test_signer_demo_warns_once_and_caches(): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + first = Signer.demo() + second = Signer.demo() + assert first is second # cached singleton + demo_warnings = [w for w in caught if "demo signer" in str(w.message)] + assert len(demo_warnings) == 1 # warn-once + + +def test_signer_generate_is_ephemeral_and_not_demo(): + a = Signer.generate() + b = Signer.generate() + assert a.pubkey() != b.pubkey() + assert a.is_demo() is False + + +def test_signer_bytes_roundtrip(): + kp = Keypair() + s = Signer.bytes(bytes(kp)) + assert s.pubkey() == str(kp.pubkey()) + + +def test_signer_bytes_sequence_of_ints(): + kp = Keypair() + s = Signer.bytes(list(bytes(kp))) + assert s.pubkey() == str(kp.pubkey()) + + +def test_signer_bytes_wrong_length_raises(): + with pytest.raises(InvalidKeyError, match="64-byte"): + Signer.bytes(b"\x00" * 10) + + +def test_signer_bytes_wrong_int_count_raises(): + with pytest.raises(InvalidKeyError, match="64 integers"): + Signer.bytes([0] * 10) + + +def test_signer_bytes_out_of_range_int_raises(): + bad = [0] * 63 + [999] + with pytest.raises(InvalidKeyError, match=r"\[0,255\]"): + Signer.bytes(bad) + + +def test_signer_bytes_str_rejected(): + with pytest.raises(InvalidKeyError, match="not str"): + Signer.bytes("not-bytes") # type: ignore[arg-type] + + +def test_signer_bytes_non_sequence_rejected(): + with pytest.raises(InvalidKeyError, match="sequence of ints"): + Signer.bytes(123) # type: ignore[arg-type] + + +def test_signer_json_roundtrip(): + kp = Keypair() + arr = json.dumps(list(bytes(kp))) + s = Signer.json(arr) + assert s.pubkey() == str(kp.pubkey()) + + +def test_signer_json_empty_raises(): + with pytest.raises(InvalidKeyError, match="empty"): + Signer.json(" ") + + +def test_signer_json_not_string_raises(): + with pytest.raises(InvalidKeyError, match="expects a string"): + Signer.json(123) # type: ignore[arg-type] + + +def test_signer_json_malformed_raises(): + with pytest.raises(InvalidKeyError, match="malformed"): + Signer.json("[1,2,") + + +def test_signer_json_not_array_raises(): + with pytest.raises(InvalidKeyError, match="expected a JSON array"): + Signer.json('{"a":1}') + + +def test_signer_base58_roundtrip(): + kp = Keypair() + s = Signer.base58(str(kp)) # solders Keypair str() is base58 secret + assert s.pubkey() == str(kp.pubkey()) + + +def test_signer_base58_empty_raises(): + with pytest.raises(InvalidKeyError, match="non-empty string"): + Signer.base58("") + + +def test_signer_base58_malformed_raises(): + with pytest.raises(InvalidKeyError, match="invalid base58"): + Signer.base58("not-valid-base58-!!!") + + +def test_signer_hex_roundtrip(): + kp = Keypair() + s = Signer.hex(bytes(kp).hex()) + assert s.pubkey() == str(kp.pubkey()) + + +def test_signer_hex_wrong_length_raises(): + with pytest.raises(InvalidKeyError, match="128 chars"): + Signer.hex("abcd") + + +def test_signer_hex_non_hex_chars_raises(): + with pytest.raises(InvalidKeyError, match="non-hex"): + Signer.hex("z" * 128) + + +def test_signer_file_roundtrip(tmp_path): + kp = Keypair() + p = tmp_path / "id.json" + p.write_text(json.dumps(list(bytes(kp)))) + s = Signer.file(str(p)) + assert s.pubkey() == str(kp.pubkey()) + + +def test_signer_file_empty_path_raises(): + with pytest.raises(InvalidKeyError, match="non-empty path"): + Signer.file("") + + +def test_signer_file_missing_raises(): + with pytest.raises(InvalidKeyError, match="cannot read"): + Signer.file("/nonexistent/keypair.json") + + +def test_signer_sign_produces_64_bytes(): + s = Signer.generate() + sig = s.sign(b"hello") + assert isinstance(sig, bytes) and len(sig) == 64 + + +def test_local_signer_from_keypair_and_secret_key(): + kp = Keypair() + s = LocalSigner.from_keypair(kp) + assert s.keypair == kp + assert len(s.secret_key()) == 64 + + +def test_local_signer_from_bytes_invalid_raises(): + with pytest.raises(InvalidKeyError): + LocalSigner.from_bytes(bytes([0]) * 64) # all-zero is an invalid keypair + + +def test_signer_namespace_not_instantiable(): + with pytest.raises(TypeError, match="factory namespace"): + Signer() + + +# -- Signer.env -------------------------------------------------------------- + + +def test_signer_env_unset_returns_none(monkeypatch): + monkeypatch.delenv("PK_TEST_KEY", raising=False) + assert Signer.env("PK_TEST_KEY") is None + + +def test_signer_env_empty_returns_none(monkeypatch): + monkeypatch.setenv("PK_TEST_KEY", " ") + assert Signer.env("PK_TEST_KEY") is None + + +def test_signer_env_empty_name_raises(): + with pytest.raises(InvalidKeyError, match="non-empty name"): + Signer.env("") + + +def test_signer_env_json_array(monkeypatch): + kp = Keypair() + monkeypatch.setenv("PK_TEST_KEY", json.dumps(list(bytes(kp)))) + s = Signer.env("PK_TEST_KEY") + assert s is not None and s.pubkey() == str(kp.pubkey()) + + +def test_signer_env_hex(monkeypatch): + kp = Keypair() + monkeypatch.setenv("PK_TEST_KEY", bytes(kp).hex()) + s = Signer.env("PK_TEST_KEY") + assert s is not None and s.pubkey() == str(kp.pubkey()) + + +def test_signer_env_base58(monkeypatch): + kp = Keypair() + monkeypatch.setenv("PK_TEST_KEY", str(kp)) + s = Signer.env("PK_TEST_KEY") + assert s is not None and s.pubkey() == str(kp.pubkey()) + + +def test_signer_env_malformed_raises(monkeypatch): + monkeypatch.setenv("PK_TEST_KEY", "[1,2,3]") # too short -> InvalidKeyError + with pytest.raises(InvalidKeyError): + Signer.env("PK_TEST_KEY") + + +# -- Operator ---------------------------------------------------------------- + + +def test_operator_defaults_resolve_to_demo(): + op = Operator().with_defaults() + assert op.signer is not None and op.signer.is_demo() + assert op.recipient == DEMO_PUBKEY # falls back to signer pubkey + + +def test_operator_effective_recipient_explicit(): + op = Operator(recipient="ExplicitRecipient111111111111111111111111") + assert op.effective_recipient() == "ExplicitRecipient111111111111111111111111" + + +def test_operator_effective_recipient_falls_back_to_signer(): + op = Operator() # no recipient, no signer + assert op.effective_recipient() == DEMO_PUBKEY + + +def test_operator_with_explicit_signer_keeps_it(): + kp = Keypair() + op = Operator(signer=LocalSigner.from_keypair(kp)).with_defaults() + assert op.signer is not None and op.signer.pubkey() == str(kp.pubkey()) + assert op.recipient == str(kp.pubkey()) + + +def test_operator_recipient_non_string_raises(): + with pytest.raises(ConfigurationError, match="recipient must be a str"): + Operator(recipient=123) # type: ignore[arg-type] + + +def test_operator_fee_payer_must_be_bool(): + with pytest.raises(ConfigurationError, match="fee_payer must be"): + Operator(fee_payer="yes") # type: ignore[arg-type] + + +def test_operator_equality_and_hash_over_resolved_identity(): + a = Operator(recipient="R1111111111111111111111111111111111111111") + b = Operator(recipient="R1111111111111111111111111111111111111111") + assert a == b + assert hash(a) == hash(b) + + +def test_operator_inequality_on_different_recipient(): + a = Operator(recipient="R1111111111111111111111111111111111111111") + b = Operator(recipient="R2222222222222222222222222222222222222222") + assert a != b + + +def test_operator_eq_with_non_operator_is_not_implemented(): + assert Operator().__eq__("nope") is NotImplemented diff --git a/python/tests/test_pk_value_objects.py b/python/tests/test_pk_value_objects.py new file mode 100644 index 000000000..8e0b12a3d --- /dev/null +++ b/python/tests/test_pk_value_objects.py @@ -0,0 +1,366 @@ +"""Price / Fee / Gate value-object coverage for pay_kit. + +Covers the Decimal-only money contract (float + bool rejection, format +parsing), settlement preference resolution, the gate total/payout math, the +``sum(fee_within) <= amount`` validator, the x402-vs-fees auto-disable rule +(silent strip on inherited accept, raise on explicit ``accept=[X402]`` with +fees, raise on a collapsed-empty accept), and the ``@gate.dynamic`` factory. +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from pay_kit import Gate, Price, Protocol, Stablecoin, gate +from pay_kit._paycore.currency import Currency +from pay_kit.errors import ( + ConfigurationError, + MixedCurrenciesError, + ProtocolIncompatibleError, +) +from pay_kit.fee import Fee +from pay_kit.gate import DynamicGate + +PAY_TO = "ALtYSsZuYyKrNSe6GnVCzxj1T2RPMTPzXMe51xhbmXEq" +FEE_A = "9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ" +FEE_B = "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU" + + +# -- Price: Decimal-only money ----------------------------------------------- + + +def test_price_accepts_str_int_decimal(): + assert Price.usd("0.10").amount == Decimal("0.10") + assert Price.usd(2).amount == Decimal(2) + assert Price.usd(Decimal("1.5")).amount == Decimal("1.5") + + +def test_price_rejects_float(): + with pytest.raises(ConfigurationError, match="not float"): + Price.usd(0.10) # type: ignore[arg-type] + + +def test_price_rejects_bool(): + # bool is an int subclass; the guard rejects it explicitly so True != 1. + with pytest.raises(ConfigurationError, match="not bool"): + Price.usd(True) + + +def test_price_rejects_malformed_string(): + with pytest.raises(ConfigurationError, match="invalid Price amount"): + Price.usd("1.2.3") + with pytest.raises(ConfigurationError, match="invalid Price amount"): + Price.usd("abc") + + +def test_price_currency_factories(): + assert Price.usd("1").currency is Currency.USD + assert Price.eur("1").currency is Currency.EUR + assert Price.gbp("1").currency is Currency.GBP + + +def test_price_settlement_preference_order(): + p = Price.usd("0.10", Stablecoin.USDT, Stablecoin.USDC) + assert p.settlements == (Stablecoin.USDT, Stablecoin.USDC) + assert p.primary_coin() is Stablecoin.USDT + + +def test_price_primary_coin_none_defers_to_config(): + assert Price.usd("0.10").primary_coin() is None + + +def test_price_amount_string_preserves_trailing_zeros(): + assert Price.usd("0.10").amount_string() == "0.10" + assert Price.usd("1").amount_string() == "1" + + +def test_price_with_amount_keeps_currency_and_settlements(): + base = Price.eur("0.10", Stablecoin.USDC) + out = base.with_amount("0.25") + assert out.amount == Decimal("0.25") + assert out.currency is Currency.EUR + assert out.settlements == (Stablecoin.USDC,) + + +def test_price_plus_same_currency(): + out = Price.usd("0.10").plus(Price.usd("0.05")) + assert out.amount == Decimal("0.15") + + +def test_price_plus_mixed_currency_raises(): + with pytest.raises(MixedCurrenciesError): + Price.usd("0.10").plus(Price.eur("0.05")) + + +def test_price_is_frozen(): + p = Price.usd("0.10") + with pytest.raises(Exception): # noqa: B017 - pydantic frozen raises ValidationError + p.amount = Decimal("1") # type: ignore[misc] + + +# -- Fee --------------------------------------------------------------------- + + +def test_fee_within_and_on_top_flags(): + within = Fee(recipient=FEE_A, price=Price.usd("0.01"), kind="within") + on_top = Fee(recipient=FEE_A, price=Price.usd("0.01"), kind="on_top") + assert within.is_within() and not within.is_on_top() + assert on_top.is_on_top() and not on_top.is_within() + + +def test_fee_rejects_empty_recipient(): + with pytest.raises(ConfigurationError, match="non-empty"): + Fee(recipient="", price=Price.usd("0.01"), kind="within") + + +# -- Gate construction + validation ------------------------------------------ + + +def test_gate_build_minimal(): + g = Gate.build(name="r", amount=Price.usd("0.10"), default_pay_to=PAY_TO) + assert g.name == "r" + assert g.pay_to == PAY_TO + assert not g.has_fees() + + +def test_gate_pay_to_override_beats_default(): + g = Gate.build(name="r", amount=Price.usd("0.10"), pay_to=FEE_A, default_pay_to=PAY_TO) + assert g.pay_to == FEE_A + + +def test_gate_requires_a_recipient(): + with pytest.raises(ConfigurationError, match="pay_to is required"): + Gate.build(name="r", amount=Price.usd("0.10")) + + +def test_gate_rejects_empty_name(): + with pytest.raises(ConfigurationError, match="name must be"): + Gate.build(name="", amount=Price.usd("0.10"), default_pay_to=PAY_TO) + + +def test_gate_rejects_non_price_amount(): + with pytest.raises(ConfigurationError, match="must be a Price"): + Gate.build(name="r", amount="0.10", default_pay_to=PAY_TO) # type: ignore[arg-type] + + +def test_gate_total_adds_on_top_fees_only(): + g = Gate.build( + name="r", + amount=Price.usd("0.10"), + default_pay_to=PAY_TO, + fee_on_top={FEE_A: Price.usd("0.02")}, + fee_within={FEE_B: Price.usd("0.01")}, + accept=(Protocol.MPP,), + ) + # customer pays base + on_top, never the within fee. + assert g.total().amount == Decimal("0.12") + + +def test_gate_payout_math_pay_to_nets_minus_within(): + g = Gate.build( + name="r", + amount=Price.usd("0.10"), + default_pay_to=PAY_TO, + fee_within={FEE_A: Price.usd("0.03")}, + fee_on_top={FEE_B: Price.usd("0.05")}, + accept=(Protocol.MPP,), + ) + payout_main = g.payout(PAY_TO) + payout_a = g.payout(FEE_A) + payout_b = g.payout(FEE_B) + assert payout_main is not None and payout_a is not None and payout_b is not None + assert payout_main.amount == Decimal("0.07") # 0.10 - 0.03 within + assert payout_a.amount == Decimal("0.03") + assert payout_b.amount == Decimal("0.05") + assert g.payout("unknownaddr") is None + + +def test_gate_within_sum_exceeds_amount_raises(): + with pytest.raises(ConfigurationError, match="exceeds amount"): + Gate.build( + name="r", + amount=Price.usd("0.10"), + default_pay_to=PAY_TO, + fee_within={FEE_A: Price.usd("0.20")}, + accept=(Protocol.MPP,), + ) + + +def test_gate_within_sum_equal_to_amount_ok(): + g = Gate.build( + name="r", + amount=Price.usd("0.10"), + default_pay_to=PAY_TO, + fee_within={FEE_A: Price.usd("0.10")}, + accept=(Protocol.MPP,), + ) + payout_main = g.payout(PAY_TO) + assert payout_main is not None + assert payout_main.amount == Decimal("0") + + +def test_gate_fee_recipient_equal_pay_to_raises(): + with pytest.raises(ConfigurationError, match="duplicates"): + Gate.build( + name="r", + amount=Price.usd("0.10"), + default_pay_to=PAY_TO, + fee_within={PAY_TO: Price.usd("0.01")}, + accept=(Protocol.MPP,), + ) + + +def test_gate_duplicate_fee_recipient_raises(): + with pytest.raises(ConfigurationError, match="duplicate fee recipient"): + Gate.build( + name="r", + amount=Price.usd("0.10"), + default_pay_to=PAY_TO, + fee_within={FEE_A: Price.usd("0.01")}, + fee_on_top={FEE_A: Price.usd("0.01")}, + accept=(Protocol.MPP,), + ) + + +def test_gate_mixed_currency_fee_raises(): + with pytest.raises(MixedCurrenciesError): + Gate.build( + name="r", + amount=Price.usd("0.10"), + default_pay_to=PAY_TO, + fee_within={FEE_A: Price.eur("0.01")}, + accept=(Protocol.MPP,), + ) + + +def test_gate_fee_price_must_be_price_instance(): + with pytest.raises(ConfigurationError, match="must be a Price"): + Gate.build( + name="r", + amount=Price.usd("0.10"), + default_pay_to=PAY_TO, + fee_within={FEE_A: "0.01"}, # type: ignore[dict-item] + ) + + +def test_gate_fee_map_must_be_dict(): + with pytest.raises(ConfigurationError, match="must be a dict"): + Gate.build( + name="r", + amount=Price.usd("0.10"), + default_pay_to=PAY_TO, + fee_within=[(FEE_A, Price.usd("0.01"))], # type: ignore[arg-type] + ) + + +def test_gate_fee_recipient_empty_in_map_raises(): + with pytest.raises(ConfigurationError, match="non-empty string"): + Gate.build( + name="r", + amount=Price.usd("0.10"), + default_pay_to=PAY_TO, + fee_within={"": Price.usd("0.01")}, + ) + + +# -- x402-vs-fees rule (Gate rule 6) ----------------------------------------- + + +def test_gate_no_fees_keeps_accept_as_given(): + g = Gate.build(name="r", amount=Price.usd("0.10"), default_pay_to=PAY_TO, accept=(Protocol.X402, Protocol.MPP)) + assert g.x402_accepted() and g.mpp_accepted() + + +def test_gate_inherited_accept_with_fees_leaves_none(): + # accept omitted -> inherited; resolver leaves None so Config strips x402. + g = Gate.build( + name="r", + amount=Price.usd("0.10"), + default_pay_to=PAY_TO, + fee_within={FEE_A: Price.usd("0.01")}, + ) + assert g.accept is None + assert g.has_fees() + + +def test_gate_explicit_x402_with_fees_raises(): + with pytest.raises(ProtocolIncompatibleError, match="x402 cannot be combined with fees"): + Gate.build( + name="r", + amount=Price.usd("0.10"), + default_pay_to=PAY_TO, + accept=(Protocol.X402, Protocol.MPP), + fee_within={FEE_A: Price.usd("0.01")}, + ) + + +def test_gate_empty_accept_with_fees_raises(): + with pytest.raises(ProtocolIncompatibleError, match="no remaining accepted protocols"): + Gate.build( + name="r", + amount=Price.usd("0.10"), + default_pay_to=PAY_TO, + accept=(), + fee_within={FEE_A: Price.usd("0.01")}, + ) + + +def test_gate_empty_accept_default_no_fees_raises(): + with pytest.raises(ConfigurationError, match="resolved to an empty list"): + Gate.build(name="r", amount=Price.usd("0.10"), default_pay_to=PAY_TO, accept_default=()) + + +def test_gate_accept_default_used_when_accept_omitted(): + g = Gate.build( + name="r", + amount=Price.usd("0.10"), + default_pay_to=PAY_TO, + accept_default=(Protocol.MPP,), + ) + assert g.accept == (Protocol.MPP,) + + +# -- DynamicGate / @gate.dynamic --------------------------------------------- + + +def test_dynamic_decorator_returns_dynamic_gate(): + @gate("by_size") # type: ignore[arg-type] + def builder(request): # noqa: ANN001 + return Price.usd("0.10") + + assert isinstance(builder, DynamicGate) + assert builder.name == "by_size" + + +def test_dynamic_resolve_from_price_applies_defaults(): + @gate("by_size", accept=(Protocol.MPP,)) # type: ignore[arg-type] + def builder(request): # noqa: ANN001 + cents = request["units"] + return Price.usd(str(cents)) + + builder._defaults.update({"pay_to": PAY_TO, "accept": (Protocol.MPP,)}) + g = builder.resolve({"units": "2"}) + assert g.amount.amount == Decimal("2") + assert g.pay_to == PAY_TO + assert g.accept == (Protocol.MPP,) + + +def test_dynamic_resolve_returns_gate_directly(): + concrete = Gate.build(name="x", amount=Price.usd("0.10"), default_pay_to=PAY_TO) + + @gate("x") + def builder(request): # noqa: ANN001 + return concrete + + assert builder.resolve({}) is concrete + + +def test_dynamic_resolve_bad_return_raises(): + @gate("x") # type: ignore[arg-type] + def builder(request): # noqa: ANN001 + return 123 # type: ignore[return-value] + + with pytest.raises(ConfigurationError, match="must return a Gate or a Price"): + builder.resolve({}) diff --git a/python/tests/test_pk_x402_settle.py b/python/tests/test_pk_x402_settle.py new file mode 100644 index 000000000..48ae1c499 --- /dev/null +++ b/python/tests/test_pk_x402_settle.py @@ -0,0 +1,321 @@ +"""x402 ``verify_and_settle`` full-flow coverage with a stubbed broadcast. + +Exercises the credential-envelope path: version / shape checks, the Tier-2 +pinned-field gate against the freshly built offer, cosign + broadcast (RPC +stubbed, never live), the replay reservation (``signature_consumed`` on a +second submit), and the response-envelope assembly. Also covers the module +helpers (``_co_sign``, ``_is_loopback_rpc``, ``_request_path``, header reader). +""" + +from __future__ import annotations + +import base64 +import json +import struct + +import pytest +from solders.hash import Hash +from solders.instruction import AccountMeta, Instruction +from solders.keypair import Keypair +from solders.message import MessageV0 +from solders.pubkey import Pubkey +from solders.transaction import VersionedTransaction + +import pay_kit.protocols.x402 as xmod +from pay_kit import Gate as GateCls +from pay_kit import ( + LocalSigner, + MemoryStore, + Operator, + Price, + Protocol, + Stablecoin, + configure, +) +from pay_kit._paycore.mints import derive_ata, resolve, token_program_for +from pay_kit.config import reset +from pay_kit.errors import InvalidProofError +from pay_kit.protocols.x402 import ( + COMPUTE_BUDGET_PROGRAM, + MEMO_PROGRAM, + X402_VERSION, + X402Adapter, + _co_sign, + _is_loopback_rpc, + _request_path, +) + +BH = "4vJ9JU1bJJQpUgJ8V6hYz7xXKz4F2tN6aBrZEcD3xKhs" +_MINT = resolve("USDC", "mainnet") +assert _MINT is not None +MINT: str = _MINT +TP = token_program_for("USDC", "mainnet") + + +class _FakeRpc: + """Stub matching solana_mpp.SolanaRpc's async send/close surface.""" + + def __init__(self, *_a, signature: str = "SIG-broadcast", fail: bool = False, **_k): + self._signature = signature + self._fail = fail + + async def send_raw_transaction(self, _raw): + if self._fail: + raise RuntimeError("broadcast boom") + + class _Resp: + value = self._signature # type: ignore[assignment] + + _Resp.value = self._signature + return _Resp() + + async def aclose(self): + return None + + +@pytest.fixture(autouse=True) +def _clean(monkeypatch): + reset() + monkeypatch.setenv("PAY_KIT_DISABLE_PREFLIGHT", "1") + yield + reset() + + +def _adapter(store=None, signature="SIG-broadcast", fail=False, monkeypatch=None): + op_kp = Keypair() + op = Operator(signer=LocalSigner.from_keypair(op_kp), recipient=str(Keypair().pubkey())) + cfg = configure( + network="solana_localnet", + preflight=False, + accept=(Protocol.X402,), + operator=op, + rpc_url="http://127.0.0.1:8899", # loopback skips the blockhash net check + ) + gate = GateCls.build( + name="report", + amount=Price.usd("0.10", Stablecoin.USDC), + default_pay_to=cfg.effective_recipient(), + accept=(Protocol.X402,), + ) + adapter = X402Adapter(cfg, replay_store=store or MemoryStore()) + + def _factory(*_a, **_k): + return _FakeRpc(signature=signature, fail=fail) + + if monkeypatch is not None: + monkeypatch.setattr(xmod, "SolanaRpc", _factory) + return adapter, gate, op_kp + + +def _build_envelope(adapter, gate, op_kp, *, amount_override=None, memo_override=None): + offer = adapter.accepts_entry(gate, {"path": "/report"}) + amt = amount_override if amount_override is not None else int(offer["amount"]) + authority = Keypair() + dest = derive_ata(offer["payTo"], MINT, TP) + src = derive_ata(str(authority.pubkey()), MINT, TP) + cl = Instruction(Pubkey.from_string(COMPUTE_BUDGET_PROGRAM), bytes([2]) + struct.pack(" Instruction: + data = bytes([disc]) + struct.pack(" Instruction: + data = bytes([disc]) + struct.pack(" Instruction: + data = bytes([disc]) + struct.pack(" Instruction: + return Instruction(Pubkey.from_string(MEMO_PROGRAM), text.encode(), []) + + +def _ata_create_ix(*, payer: Pubkey, ata: str, owner: str, mint: str, program: str) -> Instruction: + metas = [ + AccountMeta(payer, True, True), + AccountMeta(Pubkey.from_string(ata), False, True), + AccountMeta(Pubkey.from_string(owner), False, False), + AccountMeta(Pubkey.from_string(mint), False, False), + AccountMeta(Pubkey.from_string("11111111111111111111111111111111"), False, False), + AccountMeta(Pubkey.from_string(program), False, False), + ] + return Instruction(Pubkey.from_string(ASSOCIATED_TOKEN_PROGRAM), bytes([1]), metas) + + +def _tx_b64(fee_payer: Keypair, instructions, signers) -> str: + msg = MessageV0.try_compile(fee_payer.pubkey(), instructions, [], Hash.from_string(BH)) + vtx = VersionedTransaction(msg, signers) + return base64.b64encode(bytes(vtx)).decode("ascii") + + +def _scenario(*, program: str = TOKEN_PROGRAM, mint: str = MINT): + """Return (fee_payer, authority, pay_to, src, dest) for a transfer.""" + fee_payer = Keypair() + authority = Keypair() + pay_to = str(Keypair().pubkey()) + dest = derive_ata(pay_to, mint, program) + src = derive_ata(str(authority.pubkey()), mint, program) + return fee_payer, authority, pay_to, src, dest + + +def _requirement(pay_to: str, *, mint: str = MINT, program: str = TOKEN_PROGRAM, memo: str | None = None): + extra = {"tokenProgram": program, "decimals": 6} + if memo is not None: + extra["memo"] = memo + return { + "asset": mint, + "amount": str(AMOUNT), + "maxAmountRequired": str(AMOUNT), + "payTo": pay_to, + "extra": extra, + } + + +def _happy(*, program: str = TOKEN_PROGRAM, mint: str = MINT, memo: str | None = None, extra_ixs=()): + fee_payer, authority, pay_to, src, dest = _scenario(program=program, mint=mint) + ixs = [ + _compute_limit_ix(), + _compute_price_ix(), + _transfer_checked_ix(source=src, mint=mint, destination=dest, authority=authority.pubkey(), program=program), + *extra_ixs, + ] + if memo is not None: + ixs.append(_memo_ix(memo)) + tx = _tx_b64(fee_payer, ixs, [fee_payer, authority]) + return tx, _requirement(pay_to, mint=mint, program=program, memo=memo), [str(fee_payer.pubkey())] + + +# -- happy paths ------------------------------------------------------------- + + +def test_verify_happy_path(): + tx, req, managed = _happy() + out = ExactVerifier.verify(tx, req, managed) + assert out["amount"] == AMOUNT + assert out["mint"] == MINT + assert out["destinationCreateAta"] is False + + +def test_verify_happy_with_memo_binding(): + tx, req, managed = _happy(memo="/report") + out = ExactVerifier.verify(tx, req, managed) + assert out["amount"] == AMOUNT + + +def test_verify_happy_with_token_2022_program(): + tx, req, managed = _happy(program=TOKEN_2022_PROGRAM) + out = ExactVerifier.verify(tx, req, managed) + assert out["program"] == TOKEN_2022_PROGRAM + + +def test_verify_happy_with_ata_create(): + fee_payer, authority, pay_to, src, dest = _scenario() + ixs = [ + _compute_limit_ix(), + _compute_price_ix(), + _transfer_checked_ix(source=src, mint=MINT, destination=dest, authority=authority.pubkey()), + _ata_create_ix(payer=fee_payer.pubkey(), ata=dest, owner=pay_to, mint=MINT, program=TOKEN_PROGRAM), + ] + tx = _tx_b64(fee_payer, ixs, [fee_payer, authority]) + out = ExactVerifier.verify(tx, _requirement(pay_to), [str(fee_payer.pubkey())]) + assert out["destinationCreateAta"] is True + + +# -- rule 0: payload decode -------------------------------------------------- + + +def test_reject_non_base64(): + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify("!!!notbase64!!!", _requirement("x"), []) + assert e.value.code == "invalid_exact_svm_payload_base64" + + +def test_reject_empty_payload(): + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(base64.b64encode(b"").decode(), _requirement("x"), []) + assert e.value.code == "invalid_exact_svm_payload_base64" + + +def test_reject_unparseable_transaction(): + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(base64.b64encode(b"\x01\x02\x03\x04").decode(), _requirement("x"), []) + assert e.value.code == "invalid_exact_svm_payload_transaction_parse" + + +# -- rule 1: instruction count ---------------------------------------------- + + +def test_reject_too_few_instructions(): + fee_payer, authority, pay_to, src, dest = _scenario() + tx = _tx_b64(fee_payer, [_compute_limit_ix(), _compute_price_ix()], [fee_payer]) + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx, _requirement(pay_to), [str(fee_payer.pubkey())]) + assert e.value.code == "invalid_exact_svm_payload_transaction_instructions_length" + + +def test_reject_too_many_instructions(): + fee_payer, authority, pay_to, src, dest = _scenario() + ixs = [ + _compute_limit_ix(), + _compute_price_ix(), + _transfer_checked_ix(source=src, mint=MINT, destination=dest, authority=authority.pubkey()), + ] + [_memo_ix(f"m{i}") for i in range(4)] + tx = _tx_b64(fee_payer, ixs, [fee_payer, authority]) + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx, _requirement(pay_to), [str(fee_payer.pubkey())]) + assert e.value.code == "invalid_exact_svm_payload_transaction_instructions_length" + + +# -- rule 2: compute limit --------------------------------------------------- + + +def test_reject_bad_compute_limit(): + fee_payer, authority, pay_to, src, dest = _scenario() + ixs = [ + _compute_limit_ix(disc=9), # wrong discriminator + _compute_price_ix(), + _transfer_checked_ix(source=src, mint=MINT, destination=dest, authority=authority.pubkey()), + ] + tx = _tx_b64(fee_payer, ixs, [fee_payer, authority]) + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx, _requirement(pay_to), [str(fee_payer.pubkey())]) + assert e.value.code == "invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction" + + +# -- rule 3: compute price --------------------------------------------------- + + +def test_reject_bad_compute_price_disc(): + fee_payer, authority, pay_to, src, dest = _scenario() + ixs = [ + _compute_limit_ix(), + _compute_price_ix(disc=7), + _transfer_checked_ix(source=src, mint=MINT, destination=dest, authority=authority.pubkey()), + ] + tx = _tx_b64(fee_payer, ixs, [fee_payer, authority]) + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx, _requirement(pay_to), [str(fee_payer.pubkey())]) + assert e.value.code == "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction" + + +def test_reject_compute_price_too_high(): + fee_payer, authority, pay_to, src, dest = _scenario() + ixs = [ + _compute_limit_ix(), + _compute_price_ix(micro=5_000_001), # MAX is 5_000_000 + _transfer_checked_ix(source=src, mint=MINT, destination=dest, authority=authority.pubkey()), + ] + tx = _tx_b64(fee_payer, ixs, [fee_payer, authority]) + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx, _requirement(pay_to), [str(fee_payer.pubkey())]) + assert e.value.code == "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high" + + +# -- rule 4 + 11: transfer shape + token program ---------------------------- + + +def test_reject_wrong_token_program(): + fee_payer, authority, pay_to, src, dest = _scenario() + bogus = str(Keypair().pubkey()) + ixs = [ + _compute_limit_ix(), + _compute_price_ix(), + _transfer_checked_ix(source=src, mint=MINT, destination=dest, authority=authority.pubkey(), program=bogus), + ] + tx = _tx_b64(fee_payer, ixs, [fee_payer, authority]) + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx, _requirement(pay_to), [str(fee_payer.pubkey())]) + assert e.value.code == "invalid_exact_svm_payload_no_transfer_instruction" + + +def test_reject_bad_transfer_discriminator(): + fee_payer, authority, pay_to, src, dest = _scenario() + ixs = [ + _compute_limit_ix(), + _compute_price_ix(), + _transfer_checked_ix(source=src, mint=MINT, destination=dest, authority=authority.pubkey(), disc=3), + ] + tx = _tx_b64(fee_payer, ixs, [fee_payer, authority]) + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx, _requirement(pay_to), [str(fee_payer.pubkey())]) + assert e.value.code == "invalid_exact_svm_payload_no_transfer_instruction" + + +def test_reject_missing_token_program_extra(): + tx, req, managed = _happy() + del req["extra"]["tokenProgram"] + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx, req, managed) + assert e.value.code == "invalid_exact_svm_payload_missing_extra_tokenProgram" + + +# -- rule 5: managed-signer guard -------------------------------------------- + + +def test_reject_fee_payer_as_authority(): + fee_payer, _authority, pay_to, src, dest = _scenario() + # fee_payer signs as the transfer authority -> managed signer transferring. + src_fp = derive_ata(str(fee_payer.pubkey()), MINT, TOKEN_PROGRAM) + ixs = [ + _compute_limit_ix(), + _compute_price_ix(), + _transfer_checked_ix(source=src_fp, mint=MINT, destination=dest, authority=fee_payer.pubkey()), + ] + tx = _tx_b64(fee_payer, ixs, [fee_payer]) + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx, _requirement(pay_to), [str(fee_payer.pubkey())]) + assert e.value.code == "invalid_exact_svm_payload_transaction_fee_payer_transferring_funds" + + +# -- rule 6: mint mismatch --------------------------------------------------- + + +def test_reject_mint_mismatch(): + tx, req, managed = _happy() + req["asset"] = str(Keypair().pubkey()) # different mint than the tx uses + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx, req, managed) + assert e.value.code == "invalid_exact_svm_payload_mint_mismatch" + + +# -- rule 7: destination ATA mismatch ---------------------------------------- + + +def test_reject_destination_mismatch(): + fee_payer, authority, pay_to, src, _dest = _scenario() + wrong_dest = derive_ata(str(Keypair().pubkey()), MINT, TOKEN_PROGRAM) + ixs = [ + _compute_limit_ix(), + _compute_price_ix(), + _transfer_checked_ix(source=src, mint=MINT, destination=wrong_dest, authority=authority.pubkey()), + ] + tx = _tx_b64(fee_payer, ixs, [fee_payer, authority]) + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx, _requirement(pay_to), [str(fee_payer.pubkey())]) + assert e.value.code == "invalid_exact_svm_payload_recipient_mismatch" + + +# -- rule 8: amount mismatch ------------------------------------------------- + + +def test_reject_amount_mismatch(): + fee_payer, authority, pay_to, src, dest = _scenario() + ixs = [ + _compute_limit_ix(), + _compute_price_ix(), + _transfer_checked_ix(source=src, mint=MINT, destination=dest, authority=authority.pubkey(), amount=999), + ] + tx = _tx_b64(fee_payer, ixs, [fee_payer, authority]) + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx, _requirement(pay_to), [str(fee_payer.pubkey())]) + assert e.value.code == "invalid_exact_svm_payload_amount_mismatch" + + +def test_amount_from_max_amount_required_field(): + tx, req, managed = _happy() + del req["amount"] # only maxAmountRequired remains + out = ExactVerifier.verify(tx, req, managed) + assert out["amount"] == AMOUNT + + +def test_reject_missing_amount_field(): + tx, req, managed = _happy() + del req["amount"] + del req["maxAmountRequired"] + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx, req, managed) + assert e.value.code == "invalid_exact_svm_payload_missing_field_amount" + + +def test_reject_missing_pay_to_field(): + tx, req, managed = _happy() + del req["payTo"] + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx, req, managed) + assert e.value.code == "invalid_exact_svm_payload_missing_field_payTo" + + +# -- rule 9: optional-instruction allowlist ---------------------------------- + + +def test_reject_unknown_fourth_instruction(): + fee_payer, authority, pay_to, src, dest = _scenario() + junk = Instruction(Pubkey.from_string(str(Keypair().pubkey())), b"\x00", []) + ixs = [ + _compute_limit_ix(), + _compute_price_ix(), + _transfer_checked_ix(source=src, mint=MINT, destination=dest, authority=authority.pubkey()), + junk, + ] + tx = _tx_b64(fee_payer, ixs, [fee_payer, authority]) + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx, _requirement(pay_to), [str(fee_payer.pubkey())]) + assert e.value.code == "invalid_exact_svm_payload_unknown_fourth_instruction" + + +# -- rule 10: memo binding --------------------------------------------------- + + +def test_reject_memo_mismatch(): + tx, _req, managed = _happy(memo="/expected") + # Build a tx with a different memo than the requirement asks for. + fee_payer, authority, pay_to, src, dest = _scenario() + ixs = [ + _compute_limit_ix(), + _compute_price_ix(), + _transfer_checked_ix(source=src, mint=MINT, destination=dest, authority=authority.pubkey()), + _memo_ix("/actual-different"), + ] + tx2 = _tx_b64(fee_payer, ixs, [fee_payer, authority]) + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx2, _requirement(pay_to, memo="/expected"), [str(fee_payer.pubkey())]) + assert e.value.code == "invalid_exact_svm_payload_memo_mismatch" + + +def test_reject_memo_count_zero_when_expected(): + # requirement expects a memo but the tx has none. + tx, _req, managed = _happy() # no memo in tx + fee_payer, authority, pay_to, src, dest = _scenario() + ixs = [ + _compute_limit_ix(), + _compute_price_ix(), + _transfer_checked_ix(source=src, mint=MINT, destination=dest, authority=authority.pubkey()), + ] + tx2 = _tx_b64(fee_payer, ixs, [fee_payer, authority]) + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx2, _requirement(pay_to, memo="/required"), [str(fee_payer.pubkey())]) + assert e.value.code == "invalid_exact_svm_payload_memo_count" + + +# -- adapter: accepts_entry / challenge / recentBlockhash (caveat #5) -------- + + +def _gate(cfg, *, accept=(Protocol.X402, Protocol.MPP)): + return Gate.build( + name="report", + amount=Price.usd("0.10", Stablecoin.USDC), + default_pay_to=cfg.effective_recipient(), + accept=accept, + ) + + +def test_adapter_accepts_entry_shape(): + cfg = configure(network="solana_localnet", preflight=False) + adapter = X402Adapter(cfg) + entry = adapter.accepts_entry(_gate(cfg), {"path": "/report"}) + assert entry["protocol"] == "x402" + assert entry["scheme"] == "exact" + assert entry["amount"] == str(AMOUNT) + assert entry["asset"] == MINT # localnet falls back to mainnet mint (caveat #1) + assert entry["extra"]["memo"] == "/report" + assert "recentBlockhash" not in entry["extra"] # no provider wired + + +def test_adapter_embeds_recent_blockhash_when_provider_set(): + cfg = configure(network="solana_localnet", preflight=False) + adapter = X402Adapter(cfg, recent_blockhash_provider=lambda: BH) + entry = adapter.accepts_entry(_gate(cfg), {"path": "/report"}) + assert entry["extra"]["recentBlockhash"] == BH + + +def test_adapter_blockhash_provider_failure_is_swallowed(): + cfg = configure(network="solana_localnet", preflight=False) + + def boom(): + raise RuntimeError("rpc down") + + adapter = X402Adapter(cfg, recent_blockhash_provider=boom) + entry = adapter.accepts_entry(_gate(cfg), {"path": "/report"}) + assert "recentBlockhash" not in entry["extra"] + + +def test_adapter_challenge_headers_base64(): + cfg = configure(network="solana_localnet", preflight=False) + adapter = X402Adapter(cfg) + headers = adapter.challenge_headers(_gate(cfg), {"path": "/report"}) + assert "payment-required" in headers + decoded = base64.b64decode(headers["payment-required"]) + assert b"accepts" in decoded + + +def test_adapter_delegated_mode_not_implemented(): + from pay_kit import X402Config + + cfg = configure(network="solana_localnet", preflight=False, x402=X402Config(facilitator_url="https://fac")) + with pytest.raises(NotImplementedError, match="delegated mode"): + X402Adapter(cfg) From 2f95987e6b1085998471e84920e4f218d5686ed8 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 10:43:02 +0300 Subject: [PATCH 11/45] feat(harness): register pay-kit-python interop server 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. --- harness/pay-kit-python-server/server.py | 472 ++++++++++++++++++++++++ harness/src/implementations.ts | 21 ++ harness/test/x402-exact.e2e.test.ts | 7 + 3 files changed, 500 insertions(+) create mode 100644 harness/pay-kit-python-server/server.py diff --git a/harness/pay-kit-python-server/server.py b/harness/pay-kit-python-server/server.py new file mode 100644 index 000000000..f49f378c3 --- /dev/null +++ b/harness/pay-kit-python-server/server.py @@ -0,0 +1,472 @@ +"""Cross-language harness adapter for the Python PayKit umbrella surface. + +One TCP server, two settle paths (x402:exact and mpp:charge), picked per +scenario by which env namespace the harness orchestrator sets (or by the +explicit ``PAY_KIT_INTEROP_PROTOCOL`` hint). Mirrors ``harness/php-server/ +server.php`` and the Ruby/Lua pay-kit-server pattern. + +Unlike ``harness/python-server/main.py`` (which drives the lower-level +``solana_mpp`` wire directly), this adapter routes every request through the +unified ``pay_kit`` surface: + + * x402 exact -> ``pay_kit.protocols.x402.X402Adapter`` (the umbrella adapter) + * MPP charge -> ``solana_mpp.server.mpp.Mpp`` (the lower-level wire) + +This split mirrors the canonical PHP adapter (``harness/php-server/ +server.php``): x402 routes through the umbrella adapter, while MPP charge +routes through the lower-level ``solana_mpp`` handler. The umbrella's +ticker-based currency model (``Stablecoin`` enum -> ``Mints.resolve``) is the +right surface for x402, where the offer's ``asset`` is the resolved on-chain +mint; but the interop MPP charge matrix runs in *pubkey mode* (the harness +deploys the scenario mint at an arbitrary ``MPP_INTEROP_MINT`` pubkey, not the +canonical USDC mint), so the MPP challenge must advertise that literal mint as +its ``currency``. The lower-level ``solana_mpp`` handler takes the raw mint +directly, exactly as the PHP ``SolanaChargeHandler`` path and the existing +``harness/python-server/main.py`` reference do. + +Cross-route replay protection on the MPP path is enforced by +``Mpp.verify_credential_with_expected`` (pins amount/currency/recipient per +route); the x402 path pins via ``X402Adapter``'s offer-equality gate. + +Stdout discipline: ONLY the ``ready`` JSON line is written to stdout. All +diagnostics go to stderr. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import socket +import sys +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Any + + +def _find_repo_root(start: Path) -> Path: + for candidate in [start, *start.parents]: + if (candidate / ".git").exists() or (candidate / "python" / "pyproject.toml").is_file(): + return candidate + return start.parents[-1] + + +_repo_root = _find_repo_root(Path(__file__).resolve()) +_python_src = _repo_root / "python" / "src" +if _python_src.is_dir(): + sys.path.insert(0, str(_python_src)) + +from pay_kit import ( # noqa: E402 + Config, + Gate, + Network, + Operator, + Price, + Protocol, + Signer, + Stablecoin, +) +from pay_kit.errors import InvalidProofError # noqa: E402 +from pay_kit.protocols.x402 import X402Adapter # noqa: E402 +from solana_mpp._errors import PaymentError, canonical_code # noqa: E402 +from solana_mpp._headers import format_www_authenticate, parse_authorization # noqa: E402 +from solana_mpp._rpc import SolanaRpc # noqa: E402 +from solana_mpp.protocol.intents import ChargeRequest # noqa: E402 +from solana_mpp.server.mpp import ChargeOptions # noqa: E402 +from solana_mpp.server.mpp import Config as MppServerConfig # noqa: E402 +from solana_mpp.server.mpp import Mpp # noqa: E402 +from solana_mpp.store import MemoryStore # noqa: E402 + + +def require_env(name: str) -> str: + value = os.environ.get(name) + if not value: + print(f"Missing required env: {name}", file=sys.stderr) + sys.exit(2) + return value + + +def optional_env(name: str, default: str) -> str: + value = os.environ.get(name) + return value if value else default + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _resolve_network(raw: str) -> Network: + """Map the harness network string to a pay_kit Network enum. + + Charge scenarios send the short slug ``localnet``; x402 scenarios send a + CAIP-2 string (``solana:``). Mirrors PHP ``resolve_network``. + """ + if raw.startswith("solana:"): + if raw == "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": + return Network.SOLANA_MAINNET + # Devnet genesis and any other CAIP-2 fall to devnet (the localnet + # surfpool fixtures are funded under the devnet genesis hash). + return Network.SOLANA_DEVNET + return { + "mainnet": Network.SOLANA_MAINNET, + "devnet": Network.SOLANA_DEVNET, + }.get(raw, Network.SOLANA_LOCALNET) + + +def _base_units_to_human(base_units: str, decimals: int) -> str: + """Convert a base-units string (e.g. ``"1000"``) into a decimal string.""" + if decimals <= 0: + return str(int(base_units)) + units = int(base_units) + sign = "-" if units < 0 else "" + units = abs(units) + quotient, remainder = divmod(units, 10 ** decimals) + fraction = f"{remainder:0{decimals}d}".rstrip("0") + if not fraction: + return f"{sign}{quotient}" + return f"{sign}{quotient}.{fraction}" + + +def _coin_for_mint(mint: str) -> Stablecoin: + """Pick the settlement Stablecoin for the scenario mint. + + The harness sends an on-chain mint pubkey (pubkey mode) or a ticker + (symbol mode). The interop matrix's stablecoin is USDC; map any ticker we + recognise, else default to USDC. The on-chain mint is asserted by the + harness from the SDK's own resolver, so the ticker only selects which + 6-decimal coin the offer advertises. + """ + try: + return Stablecoin(mint) + except ValueError: + return Stablecoin.USDC + + +def _detect_x402() -> bool: + """Decide which protocol this run exercises (mirror PHP detection).""" + explicit = optional_env("PAY_KIT_INTEROP_PROTOCOL", "").lower() + if explicit == "x402": + return True + if explicit in ("mpp", "charge"): + return False + x402_set = bool(os.environ.get("X402_INTEROP_RPC_URL")) + mpp_set = bool(os.environ.get("MPP_INTEROP_RPC_URL")) + if x402_set == mpp_set: + print( + "set exactly one of X402_INTEROP_RPC_URL / MPP_INTEROP_RPC_URL, " + "or set PAY_KIT_INTEROP_PROTOCOL", + file=sys.stderr, + ) + sys.exit(2) + return x402_set + + +class _Adapter: + """Holds the built pay_kit adapter plus per-route gate amounts.""" + + def __init__(self) -> None: + self.x402 = _detect_x402() + if self.x402: + self._build_x402() + else: + self._build_mpp() + + # -- x402 ----------------------------------------------------------------- + + def _build_x402(self) -> None: + rpc_url = require_env("X402_INTEROP_RPC_URL") + pay_to = require_env("X402_INTEROP_PAY_TO") + facilitator_json = require_env("X402_INTEROP_FACILITATOR_SECRET_KEY") + amount_units = optional_env("X402_INTEROP_AMOUNT", "1000") + mint = optional_env("X402_INTEROP_MINT", "USDC") + network_raw = optional_env( + "X402_INTEROP_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + ) + self.resource_path = optional_env("X402_INTEROP_RESOURCE_PATH", "/protected") + self.settlement_header = optional_env( + "X402_INTEROP_SETTLEMENT_HEADER", "x-fixture-settlement" + ).lower() + self.coin = _coin_for_mint(mint) + + signer = Signer.json(facilitator_json) + config = Config( + network=_resolve_network(network_raw), + accept=(Protocol.X402,), + stablecoins=(self.coin,), + rpc_url=rpc_url, + operator=Operator(recipient=pay_to, signer=signer, fee_payer=True), + preflight=False, + ).model_copy() + self.config = config + self.adapter = X402Adapter(config) + self.pay_to = pay_to + decimals = int(optional_env("X402_INTEROP_DECIMALS", "6")) + self.routes = {self.resource_path: _base_units_to_human(amount_units, decimals)} + self.replay_path = "" + + # -- mpp ------------------------------------------------------------------ + + def _build_mpp(self) -> None: + self.rpc_url = require_env("MPP_INTEROP_RPC_URL") + pay_to = require_env("MPP_INTEROP_PAY_TO") + # Pubkey mode: the literal scenario mint pubkey is the MPP currency. + self.mint = require_env("MPP_INTEROP_MINT") + amount_units = require_env("MPP_INTEROP_AMOUNT") + secret = optional_env("MPP_INTEROP_SECRET_KEY", "mpp-interop-secret-key") + network_raw = optional_env("MPP_INTEROP_NETWORK", "localnet") + self.resource_path = optional_env("MPP_INTEROP_RESOURCE_PATH", "/paid") + self.settlement_header = optional_env( + "MPP_INTEROP_SETTLEMENT_HEADER", "x-payment-settlement-signature" + ).lower() + realm = optional_env("MPP_INTEROP_REALM", "MPP Interop") + self.splits = json.loads(optional_env("MPP_INTEROP_SPLITS", "[]")) + if not isinstance(self.splits, list): + print("MPP_INTEROP_SPLITS must decode to a JSON array", file=sys.stderr) + sys.exit(2) + + fee_payer = None + fee_payer_raw = os.environ.get("MPP_INTEROP_FEE_PAYER_SECRET_KEY") + if fee_payer_raw: + from solders.keypair import Keypair + + fee_payer = Keypair.from_bytes(bytes(json.loads(fee_payer_raw))) + self.fee_payer = fee_payer + + # Build the lower-level solana_mpp handler with the raw mint. The + # ``Mpp`` server boots with ``rpc=None``; a request-lifetime + # ``SolanaRpc`` is scoped via ``using_rpc`` in the request path + # (mirrors harness/python-server/main.py). + config = MppServerConfig( + recipient=pay_to, + currency=self.mint, + decimals=int(optional_env("MPP_INTEROP_DECIMALS", "6")), + network=network_raw, + rpc_url=self.rpc_url, + secret_key=secret, + realm=realm, + fee_payer_signer=fee_payer, + store=MemoryStore(), + rpc=None, + ) + self.handler = Mpp(config) + self.pay_to = pay_to + + decimals = int(optional_env("MPP_INTEROP_DECIMALS", "6")) + self.routes = {self.resource_path: _base_units_to_human(amount_units, decimals)} + replay_path = os.environ.get("MPP_INTEROP_REPLAY_SOURCE_PATH") or "" + if replay_path: + replay_amount = os.environ.get("MPP_INTEROP_REPLAY_SOURCE_AMOUNT") or amount_units + self.routes[replay_path] = _base_units_to_human(replay_amount, decimals) + self.replay_path = replay_path + + def charge_options(self) -> ChargeOptions: + options = ChargeOptions( + description="PayKit Python interop protected content", + splits=self.splits or [], + ) + if self.fee_payer is not None: + options.fee_payer = True + return options + + # -- x402 gate ------------------------------------------------------------ + + def gate_for(self, path: str) -> Gate: + amount = self.routes[path] + return Gate.build( + name=path.lstrip("/") or "root", + amount=Price.usd(amount, self.coin), + default_pay_to=self.pay_to, + accept=(Protocol.X402,), + description="PayKit Python interop protected content", + ) + + +class InteropHandler(BaseHTTPRequestHandler): + server_version = "pay-kit-python-interop/1.0" + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + return + + @property + def adapter(self) -> _Adapter: + return self.server.adapter # type: ignore[attr-defined] + + def _send_json(self, status: int, body: dict, extra_headers: dict | None = None) -> None: + payload = json.dumps(body).encode("utf-8") + self.send_response(status) + headers = {"content-type": "application/json"} + if extra_headers: + for name, value in extra_headers.items(): + headers[name.lower()] = value + headers["content-length"] = str(len(payload)) + headers["connection"] = "close" + for name, value in headers.items(): + self.send_header(name, value) + self.end_headers() + self.wfile.write(payload) + + def _request_bag(self) -> dict[str, Any]: + # Build the framework-agnostic request bag both adapters accept + # (``.headers``-style getter and ``path``). Header names are + # lower-cased so the adapters' case-tolerant lookups hit. + headers = {name.lower(): value for name, value in self.headers.items()} + return {"headers": headers, "path": self.path} + + def do_GET(self) -> None: # noqa: N802 + if self.path == "/health": + self._send_json(200, {"ok": True}) + return + + adapter = self.adapter + if self.path not in adapter.routes: + self._send_json(404, {"error": "not_found"}) + return + + request = self._request_bag() + + if adapter.x402: + self._handle_x402(adapter, adapter.gate_for(self.path), request) + else: + self._handle_mpp(adapter, request) + + def _handle_x402(self, adapter: _Adapter, gate: Gate, request: dict[str, Any]) -> None: + if not request["headers"].get("payment-signature"): + challenge_headers = adapter.adapter.challenge_headers(gate, request) + accepts = adapter.adapter.accepts_entry(gate, request) + self._send_json( + 402, + {"error": "payment_required", "resource": self.path, "accepts": [accepts]}, + extra_headers=challenge_headers, + ) + return + try: + payment = asyncio.run(adapter.adapter.verify_and_settle(gate, request)) + except InvalidProofError as err: + self._send_json( + 402, + {"error": err.code or "invalid_proof", "code": err.code, "message": str(err)}, + extra_headers=adapter.adapter.challenge_headers(gate, request), + ) + return + headers = dict(payment.settlement_headers) + headers[adapter.settlement_header] = payment.transaction + self._send_json( + 200, + {"ok": True, "paid": True, "protocol": "x402", "transaction": payment.transaction}, + extra_headers=headers, + ) + + def _handle_mpp(self, adapter: _Adapter, request: dict[str, Any]) -> None: + amount = adapter.routes[self.path] + options = adapter.charge_options() + auth = request["headers"].get("authorization", "") + + if not auth: + self._issue_mpp_challenge(adapter, amount, options, message="missing authorization") + return + + try: + credential = parse_authorization(auth) + except Exception as exc: # noqa: BLE001 - parse errors map to 402 + self._issue_mpp_challenge( + adapter, + amount, + options, + message=f"could not parse Authorization: {exc}", + code="payment_invalid", + ) + return + + try: + challenge = adapter.handler.charge_with_options(amount, options) + expected = ChargeRequest.from_dict(challenge.decode_request()) + + async def _verify_with_fresh_rpc(): + fresh_rpc = SolanaRpc(adapter.rpc_url) + try: + async with adapter.handler.using_rpc(fresh_rpc): + return await adapter.handler.verify_credential_with_expected( + credential, expected + ) + finally: + await fresh_rpc.aclose() + + receipt = asyncio.run(_verify_with_fresh_rpc()) + except PaymentError as err: + self._issue_mpp_challenge( + adapter, amount, options, message=str(err) or "verification failed", code=err.code + ) + return + except Exception as err: # noqa: BLE001 framework guard + print(f"interop pay-kit-python server error: {err}", file=sys.stderr) + self._issue_mpp_challenge(adapter, amount, options, message=str(err)) + return + + self._send_json( + 200, + {"ok": True, "paid": True}, + extra_headers={ + "payment-receipt": receipt.reference, + adapter.settlement_header: receipt.reference, + }, + ) + + def _issue_mpp_challenge( + self, + adapter: _Adapter, + amount: str, + options: ChargeOptions, + *, + message: str = "Payment required", + code: str = "payment_invalid", + ) -> None: + challenge = adapter.handler.charge_with_options(amount, options) + canonical = canonical_code(code) if code else "payment_invalid" + body = { + "type": f"https://paymentauth.org/problems/{canonical}", + "title": "Payment Required", + "status": 402, + "code": canonical, + "error": canonical, + "message": message, + } + self._send_json( + 402, + body, + extra_headers={ + "content-type": "application/problem+json", + "www-authenticate": format_www_authenticate(challenge), + "cache-control": "no-store", + }, + ) + + +def main() -> None: + adapter = _Adapter() + port = _free_port() + server = HTTPServer(("127.0.0.1", port), InteropHandler) + server.adapter = adapter # type: ignore[attr-defined] + + ready = { + "type": "ready", + "implementation": "pay-kit-python", + "role": "server", + "port": port, + "capabilities": ["exact" if adapter.x402 else "charge"], + } + sys.stdout.write(json.dumps(ready) + "\n") + sys.stdout.flush() + + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + thread.join() + except KeyboardInterrupt: + server.shutdown() + + +if __name__ == "__main__": + main() diff --git a/harness/src/implementations.ts b/harness/src/implementations.ts index f6c530a4b..e8121eeb0 100644 --- a/harness/src/implementations.ts +++ b/harness/src/implementations.ts @@ -226,6 +226,27 @@ export const serverImplementations: ImplementationDefinition[] = [ command: ["python3", "python-server/main.py"], enabled: isEnabled("python", "MPP_INTEROP_SERVERS", false), }, + { + id: "pay-kit-python", + label: "Python PayKit server (dual protocol)", + role: "server", + // One adapter binary, two settle paths. The dual-protocol Python + // PayKit server (harness/pay-kit-python-server/server.py) reads + // either X402_INTEROP_* or MPP_INTEROP_* (or PAY_KIT_INTEROP_PROTOCOL + // for the matrix's both-namespaces shape) and routes x402 through the + // umbrella's X402Adapter and MPP charge through the lower-level + // solana_mpp handler (the umbrella's ticker-based currency model fits + // x402's resolved-mint asset, but the pubkey-mode MPP charge matrix + // needs the literal mint as currency). Same split as the PHP adapter. + // Distinct from the lower-level `python` server above, which is + // MPP-only. Default OFF: opt in via + // `MPP_INTEROP_SERVERS=pay-kit-python` (charge) / + // `MPP_INTEROP_SERVERS=pay-kit-python X402_INTEROP_CLIENTS=rust-x402` + // with `MPP_INTEROP_INTENTS=x402-exact` (x402-exact). + command: ["python3", "pay-kit-python-server/server.py"], + enabled: isEnabled("pay-kit-python", "MPP_INTEROP_SERVERS", false), + intents: ["charge", "x402-exact"], + }, { id: "go", label: "Go PayKit umbrella server (dual protocol)", diff --git a/harness/test/x402-exact.e2e.test.ts b/harness/test/x402-exact.e2e.test.ts index 03aeb262e..7230cce78 100644 --- a/harness/test/x402-exact.e2e.test.ts +++ b/harness/test/x402-exact.e2e.test.ts @@ -88,6 +88,13 @@ describe("x402 exact intent — cross-language matrix", () => { const allowedPair = (clientId: string, serverId: string): boolean => { if (clientId === "ts-x402" && serverId === "ts-x402") return true; if (clientId === "rust-x402" && serverId === "rust-x402") return true; + // The Python PayKit x402 server does full settlement (cosign + + // broadcast), so it can only be driven by a client that emits a real + // signed Solana transaction. The rust-x402 client carries the + // canonical PaymentProof and settles end-to-end against surfpool, + // mirroring the rust<->lua x402 interop pairing. The ts-x402 stub + // client (no real transaction) is intentionally excluded. + if (clientId === "rust-x402" && serverId === "pay-kit-python") return true; return false; }; From 7bf4fd62191640c5744ffd135bb82e279062d9ed Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 10:43:02 +0300 Subject: [PATCH 12/45] docs(python): document pay_kit, add framework examples and CI job 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). --- .github/workflows/ci.yml | 52 +++ python/README.md | 545 +++++++++++++++++++--------- python/examples/django/views.py | 61 ++++ python/examples/fastapi/app.py | 68 ++++ python/examples/flask-paykit/app.py | 84 +++++ 5 files changed, 647 insertions(+), 163 deletions(-) create mode 100644 python/examples/django/views.py create mode 100644 python/examples/fastapi/app.py create mode 100644 python/examples/flask-paykit/app.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91083b906..98e10da2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,6 +215,58 @@ jobs: # workflow (triggered on pull_request and workflow_call). Kept out of # ci.yml so there is a single "Go tests" check rather than two. + test-python: + name: Python tests + needs: build-html + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: python/pyproject.toml + - name: Download HTML assets + uses: actions/download-artifact@v7 + with: + name: html-assets + - name: Install package + extras + working-directory: python + # Framework extras are needed so the fastapi/flask/django shim tests + # import; dev brings ruff, pyright, pytest, pytest-cov. + run: pip install -e ".[dev,fastapi,flask,django]" + - name: Format check + working-directory: python + run: ruff format --check src tests + - name: Lint + working-directory: python + run: ruff check src tests + - name: Type-check + working-directory: python + run: pyright + - name: Run tests with coverage + working-directory: python + env: + SURFPOOL_REPORT: "1" + # 90% line gate baked into the command so local and CI agree; the + # gate is configured in pyproject.toml (fail_under = 90) and covers + # both pay_kit and the solana_mpp wire it reuses. preflight.py is + # omitted there (live-RPC paths exercised separately). + run: pytest --cov=pay_kit --cov=solana_mpp --cov-report=xml --cov-fail-under=90 + - name: Upload Python coverage + if: always() + uses: actions/upload-artifact@v7 + with: + name: python-coverage + path: python/coverage.xml + - name: Upload Python surfpool report data + if: always() + uses: actions/upload-artifact@v7 + with: + name: surfpool-reports-python + path: python/target/surfpool-reports/ + if-no-files-found: ignore + integration: name: Integration tests needs: build-html diff --git a/python/README.md b/python/README.md index d4e74a983..4888691e6 100644 --- a/python/README.md +++ b/python/README.md @@ -8,200 +8,395 @@ # solana-pay-kit -Charge stablecoins (USDC, USDT, PYUSD, ...) for any HTTP endpoint, in -Python. Implements the Solana payment method for the -[Machine Payments Protocol](https://mpp.dev) and ships a Flask-friendly -decorator for `402 Payment Required` flows. - -**MPP** is [an open protocol proposal](https://paymentauth.org) that lets -any HTTP API accept payments using the `402 Payment Required` flow. You -do not need to know anything about Solana to use this library: pick a -currency, give it your wallet address, and gate a route in two lines. +Charge stablecoins (USDC, USDT, PYUSD, ...) for any HTTP endpoint, in Python. +One package, one surface, two protocols underneath: +[x402](https://x402.org) and the +[Machine Payments Protocol](https://paymentauth.org). FastAPI, Flask, and +Django ride on top of a framework-agnostic core. [![Python](https://img.shields.io/badge/python-3.11%2B-blue)]() -[![Coverage](https://img.shields.io/badge/coverage-81%25-yellow)]() +[![Coverage](https://img.shields.io/badge/coverage-90%25-brightgreen)]() [![Branch coverage](https://img.shields.io/badge/branch%20coverage-tracked-blue)]() +--- + ## Quick start -Gate a Flask route with the `@mpp_charge` decorator (from -[`examples/flask/app.py`](examples/flask/app.py)): +Three progressively-realistic snippets. Each one runs as-is, copy, paste, +hit the URL. Flask is the framework here; the same surface works in FastAPI +and Django (see [Examples](#examples)). + +### 1. Smallest possible app + +Gate one route with an inline price. Save the snippet as `app.py` and boot +with `python app.py`. Zero-config: the package uses a published demo +keypair as the recipient and the hosted Surfpool sandbox at +`https://402.surfnet.dev:8899` as the RPC. ```python +# app.py from flask import Flask, jsonify -from solana_mpp.server.mpp import Mpp -from config import mpp_config_from_env, server_settings_from_env -from middleware import mpp_charge +import pay_kit +from pay_kit import usd +from pay_kit.flask import require_payment + +pay_kit.configure(network="solana_localnet") -settings = server_settings_from_env() -mpp = Mpp(mpp_config_from_env()) app = Flask(__name__) -@app.get("/health") -def health(): - return jsonify(ok=True) +@app.get("/report") +@require_payment(usd("0.10")) +def report(): + return jsonify(content="premium content") + +app.run(host="127.0.0.1", port=8000) +``` + +`pay_kit.configure(...)` builds the process-wide config once at boot. +`@require_payment(usd("0.10"))` answers a 402 with a payment challenge when +no valid proof was sent, or runs the view if one was. + +Hit `/report` with [`pay curl`](#run-the-example) and the customer walks +through a USDC payment. + +### 2. Multiple gates via a registry + +When more than one route is paid, lift the prices into a single +:class:`Pricing` registry. Routes reference gates by string handle. + +```python +# app.py +from flask import Flask, jsonify + +import pay_kit +from pay_kit import Gate, Protocol, Pricing, usd +from pay_kit.flask import require_payment + +pay_kit.configure(network="solana_localnet") -@app.get("/paid") -@mpp_charge(mpp, amount=settings.amount, description="Paid endpoint") -def paid(): - return jsonify(ok=True, message="thanks for paying!") +class Catalog(Pricing): + def __init__(self): + defaults = { + "default_pay_to": pay_kit.config().effective_recipient(), + "accept_default": pay_kit.config().accept, + } + self.report = Gate.build(name="report", amount=usd("0.10"), + description="Premium report", **defaults) + self.api_call = Gate.build(name="api_call", amount=usd("0.001"), + accept=(Protocol.X402,), **defaults) -if __name__ == "__main__": - app.run(host=settings.host, port=settings.port) +catalog = Catalog() +app = Flask(__name__) + +@app.get("/report") +@require_payment("report", pricing=catalog) +def report(): + return jsonify(content="premium content") + +@app.get("/api/data") +@require_payment("api_call", pricing=catalog) +def api_data(): + return jsonify(data=[]) + +app.run(host="127.0.0.1", port=8000) ``` -The decorator handles the 402 flow end to end: it builds the challenge, -parses any `Authorization: Payment` header, runs route-aware verification -through `verify_credential_with_expected`, and emits a structured -`application/problem+json` body with the L6 canonical error code -(`payment_invalid`, `signature_consumed`, ...) on any 402. +Gates are validated in `Gate.build` at boot, wrong currency, missing +recipient, fee math that doesn't add up, so configuration errors surface +before any traffic. `accept=` is an allowlist; the `api_call` gate here +refuses to settle over MPP. -`currency` accepts a symbol like `"USDC"`, `"USDT"`, `"USDG"`, `"PYUSD"`, -or `"CASH"`. The SDK looks up the mint address and the right SPL token -program (Token vs Token-2022). You can also pass a raw mint pubkey for -tokens not in the table. +### 3. Production-shape config -### Raw SDK usage +Snippet 2's demo recipient and public sandbox are fine for poking around. +Production wants explicit keys, a dedicated RPC, and a list of accepted +stablecoins. The Flask app is unchanged, only the `configure` call grows. ```python -from solana_mpp.server.mpp import ChargeOptions, Config, Mpp -from solana_mpp.store import MemoryStore -from solana_mpp._rpc import SolanaRpc - -mpp = Mpp(Config( - recipient="CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", - currency="USDC", - decimals=6, - network="localnet", - rpc_url="https://402.surfnet.dev:8899", - secret_key="local-dev-secret", - realm="Python MPP Example", - store=MemoryStore(), - rpc=SolanaRpc("https://402.surfnet.dev:8899"), -)) - -challenge = mpp.charge_with_options("0.001", ChargeOptions(description="Paid endpoint")) +# app.py, same routes as snippet 2. +import pay_kit +from pay_kit import Gate, Operator, Pricing, Signer, Stablecoin, usd + +PLATFORM = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY" + +pay_kit.configure( + network="solana_mainnet", + stablecoins=(Stablecoin.USDC, Stablecoin.PYUSD), + operator=Operator(signer=Signer.file("operator.json")), + rpc_url="https://mainnet.helius-rpc.com/?api-key=YOUR_HELIUS_KEY", +) + +class Catalog(Pricing): + def __init__(self): + defaults = {"default_pay_to": pay_kit.config().effective_recipient()} + self.report = Gate.build(name="report", amount=usd("0.10"), + description="Premium report", **defaults) + # Platform-fee pattern: customer pays $10.00, + # operator nets $9.70, PLATFORM nets $0.30. x402 auto-disabled. + self.marketplace_sale = Gate.build( + name="marketplace_sale", + amount=usd("10.00"), + fee_within={PLATFORM: usd("0.30")}, + **defaults, + ) ``` -The Mpp handler owns every static knob (recipient, default currency, -network, RPC, optional fee payer). Per-request you only pass `amount` -and `description`. An explicit replay store is required; `MemoryStore()` -is fine for tests and single-process deployments, `FileReplayStore(path)` -persists the consumed-signature set across restarts. - -## Protocol compatibility matrix - -### MPP - -| Intent | Client | Server | -|---|:---:|:---:| -| `mpp/charge/pull` | pass | pass | -| `mpp/charge/push` | pass | pass | -| `mpp/session` | --- | --- | -| `mpp/subscription` | --- | --- | - -### x402 - -| Intent | Client | Server | -|---|:---:|:---:| -| `x402/exact` | --- | --- | -| `x402/upto` | --- | --- | -| `x402/batch-settlement` | --- | --- | - -For `mpp/charge/pull`: the server owns the full lifecycle. Issue signed -challenges with a fresh `recentBlockhash`, parse and validate the -`Authorization: Payment` credential, pin the echoed charge request, -decode the client-signed transaction and check recipient, amount, mint, -splits, ATA, memos, and compute budget, reject Surfpool-signed -transactions on non-localnet networks, optionally fee-payer co-sign -(legacy + v0), broadcast via `sendTransaction`, consume the signature -in the replay store, await confirmation, and emit `payment-receipt` with -the on-chain signature. - -For `mpp/charge/push`: the server fetches the transaction by signature -with `getTransaction`, rejects failed or missing metadata, reuses the -same structural transaction verifier as pull mode, consumes the -signature in replay storage only after the on-chain shape is known to -be correct, and emits the same receipt shape. +`configure` reads literal values here; in real deployments pull the signer +and RPC URL from your environment (`Signer.env("OPERATOR_KEY")`, +`os.getenv("RPC_URL")`) or drive the whole thing from env vars with +`pay_kit.configure_from()`. -## Examples +Two safety rails fire at boot: -Two runnable examples ship with this package: +- `network="solana_mainnet"` plus the published demo signer raises + `DemoSignerOnMainnetError`, no real funds get routed to a publicly known + address by accident. +- Missing `mpp.challenge_binding_secret`? Preflight resolves one from the + environment, falling back to `./.env`, generating and persisting one if + neither exists, so the HMAC stays stable across restarts. Override via + `PAY_KIT_MPP_CHALLENGE_BINDING_SECRET` to control it from your secret + manager. -- [`examples/flask/`](examples/flask) - Flask app with an app factory, - `config.py`, and a `middleware.py` charge decorator. Exposes - `/health` (free) and `/paid` (gated). -- [`examples/payment-links/server.py`](examples/payment-links/server.py) - - runs the same flow against a local Surfpool, serves a payment-page - HTML fallback, and is the adapter used by the interop harness. +--- -### Run the Flask example +## Run the example + +Three runnable examples ship with this package, one per framework. Each one +boots zero-config against the Surfpool sandbox. + +**Boot the server:** ```bash -cd python -pip install -e ".[dev]" -pip install flask -python examples/flask/app.py +git clone https://github.com/solana-foundation/pay-kit +cd pay-kit/python +pip install -e ".[flask]" +python examples/flask-paykit/app.py ``` -### Drive it from a client +**Consume with `pay curl`:** ```bash +# Install the pay CLI: brew install pay -curl http://127.0.0.1:8000/paid # 402 payment required -pay curl http://127.0.0.1:8000/paid # pays and succeeds +# or npm install -g @solana/pay + +# Fail with 402, payment required +curl -i http://127.0.0.1:8000/report + +# Succeed with 200, payment provided +pay curl -i http://127.0.0.1:8000/report ``` -The examples default to `localnet`, `USDC`, and a local recipient. -Override `RPC_URL` (or `MPP_RPC_URL` for the Flask example) for a -different endpoint. +--- + +## x402 + +[x402](https://x402.org) revives HTTP `402 Payment Required` as a +client-server payment handshake. Your server gates a route; a paying client +receives the 402 with payment instructions, signs a Solana transaction +off-chain, and replays the same request with a `PAYMENT-SIGNATURE` header. +The server verifies the signature, broadcasts the transaction, and returns +the original response with a `PAYMENT-RESPONSE` header carrying the on-chain +settlement signature. + +x402 is single-recipient by design: the server's facilitator pays the +network fees, the customer's signed transaction settles funds to `pay_to`. +Gates with `fee_within` or `fee_on_top` recipients auto-disable x402, +because stock x402 facilitators settle to one address. + +| Scheme | Status | +|---------|--------| +| `exact` | ✅ | +| `upto` | — | +| `batch` | — | + +## mpp + +The [Machine Payments Protocol](https://paymentauth.org) is the broader HTTP +Payment Authentication scheme, the same 402 handshake, but the challenge +carries a richer intent shape that supports multi-recipient splits, +server-side fee accounting, and a separate fee-payer signer. + +Use MPP when: + +- Your gate has a platform or gateway fee (the Stripe-Connect "application + fee" pattern). +- You want the server to subsidize the customer's network fee. +- You want one challenge per gate instead of per-mint-quoted offers. + +| Scheme | Status | +|---------------|--------| +| `charge/pull` | ✅ | +| `charge/push` | ✅ | +| `session` | — | + +The MPP server owns the full lifecycle: it issues signed challenges with a +fresh `recentBlockhash`, parses and validates the `Authorization: Payment` +credential, pins the echoed charge request, decodes the client-signed +transaction and checks recipient, amount, mint, splits, ATA, memos, and +compute budget, rejects Surfpool-signed transactions on non-localnet +networks, optionally fee-payer co-signs (legacy + v0), broadcasts via +`sendTransaction`, consumes the signature in the replay store, awaits +confirmation, and emits `payment-receipt` with the on-chain signature. + +--- + +## Vocabulary + +| Term | Meaning | +|--------------|---------| +| **gate** | A protected unit. Has an amount, optional fees, accepted protocols. | +| **amount** | The base amount a gate charges, before any `fee_on_top`. | +| **total** | What the customer pays: `amount + sum(fee_on_top)`. Derived via `Gate.total()`. | +| **price** | Value object returned by `usd(...)`: number + denom + settlement. | +| **fee_within** | Fee taken out of the amount. `pay_to` nets less. | +| **fee_on_top** | Fee added to the amount. Customer pays more; `pay_to` nets full. | +| **payment** | Proof submitted by the client to pass a gate. | +| **protocol** | `Protocol.X402` or `Protocol.MPP` (top-level dispatch). | +| **scheme** | x402 sub-form: `exact`. MPP sub-form: `charge`. | +| **accept** | Ordered preference list (protocols and stablecoins both). | +| **denom** | Fiat unit a price is quoted in (`USD`, `EUR`, `GBP`). | +| **settlement** | On-chain asset that actually transfers (`USDC`, `USDT`). | + +## Three primitives + +The framework-agnostic trio, importable from the top level for imperative +gating inside a handler, mirrored by the per-framework shims: + +| Function | Purpose | +|----------|---------| +| `require_payment(request)` | Returns the verified `Payment`, raises `PaymentRequiredError` if unpaid | +| `is_paid(request)` | Predicate, never raises | +| `get_payment(request)` | The verified `Payment`, `None` until paid | + +Each framework shim also exposes its own decorator/dependency form: +`pay_kit.flask.require_payment`, `pay_kit.fastapi.RequirePayment`, and +`pay_kit.django.require_payment`. + +## Inline pricing + +For one-off endpoints that don't warrant a registry entry, skip the gate +name and pass a price directly: -## Solana dependencies +```python +@app.get("/oneoff") +@require_payment(usd("0.25")) +def oneoff(): + return jsonify(ok=True) +``` -| Dependency | Why | Version | -|---|---|---| -| `solders` | Ed25519 signer, transaction codec, base58 helpers | `>=0.22` | -| `solana` | tests use `solana.rpc.async_api.AsyncClient` for compatibility | `>=0.35` | -| `httpx` | async JSON-RPC HTTP client (`SolanaRpc`) | `>=0.27` | -| internal canonical JSON helper | RFC 8785 byte-equal output across SDKs | `_canonical_json.py` | -| internal base64url helper | URL-safe base64 without padding | `_base64url.py` | +The bare `Price` is wrapped into an inline `Gate` using the configured +default recipient and accept list. -The Python server keeps Solana dependencies intentionally small. It -parses legacy and v0 transaction messages via `solders`, verifies -transfer instructions structurally, signs optional fee-payer pull -transactions, and uses JSON-RPC directly for submission, confirmation, -and push-mode transaction lookup. +## Gate DSL -## Coding convention +Each gate is a frozen value object built via `Gate.build` with an amount, an +ordered list of accepted protocols, and zero or more named fees. -This SDK follows the -[`skills.sh/mindrally/skills/python`](https://skills.sh/mindrally/skills/python) -best-practice skill. The implementation pass focuses on small modules, -explicit error types with canonical L6 codes, deterministic wire -serialization (RFC 8785 canonical JSON), defensive payment verification -(instruction allowlist + memo v2 enforcement), and branch tests on -security-sensitive paths. +```python +SELLER = "Ay..." +PLATFORM = "CX..." + +# Simple. Customer pays $0.10, pay_to nets $0.10. +Gate.build(name="report", amount=usd("0.10"), description="Premium report") + +# x402-only. +Gate.build(name="api_call", amount=usd("0.001"), accept=(Protocol.X402,)) + +# Stripe-Connect "application fee". Customer pays $10.00, +# SELLER nets $9.70, PLATFORM nets $0.30. x402 auto-disabled. +Gate.build(name="marketplace_sale", amount=usd("10.00"), + pay_to=SELLER, fee_within={PLATFORM: usd("0.30")}) + +# Surcharge. Customer pays $10.50, SELLER nets $10.00, PLATFORM nets $0.50. +Gate.build(name="ticket", amount=usd("10.00"), + pay_to=SELLER, fee_on_top={PLATFORM: usd("0.50")}) + +# Dynamic per-request pricing. +@gate("tiered") +def tiered(request): + tier = request.args.get("tier") + return usd("5.00") if tier == "premium" else usd("0.10") +``` + +Boot-time validations (all raise `ConfigurationError` or a subclass): -The repo-level `pay-sdk-implementation` skill remains the protocol source -of truth: Rust / spec wire format first, Python idioms second. +- `pay_to` is required (gate kwarg or a configured operator recipient). +- Fee recipient must differ from `pay_to`. Fold the fee into the amount instead. +- All fee prices share one denomination with the amount. +- `sum(fee_within) <= amount`. +- `accept=(Protocol.X402,)` on a fee-bearing gate raises `ProtocolIncompatibleError`. + +## Framework-first + +`pay_kit` carries no web-framework dependency in the base install. The +framework shims live in optional submodules imported on demand: + +- `pay_kit.flask` (install `pay_kit[flask]`), a `@require_payment` view + decorator plus `is_paid` / `payment` request accessors. +- `pay_kit.fastapi` (install `pay_kit[fastapi]`), a `RequirePayment` + dependency for `Depends(...)` plus `install_exception_handler(app)`. +- `pay_kit.django` (install `pay_kit[django]`), a `require_payment` view + decorator and an optional `PaymentMiddleware` stack form. + +Every shim delegates protocol/scheme dispatch and 402-challenge assembly to +the host-neutral `PayCore`; the shim only translates the outcome into its +framework's response idioms. A verified `Payment` is attached to the request +(`request.state` on FastAPI, `flask.g` on Flask, `request.payment` on +Django) and its settlement headers are merged onto the success response. + +```python +# Imperative gating, no decorator, any framework: +from pay_kit import require_payment + +def view(request): + payment = require_payment(request) # raises PaymentRequiredError if unpaid + ... +``` -## Test, lint, coverage +--- + +## Examples + +Three runnable examples ship with this package: + +- [`examples/fastapi/app.py`](examples/fastapi/app.py), FastAPI server using + the `RequirePayment` dependency and `install_exception_handler`. +- [`examples/flask-paykit/app.py`](examples/flask-paykit/app.py), Flask + server using the `@require_payment` decorator and the `Pricing` registry. +- [`examples/django/views.py`](examples/django/views.py), Django views + + URLconf snippet using the `@require_payment` decorator. + +The lower-level `solana_mpp` wire library also ships its own examples: + +- [`examples/flask/`](examples/flask), Flask app gated with the + `solana_mpp` `@mpp_charge` decorator (config + middleware split out). +- [`examples/payment-links/server.py`](examples/payment-links/server.py), + the same flow against a local Surfpool with an HTML payment-page fallback, + used by the interop harness. + +All examples default to `solana_localnet`, `USDC`, and the demo recipient. +Override the RPC with `rpc_url=` / `PAY_KIT_RPC_URL` (or `MPP_RPC_URL` for +the `solana_mpp` Flask example). + +## Coverage ```bash cd python pip install -e ".[dev]" -pytest -q --ignore=tests/test_server_html.py ruff check src tests +ruff format --check src tests pyright -pytest --cov=solana_mpp --cov-branch --cov-fail-under=80 \ - --ignore=tests/test_server_html.py +pytest --cov=pay_kit --cov=solana_mpp --cov-fail-under=90 ``` -Coverage gates in CI: at least 80 percent line + branch coverage (raised -to 90 after the HTML render path and `_rpc.py` get backfilled). +The `pay_kit` surface is gated at 90 percent line coverage in CI. The +`pay_kit.preflight` module is omitted from the gate: it wraps live Solana +RPC + Surfnet cheatcodes that cannot run inside the offline unit suite, and +its two opt-out knobs are covered separately against a stubbed run/RPC. -## Interop +## Harness The Python server has a direct harness adapter at [`harness/python-server/main.py`](../harness/python-server/main.py). @@ -215,35 +410,59 @@ MPP_INTEROP_CLIENTS=rust MPP_INTEROP_SERVERS=python pnpm test ## Spec -This SDK implements the [Solana Charge Intent](https://github.com/tempoxyz/mpp-specs/pull/188) -for the [HTTP Payment Authentication Scheme](https://paymentauth.org). -The wire format, error grammar, and challenge / credential shape are all -defined at [paymentauth.org](https://paymentauth.org). +This SDK implements the +[Solana Charge Intent](https://github.com/tempoxyz/mpp-specs/pull/188) for +the [HTTP Payment Authentication Scheme](https://paymentauth.org), plus the +x402 exact scheme on Solana. The wire format, error grammar, and challenge / +credential shape are all defined at [paymentauth.org](https://paymentauth.org). + +--- ## Repo layout ```text python/ -├── src/solana_mpp/ -│ ├── __init__.py -│ ├── _base64url.py base64url + canonical JSON wrapper -│ ├── _canonical_json.py RFC 8785 JSON encoder (UTF-16 sort, ES6 numbers) -│ ├── _challenge.py HMAC challenge id + constant-time compare -│ ├── _errors.py PaymentError hierarchy + canonical L6 codes -│ ├── _expires.py RFC 3339 timestamp helpers -│ ├── _headers.py WWW-Authenticate / Authorization / Receipt -│ ├── _rpc.py thin async Solana JSON-RPC client -│ ├── _types.py PaymentChallenge / Credential / Receipt -│ ├── store.py MemoryStore + FileReplayStore -│ ├── client/ charge client + HTTP transport -│ ├── protocol/ ChargeRequest + Solana protocol helpers -│ └── server/ Mpp handler + middleware + payment page -├── examples/flask/ Flask app + middleware example -├── examples/payment-links/ Surfpool-backed payment-page example -├── tests/ pytest suite +├── src/pay_kit/ unified surface over x402 + MPP +│ ├── __init__.py public exports: configure, Gate, usd, the trio +│ ├── config.py configure / configure_from / Config + sub-configs +│ ├── operator.py Operator: recipient + signer + fee-payer flag +│ ├── signer.py LocalSigner family + Signer factory namespace +│ ├── price.py Price (Decimal money) + usd/eur/gbp factories +│ ├── fee.py Fee value object (within / on_top) +│ ├── gate.py Gate.build validation + DynamicGate + gate() +│ ├── pricing.py Pricing registry + gate-reference coercion +│ ├── payment.py Payment: settled-proof record +│ ├── preflight.py boot-time live-RPC soundness check + autobootstrap +│ ├── errors.py PayKitError hierarchy + canonical codes +│ ├── _middleware.py host-neutral PayCore + require_payment/is_paid trio +│ ├── fastapi.py FastAPI Depends shim +│ ├── flask.py Flask decorator shim +│ ├── django.py Django decorator + middleware shim +│ ├── 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) +├── examples/fastapi/ FastAPI pay_kit example +├── examples/flask-paykit/ Flask pay_kit example +├── examples/django/ Django pay_kit example +├── examples/flask/ solana_mpp @mpp_charge example +├── examples/payment-links/ Surfpool-backed payment-page example +├── tests/ pytest suite └── pyproject.toml ``` +## Coding convention + +This SDK follows the +[`skills.sh/mindrally/skills/python`](https://skills.sh/mindrally/skills/python) +best-practice skill. Small modules, frozen pydantic value objects, explicit +error types with canonical codes, deterministic wire serialization (RFC 8785 +canonical JSON), defensive payment verification, and branch tests on +security-sensitive paths. + +The repo-level `pay-sdk-implementation` skill remains the protocol source of +truth: Rust spec wire format first, Python idioms second. + ## License MIT diff --git a/python/examples/django/views.py b/python/examples/django/views.py new file mode 100644 index 000000000..b9fc56763 --- /dev/null +++ b/python/examples/django/views.py @@ -0,0 +1,61 @@ +# examples/django/views.py +"""Django views + URLconf gated with pay_kit (snippet, not a full project). + +Zero-config: ``pay_kit.configure()`` boots against solana_localnet (the +hosted Surfpool sandbox at https://402.surfnet.dev:8899) with the shipped +demo signer as the recipient. + +Wire this into any Django project: drop the gate definitions and views +below into an app, then ``path("report/", views.report)`` in your URLconf +(the ``urlpatterns`` at the bottom of this file is ready to ``include()``). +``pay_kit.configure(...)`` belongs in ``settings.py`` or ``apps.py.ready()`` +so it runs once at startup. + +Two routes: + + GET /health -> free, returns {"ok": true} + GET /report -> gated. require_payment returns 402 with the challenge + until a valid proof arrives, then sets request.payment. + +Drive it from a client once the project is running: + + curl -i http://127.0.0.1:8000/report # 402 payment required + pay curl http://127.0.0.1:8000/report # pays and succeeds +""" + +from __future__ import annotations + +from django.http import HttpRequest, JsonResponse +from django.urls import path + +import pay_kit +from pay_kit import Gate, usd +from pay_kit.django import require_payment + +pay_kit.configure(network="solana_localnet") + +report_gate = Gate.build( + name="report", + amount=usd("0.10"), + description="Premium report", + default_pay_to=pay_kit.config().effective_recipient(), + accept_default=pay_kit.config().accept, +) + + +def health(_request: HttpRequest) -> JsonResponse: + """Free liveness probe.""" + return JsonResponse({"ok": True}) + + +@require_payment(report_gate) +def report(request: HttpRequest) -> JsonResponse: + """Paid route. The verified proof is on request.payment after gating.""" + proof = request.payment # type: ignore[attr-defined] + return JsonResponse({"ok": True, "tx": proof.transaction, "protocol": proof.protocol.value}) + + +urlpatterns = [ + path("health/", health), + path("report/", report), +] diff --git a/python/examples/fastapi/app.py b/python/examples/fastapi/app.py new file mode 100644 index 000000000..faac1c30c --- /dev/null +++ b/python/examples/fastapi/app.py @@ -0,0 +1,68 @@ +# examples/fastapi/app.py +"""FastAPI server gated with pay_kit. + +Zero-config: a bare ``pay_kit.configure()`` boots against solana_localnet +(the hosted Surfpool sandbox at https://402.surfnet.dev:8899) with the +shipped demo signer as the recipient. No keys, no .env, no flags. + +Two routes: + + GET /health -> free, returns {"ok": true} + GET /report -> gated. The RequirePayment dependency answers 402 with a + WWW-Authenticate challenge until a valid proof arrives, + then hands the verified Payment to the handler. + +Run: + + pip install -e ".[fastapi]" + uvicorn examples.fastapi.app:app --port 8000 + +Drive it from a client: + + curl -i http://127.0.0.1:8000/report # 402 payment required + pay curl http://127.0.0.1:8000/report # pays and succeeds +""" + +from __future__ import annotations + +from fastapi import Depends, FastAPI + +import pay_kit +from pay_kit import Gate, Pricing, usd +from pay_kit.fastapi import Payment, RequirePayment, install_exception_handler + +pay_kit.configure(network="solana_localnet") + + +class Catalog(Pricing): + """The route catalogue: every paid route declares its gate here.""" + + def __init__(self) -> None: + self.report = Gate.build( + name="report", + amount=usd("0.10"), + description="Premium report", + default_pay_to=pay_kit.config().effective_recipient(), + accept_default=pay_kit.config().accept, + ) + + +catalog = Catalog() + +# Module-level dependency singleton (FastAPI resolves it per request). +require_report = Depends(RequirePayment("report", pricing=catalog)) + +app = FastAPI() +install_exception_handler(app) + + +@app.get("/health") +async def health() -> dict[str, bool]: + """Free liveness probe.""" + return {"ok": True} + + +@app.get("/report") +async def report(payment: Payment = require_report) -> dict[str, object]: + """Paid route. ``payment`` is the verified proof for this request.""" + return {"ok": True, "tx": payment.transaction, "protocol": payment.protocol.value} diff --git a/python/examples/flask-paykit/app.py b/python/examples/flask-paykit/app.py new file mode 100644 index 000000000..aee04a77e --- /dev/null +++ b/python/examples/flask-paykit/app.py @@ -0,0 +1,84 @@ +# examples/flask-paykit/app.py +"""Flask server gated with the unified pay_kit surface. + +Zero-config: ``pay_kit.configure()`` boots against solana_localnet (the +hosted Surfpool sandbox at https://402.surfnet.dev:8899) with the shipped +demo signer as the recipient. + +This example uses the pay_kit Flask shim (``pay_kit.flask``), the unified +surface over x402 and MPP. For the lower-level solana_mpp ``@mpp_charge`` +decorator, see ../flask/app.py instead. + +Three routes: + + GET /health -> free, returns {"ok": true} + GET /report -> gated by an inline price, both protocols accepted + GET /api/data -> gated, x402-only via accept= + +Run: + + pip install -e ".[flask]" + python examples/flask-paykit/app.py + +Drive it from a client: + + curl -i http://127.0.0.1:8000/report # 402 payment required + pay curl http://127.0.0.1:8000/report # pays and succeeds +""" + +from __future__ import annotations + +from flask import Flask, jsonify + +import pay_kit +from pay_kit import Gate, Protocol, usd +from pay_kit.flask import payment, require_payment + +pay_kit.configure(network="solana_localnet") + +_defaults = { + "pay_to": pay_kit.config().effective_recipient(), + "accept": pay_kit.config().accept, +} + +report_gate = Gate.build( + name="report", + amount=usd("0.10"), + description="Premium report", + default_pay_to=_defaults["pay_to"], + accept_default=_defaults["accept"], +) + +api_gate = Gate.build( + name="api_call", + amount=usd("0.001"), + accept=(Protocol.X402,), + default_pay_to=_defaults["pay_to"], +) + +app = Flask(__name__) + + +@app.get("/health") +def health(): + """Free liveness probe.""" + return jsonify(ok=True) + + +@app.get("/report") +@require_payment(report_gate) +def report(): + """Paid route. The verified proof is readable via pay_kit.flask.payment().""" + proof = payment() + return jsonify(ok=True, tx=proof.transaction, protocol=proof.protocol.value) + + +@app.get("/api/data") +@require_payment(api_gate) +def api_data(): + """x402-only route: this gate refuses to settle over MPP.""" + return jsonify(data=[]) + + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=8000) From 9adc3a7ed02eaade8fc086d17477be256adc665a Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 11:58:43 +0300 Subject: [PATCH 13/45] refactor(python): strict-type the pay_kit package 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. --- python/pyproject.toml | 2 + python/src/pay_kit/_middleware.py | 29 +++--- python/src/pay_kit/_wire.py | 123 ++++++++++++++++++++++++++ python/src/pay_kit/config.py | 13 +-- python/src/pay_kit/django.py | 22 +++-- python/src/pay_kit/fastapi.py | 20 +++-- python/src/pay_kit/fee.py | 2 +- python/src/pay_kit/gate.py | 39 +++++--- python/src/pay_kit/operator.py | 2 +- python/src/pay_kit/payment.py | 2 +- python/src/pay_kit/preflight.py | 8 +- python/src/pay_kit/price.py | 15 ++-- python/src/pay_kit/pricing.py | 6 +- python/src/pay_kit/protocols/mpp.py | 31 ++++--- python/src/pay_kit/protocols/x402.py | 86 +++++++++++------- python/src/pay_kit/signer.py | 35 +++++--- python/tests/test_pk_mpp_adapter.py | 2 +- python/tests/test_pk_x402_verifier.py | 2 +- 18 files changed, 324 insertions(+), 115 deletions(-) create mode 100644 python/src/pay_kit/_wire.py diff --git a/python/pyproject.toml b/python/pyproject.toml index 0b59d1e8c..bdb948a8c 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -41,6 +41,8 @@ select = ["E", "F", "W", "I", "UP", "B", "SIM"] [tool.pyright] pythonVersion = "3.11" typeCheckingMode = "standard" +strict = ["src/pay_kit"] +reportMissingTypeStubs = false include = ["src", "tests"] exclude = ["**/__pycache__"] diff --git a/python/src/pay_kit/_middleware.py b/python/src/pay_kit/_middleware.py index 6921ceccc..5e1d0b21a 100644 --- a/python/src/pay_kit/_middleware.py +++ b/python/src/pay_kit/_middleware.py @@ -31,9 +31,10 @@ from __future__ import annotations from collections.abc import Callable, Mapping -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from pay_kit._paycore.protocol import Protocol +from pay_kit._wire import MppAcceptsEntry, X402AcceptsEntry from pay_kit.errors import ( InvalidProofError, PaymentRequiredError, @@ -179,7 +180,7 @@ def build_402(self, gate: Gate, request: Any) -> tuple[dict[str, str], dict[str, gate accepts it and carries no fees; MPP is offered whenever accepted. """ accept = gate.accept if gate.accept is not None else self._config.accept - accepts: list[dict[str, Any]] = [] + accepts: list[X402AcceptsEntry | MppAcceptsEntry] = [] headers: dict[str, str] = {} if self._x402 is not None and Protocol.X402 in accept and not gate.has_fees(): @@ -209,8 +210,13 @@ def _payment_required(self, gate: Gate, request: Any) -> PaymentRequiredError: error.body = body # type: ignore[attr-defined] return error - def _resolve_callable(self, builder: Callable[[Any], Gate], request: Any) -> Gate: - """Invoke a bare request-builder and coerce its Gate/Price result.""" + def _resolve_callable(self, builder: Callable[[Any], object], request: Any) -> Gate: + """Invoke a bare request-builder and coerce its Gate/Price result. + + ``builder`` is typed to return ``object`` because user request-builders + are untyped and may return a Gate, a Price, or an invalid value; the + isinstance ladder is the load-bearing runtime guard. + """ result = builder(request) if isinstance(result, Gate): return result @@ -293,14 +299,14 @@ def require_payment(request: Any) -> Payment: def _request_headers(request: Any) -> Mapping[str, str]: """Extract a case-tolerant header mapping from a generic request bag.""" - headers = getattr(request, "headers", None) + headers: object = getattr(request, "headers", None) if headers is None and isinstance(request, Mapping): - candidate = request.get("headers", request) - headers = candidate + request_map = cast("Mapping[str, object]", request) + headers = request_map.get("headers", request_map) if headers is None: return {} if isinstance(headers, Mapping): - return headers + return cast("Mapping[str, str]", headers) # Header objects exposing .get (e.g. Starlette Headers, WSGI EnvironHeaders). if callable(getattr(headers, "get", None)): return _HeaderProxy(headers) @@ -320,7 +326,7 @@ def _read_header(headers: Mapping[str, str], name: str) -> str: return str(value) if value else "" -def _read_attr(request: Any, name: str) -> Any: +def _read_attr(request: Any, name: str) -> object: """Read an attribute off a request bag, mapping, or ``.state`` namespace.""" state = getattr(request, "state", None) if state is not None and hasattr(state, name): @@ -328,7 +334,7 @@ def _read_attr(request: Any, name: str) -> Any: if hasattr(request, name): return getattr(request, name) if isinstance(request, Mapping): - return request.get(name) + return cast("Mapping[str, object]", request).get(name) return None @@ -343,7 +349,8 @@ def _request_path(request: Any) -> str: if isinstance(path, str): return path if isinstance(request, Mapping): - candidate = request.get("path") or request.get("PATH_INFO") + request_map = cast("Mapping[str, object]", request) + candidate = request_map.get("path") or request_map.get("PATH_INFO") if isinstance(candidate, str): return candidate return "/" diff --git a/python/src/pay_kit/_wire.py b/python/src/pay_kit/_wire.py new file mode 100644 index 000000000..eac461773 --- /dev/null +++ b/python/src/pay_kit/_wire.py @@ -0,0 +1,123 @@ +"""Typed wire shapes for the x402 and MPP JSON surfaces. + +These ``TypedDict`` definitions describe the exact JSON dicts pay_kit builds for +challenges/offers and parses from inbound credentials. They exist purely to give +the adapters precise static types over the wire payloads; they do not change the +serialized bytes. Optional keys use ``total=False`` so a missing field is a type +error only where the field is guaranteed present. + +Inbound payloads (decoded via ``json.loads``) are validated structurally at +runtime and then narrowed to these shapes with ``cast``; the cast is a static- +only assertion and never alters the value. +""" + +from __future__ import annotations + +from typing import TypedDict + + +class X402ExtraRequired(TypedDict): + """The always-present keys of an x402 ``accepts[].extra`` block.""" + + feePayer: str + decimals: int + tokenProgram: str + memo: str + + +class X402Extra(X402ExtraRequired, total=False): + """An x402 ``accepts[].extra`` block; ``recentBlockhash`` is optional.""" + + recentBlockhash: str + + +class X402AcceptsEntry(TypedDict): + """One x402 ``accepts[]`` offer entry (the server requirement).""" + + protocol: str + scheme: str + network: str + asset: str + amount: str + maxAmountRequired: str + payTo: str + maxTimeoutSeconds: int + extra: X402Extra + + +class X402Challenge(TypedDict): + """The base64-encoded ``payment-required`` challenge body.""" + + x402Version: int + resource: X402Resource + accepts: list[X402AcceptsEntry] + + +class X402Resource(TypedDict): + """The ``resource`` block inside an x402 challenge.""" + + type: str + url: str + + +class X402PayloadField(TypedDict, total=False): + """The ``payload`` block of an inbound X-PAYMENT envelope.""" + + transaction: str + transactionHash: str + + +class X402Envelope(TypedDict, total=False): + """An inbound X-PAYMENT envelope (decoded from the proof header). + + All keys optional because the structure is attacker-controlled and validated + field-by-field at runtime before any value is trusted. + """ + + x402Version: int + accepted: X402AcceptsEntry + payload: X402PayloadField + + +class X402ResponseEnvelope(TypedDict): + """The base64-encoded ``payment-response`` settlement receipt.""" + + success: bool + transaction: str + network: str + payer: str + + +class MppSplit(TypedDict): + """A single fee split on an MPP offer or charge request.""" + + recipient: str + amount: str + + +class MppAcceptsEntryRequired(TypedDict): + """The always-present keys of an MPP ``accepts[]`` offer entry.""" + + protocol: str + scheme: str + network: str + amount: str + currency: str + payTo: str + realm: str + + +class MppAcceptsEntry(MppAcceptsEntryRequired, total=False): + """One MPP ``accepts[]`` offer entry; ``splits`` present only with fees.""" + + splits: list[MppSplit] + + +class MppMethodDetails(TypedDict, total=False): + """The MPP ``request.methodDetails`` block (network always set).""" + + network: str + splits: list[MppSplit] + feePayer: bool + feePayerKey: str + recentBlockhash: str diff --git a/python/src/pay_kit/config.py b/python/src/pay_kit/config.py index e04f1b0a5..64900bde9 100644 --- a/python/src/pay_kit/config.py +++ b/python/src/pay_kit/config.py @@ -18,10 +18,11 @@ import logging import warnings -from typing import Any, Literal +from typing import Annotated, Any, Literal import pydantic import pydantic_settings +from pydantic import Strict from pay_kit._paycore.network import Network from pay_kit._paycore.protocol import Protocol @@ -86,7 +87,7 @@ def _deprecation_warning_for(key: str, suggestion: str) -> None: class X402Config(pydantic.BaseModel): """x402-protocol knobs: facilitator delegation, scheme, and signer override.""" - model_config = pydantic.ConfigDict(frozen=True, arbitrary_types_allowed=True) + model_config = pydantic.ConfigDict(frozen=True, arbitrary_types_allowed=True, extra="forbid") facilitator_url: str | None = None scheme: Literal["exact"] = "exact" @@ -104,11 +105,13 @@ def effective_signer(self, operator: Operator) -> LocalSigner | None: class MppConfig(pydantic.BaseModel): """MPP-protocol knobs: realm label, challenge-binding secret, expiry window.""" - model_config = pydantic.ConfigDict(frozen=True) + model_config = pydantic.ConfigDict(frozen=True, extra="forbid") realm: str = "App" challenge_binding_secret: str | None = None - expires_in: int = 120 + # Strict: reject bool (an int subclass) and float coercion; an expiry window + # must be a real int. Existing valid int inputs are unaffected. + expires_in: Annotated[int, Strict()] = 120 @pydantic.field_validator("expires_in") @classmethod @@ -126,7 +129,7 @@ def with_challenge_binding_secret(self, secret: str) -> MppConfig: class Config(pydantic.BaseModel): """Immutable boot-time configuration; build via :func:`configure`.""" - model_config = pydantic.ConfigDict(frozen=True, arbitrary_types_allowed=True) + model_config = pydantic.ConfigDict(frozen=True, arbitrary_types_allowed=True, extra="forbid") network: Network = Network.SOLANA_LOCALNET accept: tuple[Protocol, ...] = (Protocol.X402, Protocol.MPP) diff --git a/python/src/pay_kit/django.py b/python/src/pay_kit/django.py index 30292433f..522f815bf 100644 --- a/python/src/pay_kit/django.py +++ b/python/src/pay_kit/django.py @@ -26,7 +26,7 @@ import asyncio from collections.abc import Callable from functools import wraps -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from pay_kit._middleware import PAYMENT_ATTR, PayCore, is_paid from pay_kit._middleware import payment as _core_payment @@ -35,7 +35,11 @@ from pay_kit.payment import Payment if TYPE_CHECKING: - from django.http import HttpRequest, HttpResponse, JsonResponse + from django.http import ( # pyright: ignore[reportMissingTypeStubs] # django ships no type stubs (django-stubs is third-party) + HttpRequest, + HttpResponse, + JsonResponse, + ) from pay_kit.config import Config from pay_kit.gate import DynamicGate, Gate @@ -153,16 +157,20 @@ def _error_response(exc: PayKitError) -> JsonResponse: :meth:`PayCore.build_402`; everything else falls back to a minimal error body keyed on the exception's canonical code (if any). """ - from django.http import JsonResponse + from django.http import JsonResponse # pyright: ignore[reportMissingTypeStubs] # django ships no type stubs status = getattr(exc, "http_status", 500) - body = getattr(exc, "body", None) - if not isinstance(body, dict): - body = {"error": getattr(exc, "code", "payment_error"), "message": str(exc)} + raw_body = getattr(exc, "body", None) + body: dict[str, Any] = ( + cast("dict[str, Any]", raw_body) + if isinstance(raw_body, dict) + else {"error": getattr(exc, "code", "payment_error"), "message": str(exc)} + ) response = JsonResponse(body, status=status) if isinstance(exc, PaymentRequiredError): - for key, value in getattr(exc, "challenge_headers", {}).items(): + challenge_headers = cast("dict[str, str]", getattr(exc, "challenge_headers", {})) + for key, value in challenge_headers.items(): response[key] = value return response diff --git a/python/src/pay_kit/fastapi.py b/python/src/pay_kit/fastapi.py index 34f02b293..d1e835a5c 100644 --- a/python/src/pay_kit/fastapi.py +++ b/python/src/pay_kit/fastapi.py @@ -27,8 +27,8 @@ async def report(payment=Depends(RequirePayment("report", pricing=pricing))): from __future__ import annotations -from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any, cast try: from fastapi import HTTPException, Request, Response @@ -102,7 +102,9 @@ def install_exception_handler(app: Any) -> None: """ @app.exception_handler(PayKitError) - async def _paykit_error_handler(_request: Request, exc: PayKitError) -> Response: + async def _paykit_error_handler( # pyright: ignore[reportUnusedFunction] # registered via @app.exception_handler + _request: Request, exc: PayKitError + ) -> Response: http_exc = _http_exception(exc) from fastapi.responses import JSONResponse @@ -113,11 +115,13 @@ async def _paykit_error_handler(_request: Request, exc: PayKitError) -> Response ) @app.middleware("http") - async def _paykit_settlement_headers(request: Request, call_next: Any) -> Response: + async def _paykit_settlement_headers( # pyright: ignore[reportUnusedFunction] # registered via @app.middleware + request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: response = await call_next(request) settlement = getattr(request.state, _SETTLEMENT_STATE_ATTR, None) if isinstance(settlement, dict): - for name, value in settlement.items(): + for name, value in cast("dict[str, str]", settlement).items(): response.headers[name] = value return response @@ -133,9 +137,9 @@ def _http_exception(exc: PayKitError) -> HTTPException: headers = getattr(exc, "challenge_headers", None) body = getattr(exc, "body", None) - detail: Any + detail: dict[str, Any] if isinstance(body, dict): - detail = body + detail = cast("dict[str, Any]", body) else: code = getattr(exc, "code", None) detail = {"error": code or "payment_error", "message": str(exc)} @@ -143,5 +147,5 @@ def _http_exception(exc: PayKitError) -> HTTPException: return HTTPException( status_code=status, detail=detail, - headers=headers if isinstance(headers, dict) else None, + headers=cast("dict[str, str]", headers) if isinstance(headers, dict) else None, ) diff --git a/python/src/pay_kit/fee.py b/python/src/pay_kit/fee.py index 709a6b35d..c9f956f43 100644 --- a/python/src/pay_kit/fee.py +++ b/python/src/pay_kit/fee.py @@ -21,7 +21,7 @@ class Fee(pydantic.BaseModel): """A recipient address and the price they receive, within or on top.""" - model_config = pydantic.ConfigDict(frozen=True) + model_config = pydantic.ConfigDict(frozen=True, extra="forbid") recipient: str price: Price diff --git a/python/src/pay_kit/gate.py b/python/src/pay_kit/gate.py index 1981e8b41..d161f390c 100644 --- a/python/src/pay_kit/gate.py +++ b/python/src/pay_kit/gate.py @@ -27,7 +27,7 @@ from collections.abc import Callable from decimal import Decimal -from typing import Any +from typing import Any, Literal, cast import pydantic @@ -44,24 +44,34 @@ def _build_fees( - fee_within: dict[str, Price] | None, - fee_on_top: dict[str, Price] | None, + fee_within: object, + fee_on_top: object, ) -> tuple[Fee, ...]: - """Coerce the ``{recipient: Price}`` fee maps into an ordered Fee tuple.""" + """Coerce the ``{recipient: Price}`` fee maps into an ordered Fee tuple. + + Both maps are typed ``object``: they arrive from untyped DSL/config callers, + so the isinstance ladder validating the dict / recipient / Price shape is the + load-bearing runtime guard, not redundant. + """ fees: list[Fee] = [] - for kind, mapping in (("within", fee_within), ("on_top", fee_on_top)): + pairs: tuple[tuple[Literal["within", "on_top"], object], ...] = ( + ("within", fee_within), + ("on_top", fee_on_top), + ) + for kind, mapping in pairs: if mapping is None: continue if not isinstance(mapping, dict): raise ConfigurationError(f"pay_kit: fee_{kind} must be a dict of {{recipient: Price}}") - for recipient, price in mapping.items(): + items = cast("dict[object, object]", mapping) + for recipient, price in items.items(): if not isinstance(recipient, str) or not recipient: raise ConfigurationError(f"pay_kit: fee_{kind} recipient must be a non-empty string, got {recipient!r}") if not isinstance(price, Price): raise ConfigurationError( f"pay_kit: fee_{kind} price for {recipient!r} must be a Price (use usd/eur/gbp)" ) - fees.append(Fee(recipient=recipient, price=price, kind=kind)) # type: ignore[arg-type] + fees.append(Fee(recipient=recipient, price=price, kind=kind)) return tuple(fees) @@ -101,7 +111,7 @@ def _resolve_accept( class Gate(pydantic.BaseModel): """A frozen, fully validated protected unit built via :meth:`Gate.build`.""" - model_config = pydantic.ConfigDict(frozen=True) + model_config = pydantic.ConfigDict(frozen=True, extra="forbid") name: str amount: Price @@ -133,9 +143,12 @@ def build( Raises :class:`~pay_kit.errors.ConfigurationError` (and subclasses) on any rule violation so misconfiguration fails at boot. """ - if not isinstance(name, str) or not name: + # isinstance guards are load-bearing against untyped DSL callers (the + # public DX keeps the precise str/Price annotations); pyright sees them + # as redundant under strict, so silence that one rule per line. + if not isinstance(name, str) or not name: # pyright: ignore[reportUnnecessaryIsInstance] raise ConfigurationError(f"pay_kit: gate name must be a non-empty string, got {name!r}") - if not isinstance(amount, Price): + if not isinstance(amount, Price): # pyright: ignore[reportUnnecessaryIsInstance] raise ConfigurationError(f"pay_kit: gate {name!r}: amount must be a Price (use usd/eur/gbp)") resolved_pay_to = pay_to if pay_to is not None else default_pay_to @@ -258,7 +271,7 @@ def __init__( name: str, accept: tuple[Protocol, ...] | None, description: str | None, - builder: Callable[[Any], Gate], + builder: Callable[[Any], object], defaults: dict[str, Any] | None = None, ) -> None: self.name = name @@ -296,7 +309,7 @@ def dynamic( *, accept: tuple[Protocol, ...] | None = None, description: str | None = None, -) -> Callable[[Callable[[Any], Gate]], DynamicGate]: +) -> Callable[[Callable[[Any], object]], DynamicGate]: """Decorator turning a request-builder callable into a :class:`DynamicGate`. The decorated function receives the request and returns a :class:`Gate` @@ -304,7 +317,7 @@ def dynamic( :meth:`DynamicGate.resolve` time by the middleware that owns the request. """ - def _wrap(builder: Callable[[Any], Gate]) -> DynamicGate: + def _wrap(builder: Callable[[Any], object]) -> DynamicGate: return DynamicGate( name=name, accept=accept, diff --git a/python/src/pay_kit/operator.py b/python/src/pay_kit/operator.py index f7e2de0f0..039d5b53c 100644 --- a/python/src/pay_kit/operator.py +++ b/python/src/pay_kit/operator.py @@ -21,7 +21,7 @@ class Operator(pydantic.BaseModel): settlement destination. ``Config`` layers the mainnet-refusal rule on top. """ - model_config = pydantic.ConfigDict(frozen=True, arbitrary_types_allowed=True) + model_config = pydantic.ConfigDict(frozen=True, arbitrary_types_allowed=True, extra="forbid") recipient: str | None = None signer: LocalSigner | None = None diff --git a/python/src/pay_kit/payment.py b/python/src/pay_kit/payment.py index 75c033205..4ebca1b36 100644 --- a/python/src/pay_kit/payment.py +++ b/python/src/pay_kit/payment.py @@ -19,7 +19,7 @@ class Payment(pydantic.BaseModel): proof string (Authorization / Payment-Signature) for auditing. """ - model_config = pydantic.ConfigDict(frozen=True) + model_config = pydantic.ConfigDict(frozen=True, extra="forbid") protocol: Protocol transaction: str diff --git a/python/src/pay_kit/preflight.py b/python/src/pay_kit/preflight.py index ceb9ec029..436354667 100644 --- a/python/src/pay_kit/preflight.py +++ b/python/src/pay_kit/preflight.py @@ -33,7 +33,7 @@ import logging import os from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import httpx @@ -129,7 +129,7 @@ def _check_fee_payer_sol(config: Config, autofix: bool) -> None: # pragma: no c pubkey = signer.pubkey() result = _rpc_call(config, "getBalance", [pubkey, {"commitment": "confirmed"}]) - lamports = int(result["value"]) if isinstance(result, dict) and "value" in result else 0 + lamports = int(cast("dict[str, Any]", result)["value"]) if isinstance(result, dict) and "value" in result else 0 if lamports >= MIN_FEE_PAYER_LAMPORTS: return @@ -178,7 +178,7 @@ def _check_recipient_ata(config: Config, coin: str, autofix: bool) -> None: # p "getAccountInfo", [ata, {"encoding": "base64", "commitment": "confirmed"}], ) - value = info["value"] if isinstance(info, dict) and "value" in info else None + value = cast("dict[str, Any]", info)["value"] if isinstance(info, dict) and "value" in info else None if value is not None: return @@ -223,4 +223,4 @@ def _rpc_call(config: Config, method: str, params: list[Any]) -> Any: # pragma: decoded = response.json() if not isinstance(decoded, dict): raise RuntimeError(f"rpc returned non-JSON from {endpoint}") - return decoded.get("result") + return cast("dict[str, Any]", decoded).get("result") diff --git a/python/src/pay_kit/price.py b/python/src/pay_kit/price.py index d84802e95..71958ef91 100644 --- a/python/src/pay_kit/price.py +++ b/python/src/pay_kit/price.py @@ -27,8 +27,13 @@ _AMOUNT_RE = re.compile(r"^\d+(\.\d+)?$") -def _to_decimal(amount: str | int | Decimal) -> Decimal: - """Coerce a money input to Decimal, rejecting float and bad formats.""" +def _to_decimal(amount: object) -> Decimal: + """Coerce a money input to Decimal, rejecting float and bad formats. + + Accepts ``object`` (not just ``str | int | Decimal``) because the public + factories forward untyped caller input and the field validator forwards a + raw pydantic value; the isinstance ladder is the load-bearing runtime guard. + """ if isinstance(amount, bool): # bool is an int subclass; reject explicitly. raise ConfigurationError("pay_kit: Price amount must be str | int | Decimal, not bool") if isinstance(amount, float): @@ -50,7 +55,7 @@ def _to_decimal(amount: str | int | Decimal) -> Decimal: class Settlement(pydantic.BaseModel): """A single settlement preference: pay ``amount`` denominated in ``coin``.""" - model_config = pydantic.ConfigDict(frozen=True) + model_config = pydantic.ConfigDict(frozen=True, extra="forbid") coin: Stablecoin amount: str @@ -62,7 +67,7 @@ def __str__(self) -> str: class Price(pydantic.BaseModel): """Currency-denominated amount with an ordered settlement-coin preference.""" - model_config = pydantic.ConfigDict(frozen=True) + model_config = pydantic.ConfigDict(frozen=True, extra="forbid") amount: Decimal currency: Currency @@ -71,7 +76,7 @@ class Price(pydantic.BaseModel): @pydantic.field_validator("amount", mode="before") @classmethod def _coerce_amount(cls, value: object) -> Decimal: - return _to_decimal(value) # type: ignore[arg-type] + return _to_decimal(value) @classmethod def usd(cls, amount: str | int | Decimal, *settlements: Stablecoin) -> Price: diff --git a/python/src/pay_kit/pricing.py b/python/src/pay_kit/pricing.py index de3d2d7a5..be271e4a2 100644 --- a/python/src/pay_kit/pricing.py +++ b/python/src/pay_kit/pricing.py @@ -82,7 +82,7 @@ def __iter__(self) -> Iterator[Gate | DynamicGate]: def coerce( - arg: Gate | DynamicGate | Price | str, + arg: object, *, registry: Pricing | None = None, config: Config | None = None, @@ -92,7 +92,9 @@ def coerce( A ``str`` is looked up in ``registry`` (raising if none is configured); a :class:`~pay_kit.gate.Gate` / :class:`~pay_kit.gate.DynamicGate` passes through; a bare :class:`~pay_kit.price.Price` is wrapped into an inline - Gate using the Config-resolved default recipient and accept list. + Gate using the Config-resolved default recipient and accept list. ``arg`` is + typed as ``object`` so the isinstance ladder stays a load-bearing runtime + guard for untyped callers (the trailing ``raise`` is reachable). """ if isinstance(arg, (Gate, DynamicGate)): return arg diff --git a/python/src/pay_kit/protocols/mpp.py b/python/src/pay_kit/protocols/mpp.py index cd68b3fd0..7ae269131 100644 --- a/python/src/pay_kit/protocols/mpp.py +++ b/python/src/pay_kit/protocols/mpp.py @@ -17,9 +17,10 @@ import secrets from collections.abc import Callable from decimal import Decimal -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from pay_kit._paycore.protocol import Protocol +from pay_kit._wire import MppAcceptsEntry, MppMethodDetails, MppSplit from pay_kit.errors import InvalidProofError from pay_kit.payment import Payment from solana_mpp._errors import PaymentError, canonical_code @@ -158,11 +159,11 @@ def _resolve_secret(self) -> str: # -- offer / challenge -------------------------------------------------- - def accepts_entry(self, gate: Gate, request: Any) -> dict[str, Any]: + def accepts_entry(self, gate: Gate, request: Any) -> MppAcceptsEntry: """Build one ``accepts[]`` entry advertising the MPP charge offer.""" coin = self._settlement_coin(gate) pay_to = gate.pay_to or self._config.effective_recipient() - entry: dict[str, Any] = { + entry: MppAcceptsEntry = { "protocol": "mpp", "scheme": "charge", "network": self._config.network.caip2(), @@ -173,7 +174,7 @@ def accepts_entry(self, gate: Gate, request: Any) -> dict[str, Any]: } if gate.has_fees(): entry["splits"] = [ - {"recipient": fee.recipient, "amount": str(self._price_units(fee.price))} for fee in gate.fees + MppSplit(recipient=fee.recipient, amount=str(self._price_units(fee.price))) for fee in gate.fees ] return entry @@ -250,10 +251,10 @@ def _charge_request_for(self, gate: Gate) -> ChargeRequest: # ("mainnet"/"devnet"/"localnet") in request.methodDetails.network # (rust/crates/core/src/client/mpp.rs). Advertise the same slug # Mints::resolve uses so `pay --sandbox --mpp curl` matches. - method_details: dict[str, Any] = {"network": self._config.network.mints_label()} + method_details: MppMethodDetails = {"network": self._config.network.mints_label()} if gate.has_fees(): method_details["splits"] = [ - {"recipient": fee.recipient, "amount": str(self._price_units(fee.price))} for fee in gate.fees + MppSplit(recipient=fee.recipient, amount=str(self._price_units(fee.price))) for fee in gate.fees ] signer = self._config.operator.signer if self._config.operator.fee_payer and signer is not None: @@ -266,13 +267,15 @@ def _charge_request_for(self, gate: Gate) -> ChargeRequest: blockhash = self._recent_blockhash_provider() if blockhash: method_details["recentBlockhash"] = blockhash + # ChargeRequest.method_details is the untyped solana_mpp wire shape + # (dict[str, Any] | None); cast the precise TypedDict at the boundary. return ChargeRequest( amount=amount, currency=coin, recipient=pay_to, description=gate.description or "", external_id=gate.external_id or "", - method_details=method_details or None, + method_details=cast("dict[str, Any]", method_details) or None, ) def _charge_options(self, gate: Gate) -> ChargeOptions: @@ -282,9 +285,12 @@ def _charge_options(self, gate: Gate) -> ChargeOptions: external_id=gate.external_id or "", ) if gate.has_fees(): - options.splits = [ - {"recipient": fee.recipient, "amount": str(self._price_units(fee.price))} for fee in gate.fees + # ChargeOptions.splits is the untyped solana_mpp list[dict]; build the + # precise MppSplit shape and cast at the boundary. + splits: list[MppSplit] = [ + MppSplit(recipient=fee.recipient, amount=str(self._price_units(fee.price))) for fee in gate.fees ] + options.splits = cast("list[dict[str, Any]]", splits) signer = self._config.operator.signer if self._config.operator.fee_payer and signer is not None: options.fee_payer = True @@ -343,14 +349,15 @@ def _price_units(self, price: Price) -> int: @staticmethod def _header(request: Any, name: str) -> str: """Read a header off a generic request bag (dict-like or .headers).""" - headers = getattr(request, "headers", None) + headers: object = getattr(request, "headers", None) if headers is None and isinstance(request, dict): - headers = request.get("headers", request) + request_map = cast("dict[str, object]", request) + headers = request_map.get("headers", request_map) if headers is None: return "" getter = getattr(headers, "get", None) if callable(getter): - value: Any = getter(name) + value: object = getter(name) if value is None: value = getter(name.title()) return str(value) if value else "" diff --git a/python/src/pay_kit/protocols/x402.py b/python/src/pay_kit/protocols/x402.py index bebd159d7..4abe70728 100644 --- a/python/src/pay_kit/protocols/x402.py +++ b/python/src/pay_kit/protocols/x402.py @@ -20,10 +20,17 @@ import json import struct from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from pay_kit._paycore.mints import derive_ata, resolve, token_program_for from pay_kit._paycore.protocol import Protocol +from pay_kit._wire import ( + X402AcceptsEntry, + X402Challenge, + X402Extra, + X402PayloadField, + X402ResponseEnvelope, +) from pay_kit.errors import InvalidProofError from pay_kit.payment import Payment from solana_mpp._rpc import SolanaRpc @@ -207,7 +214,9 @@ def _verify_transfer( code="invalid_exact_svm_payload_no_transfer_instruction", ) data = bytes(ix.data) - accounts = list(ix.accounts) + # solders CompiledInstruction.accounts is a list of u8 account indices; + # solders ships no stubs, so annotate the shape explicitly at the boundary. + accounts: list[int] = [int(a) for a in ix.accounts] # Rule 4: transferChecked shape (disc 12, 10-byte data, >= 4 accounts). if len(accounts) < 4 or len(data) != 10 or data[0] != 12: raise InvalidProofError( @@ -345,7 +354,7 @@ def _b58_field(requirement: dict[str, Any], key: str) -> str: @staticmethod def _string_extra(requirement: dict[str, Any], key: str, *, required: bool) -> str | None: extra = requirement.get("extra") - value = extra.get(key) if isinstance(extra, dict) else None + value = cast("dict[str, object]", extra).get(key) if isinstance(extra, dict) else None if (value is None or value == "") and required: raise InvalidProofError( f"invalid_exact_svm_payload_missing_extra_{key}", @@ -385,7 +394,7 @@ def __init__( self._store: Store = replay_store if replay_store is not None else MemoryStore() self._recent_blockhash_provider = recent_blockhash_provider - def accepts_entry(self, gate: Gate, request: Any) -> dict[str, Any]: + def accepts_entry(self, gate: Gate, request: Any) -> X402AcceptsEntry: """Build one ``accepts[]`` entry (the server x402 offer for ``gate``).""" coin = gate.amount.primary_coin() coin_value = coin.value if coin is not None else self._config.stablecoins[0].value @@ -398,7 +407,7 @@ def accepts_entry(self, gate: Gate, request: Any) -> dict[str, Any]: pay_to = gate.pay_to or self._config.effective_recipient() amount = str(int(gate.total().amount * 1_000_000)) signer = self._config.x402.effective_signer(self._config.operator) - extra: dict[str, Any] = { + extra: X402Extra = { "feePayer": signer.pubkey() if signer is not None else "", "decimals": 6, "tokenProgram": token_program, @@ -425,7 +434,7 @@ def accepts_entry(self, gate: Gate, request: Any) -> dict[str, Any]: def challenge_headers(self, gate: Gate, request: Any) -> dict[str, str]: """Build the ``payment-required`` header (base64 JSON challenge).""" - challenge = { + challenge: X402Challenge = { "x402Version": X402_VERSION, "resource": {"type": "http", "url": _request_path(request)}, "accepts": [self.accepts_entry(gate, request)], @@ -458,15 +467,22 @@ async def verify_and_settle(self, gate: Gate, request: Any) -> Payment: code="invalid_exact_svm_payload_signature_json", ) from exc - if not isinstance(envelope, dict) or envelope.get("x402Version") != X402_VERSION: + if not isinstance(envelope, dict): + raise InvalidProofError("unsupported_x402_version", code="unsupported_x402_version") + # The envelope is attacker-controlled; it is validated field-by-field + # below, then narrowed to the typed wire shape for the rest of the flow. + envelope_map = cast("dict[str, object]", envelope) + if envelope_map.get("x402Version") != X402_VERSION: raise InvalidProofError("unsupported_x402_version", code="unsupported_x402_version") - accepted = envelope.get("accepted") - payload = envelope.get("payload") - if not isinstance(accepted, dict) or not isinstance(payload, dict): + accepted_raw = envelope_map.get("accepted") + payload_raw = envelope_map.get("payload") + if not isinstance(accepted_raw, dict) or not isinstance(payload_raw, dict): raise InvalidProofError( "invalid_exact_svm_payload_envelope", code="invalid_exact_svm_payload_envelope", ) + accepted = cast("dict[str, object]", accepted_raw) + payload = cast("X402PayloadField", payload_raw) # Tier-2 identity-key match: the credential's accepted requirement must # match the server's freshly built offer for this route. x402 has no @@ -474,21 +490,23 @@ async def verify_and_settle(self, gate: Gate, request: Any) -> Payment: # credential's `accepted` is never trusted for the route's parameters # (mirrors rust verify_pinned_fields + the targeted deepEqual gate). offer = self.accepts_entry(gate, request) + offer_map = cast("dict[str, object]", offer) for key in ("scheme", "network", "asset", "payTo"): - if accepted.get(key) != offer.get(key): + if accepted.get(key) != offer_map.get(key): raise InvalidProofError( "pay_kit: charge_request_mismatch: accepted payment requirement does not match server challenge", code="charge_request_mismatch", ) - if accepted.get("amount") != offer.get("amount") and accepted.get("maxAmountRequired") != offer.get( + if accepted.get("amount") != offer_map.get("amount") and accepted.get("maxAmountRequired") != offer_map.get( "maxAmountRequired" ): raise InvalidProofError( "pay_kit: charge_request_mismatch (amount)", code="charge_request_mismatch", ) - offer_extra = offer.get("extra") or {} - accepted_extra = accepted.get("extra") or {} + offer_extra = cast("dict[str, object]", offer_map.get("extra") or {}) + accepted_extra_raw = accepted.get("extra") + accepted_extra = cast("dict[str, object]", accepted_extra_raw if isinstance(accepted_extra_raw, dict) else {}) for key in ("feePayer", "tokenProgram", "memo"): if key in offer_extra and accepted_extra.get(key) != offer_extra[key]: raise InvalidProofError( @@ -504,7 +522,7 @@ async def verify_and_settle(self, gate: Gate, request: Any) -> Payment: ) # Structural shape (11 rules) against the server offer. - ExactVerifier.verify(tx_base64, offer, [signer.pubkey()]) + ExactVerifier.verify(tx_base64, cast("dict[str, Any]", offer), [signer.pubkey()]) # Reject up-front if the client signed against the wrong cluster. # Skip on a loopback RPC where a Surfpool blockhash is expected. @@ -533,17 +551,16 @@ async def verify_and_settle(self, gate: Gate, request: Any) -> Payment: if not await self._store.put_if_absent(_REPLAY_PREFIX + signature, True): raise InvalidProofError("pay_kit: signature_consumed", code="signature_consumed") - response_envelope = base64.b64encode( - json.dumps( - { - "success": True, - "transaction": signature, - "network": accepted.get("network") or self._caip2(), - "payer": payload.get("transactionHash", ""), - }, - separators=(",", ":"), - ).encode("utf-8") - ).decode("ascii") + accepted_network = accepted.get("network") + response_body: X402ResponseEnvelope = { + "success": True, + "transaction": signature, + "network": accepted_network if isinstance(accepted_network, str) and accepted_network else self._caip2(), + "payer": payload.get("transactionHash", ""), + } + response_envelope = base64.b64encode(json.dumps(response_body, separators=(",", ":")).encode("utf-8")).decode( + "ascii" + ) return Payment( protocol=Protocol.X402, @@ -580,7 +597,10 @@ def _co_sign(transaction_b64: str, signer: Any) -> bytes: from solders.pubkey import Pubkey from solders.transaction import Transaction, VersionedTransaction - from solana_mpp.server.mpp import _is_v0_wire_bytes + # Intentional reuse of solana_mpp's v0-wire detector (see docstring above) + # rather than re-implementing parallel detection logic; private by package + # convention but a deliberate cross-module dependency. + from solana_mpp.server.mpp import _is_v0_wire_bytes # pyright: ignore[reportPrivateUsage] raw = base64.b64decode(transaction_b64) fee_payer_pubkey = Pubkey.from_string(signer.pubkey()) @@ -675,7 +695,7 @@ def _request_path(request: Any) -> str: if isinstance(url_path, str): return url_path if isinstance(request, dict): - candidate = request.get("path") + candidate = cast("dict[str, object]", request).get("path") if isinstance(candidate, str): return candidate return "/" @@ -688,13 +708,13 @@ def _payment_signature_header(request: Any) -> str: getter = getattr(headers, "get", None) if callable(getter): for name in ("payment-signature", "Payment-Signature", "PAYMENT-SIGNATURE"): - value = getter(name) + value: object = getter(name) if value: return str(value) if isinstance(request, dict): - raw_headers = request.get("headers") + raw_headers = cast("dict[str, object]", request).get("headers") if isinstance(raw_headers, dict): - for key, value in raw_headers.items(): - if key.lower() == "payment-signature" and value: - return str(value) + for key, header_value in cast("dict[object, object]", raw_headers).items(): + if isinstance(key, str) and key.lower() == "payment-signature" and header_value: + return str(header_value) return "" diff --git a/python/src/pay_kit/signer.py b/python/src/pay_kit/signer.py index b4ee8763e..bdcab0fd7 100644 --- a/python/src/pay_kit/signer.py +++ b/python/src/pay_kit/signer.py @@ -22,7 +22,7 @@ import logging import os import warnings -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from solders.keypair import Keypair @@ -175,7 +175,9 @@ def from_bytes(cls, secret: bytes | Sequence[int]) -> LocalSigner: @classmethod def from_base58(cls, s: str) -> LocalSigner: """Build a signer from a base58-encoded 64-byte secret (Phantom/Solflare).""" - if not isinstance(s, str) or s == "": + # isinstance guard is load-bearing against untyped callers; the public + # ``str`` annotation is the typed-caller contract, so silence the rule. + if not isinstance(s, str) or s == "": # pyright: ignore[reportUnnecessaryIsInstance] raise InvalidKeyError("pay_kit: Signer.base58 expects a non-empty string") try: kp = Keypair.from_base58_string(s) @@ -190,8 +192,10 @@ def from_base58(cls, s: str) -> LocalSigner: @classmethod def from_hex(cls, s: str) -> LocalSigner: """Build a signer from a 128-character hex string (64 bytes hex-encoded).""" - if not isinstance(s, str) or len(s) != 128: - length = len(s) if isinstance(s, str) else 0 + # isinstance guards are load-bearing against untyped callers; keep the + # public ``str`` contract and silence the redundancy rule per line. + if not isinstance(s, str) or len(s) != 128: # pyright: ignore[reportUnnecessaryIsInstance] + length = len(s) if isinstance(s, str) else 0 # pyright: ignore[reportUnnecessaryIsInstance] raise InvalidKeyError(f"pay_kit: Signer.hex expects 128 chars, got {length}") if any(ch not in _HEX_DIGITS for ch in s): raise InvalidKeyError("pay_kit: Signer.hex contains non-hex characters") @@ -242,7 +246,9 @@ def bytes(secret: bytes | Sequence[int]) -> LocalSigner: @staticmethod def json(json_array: str) -> LocalSigner: """Build a signer from a Solana-CLI JSON-array string ``"[1,2,...,64]"``.""" - if not isinstance(json_array, str): + # isinstance guard is load-bearing against untyped callers; keep the + # public ``str`` contract and silence the redundancy rule per line. + if not isinstance(json_array, str): # pyright: ignore[reportUnnecessaryIsInstance] raise InvalidKeyError("pay_kit: Signer.json expects a string") trimmed = json_array.strip() if trimmed == "": @@ -253,7 +259,9 @@ def json(json_array: str) -> LocalSigner: raise InvalidKeyError(f"pay_kit: malformed Solana CLI JSON-array keypair: {exc}") from exc if not isinstance(decoded, list): raise InvalidKeyError("pay_kit: Signer.json expected a JSON array") - return LocalSigner.from_bytes(decoded) + # json.loads yields list[Any]; element types (int in [0,255], length 64) + # are validated inside _coerce_secret_bytes, so cast to the declared shape. + return LocalSigner.from_bytes(cast("Sequence[int]", decoded)) @staticmethod def base58(s: str) -> LocalSigner: @@ -268,7 +276,9 @@ def hex(s: str) -> LocalSigner: @staticmethod def file(path: str) -> LocalSigner: """Read a Solana-CLI JSON-array keypair file and build a signer.""" - if not isinstance(path, str) or path == "": + # isinstance guard is load-bearing against untyped callers; keep the + # public ``str`` contract and silence the redundancy rule per line. + if not isinstance(path, str) or path == "": # pyright: ignore[reportUnnecessaryIsInstance] raise InvalidKeyError("pay_kit: Signer.file expects a non-empty path") try: with open(path, encoding="utf-8") as handle: @@ -287,7 +297,9 @@ def env(name: str) -> LocalSigner | None: parsed as JSON-array / hex / base58, because silent fallback would mask a real misconfiguration. """ - if not isinstance(name, str) or name == "": + # isinstance guard is load-bearing against untyped callers; keep the + # public ``str`` contract and silence the redundancy rule per line. + if not isinstance(name, str) or name == "": # pyright: ignore[reportUnnecessaryIsInstance] raise InvalidKeyError("pay_kit: Signer.env expects a non-empty name") raw = os.environ.get(name) if raw is None or raw == "": @@ -322,7 +334,10 @@ def _coerce_secret_bytes(secret: bytes | Sequence[int]) -> bytes: if len(items) != 64: raise InvalidKeyError(f"pay_kit: Signer.bytes expects 64 integers, got {len(items)}") for i, value in enumerate(items): - if not isinstance(value, int) or isinstance(value, bool) or value < 0 or value > 255: + # The declared element type is int, but a JSON-array secret (Signer.json) + # may carry non-int / float / bool elements at runtime, so the per-element + # isinstance check is load-bearing; silence the redundancy rule here. + if not isinstance(value, int) or isinstance(value, bool) or value < 0 or value > 255: # pyright: ignore[reportUnnecessaryIsInstance] raise InvalidKeyError(f"pay_kit: Signer.bytes[{i}] must be an int in [0,255]") return bytes(items) @@ -339,7 +354,7 @@ def _keypair_from_bytes(raw: bytes) -> Keypair: ) -def _reset_demo_for_tests() -> None: +def _reset_demo_for_tests() -> None: # pyright: ignore[reportUnusedFunction] # external test hook (test_pk_signer_operator) """Reset the cached demo singleton + warning guard so the next call rebuilds. @internal diff --git a/python/tests/test_pk_mpp_adapter.py b/python/tests/test_pk_mpp_adapter.py index 7158c6167..9267ef199 100644 --- a/python/tests/test_pk_mpp_adapter.py +++ b/python/tests/test_pk_mpp_adapter.py @@ -87,7 +87,7 @@ def test_accepts_entry_includes_splits_when_fees(): cfg = _cfg() gate = _gate(cfg, fee_on_top={FEE_A: Price.usd("0.02", Stablecoin.USDC)}) entry = MppAdapter(cfg).accepts_entry(gate, {"path": "/report"}) - assert entry["splits"] == [{"recipient": FEE_A, "amount": "20000"}] + assert entry.get("splits") == [{"recipient": FEE_A, "amount": "20000"}] # on-top fee raises the advertised total to 0.12. assert entry["amount"] == "120000" diff --git a/python/tests/test_pk_x402_verifier.py b/python/tests/test_pk_x402_verifier.py index 7fc96b012..8598bff6c 100644 --- a/python/tests/test_pk_x402_verifier.py +++ b/python/tests/test_pk_x402_verifier.py @@ -474,7 +474,7 @@ def test_adapter_embeds_recent_blockhash_when_provider_set(): cfg = configure(network="solana_localnet", preflight=False) adapter = X402Adapter(cfg, recent_blockhash_provider=lambda: BH) entry = adapter.accepts_entry(_gate(cfg), {"path": "/report"}) - assert entry["extra"]["recentBlockhash"] == BH + assert entry["extra"].get("recentBlockhash") == BH def test_adapter_blockhash_provider_failure_is_swallowed(): From c6bd5cde1762e9950799042cc2843f430ef5f2fc Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 14:33:54 +0300 Subject: [PATCH 14/45] ci(python): point pyright at the active interpreter 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. --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98e10da2b..f8b627f48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -243,7 +243,10 @@ jobs: run: ruff check src tests - name: Type-check working-directory: python - run: pyright + # Point pyright at the interpreter the extras were installed into, so it + # resolves fastapi/flask/django in the shim tests (it does not reliably + # auto-detect the active env on the runner). + run: pyright --pythonpath "$(python -c 'import sys; print(sys.executable)')" - name: Run tests with coverage working-directory: python env: From e0fcf0ec8b71fdb8da86769465d1ebe54239b132 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 14:33:54 +0300 Subject: [PATCH 15/45] docs(python): correct the pay_kit repo-layout tree 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. --- python/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/README.md b/python/README.md index 4888691e6..3d7c03f92 100644 --- a/python/README.md +++ b/python/README.md @@ -439,8 +439,9 @@ python/ │ ├── flask.py Flask decorator shim │ ├── django.py Django decorator + middleware shim │ ├── kms.py reserved remote-enclave signer namespace -│ ├── _paycore/ Currency / Network / Protocol / Stablecoin enums -│ └── protocols/{x402,mpp}/ protocol adapters over the solana_mpp wire +│ ├── _wire.py TypedDict wire shapes (x402 offer/payload, MPP request) +│ ├── _paycore/ Currency / Network / Protocol / Stablecoin / Mints +│ └── protocols/ x402.py (exact) + mpp.py adapters over the solana_mpp wire ├── src/solana_mpp/ lower-level MPP wire library (reused, not reimplemented) ├── examples/fastapi/ FastAPI pay_kit example ├── examples/flask-paykit/ Flask pay_kit example From 90565aa48b253b3445de441deab8672611ffe41d Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 14:37:23 +0300 Subject: [PATCH 16/45] ci(python): fix the canonical python.yml for pay_kit, drop ci.yml duplicate 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. --- .github/workflows/ci.yml | 55 ------------------------------------ .github/workflows/python.yml | 13 +++++++-- 2 files changed, 10 insertions(+), 58 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8b627f48..91083b906 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,61 +215,6 @@ jobs: # workflow (triggered on pull_request and workflow_call). Kept out of # ci.yml so there is a single "Go tests" check rather than two. - test-python: - name: Python tests - needs: build-html - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - cache: pip - cache-dependency-path: python/pyproject.toml - - name: Download HTML assets - uses: actions/download-artifact@v7 - with: - name: html-assets - - name: Install package + extras - working-directory: python - # Framework extras are needed so the fastapi/flask/django shim tests - # import; dev brings ruff, pyright, pytest, pytest-cov. - run: pip install -e ".[dev,fastapi,flask,django]" - - name: Format check - working-directory: python - run: ruff format --check src tests - - name: Lint - working-directory: python - run: ruff check src tests - - name: Type-check - working-directory: python - # Point pyright at the interpreter the extras were installed into, so it - # resolves fastapi/flask/django in the shim tests (it does not reliably - # auto-detect the active env on the runner). - run: pyright --pythonpath "$(python -c 'import sys; print(sys.executable)')" - - name: Run tests with coverage - working-directory: python - env: - SURFPOOL_REPORT: "1" - # 90% line gate baked into the command so local and CI agree; the - # gate is configured in pyproject.toml (fail_under = 90) and covers - # both pay_kit and the solana_mpp wire it reuses. preflight.py is - # omitted there (live-RPC paths exercised separately). - run: pytest --cov=pay_kit --cov=solana_mpp --cov-report=xml --cov-fail-under=90 - - name: Upload Python coverage - if: always() - uses: actions/upload-artifact@v7 - with: - name: python-coverage - path: python/coverage.xml - - name: Upload Python surfpool report data - if: always() - uses: actions/upload-artifact@v7 - with: - name: surfpool-reports-python - path: python/target/surfpool-reports/ - if-no-files-found: ignore - integration: name: Integration tests needs: build-html diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index d04251b93..bec7f368f 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -18,20 +18,27 @@ jobs: cache: pip - name: Install Python SDK working-directory: python + # Framework extras bring fastapi/flask/django so the pay_kit shim tests + # import and pyright can resolve them; dev brings ruff, pyright, pytest. run: | python -m pip install --upgrade pip - pip install -e ".[dev]" + pip install -e ".[dev,fastapi,flask,django]" - name: Lint with ruff working-directory: python run: ruff check src tests - name: Type check with pyright working-directory: python - run: pyright + # Point pyright at the interpreter the extras were installed into; it + # does not reliably auto-detect the active env on the runner. + run: pyright --pythonpath "$(python -c 'import sys; print(sys.executable)')" - name: Run tests with coverage working-directory: python - # Coverage gate: line coverage at 90%. Branch coverage gate is follow-up work, tracked in #108. + # Coverage gate: line coverage at 90% over both pay_kit and the + # solana_mpp wire it reuses. preflight.py is omitted in pyproject + # (live-RPC paths). Branch coverage gate is follow-up work, tracked in #108. run: | pytest \ + --cov=pay_kit \ --cov=solana_mpp \ --cov-report=term-missing \ --cov-report=json:coverage.json \ From b1d6c94641b849388304a2c684285df0220c50f1 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 15:02:45 +0300 Subject: [PATCH 17/45] docs(python): align the README with the sibling SDKs 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. --- python/README.md | 42 ++++++++++++++---------------------------- 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/python/README.md b/python/README.md index 3d7c03f92..b02cc5beb 100644 --- a/python/README.md +++ b/python/README.md @@ -211,7 +211,7 @@ because stock x402 facilitators settle to one address. | `upto` | — | | `batch` | — | -## mpp +## MPP The [Machine Payments Protocol](https://paymentauth.org) is the broader HTTP Payment Authentication scheme, the same 402 handshake, but the challenge @@ -422,33 +422,19 @@ credential shape are all defined at [paymentauth.org](https://paymentauth.org). ```text python/ -├── src/pay_kit/ unified surface over x402 + MPP -│ ├── __init__.py public exports: configure, Gate, usd, the trio -│ ├── config.py configure / configure_from / Config + sub-configs -│ ├── operator.py Operator: recipient + signer + fee-payer flag -│ ├── signer.py LocalSigner family + Signer factory namespace -│ ├── price.py Price (Decimal money) + usd/eur/gbp factories -│ ├── fee.py Fee value object (within / on_top) -│ ├── gate.py Gate.build validation + DynamicGate + gate() -│ ├── pricing.py Pricing registry + gate-reference coercion -│ ├── payment.py Payment: settled-proof record -│ ├── preflight.py boot-time live-RPC soundness check + autobootstrap -│ ├── errors.py PayKitError hierarchy + canonical codes -│ ├── _middleware.py host-neutral PayCore + require_payment/is_paid trio -│ ├── fastapi.py FastAPI Depends shim -│ ├── flask.py Flask decorator shim -│ ├── django.py Django decorator + middleware shim -│ ├── kms.py reserved remote-enclave signer namespace -│ ├── _wire.py TypedDict wire shapes (x402 offer/payload, MPP request) -│ ├── _paycore/ Currency / Network / Protocol / Stablecoin / Mints -│ └── protocols/ x402.py (exact) + mpp.py adapters over the solana_mpp wire -├── src/solana_mpp/ lower-level MPP wire library (reused, not reimplemented) -├── examples/fastapi/ FastAPI pay_kit example -├── examples/flask-paykit/ Flask pay_kit example -├── examples/django/ Django pay_kit example -├── examples/flask/ solana_mpp @mpp_charge example -├── examples/payment-links/ Surfpool-backed payment-page example -├── tests/ pytest suite +├── src/pay_kit/ unified surface over x402 + MPP +│ ├── config.py, operator.py, signer.py, price.py, fee.py, gate.py, +│ │ pricing.py, payment.py, preflight.py, errors.py # umbrella surface +│ ├── _paycore/ Currency / Network / Protocol / Stablecoin / Mints +│ ├── _wire.py TypedDict wire shapes (x402 offer/payload, MPP request) +│ ├── _middleware.py host-neutral resolver + require_payment/is_paid/get_payment +│ ├── fastapi.py, flask.py, django.py framework shims +│ ├── kms.py reserved remote-enclave signer namespace +│ └── protocols/{x402,mpp}.py x402-exact + MPP-charge adapters over the solana_mpp wire +├── src/solana_mpp/ lower-level MPP wire library (reused, not reimplemented) +├── examples/{fastapi,flask-paykit,django}/ pay_kit framework examples +├── examples/{flask,payment-links}/ solana_mpp examples +├── tests/ pytest suite └── pyproject.toml ``` From 96798b898d609bfe2da04efd38cdd74c528e8bd9 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 18:37:35 +0300 Subject: [PATCH 18/45] refactor(python): reorganize pay_kit to mirror the rust crate layout 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. --- .github/workflows/ci.yml | 6 +- .github/workflows/python.yml | 8 +- README.md | 4 +- docs/security/compute-budget-caps.md | 2 +- docs/security/fee-payer-drain.md | 4 +- harness/python-server/main.py | 421 ------------------ .../server.py | 40 +- harness/src/canonical-codes.ts | 2 +- harness/src/implementations.ts | 44 +- harness/test/compute-budget-caps.test.ts | 6 +- harness/test/x402-exact.e2e.test.ts | 2 +- html/build.ts | 2 +- python/README.md | 44 +- python/examples/flask-paykit/app.py | 84 ---- python/examples/flask/README.md | 43 -- python/examples/flask/app.py | 94 ++-- python/examples/flask/config.py | 55 --- python/examples/flask/middleware.py | 69 --- python/examples/payment-links/server.py | 10 +- python/pyproject.toml | 41 +- python/src/pay_kit/__init__.py | 8 +- python/src/pay_kit/_middleware.py | 3 +- python/src/pay_kit/_paycore/__init__.py | 2 +- python/src/pay_kit/_paycore/mints.py | 6 +- .../protocol => pay_kit/_paycore}/solana.py | 0 python/src/pay_kit/_wire.py | 123 ----- python/src/pay_kit/errors.py | 4 +- python/src/pay_kit/protocols/__init__.py | 2 +- .../protocols/{mpp.py => mpp/__init__.py} | 77 +++- .../protocols/mpp}/client/__init__.py | 2 +- .../protocols/mpp}/client/charge.py | 10 +- .../protocols/mpp}/client/transport.py | 4 +- .../pay_kit/protocols/mpp/core/__init__.py | 3 + .../protocols/mpp/core/base64url.py} | 2 +- .../protocols/mpp/core/challenge.py} | 2 +- .../protocols/mpp/core/errors.py} | 0 .../protocols/mpp/core/expires.py} | 0 .../protocols/mpp/core/headers.py} | 4 +- .../protocols/mpp/core/json.py} | 0 .../protocols/mpp/core/rpc.py} | 2 +- .../protocols/mpp/core}/store.py | 0 .../protocols/mpp/core/types.py} | 4 +- .../pay_kit/protocols/mpp/intents/__init__.py | 3 + .../protocols/mpp/intents/charge.py} | 0 .../pay_kit/protocols/mpp/server/__init__.py | 24 + .../protocols/mpp/server/charge.py} | 34 +- .../protocols/mpp}/server/defaults.py | 0 .../protocols/mpp}/server/html/__init__.py | 0 .../mpp}/server/html/service_worker.gen.js | 0 .../mpp}/server/html/template.gen.html | 0 .../protocols/mpp}/server/middleware.py | 10 +- .../protocols/mpp}/server/network_check.py | 2 +- .../protocols/mpp}/server/payment_page.py | 6 +- python/src/pay_kit/protocols/x402/__init__.py | 13 + .../protocols/{x402.py => x402/verify.py} | 106 ++++- python/src/solana_mpp/__init__.py | 35 -- python/src/solana_mpp/protocol/__init__.py | 3 - python/src/solana_mpp/server/__init__.py | 19 - python/tests/conftest.py | 8 +- python/tests/test_base64url.py | 2 +- python/tests/test_canonical_json.py | 2 +- python/tests/test_challenge.py | 2 +- python/tests/test_client_charge.py | 4 +- python/tests/test_client_charge_edge.py | 12 +- python/tests/test_client_transport.py | 10 +- python/tests/test_cross_route_replay.py | 14 +- python/tests/test_errors.py | 20 +- python/tests/test_expires.py | 4 +- python/tests/test_headers.py | 6 +- python/tests/test_headers_edge.py | 8 +- python/tests/test_intents.py | 2 +- python/tests/test_interop_adapter.py | 4 +- python/tests/test_middleware.py | 10 +- python/tests/test_mpp_helpers.py | 8 +- python/tests/test_network_check.py | 2 +- python/tests/test_pk_mpp_adapter.py | 6 +- python/tests/test_pk_x402_settle.py | 6 +- python/tests/test_pk_x402_verifier.py | 4 +- python/tests/test_rpc_contract.py | 6 +- python/tests/test_rpc_methods.py | 8 +- python/tests/test_rpc_send_validation.py | 2 +- python/tests/test_server.py | 102 ++--- python/tests/test_server_defaults.py | 2 +- python/tests/test_server_html.py | 6 +- python/tests/test_server_v0_transactions.py | 10 +- python/tests/test_solana_protocol.py | 4 +- python/tests/test_store.py | 6 +- python/tests/test_types.py | 4 +- .../Examples/iOSDemo/MerchantServer/serve.py | 14 +- 89 files changed, 567 insertions(+), 1225 deletions(-) delete mode 100644 harness/python-server/main.py rename harness/{pay-kit-python-server => python-server}/server.py (92%) delete mode 100644 python/examples/flask-paykit/app.py delete mode 100644 python/examples/flask/README.md delete mode 100644 python/examples/flask/config.py delete mode 100644 python/examples/flask/middleware.py rename python/src/{solana_mpp/protocol => pay_kit/_paycore}/solana.py (100%) delete mode 100644 python/src/pay_kit/_wire.py rename python/src/pay_kit/protocols/{mpp.py => mpp/__init__.py} (86%) rename python/src/{solana_mpp => pay_kit/protocols/mpp}/client/__init__.py (64%) rename python/src/{solana_mpp => pay_kit/protocols/mpp}/client/charge.py (94%) rename python/src/{solana_mpp => pay_kit/protocols/mpp}/client/transport.py (94%) create mode 100644 python/src/pay_kit/protocols/mpp/core/__init__.py rename python/src/{solana_mpp/_base64url.py => pay_kit/protocols/mpp/core/base64url.py} (95%) rename python/src/{solana_mpp/_challenge.py => pay_kit/protocols/mpp/core/challenge.py} (93%) rename python/src/{solana_mpp/_errors.py => pay_kit/protocols/mpp/core/errors.py} (100%) rename python/src/{solana_mpp/_expires.py => pay_kit/protocols/mpp/core/expires.py} (100%) rename python/src/{solana_mpp/_headers.py => pay_kit/protocols/mpp/core/headers.py} (98%) rename python/src/{solana_mpp/_canonical_json.py => pay_kit/protocols/mpp/core/json.py} (100%) rename python/src/{solana_mpp/_rpc.py => pay_kit/protocols/mpp/core/rpc.py} (99%) rename python/src/{solana_mpp => pay_kit/protocols/mpp/core}/store.py (100%) rename python/src/{solana_mpp/_types.py => pay_kit/protocols/mpp/core/types.py} (97%) create mode 100644 python/src/pay_kit/protocols/mpp/intents/__init__.py rename python/src/{solana_mpp/protocol/intents.py => pay_kit/protocols/mpp/intents/charge.py} (100%) create mode 100644 python/src/pay_kit/protocols/mpp/server/__init__.py rename python/src/{solana_mpp/server/mpp.py => pay_kit/protocols/mpp/server/charge.py} (98%) rename python/src/{solana_mpp => pay_kit/protocols/mpp}/server/defaults.py (100%) rename python/src/{solana_mpp => pay_kit/protocols/mpp}/server/html/__init__.py (100%) rename python/src/{solana_mpp => pay_kit/protocols/mpp}/server/html/service_worker.gen.js (100%) rename python/src/{solana_mpp => pay_kit/protocols/mpp}/server/html/template.gen.html (100%) rename python/src/{solana_mpp => pay_kit/protocols/mpp}/server/middleware.py (89%) rename python/src/{solana_mpp => pay_kit/protocols/mpp}/server/network_check.py (97%) rename python/src/{solana_mpp => pay_kit/protocols/mpp}/server/payment_page.py (95%) create mode 100644 python/src/pay_kit/protocols/x402/__init__.py rename python/src/pay_kit/protocols/{x402.py => x402/verify.py} (91%) delete mode 100644 python/src/solana_mpp/__init__.py delete mode 100644 python/src/solana_mpp/protocol/__init__.py delete mode 100644 python/src/solana_mpp/server/__init__.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91083b906..e5aa95293 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,9 +123,9 @@ jobs: - name: Verify committed gen files are up to date working-directory: . run: | - if ! git diff --quiet -- rust/src/server/html/ go/protocols/mpp/server/html/ lua/mpp/server/html_assets/ python/src/solana_mpp/server/html/; then + if ! git diff --quiet -- rust/src/server/html/ go/protocols/mpp/server/html/ lua/mpp/server/html_assets/ python/src/pay_kit/protocols/mpp/server/html/; then echo "::error::Generated files are out of date. Run 'just html-build' and commit the results." - git diff --stat -- rust/src/server/html/ go/protocols/mpp/server/html/ lua/mpp/server/html_assets/ python/src/solana_mpp/server/html/ + git diff --stat -- rust/src/server/html/ go/protocols/mpp/server/html/ lua/mpp/server/html_assets/ python/src/pay_kit/protocols/mpp/server/html/ exit 1 fi - name: Upload HTML build artifacts @@ -137,7 +137,7 @@ jobs: rust/src/server/html/ go/protocols/mpp/server/html/ lua/mpp/server/html_assets/ - python/src/solana_mpp/server/html/ + python/src/pay_kit/protocols/mpp/server/html/ typescript/packages/mpp/src/server/html-assets.gen.ts test-rust: diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index bec7f368f..fe5dff080 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -33,13 +33,13 @@ jobs: run: pyright --pythonpath "$(python -c 'import sys; print(sys.executable)')" - name: Run tests with coverage working-directory: python - # Coverage gate: line coverage at 90% over both pay_kit and the - # solana_mpp wire it reuses. preflight.py is omitted in pyproject - # (live-RPC paths). Branch coverage gate is follow-up work, tracked in #108. + # Coverage gate: line coverage at 90% over pay_kit (the MPP wire layer + # now lives under pay_kit/protocols/mpp). preflight.py is omitted in + # pyproject (live-RPC paths). Branch coverage gate is follow-up work, + # tracked in #108. run: | pytest \ --cov=pay_kit \ - --cov=solana_mpp \ --cov-report=term-missing \ --cov-report=json:coverage.json \ --cov-fail-under=90 \ diff --git a/README.md b/README.md index a8113948e..e51a71941 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ cargo add solana-mpp go get github.com/solana-foundation/pay-kit/go # Python -pip install solana-mpp +pip install solana-pay-kit # Ruby cd ruby && bundle install @@ -121,7 +121,7 @@ return result.withReceipt(Response.json({ data: '...' })) Python ```python -from solana_mpp.server import Mpp, Config +from pay_kit.protocols.mpp.server import Mpp, Config mpp = Mpp(Config( recipient="RecipientPubkey...", diff --git a/docs/security/compute-budget-caps.md b/docs/security/compute-budget-caps.md index 7c84b4690..286aec758 100644 --- a/docs/security/compute-budget-caps.md +++ b/docs/security/compute-budget-caps.md @@ -51,7 +51,7 @@ this monorepo. | Lua (#103) | `lua/mpp/methods/solana/instructions.lua:31` | `MAX_COMPUTE_UNIT_LIMIT` (pending PR #103 merge) | | Lua (#103) | `lua/mpp/methods/solana/instructions.lua:32` | `MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS` (pending PR #103 merge) | | Go (#101) | `go/protocols/mpp/server/server.go` (`maxComputeUnitLimit`) | pending PR #101 merge | -| Python (#106) | `python/src/solana_mpp/server/mpp.py` | pending PR #106 merge | +| Python (#106) | `python/src/pay_kit/protocols/mpp/server/charge.py` | pending PR #106 merge | `harness/test/compute-budget-caps.test.ts` parses each file above and asserts byte-identical literals against the canonical pair. Go and diff --git a/docs/security/fee-payer-drain.md b/docs/security/fee-payer-drain.md index 8b19f0cdc..7afad3384 100644 --- a/docs/security/fee-payer-drain.md +++ b/docs/security/fee-payer-drain.md @@ -28,7 +28,7 @@ Client crafts a transaction where the fee-payer is placed at a non-canonical sig ### 4. Tampered-Details Attack (Client-Supplied `methodDetails.feePayerKey`) -The MPP charge request carries `methodDetails.feePayerKey` (string, base58 pubkey; this is the canonical wire field across all SDKs, see [`typescript/packages/mpp/src/Methods.ts`](../../typescript/packages/mpp/src/Methods.ts) L62, [`go/paycore/solana.go`](../../go/paycore/solana.go) L115, [`python/src/solana_mpp/protocol/solana.py`](../../python/src/solana_mpp/protocol/solana.py) L120, [`rust/crates/mpp/src/protocol/solana.rs`](../../rust/crates/mpp/src/protocol/solana.rs) L394, [`php/src/Server/SolanaChargeTransactionVerifier.php`](../../php/src/Server/SolanaChargeTransactionVerifier.php) L304, [`ruby/lib/mpp/methods/solana/verifier.rb`](../../ruby/lib/mpp/methods/solana/verifier.rb), [`lua/mpp/server/init.lua`](../../lua/mpp/server/init.lua) L85). A malicious client supplies `methodDetails.feePayerKey = ATTACKER_PUBKEY` while the server's actual signing key is `SERVER_PUBKEY`. If the verifier trusts the client-supplied details field as the source of truth for "who is the fee-payer", it will validate guards (source != fee-payer, slot, etc.) against `ATTACKER_PUBKEY`. The real `SERVER_PUBKEY` then signs a transaction that drains itself. +The MPP charge request carries `methodDetails.feePayerKey` (string, base58 pubkey; this is the canonical wire field across all SDKs, see [`typescript/packages/mpp/src/Methods.ts`](../../typescript/packages/mpp/src/Methods.ts) L62, [`go/paycore/solana.go`](../../go/paycore/solana.go) L115, [`python/src/pay_kit/_paycore/solana.py`](../../python/src/pay_kit/_paycore/solana.py) L120, [`rust/crates/mpp/src/protocol/solana.rs`](../../rust/crates/mpp/src/protocol/solana.rs) L394, [`php/src/Server/SolanaChargeTransactionVerifier.php`](../../php/src/Server/SolanaChargeTransactionVerifier.php) L304, [`ruby/lib/mpp/methods/solana/verifier.rb`](../../ruby/lib/mpp/methods/solana/verifier.rb), [`lua/mpp/server/init.lua`](../../lua/mpp/server/init.lua) L85). A malicious client supplies `methodDetails.feePayerKey = ATTACKER_PUBKEY` while the server's actual signing key is `SERVER_PUBKEY`. If the verifier trusts the client-supplied details field as the source of truth for "who is the fee-payer", it will validate guards (source != fee-payer, slot, etc.) against `ATTACKER_PUBKEY`. The real `SERVER_PUBKEY` then signs a transaction that drains itself. Source of truth MUST be the server-context fee-payer pubkey (the public key of the server's signer keypair), never a client-controlled field. @@ -67,7 +67,7 @@ A passing fee-payer co-sign path is the conjunction of all four. Missing any one | PHP | [`php/src/Server/SolanaChargeTransactionVerifier.php`](../../php/src/Server/SolanaChargeTransactionVerifier.php): `validateInstructionAllowlist` (L454), invoked from both push (L169) and pull (L216) paths in the same file | | Ruby | [`ruby/lib/mpp/methods/solana/verifier.rb`](../../ruby/lib/mpp/methods/solana/verifier.rb): `validate_allowlist` (L191), `expected_fee_payer` (L100), source-vs-fee-payer guards at L128, L156, L158 | | Lua | [`lua/mpp/server/solana_verify.lua`](../../lua/mpp/server/solana_verify.lua): `verify_instruction_allowlist` (L330), invoked from the main verify path at L140 | -| Python | `python/src/solana_mpp/server/mpp.py`: `_validate_instruction_allowlist` (lands with [#106](https://github.com/solana-foundation/mpp-sdk/pull/106)) | +| Python | `python/src/pay_kit/protocols/mpp/server/charge.py`: `_validate_instruction_allowlist` (lands with [#106](https://github.com/solana-foundation/mpp-sdk/pull/106)) | | Go | `go/protocols/mpp/server/server.go`: allowlist branch inside `verifyTransaction` (lands with [#101](https://github.com/solana-foundation/mpp-sdk/pull/101)) | The Rust path is the spine. PHP, Ruby, Lua, Python, and Go port the same four invariants with language-idiomatic surfaces. diff --git a/harness/python-server/main.py b/harness/python-server/main.py deleted file mode 100644 index 2d2c42142..000000000 --- a/harness/python-server/main.py +++ /dev/null @@ -1,421 +0,0 @@ -"""Interop adapter: Python HTTP charge server. - -Mirrors the contract in skills/pay-sdk-implementation/references/interop-harness.md -and the Ruby adapter at harness/ruby-server/server.rb. The harness -launches this process, reads one ``ready`` JSON line from stdout, then sends -HTTP requests to the protected resource. - -Stdout discipline: ONLY the ``ready`` JSON line is written to stdout. All -diagnostics (logging, traceback) go to stderr. -""" - -from __future__ import annotations - -import asyncio -import json -import os -import socket -import sys -import threading -from http.server import BaseHTTPRequestHandler, HTTPServer -from pathlib import Path -from typing import Any - -# Ensure the local Python SDK is importable when run from the harness. -# Walk parents looking for the repo root marker (pyproject.toml at python/ -# or .git) so the adapter stays self-contained regardless of how deep this -# file lives inside ``harness/``. The harness invokes us from -# ``harness/python-server``; the previous fixed ``parents[2]`` index -# silently fell through to a global ``solana-mpp`` install, hiding local -# SDK regressions. -def _find_repo_root(start: Path) -> Path: - for candidate in [start, *start.parents]: - if (candidate / ".git").exists() or (candidate / "python" / "pyproject.toml").is_file(): - return candidate - return start.parents[-1] - - -_repo_root = _find_repo_root(Path(__file__).resolve()) -_python_src = _repo_root / "python" / "src" -if _python_src.is_dir(): - sys.path.insert(0, str(_python_src)) - -from solana_mpp._errors import ( # noqa: E402 - PaymentError, - canonical_code, -) -from solana_mpp._rpc import SolanaRpc # noqa: E402 -from solana_mpp._headers import ( # noqa: E402 - format_www_authenticate, - parse_authorization, -) -from solana_mpp.protocol.intents import ChargeRequest # noqa: E402 -from solana_mpp.server.mpp import ChargeOptions, Config, Mpp # noqa: E402 -from solana_mpp.store import MemoryStore # noqa: E402 - - -def require_env(name: str) -> str: - value = os.environ.get(name) - if not value: - print(f"Missing required env: {name}", file=sys.stderr) - sys.exit(2) - return value - - -def optional_env(name: str, default: str) -> str: - value = os.environ.get(name) - return value if value else default - - -def _decode_keypair_env(name: str) -> bytes: - """Decode the Solana JSON-array keypair format used by the harness.""" - raw = require_env(name) - arr = json.loads(raw) - if not isinstance(arr, list) or not all(isinstance(b, int) for b in arr): - print(f"{name} must be a JSON array of integers", file=sys.stderr) - sys.exit(2) - return bytes(arr) - - -def _free_port() -> int: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("127.0.0.1", 0)) - return sock.getsockname()[1] - - -def _base_units_to_human(base_units: str, decimals: int) -> str: - """Convert a base-units string back into a fixed-decimal human string. - - The harness passes amounts in base units (e.g. ``"1000"`` for 0.001 - USDC at 6 decimals). The SDK's ``charge_with_options`` re-applies - ``parse_units`` on the value, so we must hand it a human-readable - decimal string to round-trip back to the same base units. - """ - if decimals <= 0: - return str(int(base_units)) - units = int(base_units) - sign = "-" if units < 0 else "" - units = abs(units) - quotient, remainder = divmod(units, 10 ** decimals) - fraction = f"{remainder:0{decimals}d}".rstrip("0") - if not fraction: - return f"{sign}{quotient}" - return f"{sign}{quotient}.{fraction}" - - -def _build_mpp() -> tuple[Mpp, dict[str, Any]]: - """Construct the MPP server handler from the harness environment. - - Returns the handler plus a dict carrying per-route protected amounts. - """ - rpc_url = require_env("MPP_INTEROP_RPC_URL") - network = optional_env("MPP_INTEROP_NETWORK", "localnet") - mint = require_env("MPP_INTEROP_MINT") - amount = require_env("MPP_INTEROP_AMOUNT") - pay_to = require_env("MPP_INTEROP_PAY_TO") - secret_key = optional_env("MPP_INTEROP_SECRET_KEY", "mpp-interop-secret-key") - resource_path = optional_env("MPP_INTEROP_RESOURCE_PATH", "/paid") - settlement_header = optional_env( - "MPP_INTEROP_SETTLEMENT_HEADER", "x-payment-settlement-signature" - ) - splits_raw = optional_env("MPP_INTEROP_SPLITS", "[]") - splits = json.loads(splits_raw) - if not isinstance(splits, list): - print("MPP_INTEROP_SPLITS must decode to a JSON array", file=sys.stderr) - sys.exit(2) - - # Fee-payer keypair is optional in the harness. Only scenarios that - # exercise server-side fee sponsorship export - # ``MPP_INTEROP_FEE_PAYER_SECRET_KEY``; absence must not crash the - # adapter at startup, and the challenge must not unconditionally - # advertise ``feePayer=true`` when there is no fee payer to sign. - fee_payer = None - fee_payer_raw = os.environ.get("MPP_INTEROP_FEE_PAYER_SECRET_KEY") - if fee_payer_raw: - try: - arr = json.loads(fee_payer_raw) - except json.JSONDecodeError as exc: - print( - f"MPP_INTEROP_FEE_PAYER_SECRET_KEY must be JSON: {exc}", - file=sys.stderr, - ) - sys.exit(2) - if not isinstance(arr, list) or not all(isinstance(b, int) for b in arr): - print( - "MPP_INTEROP_FEE_PAYER_SECRET_KEY must be a JSON array of integers", - file=sys.stderr, - ) - sys.exit(2) - from solders.keypair import Keypair - - fee_payer = Keypair.from_bytes(bytes(arr)) - - # Greptile P1 (follow-up): do NOT construct a SolanaRpc / - # httpx.AsyncClient at adapter boot. Each ``BaseHTTPRequestHandler`` - # request runs inside its own ``asyncio.run()`` event loop, and - # ``httpx.AsyncClient`` anchors its connection-pool primitives to - # the loop it is first used in. A boot-time client created on the - # main thread (no running loop) and then handed off to multiple - # per-request loops relies on httpx's undocumented reconnection - # behavior. We construct a fresh ``SolanaRpc`` inside every - # ``do_GET`` instead and ``aclose()`` it immediately after the - # verify call returns; the Mpp handler boots with ``rpc=None`` and - # the per-request client is plugged in just-in-time. - config = Config( - recipient=pay_to, - currency=mint, - decimals=int(optional_env("MPP_INTEROP_DECIMALS", "6")), - network=network, - rpc_url=rpc_url, - secret_key=secret_key, - realm=optional_env("MPP_INTEROP_REALM", "MPP Interop"), - fee_payer_signer=fee_payer, - store=MemoryStore(), - rpc=None, - ) - handler = Mpp(config) - - decimals = int(optional_env("MPP_INTEROP_DECIMALS", "6")) - routes = { - resource_path: _base_units_to_human(amount, decimals), - } - replay_path = os.environ.get("MPP_INTEROP_REPLAY_SOURCE_PATH") or "" - if replay_path: - replay_amount = os.environ.get("MPP_INTEROP_REPLAY_SOURCE_AMOUNT") or amount - routes[replay_path] = _base_units_to_human(replay_amount, decimals) - - return handler, { - "routes": routes, - "settlement_header": settlement_header.lower(), - "splits": splits, - "rpc_url": rpc_url, - } - - -class InteropHandler(BaseHTTPRequestHandler): - server_version = "mpp-python-interop/1.0" - - # Suppress access log; everything we say goes to stderr explicitly. - def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - return - - @property - def mpp(self) -> Mpp: - return self.server.mpp # type: ignore[attr-defined] - - @property - def cfg(self) -> dict[str, Any]: - return self.server.cfg # type: ignore[attr-defined] - - def _send_json(self, status: int, body: dict, extra_headers: dict | None = None) -> None: - payload = json.dumps(body).encode("utf-8") - self.send_response(status) - # Allow callers to override the default ``application/json`` by - # putting ``content-type`` in ``extra_headers``. The 402 path uses - # this to emit ``application/problem+json`` per RFC 7807 §3. - headers = {"content-type": "application/json"} - if extra_headers: - for name, value in extra_headers.items(): - headers[name.lower()] = value - headers["content-length"] = str(len(payload)) - headers["connection"] = "close" - for name, value in headers.items(): - self.send_header(name, value) - self.end_headers() - self.wfile.write(payload) - - def do_GET(self) -> None: # noqa: N802 - if self.path == "/health": - self._send_json(200, {"ok": True}) - return - - routes = self.cfg["routes"] - protected_amount = routes.get(self.path) - if protected_amount is None: - self._send_json(404, {"error": "not_found"}) - return - - auth = self.headers.get("Authorization", "") - splits = self.cfg["splits"] - options = ChargeOptions( - description="Python interop protected content", - splits=splits or [], - ) - - if not auth: - self._issue_challenge(protected_amount, options, message="missing authorization") - return - - try: - credential = parse_authorization(auth) - except Exception as exc: # noqa: BLE001 (parse errors map to 402) - self._issue_challenge( - protected_amount, - options, - message=f"could not parse Authorization: {exc}", - code="payment_invalid", - ) - return - - try: - challenge = self.mpp.charge_with_options(protected_amount, options) - expected = ChargeRequest.from_dict(challenge.decode_request()) - # Build a per-request SolanaRpc tied to this request's event loop - # (Greptile P1). The httpx.AsyncClient inside SolanaRpc anchors - # its connection-pool primitives to the loop it is first used - # in; reusing one across multiple ``asyncio.run`` calls is - # fragile. We close the request-scoped client immediately - # after the verify call returns. - async def _verify_with_fresh_rpc(): - # Use the explicit ``using_rpc`` context manager rather - # than mutating ``self.mpp._rpc`` directly. The previous - # in-place mutation was safe under a sequential - # HTTPServer, but it is a race waiting to happen the - # moment anyone swaps in ThreadingMixIn or runs two - # ``asyncio.run`` invocations concurrently. ``using_rpc`` - # serializes the swap under a per-instance lock and - # always restores the prior RPC on exit. - fresh_rpc = SolanaRpc(self.cfg["rpc_url"]) - try: - async with self.mpp.using_rpc(fresh_rpc): - return await self.mpp.verify_credential_with_expected(credential, expected) - finally: - await fresh_rpc.aclose() - - receipt = asyncio.run(_verify_with_fresh_rpc()) - except PaymentError as err: - self._issue_challenge( - protected_amount, options, message=str(err) or "verification failed", code=err.code - ) - return - except Exception as err: # noqa: BLE001 framework guard - print(f"interop python server error: {err}", file=sys.stderr) - self._issue_challenge(protected_amount, options, message=str(err)) - return - - settlement_header = self.cfg["settlement_header"] - self._send_json( - 200, - {"ok": True, "paid": True}, - extra_headers={ - "payment-receipt": receipt.reference, - settlement_header: receipt.reference, - }, - ) - - def _issue_challenge( - self, - amount: str, - options: ChargeOptions, - *, - message: str = "Payment required", - code: str = "payment_invalid", - ) -> None: - challenge = self.mpp.charge_with_options(amount, options) - www_auth = format_www_authenticate(challenge) - canonical = canonical_code(code) if code else "payment_invalid" - body = { - "type": f"https://paymentauth.org/problems/{canonical}", - "title": "Payment Required", - "status": 402, - "code": canonical, - "error": canonical, - "message": message, - } - self._send_json( - 402, - body, - extra_headers={ - # RFC 7807 §3: problem detail responses use - # ``application/problem+json``. The L6 canonical body shape - # is exactly the RFC 7807 ``type/title/status`` envelope - # plus our ``code`` field, so this is the correct media - # type for every 402 the adapter emits. - "content-type": "application/problem+json", - "www-authenticate": www_auth, - "cache-control": "no-store", - }, - ) - - -class _ThreadedHTTPServer(HTTPServer): - pass - - -def _fund_recipient_via_surfpool(rpc_url: str, pay_to: str, mint: str) -> None: - """Best-effort Surfpool seeding; mirrors Ruby adapter behavior.""" - try: - import httpx - - httpx.post( - rpc_url, - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "surfnet_setAccount", - "params": [ - pay_to, - { - "lamports": 1_000_000_000, - "data": "", - "executable": False, - "owner": "11111111111111111111111111111111", - "rentEpoch": 0, - }, - ], - }, - timeout=5, - ) - httpx.post( - rpc_url, - json={ - "jsonrpc": "2.0", - "id": 1, - "method": "surfnet_setTokenAccount", - "params": [ - pay_to, - mint, - {"amount": 0, "state": "initialized"}, - "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", - ], - }, - timeout=5, - ) - except Exception as err: # noqa: BLE001 - print(f"interop python surfpool seed failed: {err}", file=sys.stderr) - - -def main() -> None: - handler, cfg = _build_mpp() - port = _free_port() - server = _ThreadedHTTPServer(("127.0.0.1", port), InteropHandler) - server.mpp = handler # type: ignore[attr-defined] - server.cfg = cfg # type: ignore[attr-defined] - - # NOTE: do NOT pre-seed recipient ATA via surfnet_setTokenAccount in the - # interop harness. The harness funds payTo via Surfnet.fundToken before - # starting the adapter and captures ``initialBalance``; an unconditional - # reset would zero that balance and break the post-settlement delta - # assertion. The standalone example server still seeds because it is - # not under harness control. - - ready = { - "type": "ready", - "implementation": "python", - "role": "server", - "port": port, - "capabilities": ["charge"], - } - sys.stdout.write(json.dumps(ready) + "\n") - sys.stdout.flush() - - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - thread.join() - except KeyboardInterrupt: - server.shutdown() - - -if __name__ == "__main__": - main() diff --git a/harness/pay-kit-python-server/server.py b/harness/python-server/server.py similarity index 92% rename from harness/pay-kit-python-server/server.py rename to harness/python-server/server.py index f49f378c3..faa5a8881 100644 --- a/harness/pay-kit-python-server/server.py +++ b/harness/python-server/server.py @@ -5,24 +5,21 @@ explicit ``PAY_KIT_INTEROP_PROTOCOL`` hint). Mirrors ``harness/php-server/ server.php`` and the Ruby/Lua pay-kit-server pattern. -Unlike ``harness/python-server/main.py`` (which drives the lower-level -``solana_mpp`` wire directly), this adapter routes every request through the -unified ``pay_kit`` surface: +This adapter routes every request through the unified ``pay_kit`` surface: * x402 exact -> ``pay_kit.protocols.x402.X402Adapter`` (the umbrella adapter) - * MPP charge -> ``solana_mpp.server.mpp.Mpp`` (the lower-level wire) + * MPP charge -> ``pay_kit.protocols.mpp.server.charge.Mpp`` (the lower-level wire) This split mirrors the canonical PHP adapter (``harness/php-server/ server.php``): x402 routes through the umbrella adapter, while MPP charge -routes through the lower-level ``solana_mpp`` handler. The umbrella's +routes through the lower-level ``pay_kit.protocols.mpp`` handler. The umbrella's ticker-based currency model (``Stablecoin`` enum -> ``Mints.resolve``) is the right surface for x402, where the offer's ``asset`` is the resolved on-chain mint; but the interop MPP charge matrix runs in *pubkey mode* (the harness deploys the scenario mint at an arbitrary ``MPP_INTEROP_MINT`` pubkey, not the canonical USDC mint), so the MPP challenge must advertise that literal mint as -its ``currency``. The lower-level ``solana_mpp`` handler takes the raw mint -directly, exactly as the PHP ``SolanaChargeHandler`` path and the existing -``harness/python-server/main.py`` reference do. +its ``currency``. The lower-level ``pay_kit.protocols.mpp`` handler takes the raw mint +directly, exactly as the PHP ``SolanaChargeHandler`` path does. Cross-route replay protection on the MPP path is enforced by ``Mpp.verify_credential_with_expected`` (pins amount/currency/recipient per @@ -69,14 +66,14 @@ def _find_repo_root(start: Path) -> Path: ) from pay_kit.errors import InvalidProofError # noqa: E402 from pay_kit.protocols.x402 import X402Adapter # noqa: E402 -from solana_mpp._errors import PaymentError, canonical_code # noqa: E402 -from solana_mpp._headers import format_www_authenticate, parse_authorization # noqa: E402 -from solana_mpp._rpc import SolanaRpc # noqa: E402 -from solana_mpp.protocol.intents import ChargeRequest # noqa: E402 -from solana_mpp.server.mpp import ChargeOptions # noqa: E402 -from solana_mpp.server.mpp import Config as MppServerConfig # noqa: E402 -from solana_mpp.server.mpp import Mpp # noqa: E402 -from solana_mpp.store import MemoryStore # noqa: E402 +from pay_kit.protocols.mpp.core.errors import PaymentError, canonical_code # noqa: E402 +from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization # noqa: E402 +from pay_kit.protocols.mpp.core.rpc import SolanaRpc # noqa: E402 +from pay_kit.protocols.mpp.intents.charge import ChargeRequest # noqa: E402 +from pay_kit.protocols.mpp.server.charge import ChargeOptions # noqa: E402 +from pay_kit.protocols.mpp.server.charge import Config as MppServerConfig # noqa: E402 +from pay_kit.protocols.mpp.server.charge import Mpp # noqa: E402 +from pay_kit.protocols.mpp.core.store import MemoryStore # noqa: E402 def require_env(name: str) -> str: @@ -235,10 +232,9 @@ def _build_mpp(self) -> None: fee_payer = Keypair.from_bytes(bytes(json.loads(fee_payer_raw))) self.fee_payer = fee_payer - # Build the lower-level solana_mpp handler with the raw mint. The + # Build the lower-level pay_kit.protocols.mpp handler with the raw mint. The # ``Mpp`` server boots with ``rpc=None``; a request-lifetime - # ``SolanaRpc`` is scoped via ``using_rpc`` in the request path - # (mirrors harness/python-server/main.py). + # ``SolanaRpc`` is scoped via ``using_rpc`` in the request path. config = MppServerConfig( recipient=pay_to, currency=self.mint, @@ -285,7 +281,7 @@ def gate_for(self, path: str) -> Gate: class InteropHandler(BaseHTTPRequestHandler): - server_version = "pay-kit-python-interop/1.0" + server_version = "python-interop/1.0" def log_message(self, format: str, *args: Any) -> None: # noqa: A002 return @@ -401,7 +397,7 @@ async def _verify_with_fresh_rpc(): ) return except Exception as err: # noqa: BLE001 framework guard - print(f"interop pay-kit-python server error: {err}", file=sys.stderr) + print(f"interop python server error: {err}", file=sys.stderr) self._issue_mpp_challenge(adapter, amount, options, message=str(err)) return @@ -452,7 +448,7 @@ def main() -> None: ready = { "type": "ready", - "implementation": "pay-kit-python", + "implementation": "python", "role": "server", "port": port, "capabilities": ["exact" if adapter.x402 else "charge"], diff --git a/harness/src/canonical-codes.ts b/harness/src/canonical-codes.ts index d46de2c95..64fd184ee 100644 --- a/harness/src/canonical-codes.ts +++ b/harness/src/canonical-codes.ts @@ -1,5 +1,5 @@ // Canonical L6 / P1 structured error codes shared by every server adapter. -// Source of truth: python/src/solana_mpp/_errors.py CANONICAL_CODES, +// Source of truth: python/src/pay_kit/protocols/mpp/core/errors.py CANONICAL_CODES, // ruby/lib/mpp/error_codes.rb CANONICAL_CODES. // // The G39 fault matrix asserts that every server SDK emits the same code diff --git a/harness/src/implementations.ts b/harness/src/implementations.ts index e8121eeb0..62f2a7276 100644 --- a/harness/src/implementations.ts +++ b/harness/src/implementations.ts @@ -216,35 +216,25 @@ export const serverImplementations: ImplementationDefinition[] = [ }, { id: "python", - label: "Python HTTP server", - role: "server", - // Default OFF to match the other newly-landed adapters (PHP, Ruby, Go). - // The default interop matrix should not require a Python toolchain on - // every contributor's machine; opt-in via - // ``MPP_INTEROP_SERVERS=python`` (or the dedicated focused-matrix CI - // jobs in .github/workflows/python.yml). - command: ["python3", "python-server/main.py"], - enabled: isEnabled("python", "MPP_INTEROP_SERVERS", false), - }, - { - id: "pay-kit-python", - label: "Python PayKit server (dual protocol)", + label: "Python pay_kit server (dual protocol)", role: "server", // One adapter binary, two settle paths. The dual-protocol Python - // PayKit server (harness/pay-kit-python-server/server.py) reads - // either X402_INTEROP_* or MPP_INTEROP_* (or PAY_KIT_INTEROP_PROTOCOL - // for the matrix's both-namespaces shape) and routes x402 through the - // umbrella's X402Adapter and MPP charge through the lower-level - // solana_mpp handler (the umbrella's ticker-based currency model fits - // x402's resolved-mint asset, but the pubkey-mode MPP charge matrix - // needs the literal mint as currency). Same split as the PHP adapter. - // Distinct from the lower-level `python` server above, which is - // MPP-only. Default OFF: opt in via - // `MPP_INTEROP_SERVERS=pay-kit-python` (charge) / - // `MPP_INTEROP_SERVERS=pay-kit-python X402_INTEROP_CLIENTS=rust-x402` - // with `MPP_INTEROP_INTENTS=x402-exact` (x402-exact). - command: ["python3", "pay-kit-python-server/server.py"], - enabled: isEnabled("pay-kit-python", "MPP_INTEROP_SERVERS", false), + // pay_kit server (harness/python-server/server.py) reads either + // X402_INTEROP_* or MPP_INTEROP_* (or PAY_KIT_INTEROP_PROTOCOL for the + // matrix's both-namespaces shape) and routes x402 through the umbrella's + // X402Adapter and MPP charge through the lower-level + // pay_kit.protocols.mpp handler (the umbrella's ticker-based currency + // model fits x402's resolved-mint asset, but the pubkey-mode MPP charge + // matrix needs the literal mint as currency). Same split as the PHP + // adapter. Default OFF to match the other newly-landed adapters (PHP, + // Ruby): the default interop matrix should not require a Python toolchain + // on every contributor's machine; opt in via + // ``MPP_INTEROP_SERVERS=python`` (charge) / + // ``MPP_INTEROP_SERVERS=python X402_INTEROP_CLIENTS=rust-x402`` with + // ``MPP_INTEROP_INTENTS=x402-exact`` (x402-exact), or the dedicated + // focused-matrix CI jobs in .github/workflows/python.yml. + command: ["python3", "python-server/server.py"], + enabled: isEnabled("python", "MPP_INTEROP_SERVERS", false), intents: ["charge", "x402-exact"], }, { diff --git a/harness/test/compute-budget-caps.test.ts b/harness/test/compute-budget-caps.test.ts index 494ee5c8a..c6b693cd1 100644 --- a/harness/test/compute-budget-caps.test.ts +++ b/harness/test/compute-budget-caps.test.ts @@ -91,11 +91,11 @@ const SDKS: Sdk[] = [ pricePattern: /maxComputeUnitPriceMicroLamports\s+uint64\s*=\s*([0-9_]+)/, optional: true, }, - // Python #106 lands MAX_COMPUTE_UNIT_* in python/src/solana_mpp/server/mpp.py; - // gated until merge. + // Python #106 lands MAX_COMPUTE_UNIT_* in + // python/src/pay_kit/protocols/mpp/server/charge.py; gated until merge. { language: "python", - file: "python/src/solana_mpp/server/mpp.py", + file: "python/src/pay_kit/protocols/mpp/server/charge.py", limitPattern: /MAX_COMPUTE_UNIT_LIMIT\s*=\s*([0-9_]+)/, pricePattern: /MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS\s*=\s*([0-9_]+)/, optional: true, diff --git a/harness/test/x402-exact.e2e.test.ts b/harness/test/x402-exact.e2e.test.ts index 7230cce78..321cb5d17 100644 --- a/harness/test/x402-exact.e2e.test.ts +++ b/harness/test/x402-exact.e2e.test.ts @@ -94,7 +94,7 @@ describe("x402 exact intent — cross-language matrix", () => { // canonical PaymentProof and settles end-to-end against surfpool, // mirroring the rust<->lua x402 interop pairing. The ts-x402 stub // client (no real transaction) is intentionally excluded. - if (clientId === "rust-x402" && serverId === "pay-kit-python") return true; + if (clientId === "rust-x402" && serverId === "python") return true; return false; }; diff --git a/html/build.ts b/html/build.ts index abcd29068..fe9070f9b 100644 --- a/html/build.ts +++ b/html/build.ts @@ -137,7 +137,7 @@ async function main() { ); // Python: write template + service worker as raw files for importlib.resources - const pyDir = resolve(import.meta.dirname, '..', 'python', 'src', 'solana_mpp', 'server', 'html'); + const pyDir = resolve(import.meta.dirname, '..', 'python', 'src', 'pay_kit', 'protocols', 'mpp', 'server', 'html'); mkdirSync(pyDir, { recursive: true }); writeFileSync(resolve(pyDir, 'template.gen.html'), htmlTemplate); writeFileSync(resolve(pyDir, 'service_worker.gen.js'), mppxServiceWorker); diff --git a/python/README.md b/python/README.md index b02cc5beb..42b09076b 100644 --- a/python/README.md +++ b/python/README.md @@ -171,7 +171,7 @@ boots zero-config against the Surfpool sandbox. git clone https://github.com/solana-foundation/pay-kit cd pay-kit/python pip install -e ".[flask]" -python examples/flask-paykit/app.py +python examples/flask/app.py ``` **Consume with `pay curl`:** @@ -359,26 +359,23 @@ def view(request): ## Examples -Three runnable examples ship with this package: +Runnable examples ship with this package: - [`examples/fastapi/app.py`](examples/fastapi/app.py), FastAPI server using the `RequirePayment` dependency and `install_exception_handler`. -- [`examples/flask-paykit/app.py`](examples/flask-paykit/app.py), Flask - server using the `@require_payment` decorator and the `Pricing` registry. +- [`examples/flask/app.py`](examples/flask/app.py), Flask server gated with + the unified `pay_kit` surface (`@require_payment` decorator and the + `Pricing` registry). - [`examples/django/views.py`](examples/django/views.py), Django views + URLconf snippet using the `@require_payment` decorator. - -The lower-level `solana_mpp` wire library also ships its own examples: - -- [`examples/flask/`](examples/flask), Flask app gated with the - `solana_mpp` `@mpp_charge` decorator (config + middleware split out). - [`examples/payment-links/server.py`](examples/payment-links/server.py), - the same flow against a local Surfpool with an HTML payment-page fallback, - used by the interop harness. + a lower-level flow against a local Surfpool with an HTML payment-page + fallback (built directly on `pay_kit.protocols.mpp`), used by the interop + harness. All examples default to `solana_localnet`, `USDC`, and the demo recipient. Override the RPC with `rpc_url=` / `PAY_KIT_RPC_URL` (or `MPP_RPC_URL` for -the `solana_mpp` Flask example). +the lower-level payment-links example). ## Coverage @@ -388,7 +385,7 @@ pip install -e ".[dev]" ruff check src tests ruff format --check src tests pyright -pytest --cov=pay_kit --cov=solana_mpp --cov-fail-under=90 +pytest --cov=pay_kit --cov-fail-under=90 ``` The `pay_kit` surface is gated at 90 percent line coverage in CI. The @@ -399,8 +396,9 @@ its two opt-out knobs are covered separately against a stubbed run/RPC. ## Harness The Python server has a direct harness adapter at -[`harness/python-server/main.py`](../harness/python-server/main.py). -Focused harness commands: +[`harness/python-server/server.py`](../harness/python-server/server.py), a +dual-protocol server that settles both MPP charge and x402-exact. Focused +harness commands: ```bash cd harness @@ -425,15 +423,19 @@ python/ ├── src/pay_kit/ unified surface over x402 + MPP │ ├── config.py, operator.py, signer.py, price.py, fee.py, gate.py, │ │ pricing.py, payment.py, preflight.py, errors.py # umbrella surface -│ ├── _paycore/ Currency / Network / Protocol / Stablecoin / Mints -│ ├── _wire.py TypedDict wire shapes (x402 offer/payload, MPP request) +│ ├── _paycore/ Currency / Network / Protocol / Stablecoin / Mints / Solana │ ├── _middleware.py host-neutral resolver + require_payment/is_paid/get_payment │ ├── fastapi.py, flask.py, django.py framework shims │ ├── kms.py reserved remote-enclave signer namespace -│ └── protocols/{x402,mpp}.py x402-exact + MPP-charge adapters over the solana_mpp wire -├── src/solana_mpp/ lower-level MPP wire library (reused, not reimplemented) -├── examples/{fastapi,flask-paykit,django}/ pay_kit framework examples -├── examples/{flask,payment-links}/ solana_mpp examples +│ └── protocols/ +│ ├── x402/ x402-exact adapter (__init__) + verifier/wire shapes (verify.py) +│ └── mpp/ MPP-charge adapter (__init__) over the consolidated wire layer +│ ├── core/ canonical JSON, headers, challenge, types, errors, RPC, store +│ ├── intents/charge.py charge intent +│ ├── server/ charge handler, middleware, network check, defaults, payment page +│ └── client/ charge + transport +├── examples/{fastapi,flask,django}/ pay_kit framework examples +├── examples/payment-links/ lower-level MPP server example (interop harness) ├── tests/ pytest suite └── pyproject.toml ``` diff --git a/python/examples/flask-paykit/app.py b/python/examples/flask-paykit/app.py deleted file mode 100644 index aee04a77e..000000000 --- a/python/examples/flask-paykit/app.py +++ /dev/null @@ -1,84 +0,0 @@ -# examples/flask-paykit/app.py -"""Flask server gated with the unified pay_kit surface. - -Zero-config: ``pay_kit.configure()`` boots against solana_localnet (the -hosted Surfpool sandbox at https://402.surfnet.dev:8899) with the shipped -demo signer as the recipient. - -This example uses the pay_kit Flask shim (``pay_kit.flask``), the unified -surface over x402 and MPP. For the lower-level solana_mpp ``@mpp_charge`` -decorator, see ../flask/app.py instead. - -Three routes: - - GET /health -> free, returns {"ok": true} - GET /report -> gated by an inline price, both protocols accepted - GET /api/data -> gated, x402-only via accept= - -Run: - - pip install -e ".[flask]" - python examples/flask-paykit/app.py - -Drive it from a client: - - curl -i http://127.0.0.1:8000/report # 402 payment required - pay curl http://127.0.0.1:8000/report # pays and succeeds -""" - -from __future__ import annotations - -from flask import Flask, jsonify - -import pay_kit -from pay_kit import Gate, Protocol, usd -from pay_kit.flask import payment, require_payment - -pay_kit.configure(network="solana_localnet") - -_defaults = { - "pay_to": pay_kit.config().effective_recipient(), - "accept": pay_kit.config().accept, -} - -report_gate = Gate.build( - name="report", - amount=usd("0.10"), - description="Premium report", - default_pay_to=_defaults["pay_to"], - accept_default=_defaults["accept"], -) - -api_gate = Gate.build( - name="api_call", - amount=usd("0.001"), - accept=(Protocol.X402,), - default_pay_to=_defaults["pay_to"], -) - -app = Flask(__name__) - - -@app.get("/health") -def health(): - """Free liveness probe.""" - return jsonify(ok=True) - - -@app.get("/report") -@require_payment(report_gate) -def report(): - """Paid route. The verified proof is readable via pay_kit.flask.payment().""" - proof = payment() - return jsonify(ok=True, tx=proof.transaction, protocol=proof.protocol.value) - - -@app.get("/api/data") -@require_payment(api_gate) -def api_data(): - """x402-only route: this gate refuses to settle over MPP.""" - return jsonify(data=[]) - - -if __name__ == "__main__": - app.run(host="127.0.0.1", port=8000) diff --git a/python/examples/flask/README.md b/python/examples/flask/README.md deleted file mode 100644 index 32750a90a..000000000 --- a/python/examples/flask/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# Python Flask MPP example - -A minimal Flask app with one MPP-protected endpoint, organized as an -app factory with separate `config` and `middleware` modules so it can -double as a Flask best-practice template. - -Layout: - -- `app.py` builds the Flask app via a `create_app(mpp, settings)` factory -- `config.py` reads `ServerSettings` and the MPP `Config` from env vars -- `middleware.py` exposes the `mpp_charge(mpp, amount, description)` decorator - -Two routes: - -- `GET /health` is free and returns `{"ok": true}` -- `GET /paid` is gated by an `@mpp_charge(...)` decorator that inspects - the `Authorization: Payment` header, returns a 402 with a signed - challenge when none is supplied, and otherwise lets the view render - any body while attaching the on-chain `Payment-Receipt` header. - -## Run - -```bash -cd python -pip install -e ".[dev]" -pip install flask -python examples/flask/app.py -``` - -In another terminal: - -```bash -curl -i http://127.0.0.1:8000/paid -# HTTP/1.1 402 Payment Required -# WWW-Authenticate: Payment realm="Python Flask Example", ... -``` - -## Environment - -`HOST`, `PORT`, `MPP_RPC_URL`, `MPP_NETWORK`, `MPP_CURRENCY`, -`MPP_PAY_TO`, `MPP_SECRET_KEY`, `MPP_AMOUNT`. Defaults match the other -language examples so cross-language clients can hit either server with -the same configuration. diff --git a/python/examples/flask/app.py b/python/examples/flask/app.py index 209a85ed7..c4be862ba 100644 --- a/python/examples/flask/app.py +++ b/python/examples/flask/app.py @@ -1,64 +1,84 @@ -"""Flask app with one MPP-protected endpoint. +# examples/flask/app.py +"""Flask server gated with the unified pay_kit surface. -Routes: +Zero-config: ``pay_kit.configure()`` boots against solana_localnet (the +hosted Surfpool sandbox at https://402.surfnet.dev:8899) with the shipped +demo signer as the recipient. - GET /health -> free, returns {"ok": true} - GET /paid -> gated by the @mpp_charge decorator. The decorator - inspects the Authorization: Payment header, returns - a 402 with a WWW-Authenticate challenge when no valid - credential is supplied, and otherwise lets the route - render any body it likes while emitting the - Payment-Receipt header. +This example uses the pay_kit Flask shim (``pay_kit.flask``), the unified +surface over x402 and MPP. For the lower-level pay_kit.protocols.mpp ``@mpp_charge`` +decorator, see ../flask/app.py instead. -Override the defaults via env vars: +Three routes: - HOST, PORT, MPP_RPC_URL, MPP_NETWORK, MPP_CURRENCY, - MPP_PAY_TO, MPP_SECRET_KEY, MPP_AMOUNT, - MPP_FEE_PAYER_SECRET_KEY (optional JSON-array secret key). + GET /health -> free, returns {"ok": true} + GET /report -> gated by an inline price, both protocols accepted + GET /api/data -> gated, x402-only via accept= Run: - pip install flask + pip install -e ".[flask]" python examples/flask/app.py -In another terminal: +Drive it from a client: - curl -i http://127.0.0.1:8000/paid - # 402 Payment Required with WWW-Authenticate: Payment ... challenge + curl -i http://127.0.0.1:8000/report # 402 payment required + pay curl http://127.0.0.1:8000/report # pays and succeeds """ from __future__ import annotations from flask import Flask, jsonify -from solana_mpp.server.mpp import Mpp +import pay_kit +from pay_kit import Gate, Protocol, usd +from pay_kit.flask import payment, require_payment -from config import ServerSettings, mpp_config_from_env, server_settings_from_env -from middleware import mpp_charge +pay_kit.configure(network="solana_localnet") +_defaults = { + "pay_to": pay_kit.config().effective_recipient(), + "accept": pay_kit.config().accept, +} -def create_app(mpp: Mpp, settings: ServerSettings) -> Flask: - """Flask app factory wiring the MPP charge decorator onto /paid.""" - app = Flask(__name__) +report_gate = Gate.build( + name="report", + amount=usd("0.10"), + description="Premium report", + default_pay_to=_defaults["pay_to"], + accept_default=_defaults["accept"], +) - @app.get("/health") - def health(): - return jsonify(ok=True) +api_gate = Gate.build( + name="api_call", + amount=usd("0.001"), + accept=(Protocol.X402,), + default_pay_to=_defaults["pay_to"], +) - @app.get("/paid") - @mpp_charge(mpp, amount=settings.amount, description="Paid endpoint") - def paid(): - return jsonify(ok=True, message="thanks for paying!") +app = Flask(__name__) - return app +@app.get("/health") +def health(): + """Free liveness probe.""" + return jsonify(ok=True) -def main() -> None: - settings = server_settings_from_env() - mpp = Mpp(mpp_config_from_env()) - app = create_app(mpp, settings) - app.run(host=settings.host, port=settings.port) + +@app.get("/report") +@require_payment(report_gate) +def report(): + """Paid route. The verified proof is readable via pay_kit.flask.payment().""" + proof = payment() + return jsonify(ok=True, tx=proof.transaction, protocol=proof.protocol.value) + + +@app.get("/api/data") +@require_payment(api_gate) +def api_data(): + """x402-only route: this gate refuses to settle over MPP.""" + return jsonify(data=[]) if __name__ == "__main__": - main() + app.run(host="127.0.0.1", port=8000) diff --git a/python/examples/flask/config.py b/python/examples/flask/config.py deleted file mode 100644 index d5abc595a..000000000 --- a/python/examples/flask/config.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Environment-driven configuration for the Flask MPP example. - -All knobs are read from environment variables so the same module can be -imported by the app factory, tests, and ad-hoc scripts without mutating -process state. -""" - -from __future__ import annotations - -import os -from dataclasses import dataclass - -from solana_mpp._rpc import SolanaRpc -from solana_mpp.server.mpp import Config -from solana_mpp.store import MemoryStore - -DEFAULT_RPC_URL = "https://402.surfnet.dev:8899" -DEFAULT_CURRENCY = "USDC" -DEFAULT_PAY_TO = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY" -DEFAULT_AMOUNT = "0.001" -DEFAULT_HOST = "127.0.0.1" -DEFAULT_PORT = 8000 - - -@dataclass(frozen=True) -class ServerSettings: - """Top-level Flask runtime settings (host, port, default amount).""" - - host: str - port: int - amount: str - - -def server_settings_from_env() -> ServerSettings: - return ServerSettings( - host=os.environ.get("HOST", DEFAULT_HOST), - port=int(os.environ.get("PORT", str(DEFAULT_PORT))), - amount=os.environ.get("MPP_AMOUNT", DEFAULT_AMOUNT), - ) - - -def mpp_config_from_env() -> Config: - """Build the :class:`Config` for the MPP server from environment vars.""" - rpc_url = os.environ.get("MPP_RPC_URL", DEFAULT_RPC_URL) - return Config( - recipient=os.environ.get("MPP_PAY_TO", DEFAULT_PAY_TO), - currency=os.environ.get("MPP_CURRENCY", DEFAULT_CURRENCY), - decimals=6, - network=os.environ.get("MPP_NETWORK", "localnet"), - rpc_url=rpc_url, - secret_key=os.environ.get("MPP_SECRET_KEY", "python-mpp-dev-secret"), - realm="Python Flask Example", - store=MemoryStore(), - rpc=SolanaRpc(rpc_url), - ) diff --git a/python/examples/flask/middleware.py b/python/examples/flask/middleware.py deleted file mode 100644 index 250df2036..000000000 --- a/python/examples/flask/middleware.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Flask middleware that gates a view behind an MPP charge. - -Exposes :func:`mpp_charge`, a decorator factory that: - -- builds a route-aware charge challenge, -- returns a 402 with a ``WWW-Authenticate: Payment ...`` header when the - request has no valid ``Authorization: Payment`` credential, -- otherwise verifies the credential and attaches the on-chain - ``Payment-Receipt`` header to the wrapped view's response. -""" - -from __future__ import annotations - -import asyncio -import json -from functools import wraps - -from flask import Response, jsonify, request - -from solana_mpp._headers import format_www_authenticate, parse_authorization -from solana_mpp.protocol.intents import ChargeRequest -from solana_mpp.server.mpp import ChargeOptions, Mpp - - -def mpp_charge(mpp: Mpp, amount: str, description: str = ""): - """Return a Flask view decorator that requires a paid MPP credential.""" - - options = ChargeOptions(description=description) - - def decorator(view): - @wraps(view) - def wrapper(*args, **kwargs): - challenge = mpp.charge_with_options(amount, options) - auth_header = request.headers.get("Authorization") - if auth_header: - try: - credential = parse_authorization(auth_header) - expected = ChargeRequest.from_dict(challenge.decode_request()) - receipt = asyncio.run( - mpp.verify_credential_with_expected(credential, expected) - ) - response = view(*args, **kwargs) - if not isinstance(response, Response): - response = jsonify(response) - response.headers["Payment-Receipt"] = receipt.reference - return response - except Exception: # noqa: BLE001 - pass # Fall through to a fresh challenge. - - body = json.dumps( - { - "type": "https://paymentauth.org/problems/payment-required", - "title": "Payment Required", - "status": 402, - } - ) - return Response( - body, - status=402, - headers={ - "Content-Type": "application/json", - "WWW-Authenticate": format_www_authenticate(challenge), - "Cache-Control": "no-store", - }, - ) - - return wrapper - - return decorator diff --git a/python/examples/payment-links/server.py b/python/examples/payment-links/server.py index 963dd2b44..712bfec6b 100644 --- a/python/examples/payment-links/server.py +++ b/python/examples/payment-links/server.py @@ -11,16 +11,16 @@ import random from http.server import BaseHTTPRequestHandler, HTTPServer -from solana_mpp._headers import format_www_authenticate, parse_authorization -from solana_mpp._rpc import SolanaRpc -from solana_mpp.server.mpp import ChargeOptions, Config, Mpp -from solana_mpp.server.payment_page import ( +from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization +from pay_kit.protocols.mpp.core.rpc import SolanaRpc +from pay_kit.protocols.mpp.server.charge import ChargeOptions, Config, Mpp +from pay_kit.protocols.mpp.server.payment_page import ( accepts_html, challenge_to_html, is_service_worker_request, service_worker_js, ) -from solana_mpp.store import MemoryStore +from pay_kit.protocols.mpp.core.store import MemoryStore RECIPIENT = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY" USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" diff --git a/python/pyproject.toml b/python/pyproject.toml index bdb948a8c..e607374a4 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "solana-mpp" +name = "solana-pay-kit" version = "0.1.0" description = "Solana payment method for the Machine Payments Protocol" requires-python = ">=3.11" @@ -29,7 +29,7 @@ dev = [ ] [tool.hatch.build.targets.wheel] -packages = ["src/solana_mpp", "src/pay_kit"] +packages = ["src/pay_kit"] [tool.ruff] target-version = "py311" @@ -41,7 +41,40 @@ select = ["E", "F", "W", "I", "UP", "B", "SIM"] [tool.pyright] pythonVersion = "3.11" typeCheckingMode = "standard" -strict = ["src/pay_kit"] +# Strict scope is the hand-authored pay_kit SDK surface plus the two protocol +# adapters/verifier (pay_kit/protocols/mpp/__init__.py, pay_kit/protocols/x402/*). +# The MPP wire layer consolidated in from the former solana_mpp package +# (pay_kit/_paycore/solana.py and pay_kit/protocols/mpp/{core,server,client,intents}) +# was authored under standard typing and is left at standard to keep its +# behavior byte-identical; tightening it to strict is follow-up typing work. +strict = [ + "src/pay_kit/__init__.py", + "src/pay_kit/_middleware.py", + "src/pay_kit/config.py", + "src/pay_kit/django.py", + "src/pay_kit/errors.py", + "src/pay_kit/fastapi.py", + "src/pay_kit/fee.py", + "src/pay_kit/flask.py", + "src/pay_kit/gate.py", + "src/pay_kit/kms.py", + "src/pay_kit/operator.py", + "src/pay_kit/payment.py", + "src/pay_kit/preflight.py", + "src/pay_kit/price.py", + "src/pay_kit/pricing.py", + "src/pay_kit/signer.py", + "src/pay_kit/_paycore/__init__.py", + "src/pay_kit/_paycore/currency.py", + "src/pay_kit/_paycore/mints.py", + "src/pay_kit/_paycore/network.py", + "src/pay_kit/_paycore/protocol.py", + "src/pay_kit/_paycore/stablecoin.py", + "src/pay_kit/protocols/__init__.py", + "src/pay_kit/protocols/mpp/__init__.py", + "src/pay_kit/protocols/x402/__init__.py", + "src/pay_kit/protocols/x402/verify.py", +] reportMissingTypeStubs = false include = ["src", "tests"] exclude = ["**/__pycache__"] @@ -51,7 +84,7 @@ asyncio_mode = "auto" testpaths = ["tests"] [tool.coverage.run] -source = ["solana_mpp", "pay_kit"] +source = ["pay_kit"] # Line coverage gate is 90%. Branch coverage is follow-up work tracked in # issue #108. branch = false diff --git a/python/src/pay_kit/__init__.py b/python/src/pay_kit/__init__.py index 1e14d0ddd..1419d46ab 100644 --- a/python/src/pay_kit/__init__.py +++ b/python/src/pay_kit/__init__.py @@ -7,7 +7,7 @@ submodules (``pay_kit.fastapi``, ``pay_kit.flask``, ``pay_kit.django``) and are imported on demand so the base install carries no web-framework dependency. -This package ships alongside :mod:`solana_mpp`, whose wire internals it reuses +This package ships alongside :mod:`pay_kit.protocols.mpp`, whose wire internals it reuses rather than reimplements. """ @@ -54,9 +54,9 @@ from pay_kit.payment import Payment from pay_kit.price import Price from pay_kit.pricing import Pricing +from pay_kit.protocols.mpp.core.expires import days, hours, minutes, seconds, weeks +from pay_kit.protocols.mpp.core.store import FileReplayStore, MemoryStore, Store from pay_kit.signer import LocalSigner, Signer -from solana_mpp._expires import days, hours, minutes, seconds, weeks -from solana_mpp.store import FileReplayStore, MemoryStore, Store __all__ = [ # enums / paycore @@ -108,7 +108,7 @@ "ChallengeExpiredError", "PaymentRequiredError", "ProtocolNotSupportedError", - # expiry helpers (re-exported from solana_mpp) + # expiry helpers (re-exported from pay_kit.protocols.mpp) "seconds", "minutes", "hours", diff --git a/python/src/pay_kit/_middleware.py b/python/src/pay_kit/_middleware.py index 5e1d0b21a..a37f7f76b 100644 --- a/python/src/pay_kit/_middleware.py +++ b/python/src/pay_kit/_middleware.py @@ -34,7 +34,6 @@ from typing import TYPE_CHECKING, Any, cast from pay_kit._paycore.protocol import Protocol -from pay_kit._wire import MppAcceptsEntry, X402AcceptsEntry from pay_kit.errors import ( InvalidProofError, PaymentRequiredError, @@ -49,6 +48,8 @@ if TYPE_CHECKING: from pay_kit.config import Config + from pay_kit.protocols.mpp import MppAcceptsEntry + from pay_kit.protocols.x402.verify import X402AcceptsEntry __all__ = [ "PayCore", diff --git a/python/src/pay_kit/_paycore/__init__.py b/python/src/pay_kit/_paycore/__init__.py index 56a923476..7a61c41ff 100644 --- a/python/src/pay_kit/_paycore/__init__.py +++ b/python/src/pay_kit/_paycore/__init__.py @@ -1,4 +1,4 @@ -"""Layer A: paycore primitives (enums, mints) plus solana_mpp re-exports.""" +"""Layer A: paycore primitives (enums, mints) plus pay_kit.protocols.mpp re-exports.""" from __future__ import annotations diff --git a/python/src/pay_kit/_paycore/mints.py b/python/src/pay_kit/_paycore/mints.py index cb8bba187..b7b4b3d0c 100644 --- a/python/src/pay_kit/_paycore/mints.py +++ b/python/src/pay_kit/_paycore/mints.py @@ -1,7 +1,7 @@ -"""Stablecoin mint resolution and ATA derivation over solana_mpp data. +"""Stablecoin mint resolution and ATA derivation over pay_kit.protocols.mpp data. Mirrors PHP ``PayCore/Solana/Mints.php``. All mint/program tables live in -``solana_mpp.protocol.solana`` and are reused here rather than duplicated, so +``pay_kit._paycore.solana`` and are reused here rather than duplicated, so pay_kit and the legacy surface always agree on wire values. """ @@ -9,7 +9,7 @@ from solders.pubkey import Pubkey -from solana_mpp.protocol.solana import ( +from pay_kit._paycore.solana import ( ASSOCIATED_TOKEN_PROGRAM, TOKEN_2022_PROGRAM, TOKEN_PROGRAM, diff --git a/python/src/solana_mpp/protocol/solana.py b/python/src/pay_kit/_paycore/solana.py similarity index 100% rename from python/src/solana_mpp/protocol/solana.py rename to python/src/pay_kit/_paycore/solana.py diff --git a/python/src/pay_kit/_wire.py b/python/src/pay_kit/_wire.py deleted file mode 100644 index eac461773..000000000 --- a/python/src/pay_kit/_wire.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Typed wire shapes for the x402 and MPP JSON surfaces. - -These ``TypedDict`` definitions describe the exact JSON dicts pay_kit builds for -challenges/offers and parses from inbound credentials. They exist purely to give -the adapters precise static types over the wire payloads; they do not change the -serialized bytes. Optional keys use ``total=False`` so a missing field is a type -error only where the field is guaranteed present. - -Inbound payloads (decoded via ``json.loads``) are validated structurally at -runtime and then narrowed to these shapes with ``cast``; the cast is a static- -only assertion and never alters the value. -""" - -from __future__ import annotations - -from typing import TypedDict - - -class X402ExtraRequired(TypedDict): - """The always-present keys of an x402 ``accepts[].extra`` block.""" - - feePayer: str - decimals: int - tokenProgram: str - memo: str - - -class X402Extra(X402ExtraRequired, total=False): - """An x402 ``accepts[].extra`` block; ``recentBlockhash`` is optional.""" - - recentBlockhash: str - - -class X402AcceptsEntry(TypedDict): - """One x402 ``accepts[]`` offer entry (the server requirement).""" - - protocol: str - scheme: str - network: str - asset: str - amount: str - maxAmountRequired: str - payTo: str - maxTimeoutSeconds: int - extra: X402Extra - - -class X402Challenge(TypedDict): - """The base64-encoded ``payment-required`` challenge body.""" - - x402Version: int - resource: X402Resource - accepts: list[X402AcceptsEntry] - - -class X402Resource(TypedDict): - """The ``resource`` block inside an x402 challenge.""" - - type: str - url: str - - -class X402PayloadField(TypedDict, total=False): - """The ``payload`` block of an inbound X-PAYMENT envelope.""" - - transaction: str - transactionHash: str - - -class X402Envelope(TypedDict, total=False): - """An inbound X-PAYMENT envelope (decoded from the proof header). - - All keys optional because the structure is attacker-controlled and validated - field-by-field at runtime before any value is trusted. - """ - - x402Version: int - accepted: X402AcceptsEntry - payload: X402PayloadField - - -class X402ResponseEnvelope(TypedDict): - """The base64-encoded ``payment-response`` settlement receipt.""" - - success: bool - transaction: str - network: str - payer: str - - -class MppSplit(TypedDict): - """A single fee split on an MPP offer or charge request.""" - - recipient: str - amount: str - - -class MppAcceptsEntryRequired(TypedDict): - """The always-present keys of an MPP ``accepts[]`` offer entry.""" - - protocol: str - scheme: str - network: str - amount: str - currency: str - payTo: str - realm: str - - -class MppAcceptsEntry(MppAcceptsEntryRequired, total=False): - """One MPP ``accepts[]`` offer entry; ``splits`` present only with fees.""" - - splits: list[MppSplit] - - -class MppMethodDetails(TypedDict, total=False): - """The MPP ``request.methodDetails`` block (network always set).""" - - network: str - splits: list[MppSplit] - feePayer: bool - feePayerKey: str - recentBlockhash: str diff --git a/python/src/pay_kit/errors.py b/python/src/pay_kit/errors.py index 8870646f5..6bad5a7ed 100644 --- a/python/src/pay_kit/errors.py +++ b/python/src/pay_kit/errors.py @@ -12,8 +12,8 @@ ``InvalidProofError.code`` carries the canonical cross-SDK L6 error string (e.g. ``charge_request_mismatch``, ``signature_consumed``). Adapters map the -underlying ``solana_mpp`` ``PaymentError.code`` to these at the boundary via -``solana_mpp._errors.canonical_code``. +underlying ``pay_kit.protocols.mpp`` ``PaymentError.code`` to these at the boundary via +``pay_kit.protocols.mpp.core.errors.canonical_code``. """ from __future__ import annotations diff --git a/python/src/pay_kit/protocols/__init__.py b/python/src/pay_kit/protocols/__init__.py index be084d4f7..8fb20a264 100644 --- a/python/src/pay_kit/protocols/__init__.py +++ b/python/src/pay_kit/protocols/__init__.py @@ -1,4 +1,4 @@ -"""Protocol adapters that bridge gates to the solana_mpp wire layer.""" +"""Protocol adapters that bridge gates to the pay_kit.protocols.mpp wire layer.""" from __future__ import annotations diff --git a/python/src/pay_kit/protocols/mpp.py b/python/src/pay_kit/protocols/mpp/__init__.py similarity index 86% rename from python/src/pay_kit/protocols/mpp.py rename to python/src/pay_kit/protocols/mpp/__init__.py index 7ae269131..c2390ce94 100644 --- a/python/src/pay_kit/protocols/mpp.py +++ b/python/src/pay_kit/protocols/mpp/__init__.py @@ -1,12 +1,12 @@ -"""MPP charge adapter wrapping the solana_mpp server wire layer. +"""MPP charge adapter wrapping the pay_kit.protocols.mpp server wire layer. Mirrors PHP ``Protocols/Mpp/{Adapter,SecretResolver}`` and the Ruby reference. The adapter never reimplements canonical JSON, header parsing, challenge HMAC binding, or the on-chain Solana verifier; those all live in -:mod:`solana_mpp` and are reused per the blueprint reuse map. This module +:mod:`pay_kit.protocols.mpp` and are reused per the blueprint reuse map. This module only translates a unified :class:`pay_kit.gate.Gate` into the wire request, builds the 402 challenge, and runs cross-route-safe verification through -``solana_mpp.server.mpp.Mpp.verify_credential_with_expected``. +``pay_kit.protocols.mpp.server.charge.Mpp.verify_credential_with_expected``. """ from __future__ import annotations @@ -17,19 +17,18 @@ import secrets from collections.abc import Callable from decimal import Decimal -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, TypedDict, cast from pay_kit._paycore.protocol import Protocol -from pay_kit._wire import MppAcceptsEntry, MppMethodDetails, MppSplit from pay_kit.errors import InvalidProofError from pay_kit.payment import Payment -from solana_mpp._errors import PaymentError, canonical_code -from solana_mpp._headers import format_www_authenticate, parse_authorization -from solana_mpp._rpc import SolanaRpc -from solana_mpp.protocol.intents import ChargeRequest -from solana_mpp.server.mpp import ChargeOptions, Mpp -from solana_mpp.server.mpp import Config as MppServerConfig -from solana_mpp.store import MemoryStore, Store +from pay_kit.protocols.mpp.core.errors import PaymentError, canonical_code +from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization +from pay_kit.protocols.mpp.core.rpc import SolanaRpc +from pay_kit.protocols.mpp.core.store import MemoryStore, Store +from pay_kit.protocols.mpp.intents.charge import ChargeRequest +from pay_kit.protocols.mpp.server.charge import ChargeOptions, Mpp +from pay_kit.protocols.mpp.server.charge import Config as MppServerConfig if TYPE_CHECKING: from pay_kit.config import Config @@ -38,6 +37,48 @@ __all__ = ["MppAdapter", "SecretResolver"] + +# --- MPP wire shapes -------------------------------------------------------- +# TypedDicts describing the MPP offer/charge-request JSON dicts the adapter +# builds. They give precise static types over the wire payloads and never +# change the serialized bytes. Optional keys use ``total=False``. + + +class MppSplit(TypedDict): + """A single fee split on an MPP offer or charge request.""" + + recipient: str + amount: str + + +class MppAcceptsEntryRequired(TypedDict): + """The always-present keys of an MPP ``accepts[]`` offer entry.""" + + protocol: str + scheme: str + network: str + amount: str + currency: str + payTo: str + realm: str + + +class MppAcceptsEntry(MppAcceptsEntryRequired, total=False): + """One MPP ``accepts[]`` offer entry; ``splits`` present only with fees.""" + + splits: list[MppSplit] + + +class MppMethodDetails(TypedDict, total=False): + """The MPP ``request.methodDetails`` block (network always set).""" + + network: str + splits: list[MppSplit] + feePayer: bool + feePayerKey: str + recentBlockhash: str + + logger = logging.getLogger(__name__) # USDC/USDT/USDG/PYUSD/CASH are all 6-decimal mints; base units = amount * 1e6. @@ -133,7 +174,7 @@ def _append_to_dotenv(path: str, key: str, value: str) -> bool: class MppAdapter: - """Bridges a unified gate to ``solana_mpp.server.mpp.Mpp`` charge flow.""" + """Bridges a unified gate to ``pay_kit.protocols.mpp.server.charge.Mpp`` charge flow.""" def __init__( self, @@ -144,7 +185,7 @@ def __init__( self._config = config self._replay_store: Store = replay_store if replay_store is not None else MemoryStore() self._recent_blockhash_provider = recent_blockhash_provider - # Cache one solana_mpp.Mpp per (payTo|coin) key, like the PHP + # Cache one pay_kit.protocols.mpp.Mpp per (payTo|coin) key, like the PHP # handlerCache, so the HMAC secret and RPC client are reused. self._handler_cache: dict[str, Mpp] = {} self._secret = self._resolve_secret() @@ -209,7 +250,7 @@ async def verify_and_settle(self, gate: Gate, request: Any) -> Payment: mpp = self._server_for(gate) expected = self._charge_request_for(gate) - # The cached ``solana_mpp.Mpp`` is built with ``rpc=None`` (the + # The cached ``pay_kit.protocols.mpp.Mpp`` is built with ``rpc=None`` (the # adapter is constructed at boot, before any event loop exists). # Transaction verification + broadcast need a live RPC, so scope a # request-lifetime ``SolanaRpc`` to this verify via ``using_rpc`` @@ -267,7 +308,7 @@ def _charge_request_for(self, gate: Gate) -> ChargeRequest: blockhash = self._recent_blockhash_provider() if blockhash: method_details["recentBlockhash"] = blockhash - # ChargeRequest.method_details is the untyped solana_mpp wire shape + # ChargeRequest.method_details is the untyped pay_kit.protocols.mpp wire shape # (dict[str, Any] | None); cast the precise TypedDict at the boundary. return ChargeRequest( amount=amount, @@ -285,7 +326,7 @@ def _charge_options(self, gate: Gate) -> ChargeOptions: external_id=gate.external_id or "", ) if gate.has_fees(): - # ChargeOptions.splits is the untyped solana_mpp list[dict]; build the + # ChargeOptions.splits is the untyped pay_kit.protocols.mpp list[dict]; build the # precise MppSplit shape and cast at the boundary. splits: list[MppSplit] = [ MppSplit(recipient=fee.recipient, amount=str(self._price_units(fee.price))) for fee in gate.fees @@ -297,7 +338,7 @@ def _charge_options(self, gate: Gate) -> ChargeOptions: return options def _server_for(self, gate: Gate) -> Mpp: - """Return a cached ``solana_mpp.Mpp`` keyed on (payTo|coin).""" + """Return a cached ``pay_kit.protocols.mpp.Mpp`` keyed on (payTo|coin).""" coin = self._settlement_coin(gate) pay_to = gate.pay_to or self._config.effective_recipient() key = f"{pay_to}|{coin}" diff --git a/python/src/solana_mpp/client/__init__.py b/python/src/pay_kit/protocols/mpp/client/__init__.py similarity index 64% rename from python/src/solana_mpp/client/__init__.py rename to python/src/pay_kit/protocols/mpp/client/__init__.py index 00649dca4..381aaef44 100644 --- a/python/src/solana_mpp/client/__init__.py +++ b/python/src/pay_kit/protocols/mpp/client/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from solana_mpp.client.transport import PaymentTransport +from pay_kit.protocols.mpp.client.transport import PaymentTransport __all__ = [ "PaymentTransport", diff --git a/python/src/solana_mpp/client/charge.py b/python/src/pay_kit/protocols/mpp/client/charge.py similarity index 94% rename from python/src/solana_mpp/client/charge.py rename to python/src/pay_kit/protocols/mpp/client/charge.py index 6a306a57a..9458d10d6 100644 --- a/python/src/solana_mpp/client/charge.py +++ b/python/src/pay_kit/protocols/mpp/client/charge.py @@ -5,16 +5,16 @@ import logging from typing import Any -from solana_mpp._base64url import decode_json -from solana_mpp._headers import format_authorization -from solana_mpp._types import PaymentChallenge, PaymentCredential -from solana_mpp.protocol.intents import ChargeRequest -from solana_mpp.protocol.solana import ( +from pay_kit._paycore.solana import ( MEMO_PROGRAM, CredentialPayload, MethodDetails, is_native_sol, ) +from pay_kit.protocols.mpp.core.base64url import decode_json +from pay_kit.protocols.mpp.core.headers import format_authorization +from pay_kit.protocols.mpp.core.types import PaymentChallenge, PaymentCredential +from pay_kit.protocols.mpp.intents.charge import ChargeRequest logger = logging.getLogger(__name__) diff --git a/python/src/solana_mpp/client/transport.py b/python/src/pay_kit/protocols/mpp/client/transport.py similarity index 94% rename from python/src/solana_mpp/client/transport.py rename to python/src/pay_kit/protocols/mpp/client/transport.py index b87e77929..f0b4e821b 100644 --- a/python/src/solana_mpp/client/transport.py +++ b/python/src/pay_kit/protocols/mpp/client/transport.py @@ -7,8 +7,8 @@ import httpx -from solana_mpp._headers import parse_www_authenticate_all -from solana_mpp.client.charge import build_credential_header +from pay_kit.protocols.mpp.client.charge import build_credential_header +from pay_kit.protocols.mpp.core.headers import parse_www_authenticate_all logger = logging.getLogger(__name__) diff --git a/python/src/pay_kit/protocols/mpp/core/__init__.py b/python/src/pay_kit/protocols/mpp/core/__init__.py new file mode 100644 index 000000000..0b09f2d85 --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/core/__init__.py @@ -0,0 +1,3 @@ +"""MPP core wire primitives (canonical JSON, headers, challenge, types, RPC, store).""" + +from __future__ import annotations diff --git a/python/src/solana_mpp/_base64url.py b/python/src/pay_kit/protocols/mpp/core/base64url.py similarity index 95% rename from python/src/solana_mpp/_base64url.py rename to python/src/pay_kit/protocols/mpp/core/base64url.py index 4c24bc0f5..303df1a51 100644 --- a/python/src/solana_mpp/_base64url.py +++ b/python/src/pay_kit/protocols/mpp/core/base64url.py @@ -30,7 +30,7 @@ def encode_json(obj: Any) -> str: ``json.dumps(sort_keys=True)`` path sorted by Unicode code point, which diverges from Ruby / PHP / Lua / Rust on supplementary-plane keys. """ - from solana_mpp._canonical_json import encode_canonical + from pay_kit.protocols.mpp.core.json import encode_canonical return encode(encode_canonical(obj)) diff --git a/python/src/solana_mpp/_challenge.py b/python/src/pay_kit/protocols/mpp/core/challenge.py similarity index 93% rename from python/src/solana_mpp/_challenge.py rename to python/src/pay_kit/protocols/mpp/core/challenge.py index d7ee7b5ae..aeef72f26 100644 --- a/python/src/solana_mpp/_challenge.py +++ b/python/src/pay_kit/protocols/mpp/core/challenge.py @@ -5,7 +5,7 @@ import hashlib import hmac -from solana_mpp._base64url import encode +from pay_kit.protocols.mpp.core.base64url import encode def compute_challenge_id( diff --git a/python/src/solana_mpp/_errors.py b/python/src/pay_kit/protocols/mpp/core/errors.py similarity index 100% rename from python/src/solana_mpp/_errors.py rename to python/src/pay_kit/protocols/mpp/core/errors.py diff --git a/python/src/solana_mpp/_expires.py b/python/src/pay_kit/protocols/mpp/core/expires.py similarity index 100% rename from python/src/solana_mpp/_expires.py rename to python/src/pay_kit/protocols/mpp/core/expires.py diff --git a/python/src/solana_mpp/_headers.py b/python/src/pay_kit/protocols/mpp/core/headers.py similarity index 98% rename from python/src/solana_mpp/_headers.py rename to python/src/pay_kit/protocols/mpp/core/headers.py index b0709c1c4..2c985c92c 100644 --- a/python/src/solana_mpp/_headers.py +++ b/python/src/pay_kit/protocols/mpp/core/headers.py @@ -10,8 +10,8 @@ from collections.abc import Iterable from typing import Any -from solana_mpp._base64url import decode, decode_json, encode_json -from solana_mpp._types import ChallengeEcho, PaymentChallenge, PaymentCredential, Receipt +from pay_kit.protocols.mpp.core.base64url import decode, decode_json, encode_json +from pay_kit.protocols.mpp.core.types import ChallengeEcho, PaymentChallenge, PaymentCredential, Receipt MAX_TOKEN_LEN = 16 * 1024 diff --git a/python/src/solana_mpp/_canonical_json.py b/python/src/pay_kit/protocols/mpp/core/json.py similarity index 100% rename from python/src/solana_mpp/_canonical_json.py rename to python/src/pay_kit/protocols/mpp/core/json.py diff --git a/python/src/solana_mpp/_rpc.py b/python/src/pay_kit/protocols/mpp/core/rpc.py similarity index 99% rename from python/src/solana_mpp/_rpc.py rename to python/src/pay_kit/protocols/mpp/core/rpc.py index 11a2f34f8..49aa74ccf 100644 --- a/python/src/solana_mpp/_rpc.py +++ b/python/src/pay_kit/protocols/mpp/core/rpc.py @@ -25,7 +25,7 @@ import httpx -from solana_mpp._errors import PaymentError +from pay_kit.protocols.mpp.core.errors import PaymentError class _RpcError(PaymentError): diff --git a/python/src/solana_mpp/store.py b/python/src/pay_kit/protocols/mpp/core/store.py similarity index 100% rename from python/src/solana_mpp/store.py rename to python/src/pay_kit/protocols/mpp/core/store.py diff --git a/python/src/solana_mpp/_types.py b/python/src/pay_kit/protocols/mpp/core/types.py similarity index 97% rename from python/src/solana_mpp/_types.py rename to python/src/pay_kit/protocols/mpp/core/types.py index 062a8b9a6..9c47fdc8b 100644 --- a/python/src/solana_mpp/_types.py +++ b/python/src/pay_kit/protocols/mpp/core/types.py @@ -7,8 +7,8 @@ from datetime import UTC, datetime from typing import Any -from solana_mpp._base64url import decode_json, encode_json -from solana_mpp._challenge import compute_challenge_id, constant_time_equal +from pay_kit.protocols.mpp.core.base64url import decode_json, encode_json +from pay_kit.protocols.mpp.core.challenge import compute_challenge_id, constant_time_equal # RFC 3339 section 5.6 ``date-time`` grammar. The capture groups are # year-month-day-T-hh-mm-ss[.frac][offset]. ``T`` and ``Z`` may appear in diff --git a/python/src/pay_kit/protocols/mpp/intents/__init__.py b/python/src/pay_kit/protocols/mpp/intents/__init__.py new file mode 100644 index 000000000..fbceb9421 --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/intents/__init__.py @@ -0,0 +1,3 @@ +"""MPP intent layer.""" + +from __future__ import annotations diff --git a/python/src/solana_mpp/protocol/intents.py b/python/src/pay_kit/protocols/mpp/intents/charge.py similarity index 100% rename from python/src/solana_mpp/protocol/intents.py rename to python/src/pay_kit/protocols/mpp/intents/charge.py diff --git a/python/src/pay_kit/protocols/mpp/server/__init__.py b/python/src/pay_kit/protocols/mpp/server/__init__.py new file mode 100644 index 000000000..6dd43b442 --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/server/__init__.py @@ -0,0 +1,24 @@ +"""Server-side Solana MPP handler.""" + +from __future__ import annotations + +from pay_kit.protocols.mpp.server.charge import ChargeOptions, Config, Mpp +from pay_kit.protocols.mpp.server.defaults import detect_realm, detect_secret_key +from pay_kit.protocols.mpp.server.payment_page import ( + accepts_html, + challenge_to_html, + is_service_worker_request, + service_worker_js, +) + +__all__ = [ + "ChargeOptions", + "Config", + "Mpp", + "accepts_html", + "challenge_to_html", + "detect_realm", + "detect_secret_key", + "is_service_worker_request", + "service_worker_js", +] diff --git a/python/src/solana_mpp/server/mpp.py b/python/src/pay_kit/protocols/mpp/server/charge.py similarity index 98% rename from python/src/solana_mpp/server/mpp.py rename to python/src/pay_kit/protocols/mpp/server/charge.py index 7aaffb6fa..ad17d702e 100644 --- a/python/src/solana_mpp/server/mpp.py +++ b/python/src/pay_kit/protocols/mpp/server/charge.py @@ -10,16 +10,7 @@ from dataclasses import dataclass, field from typing import Any -from solana_mpp._base64url import encode_json -from solana_mpp._errors import ( - ChallengeExpiredError, - ChallengeMismatchError, - PaymentError, - ReplayError, -) -from solana_mpp._types import PaymentChallenge, PaymentCredential, Receipt -from solana_mpp.protocol.intents import ChargeRequest, parse_units -from solana_mpp.protocol.solana import ( +from pay_kit._paycore.solana import ( ASSOCIATED_TOKEN_PROGRAM, MEMO_PROGRAM, TOKEN_2022_PROGRAM, @@ -32,8 +23,17 @@ resolve_mint, stablecoin_symbol, ) -from solana_mpp.server.network_check import check_network_blockhash -from solana_mpp.store import Store +from pay_kit.protocols.mpp.core.base64url import encode_json +from pay_kit.protocols.mpp.core.errors import ( + ChallengeExpiredError, + ChallengeMismatchError, + PaymentError, + ReplayError, +) +from pay_kit.protocols.mpp.core.store import Store +from pay_kit.protocols.mpp.core.types import PaymentChallenge, PaymentCredential, Receipt +from pay_kit.protocols.mpp.intents.charge import ChargeRequest, parse_units +from pay_kit.protocols.mpp.server.network_check import check_network_blockhash logger = logging.getLogger(__name__) @@ -67,7 +67,7 @@ MAX_SPLITS = 8 # Legacy Solana memo program (v1). MPP charge transactions MUST use memo v2 -# (``MEMO_PROGRAM`` from :mod:`solana_mpp.protocol.solana`). v1 had a different +# (``MEMO_PROGRAM`` from :mod:`pay_kit._paycore.solana`). v1 had a different # instruction shape and is rejected to match the L2 lock landed on PHP fde0efb # and mirrored in Ruby, Rust, Lua. _MEMO_V1_PROGRAM = "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo" @@ -1168,7 +1168,7 @@ class Config: fee_payer_signer: Any = None store: Store | None = None # The RPC client MUST expose at least the methods on - # :class:`solana_mpp._rpc.SolanaRpc`: ``send_raw_transaction``, + # :class:`pay_kit.protocols.mpp.core.rpc.SolanaRpc`: ``send_raw_transaction``, # ``get_signature_statuses``, ``await_confirmation``, # ``get_recent_blockhash`` and ``get_transaction``. The previous # ``# solana.rpc.async_api.AsyncClient`` comment suggested the legacy @@ -1201,7 +1201,7 @@ def __init__(self, config: Config) -> None: self._recipient = config.recipient self._currency = config.currency or "USDC" self._decimals = config.decimals or 6 - from solana_mpp.protocol.solana import _canonical_network as _canonical_net + from pay_kit._paycore.solana import _canonical_network as _canonical_net self._network = _canonical_net(config.network or "mainnet") self._rpc_url = config.rpc_url or default_rpc_url(self._network) @@ -1228,7 +1228,7 @@ def __init__(self, config: Config) -> None: if not callable(getattr(config.rpc, method_name, None)): raise PaymentError( f"rpc client missing required method '{method_name}'; " - "use solana_mpp._rpc.SolanaRpc or a compatible client", + "use pay_kit.protocols.mpp.core.rpc.SolanaRpc or a compatible client", code="invalid-config", ) self._rpc = config.rpc @@ -1310,7 +1310,7 @@ def charge_with_options(self, amount: str, options: ChargeOptions) -> PaymentCha request_b64 = encode_json(request_obj) - from solana_mpp._expires import minutes + from pay_kit.protocols.mpp.core.expires import minutes default_expires = minutes(5) return PaymentChallenge.with_secret_key( diff --git a/python/src/solana_mpp/server/defaults.py b/python/src/pay_kit/protocols/mpp/server/defaults.py similarity index 100% rename from python/src/solana_mpp/server/defaults.py rename to python/src/pay_kit/protocols/mpp/server/defaults.py diff --git a/python/src/solana_mpp/server/html/__init__.py b/python/src/pay_kit/protocols/mpp/server/html/__init__.py similarity index 100% rename from python/src/solana_mpp/server/html/__init__.py rename to python/src/pay_kit/protocols/mpp/server/html/__init__.py diff --git a/python/src/solana_mpp/server/html/service_worker.gen.js b/python/src/pay_kit/protocols/mpp/server/html/service_worker.gen.js similarity index 100% rename from python/src/solana_mpp/server/html/service_worker.gen.js rename to python/src/pay_kit/protocols/mpp/server/html/service_worker.gen.js diff --git a/python/src/solana_mpp/server/html/template.gen.html b/python/src/pay_kit/protocols/mpp/server/html/template.gen.html similarity index 100% rename from python/src/solana_mpp/server/html/template.gen.html rename to python/src/pay_kit/protocols/mpp/server/html/template.gen.html diff --git a/python/src/solana_mpp/server/middleware.py b/python/src/pay_kit/protocols/mpp/server/middleware.py similarity index 89% rename from python/src/solana_mpp/server/middleware.py rename to python/src/pay_kit/protocols/mpp/server/middleware.py index 9384127e8..72ef7c91c 100644 --- a/python/src/solana_mpp/server/middleware.py +++ b/python/src/pay_kit/protocols/mpp/server/middleware.py @@ -6,9 +6,9 @@ from collections.abc import Callable from typing import Any -from solana_mpp._errors import PaymentError, payment_required_response -from solana_mpp._headers import format_www_authenticate, parse_authorization -from solana_mpp.server.mpp import Mpp +from pay_kit.protocols.mpp.core.errors import PaymentError, payment_required_response +from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization +from pay_kit.protocols.mpp.server.charge import Mpp def pay(mpp_handler: Mpp, amount: str, **options: Any) -> Callable: @@ -24,8 +24,8 @@ def pay(mpp_handler: Mpp, amount: str, **options: Any) -> Callable: async def handler(request, credential, receipt): return {"data": "paid content"} """ - from solana_mpp.protocol.intents import ChargeRequest - from solana_mpp.server.mpp import ChargeOptions + from pay_kit.protocols.mpp.intents.charge import ChargeRequest + from pay_kit.protocols.mpp.server.charge import ChargeOptions charge_options = ChargeOptions( description=options.get("description", ""), diff --git a/python/src/solana_mpp/server/network_check.py b/python/src/pay_kit/protocols/mpp/server/network_check.py similarity index 97% rename from python/src/solana_mpp/server/network_check.py rename to python/src/pay_kit/protocols/mpp/server/network_check.py index 4cb6f61ec..91612ce75 100644 --- a/python/src/solana_mpp/server/network_check.py +++ b/python/src/pay_kit/protocols/mpp/server/network_check.py @@ -19,7 +19,7 @@ from __future__ import annotations -from solana_mpp._errors import PaymentError +from pay_kit.protocols.mpp.core.errors import PaymentError #: Base58 prefix embedded in every blockhash returned by the Surfpool #: localnet implementation. diff --git a/python/src/solana_mpp/server/payment_page.py b/python/src/pay_kit/protocols/mpp/server/payment_page.py similarity index 95% rename from python/src/solana_mpp/server/payment_page.py rename to python/src/pay_kit/protocols/mpp/server/payment_page.py index 63cd01753..456df9e8e 100644 --- a/python/src/solana_mpp/server/payment_page.py +++ b/python/src/pay_kit/protocols/mpp/server/payment_page.py @@ -14,8 +14,8 @@ from typing import Any from urllib.parse import parse_qs, urlparse -from solana_mpp._base64url import decode_json -from solana_mpp._types import PaymentChallenge +from pay_kit.protocols.mpp.core.base64url import decode_json +from pay_kit.protocols.mpp.core.types import PaymentChallenge SERVICE_WORKER_PARAM = "__mpp_worker" @@ -41,7 +41,7 @@ def _load_resource(filename: str) -> str: - return importlib.resources.files("solana_mpp.server.html").joinpath(filename).read_text("utf-8") + return importlib.resources.files("pay_kit.protocols.mpp.server.html").joinpath(filename).read_text("utf-8") def challenge_to_html(challenge: PaymentChallenge, rpc_url: str, network: str) -> str: diff --git a/python/src/pay_kit/protocols/x402/__init__.py b/python/src/pay_kit/protocols/x402/__init__.py new file mode 100644 index 000000000..25668d5b5 --- /dev/null +++ b/python/src/pay_kit/protocols/x402/__init__.py @@ -0,0 +1,13 @@ +"""x402 ``exact`` (Solana) protocol package. + +Public surface mirrors the former flat ``pay_kit.protocols.x402`` module: +the ``X402Adapter`` server adapter plus the ``ExactVerifier`` structural +verifier and the ``X402_VERSION`` constant. The verifier, adapter, and the +``X402*`` wire TypedDicts live in :mod:`pay_kit.protocols.x402.verify`. +""" + +from __future__ import annotations + +from pay_kit.protocols.x402.verify import X402_VERSION, ExactVerifier, X402Adapter + +__all__ = ["X402Adapter", "ExactVerifier", "X402_VERSION"] diff --git a/python/src/pay_kit/protocols/x402.py b/python/src/pay_kit/protocols/x402/verify.py similarity index 91% rename from python/src/pay_kit/protocols/x402.py rename to python/src/pay_kit/protocols/x402/verify.py index 4abe70728..abc5a7949 100644 --- a/python/src/pay_kit/protocols/x402.py +++ b/python/src/pay_kit/protocols/x402/verify.py @@ -20,23 +20,16 @@ import json import struct from collections.abc import Callable -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, TypedDict, cast from pay_kit._paycore.mints import derive_ata, resolve, token_program_for from pay_kit._paycore.protocol import Protocol -from pay_kit._wire import ( - X402AcceptsEntry, - X402Challenge, - X402Extra, - X402PayloadField, - X402ResponseEnvelope, -) +from pay_kit._paycore.solana import ASSOCIATED_TOKEN_PROGRAM from pay_kit.errors import InvalidProofError from pay_kit.payment import Payment -from solana_mpp._rpc import SolanaRpc -from solana_mpp.protocol.solana import ASSOCIATED_TOKEN_PROGRAM -from solana_mpp.server.network_check import check_network_blockhash -from solana_mpp.store import MemoryStore, Store +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 if TYPE_CHECKING: from pay_kit.config import Config @@ -44,6 +37,87 @@ __all__ = ["X402Adapter", "ExactVerifier", "X402_VERSION"] + +# --- x402 wire shapes ------------------------------------------------------- +# TypedDicts describing the exact JSON dicts the adapter builds for challenges/ +# offers and parses from inbound credentials. They give the adapter precise +# static types over the wire payloads and never change the serialized bytes. +# Optional keys use ``total=False``. Inbound payloads are validated field-by- +# field at runtime and then narrowed to these shapes with ``cast``. + + +class X402ExtraRequired(TypedDict): + """The always-present keys of an x402 ``accepts[].extra`` block.""" + + feePayer: str + decimals: int + tokenProgram: str + memo: str + + +class X402Extra(X402ExtraRequired, total=False): + """An x402 ``accepts[].extra`` block; ``recentBlockhash`` is optional.""" + + recentBlockhash: str + + +class X402Resource(TypedDict): + """The ``resource`` block inside an x402 challenge.""" + + type: str + url: str + + +class X402AcceptsEntry(TypedDict): + """One x402 ``accepts[]`` offer entry (the server requirement).""" + + protocol: str + scheme: str + network: str + asset: str + amount: str + maxAmountRequired: str + payTo: str + maxTimeoutSeconds: int + extra: X402Extra + + +class X402Challenge(TypedDict): + """The base64-encoded ``payment-required`` challenge body.""" + + x402Version: int + resource: X402Resource + accepts: list[X402AcceptsEntry] + + +class X402PayloadField(TypedDict, total=False): + """The ``payload`` block of an inbound X-PAYMENT envelope.""" + + transaction: str + transactionHash: str + + +class X402Envelope(TypedDict, total=False): + """An inbound X-PAYMENT envelope (decoded from the proof header). + + All keys optional because the structure is attacker-controlled and validated + field-by-field at runtime before any value is trusted. + """ + + x402Version: int + accepted: X402AcceptsEntry + payload: X402PayloadField + + +class X402ResponseEnvelope(TypedDict): + """The base64-encoded ``payment-response`` settlement receipt.""" + + success: bool + transaction: str + network: str + payer: str + + #: x402 protocol version emitted in challenges and required on credentials. X402_VERSION = 2 @@ -589,7 +663,7 @@ def _caip2(self) -> str: def _co_sign(transaction_b64: str, signer: Any) -> bytes: """Splice the facilitator signature into the fee-payer slot, return wire. - Mirrors ``solana_mpp.server.mpp._co_sign_with_fee_payer``: legacy messages + Mirrors ``pay_kit.protocols.mpp.server.charge._co_sign_with_fee_payer``: legacy messages are signed over ``bytes(msg)``, v0 over ``to_bytes_versioned(msg)`` (0x80 prefix). The fee payer must occupy a signature slot. """ @@ -597,10 +671,10 @@ def _co_sign(transaction_b64: str, signer: Any) -> bytes: from solders.pubkey import Pubkey from solders.transaction import Transaction, VersionedTransaction - # Intentional reuse of solana_mpp's v0-wire detector (see docstring above) + # Intentional reuse of pay_kit.protocols.mpp's v0-wire detector (see docstring above) # rather than re-implementing parallel detection logic; private by package # convention but a deliberate cross-module dependency. - from solana_mpp.server.mpp import _is_v0_wire_bytes # pyright: ignore[reportPrivateUsage] + from pay_kit.protocols.mpp.server.charge import _is_v0_wire_bytes # pyright: ignore[reportPrivateUsage] raw = base64.b64decode(transaction_b64) fee_payer_pubkey = Pubkey.from_string(signer.pubkey()) @@ -611,7 +685,7 @@ def _co_sign(transaction_b64: str, signer: Any) -> bytes: # account keys. The rust x402 client (and the canonical PaymentProof # builder) emit v0 messages, so we must route on the message-version # prefix byte rather than trusting a legacy parse to fail. Mirrors - # ``solana_mpp.server.mpp._co_sign_with_fee_payer`` and reuses its + # ``pay_kit.protocols.mpp.server.charge._co_sign_with_fee_payer`` and reuses its # ``_is_v0_wire_bytes`` guard (no parallel detection logic). if _is_v0_wire_bytes(raw): try: diff --git a/python/src/solana_mpp/__init__.py b/python/src/solana_mpp/__init__.py deleted file mode 100644 index 7a283be9d..000000000 --- a/python/src/solana_mpp/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Solana payment method for the Machine Payments Protocol.""" - -from __future__ import annotations - -from solana_mpp._errors import ( - ChallengeExpiredError, - ChallengeMismatchError, - PaymentError, - ReplayError, - VerificationError, -) -from solana_mpp._expires import days, hours, minutes, seconds, weeks -from solana_mpp._rpc import SolanaRpc -from solana_mpp._types import ChallengeEcho, PaymentChallenge, PaymentCredential, Receipt -from solana_mpp.store import MemoryStore, Store - -__all__ = [ - "ChallengeEcho", - "ChallengeExpiredError", - "ChallengeMismatchError", - "MemoryStore", - "PaymentChallenge", - "PaymentCredential", - "PaymentError", - "Receipt", - "ReplayError", - "SolanaRpc", - "Store", - "VerificationError", - "days", - "hours", - "minutes", - "seconds", - "weeks", -] diff --git a/python/src/solana_mpp/protocol/__init__.py b/python/src/solana_mpp/protocol/__init__.py deleted file mode 100644 index 56b7ac8f7..000000000 --- a/python/src/solana_mpp/protocol/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Protocol layer for Solana MPP.""" - -from __future__ import annotations diff --git a/python/src/solana_mpp/server/__init__.py b/python/src/solana_mpp/server/__init__.py deleted file mode 100644 index a52ad7ae3..000000000 --- a/python/src/solana_mpp/server/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Server-side Solana MPP handler.""" - -from __future__ import annotations - -from solana_mpp.server.defaults import detect_realm, detect_secret_key -from solana_mpp.server.mpp import ChargeOptions, Config, Mpp -from solana_mpp.server.payment_page import accepts_html, challenge_to_html, is_service_worker_request, service_worker_js - -__all__ = [ - "ChargeOptions", - "Config", - "Mpp", - "accepts_html", - "challenge_to_html", - "detect_realm", - "detect_secret_key", - "is_service_worker_request", - "service_worker_js", -] diff --git a/python/tests/conftest.py b/python/tests/conftest.py index 4e3acf002..322722a92 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -4,10 +4,10 @@ import pytest -from solana_mpp._base64url import encode_json -from solana_mpp._types import PaymentChallenge -from solana_mpp.server.mpp import Config, Mpp -from solana_mpp.store import MemoryStore +from pay_kit.protocols.mpp.core.base64url import encode_json +from pay_kit.protocols.mpp.core.store import MemoryStore +from pay_kit.protocols.mpp.core.types import PaymentChallenge +from pay_kit.protocols.mpp.server.charge import Config, Mpp TEST_SECRET_KEY = "test-secret-key-that-is-long-enough-for-hmac-sha256" diff --git a/python/tests/test_base64url.py b/python/tests/test_base64url.py index 32b6ec899..774978d78 100644 --- a/python/tests/test_base64url.py +++ b/python/tests/test_base64url.py @@ -2,7 +2,7 @@ from __future__ import annotations -from solana_mpp._base64url import decode, decode_json, encode, encode_json +from pay_kit.protocols.mpp.core.base64url import decode, decode_json, encode, encode_json def test_encode_decode_roundtrip(): diff --git a/python/tests/test_canonical_json.py b/python/tests/test_canonical_json.py index 3a62d983a..ee64c1a9e 100644 --- a/python/tests/test_canonical_json.py +++ b/python/tests/test_canonical_json.py @@ -9,7 +9,7 @@ import pytest -from solana_mpp._canonical_json import encode_canonical +from pay_kit.protocols.mpp.core.json import encode_canonical class TestKeySort: diff --git a/python/tests/test_challenge.py b/python/tests/test_challenge.py index 6df1383ee..d744dec02 100644 --- a/python/tests/test_challenge.py +++ b/python/tests/test_challenge.py @@ -2,7 +2,7 @@ from __future__ import annotations -from solana_mpp._challenge import compute_challenge_id, constant_time_equal +from pay_kit.protocols.mpp.core.challenge import compute_challenge_id, constant_time_equal class TestComputeChallengeId: diff --git a/python/tests/test_client_charge.py b/python/tests/test_client_charge.py index c2aa54d9e..98af03dab 100644 --- a/python/tests/test_client_charge.py +++ b/python/tests/test_client_charge.py @@ -8,8 +8,8 @@ from solders.keypair import Keypair from solders.transaction import Transaction -from solana_mpp.client.charge import build_charge_transaction -from solana_mpp.protocol.solana import MEMO_PROGRAM, MethodDetails, Split +from pay_kit._paycore.solana import MEMO_PROGRAM, MethodDetails, Split +from pay_kit.protocols.mpp.client.charge import build_charge_transaction BLOCKHASH = "11111111111111111111111111111111" diff --git a/python/tests/test_client_charge_edge.py b/python/tests/test_client_charge_edge.py index 857656dcc..094320415 100644 --- a/python/tests/test_client_charge_edge.py +++ b/python/tests/test_client_charge_edge.py @@ -1,4 +1,4 @@ -"""Edge-case coverage for solana_mpp.client.charge.""" +"""Edge-case coverage for pay_kit.protocols.mpp.client.charge.""" from __future__ import annotations @@ -9,14 +9,14 @@ from solders.hash import Hash from solders.keypair import Keypair -from solana_mpp._base64url import encode_json -from solana_mpp._headers import parse_authorization -from solana_mpp._types import PaymentChallenge -from solana_mpp.client.charge import ( +from pay_kit._paycore.solana import MethodDetails, Split +from pay_kit.protocols.mpp.client.charge import ( build_charge_transaction, build_credential_header, ) -from solana_mpp.protocol.solana import MethodDetails, Split +from pay_kit.protocols.mpp.core.base64url import encode_json +from pay_kit.protocols.mpp.core.headers import parse_authorization +from pay_kit.protocols.mpp.core.types import PaymentChallenge BLOCKHASH = "11111111111111111111111111111111" diff --git a/python/tests/test_client_transport.py b/python/tests/test_client_transport.py index 6db886c2d..d1caf141e 100644 --- a/python/tests/test_client_transport.py +++ b/python/tests/test_client_transport.py @@ -6,10 +6,10 @@ import httpx -from solana_mpp._base64url import encode_json -from solana_mpp._headers import format_www_authenticate -from solana_mpp._types import PaymentChallenge -from solana_mpp.client.transport import PaymentTransport +from pay_kit.protocols.mpp.client.transport import PaymentTransport +from pay_kit.protocols.mpp.core.base64url import encode_json +from pay_kit.protocols.mpp.core.headers import format_www_authenticate +from pay_kit.protocols.mpp.core.types import PaymentChallenge class MockTransport(httpx.AsyncBaseTransport): @@ -120,7 +120,7 @@ async def fake_build_credential_header(**kwargs): return "Payment credential" monkeypatch.setattr( - "solana_mpp.client.transport.build_credential_header", + "pay_kit.protocols.mpp.client.transport.build_credential_header", fake_build_credential_header, ) diff --git a/python/tests/test_cross_route_replay.py b/python/tests/test_cross_route_replay.py index 103b10d7e..16c89dba4 100644 --- a/python/tests/test_cross_route_replay.py +++ b/python/tests/test_cross_route_replay.py @@ -9,13 +9,13 @@ import pytest -from solana_mpp._base64url import encode_json -from solana_mpp._challenge import compute_challenge_id -from solana_mpp._errors import PaymentError -from solana_mpp._types import ChallengeEcho, PaymentCredential -from solana_mpp.protocol.intents import ChargeRequest -from solana_mpp.server.mpp import Config, Mpp -from solana_mpp.store import MemoryStore +from pay_kit.protocols.mpp.core.base64url import encode_json +from pay_kit.protocols.mpp.core.challenge import compute_challenge_id +from pay_kit.protocols.mpp.core.errors import PaymentError +from pay_kit.protocols.mpp.core.store import MemoryStore +from pay_kit.protocols.mpp.core.types import ChallengeEcho, PaymentCredential +from pay_kit.protocols.mpp.intents.charge import ChargeRequest +from pay_kit.protocols.mpp.server.charge import Config, Mpp TEST_SECRET = "cross-route-replay-test-secret-key" TEST_RECIPIENT = "11111111111111111111111111111112" diff --git a/python/tests/test_errors.py b/python/tests/test_errors.py index 53122d78f..925bfad83 100644 --- a/python/tests/test_errors.py +++ b/python/tests/test_errors.py @@ -2,7 +2,7 @@ from __future__ import annotations -from solana_mpp._errors import ( +from pay_kit.protocols.mpp.core.errors import ( ChallengeExpiredError, ChallengeMismatchError, PaymentError, @@ -61,7 +61,7 @@ class TestCanonicalCodes: """L6 / P1 lock: every 402 path emits one of the canonical codes.""" def test_canonical_codes_set(self): - from solana_mpp._errors import CANONICAL_CODES + from pay_kit.protocols.mpp.core.errors import CANONICAL_CODES assert ( frozenset( @@ -79,13 +79,13 @@ def test_canonical_codes_set(self): ) def test_canonical_code_returns_canonical_unchanged(self): - from solana_mpp._errors import canonical_code + from pay_kit.protocols.mpp.core.errors import canonical_code assert canonical_code("payment_invalid") == "payment_invalid" assert canonical_code("wrong_network") == "wrong_network" def test_canonical_code_maps_legacy_kebab(self): - from solana_mpp._errors import canonical_code + from pay_kit.protocols.mpp.core.errors import canonical_code assert canonical_code("challenge-expired") == "challenge_expired" assert canonical_code("signature-consumed") == "signature_consumed" @@ -100,7 +100,7 @@ def test_route_mismatch_distinguished_from_hmac_failure(self): # route/realm/method/intent/currency MUST surface as # ``challenge_route_mismatch``, not as ``challenge_verification_failed``. # Codex P2 fix. - from solana_mpp._errors import canonical_code + from pay_kit.protocols.mpp.core.errors import canonical_code assert canonical_code("challenge-mismatch") == "challenge_verification_failed" assert canonical_code("currency-mismatch") == "challenge_route_mismatch" @@ -109,7 +109,7 @@ def test_route_mismatch_distinguished_from_hmac_failure(self): assert canonical_code("realm-mismatch") == "challenge_route_mismatch" def test_canonical_code_falls_back_to_payment_invalid(self): - from solana_mpp._errors import canonical_code + from pay_kit.protocols.mpp.core.errors import canonical_code assert canonical_code("unknown-thing") == "payment_invalid" assert canonical_code("") == "payment_invalid" @@ -117,7 +117,7 @@ def test_canonical_code_falls_back_to_payment_invalid(self): class TestPaymentRequiredResponseBuilder: def test_emits_canonical_code(self): - from solana_mpp._errors import payment_required_response + from pay_kit.protocols.mpp.core.errors import payment_required_response resp = payment_required_response("nope", code="challenge-expired") assert resp["status_code"] == 402 @@ -129,19 +129,19 @@ def test_emits_canonical_code(self): assert resp["headers"]["content-type"] == "application/problem+json" def test_includes_challenge_header_when_provided(self): - from solana_mpp._errors import payment_required_response + from pay_kit.protocols.mpp.core.errors import payment_required_response resp = payment_required_response("challenge", code="payment_invalid", challenge_header='Payment id="x"') assert resp["headers"]["www-authenticate"] == 'Payment id="x"' def test_omits_challenge_header_by_default(self): - from solana_mpp._errors import payment_required_response + from pay_kit.protocols.mpp.core.errors import payment_required_response resp = payment_required_response("x", code="payment_invalid") assert "www-authenticate" not in resp["headers"] def test_unknown_code_falls_back_to_payment_invalid(self): - from solana_mpp._errors import payment_required_response + from pay_kit.protocols.mpp.core.errors import payment_required_response resp = payment_required_response("x", code="foo-bar-baz") assert resp["body"]["code"] == "payment_invalid" diff --git a/python/tests/test_expires.py b/python/tests/test_expires.py index 231a049cb..3f48ba85a 100644 --- a/python/tests/test_expires.py +++ b/python/tests/test_expires.py @@ -4,7 +4,7 @@ from datetime import UTC, datetime -from solana_mpp._expires import days, hours, minutes, seconds, weeks +from pay_kit.protocols.mpp.core.expires import days, hours, minutes, seconds, weeks def _parse_timestamp(ts: str) -> datetime: @@ -75,7 +75,7 @@ class TestStrictRFC3339: """ def _make_challenge(self, expires: str): - from solana_mpp._types import PaymentChallenge + from pay_kit.protocols.mpp.core.types import PaymentChallenge return PaymentChallenge( id="x", diff --git a/python/tests/test_headers.py b/python/tests/test_headers.py index 82b3c2ec7..65f039a1f 100644 --- a/python/tests/test_headers.py +++ b/python/tests/test_headers.py @@ -4,8 +4,8 @@ import pytest -from solana_mpp._base64url import encode, encode_json -from solana_mpp._headers import ( +from pay_kit.protocols.mpp.core.base64url import encode, encode_json +from pay_kit.protocols.mpp.core.headers import ( ParseError, format_authorization, format_receipt, @@ -15,7 +15,7 @@ parse_www_authenticate, parse_www_authenticate_all, ) -from solana_mpp._types import ChallengeEcho, PaymentChallenge, PaymentCredential, Receipt +from pay_kit.protocols.mpp.core.types import ChallengeEcho, PaymentChallenge, PaymentCredential, Receipt class TestWWWAuthenticate: diff --git a/python/tests/test_headers_edge.py b/python/tests/test_headers_edge.py index 85de9456d..dd72cec28 100644 --- a/python/tests/test_headers_edge.py +++ b/python/tests/test_headers_edge.py @@ -1,18 +1,18 @@ -"""Edge-case parse error coverage for solana_mpp._headers.""" +"""Edge-case parse error coverage for pay_kit.protocols.mpp.core.headers.""" from __future__ import annotations import pytest -from solana_mpp._base64url import encode_json -from solana_mpp._headers import ( +from pay_kit.protocols.mpp.core.base64url import encode_json +from pay_kit.protocols.mpp.core.headers import ( ParseError, format_authorization, format_receipt, parse_authorization, parse_receipt, ) -from solana_mpp._types import ( +from pay_kit.protocols.mpp.core.types import ( ChallengeEcho, PaymentCredential, Receipt, diff --git a/python/tests/test_intents.py b/python/tests/test_intents.py index d2ba25dfc..32eb1cbdd 100644 --- a/python/tests/test_intents.py +++ b/python/tests/test_intents.py @@ -4,7 +4,7 @@ import pytest -from solana_mpp.protocol.intents import ChargeRequest, parse_units, validate_max_amount +from pay_kit.protocols.mpp.intents.charge import ChargeRequest, parse_units, validate_max_amount class TestParseUnits: diff --git a/python/tests/test_interop_adapter.py b/python/tests/test_interop_adapter.py index d361852b7..155834227 100644 --- a/python/tests/test_interop_adapter.py +++ b/python/tests/test_interop_adapter.py @@ -1,5 +1,5 @@ """Regression tests for the Python interop adapter at -``harness/python-server/main.py``. +``harness/python-server/server.py``. Spawns the adapter as a subprocess, reads the ``ready`` handshake JSON from stdout, hits the protected resource without credentials, and @@ -23,7 +23,7 @@ import pytest _REPO_ROOT = Path(__file__).resolve().parents[2] -_ADAPTER = _REPO_ROOT / "harness" / "python-server" / "main.py" +_ADAPTER = _REPO_ROOT / "harness" / "python-server" / "server.py" def _wait_for_port(port: int, timeout: float = 5.0) -> None: diff --git a/python/tests/test_middleware.py b/python/tests/test_middleware.py index d1e7d8815..5a9318511 100644 --- a/python/tests/test_middleware.py +++ b/python/tests/test_middleware.py @@ -4,11 +4,11 @@ import pytest -from solana_mpp._headers import format_authorization -from solana_mpp._types import PaymentCredential -from solana_mpp.server.middleware import pay -from solana_mpp.server.mpp import Config, Mpp -from solana_mpp.store import MemoryStore +from pay_kit.protocols.mpp.core.headers import format_authorization +from pay_kit.protocols.mpp.core.store import MemoryStore +from pay_kit.protocols.mpp.core.types import PaymentCredential +from pay_kit.protocols.mpp.server.charge import Config, Mpp +from pay_kit.protocols.mpp.server.middleware import pay from tests.test_server import ( TEST_RECIPIENT, TEST_SECRET, diff --git a/python/tests/test_mpp_helpers.py b/python/tests/test_mpp_helpers.py index b15569e6f..2d05692dd 100644 --- a/python/tests/test_mpp_helpers.py +++ b/python/tests/test_mpp_helpers.py @@ -1,4 +1,4 @@ -"""Unit coverage for the private helpers in :mod:`solana_mpp.server.mpp`. +"""Unit coverage for the private helpers in :mod:`pay_kit.protocols.mpp.server.charge`. These tests exercise the small pure helpers (no RPC, no I/O) so the ``server/mpp.py`` line coverage clears the 90 percent gate. Each test @@ -18,14 +18,14 @@ from solders.system_program import TransferParams, transfer from solders.transaction import Transaction -from solana_mpp._errors import PaymentError -from solana_mpp.protocol.solana import ( +from pay_kit._paycore.solana import ( TOKEN_2022_PROGRAM, TOKEN_PROGRAM, MethodDetails, Split, ) -from solana_mpp.server import mpp as M +from pay_kit.protocols.mpp.core.errors import PaymentError +from pay_kit.protocols.mpp.server import charge as M # --------------------------------------------------------------------------- # _rpc_value / _json_like / _transaction_dict / _status_ok diff --git a/python/tests/test_network_check.py b/python/tests/test_network_check.py index 2b357322b..dc31198d2 100644 --- a/python/tests/test_network_check.py +++ b/python/tests/test_network_check.py @@ -9,7 +9,7 @@ import pytest -from solana_mpp.server.network_check import ( +from pay_kit.protocols.mpp.server.network_check import ( SURFPOOL_BLOCKHASH_PREFIX, WrongNetworkError, check_network_blockhash, diff --git a/python/tests/test_pk_mpp_adapter.py b/python/tests/test_pk_mpp_adapter.py index 9267ef199..0840b9f46 100644 --- a/python/tests/test_pk_mpp_adapter.py +++ b/python/tests/test_pk_mpp_adapter.py @@ -2,7 +2,7 @@ fee splits, and the caveat #4 HMAC secret auto-resolution chain. No live RPC: all verify paths assert on the binding/Tier-2 layer, which rejects -before settlement. The cross-route test reuses ``solana_mpp``'s real challenge +before settlement. The cross-route test reuses ``pay_kit.protocols.mpp``'s real challenge HMAC so the pin actually fires. """ @@ -14,8 +14,8 @@ from pay_kit.config import reset from pay_kit.errors import InvalidProofError from pay_kit.protocols.mpp import MppAdapter, SecretResolver -from solana_mpp._headers import format_authorization -from solana_mpp._types import ChallengeEcho, PaymentCredential +from pay_kit.protocols.mpp.core.headers import format_authorization +from pay_kit.protocols.mpp.core.types import ChallengeEcho, PaymentCredential SECRET = "challenge-binding-secret-long-enough-for-hmac" FEE_A = "9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ" diff --git a/python/tests/test_pk_x402_settle.py b/python/tests/test_pk_x402_settle.py index 48ae1c499..f2ed78d43 100644 --- a/python/tests/test_pk_x402_settle.py +++ b/python/tests/test_pk_x402_settle.py @@ -21,7 +21,7 @@ from solders.pubkey import Pubkey from solders.transaction import VersionedTransaction -import pay_kit.protocols.x402 as xmod +import pay_kit.protocols.x402.verify as xmod from pay_kit import Gate as GateCls from pay_kit import ( LocalSigner, @@ -35,7 +35,7 @@ from pay_kit._paycore.mints import derive_ata, resolve, token_program_for from pay_kit.config import reset from pay_kit.errors import InvalidProofError -from pay_kit.protocols.x402 import ( +from pay_kit.protocols.x402.verify import ( COMPUTE_BUDGET_PROGRAM, MEMO_PROGRAM, X402_VERSION, @@ -53,7 +53,7 @@ class _FakeRpc: - """Stub matching solana_mpp.SolanaRpc's async send/close surface.""" + """Stub matching pay_kit.protocols.mpp.SolanaRpc's async send/close surface.""" def __init__(self, *_a, signature: str = "SIG-broadcast", fail: bool = False, **_k): self._signature = signature diff --git a/python/tests/test_pk_x402_verifier.py b/python/tests/test_pk_x402_verifier.py index 8598bff6c..8f43d3e7a 100644 --- a/python/tests/test_pk_x402_verifier.py +++ b/python/tests/test_pk_x402_verifier.py @@ -22,16 +22,16 @@ from pay_kit import Gate, Price, Protocol, Stablecoin, configure from pay_kit._paycore.mints import derive_ata, resolve, token_program_for +from pay_kit._paycore.solana import ASSOCIATED_TOKEN_PROGRAM from pay_kit.config import reset from pay_kit.errors import InvalidProofError -from pay_kit.protocols.x402 import ( +from pay_kit.protocols.x402.verify import ( COMPUTE_BUDGET_PROGRAM, MEMO_PROGRAM, TOKEN_2022_PROGRAM, ExactVerifier, X402Adapter, ) -from solana_mpp.protocol.solana import ASSOCIATED_TOKEN_PROGRAM BH = "4vJ9JU1bJJQpUgJ8V6hYz7xXKz4F2tN6aBrZEcD3xKhs" _MINT = resolve("USDC", "mainnet") diff --git a/python/tests/test_rpc_contract.py b/python/tests/test_rpc_contract.py index c8c6a1191..2de48bee6 100644 --- a/python/tests/test_rpc_contract.py +++ b/python/tests/test_rpc_contract.py @@ -1,8 +1,8 @@ import pytest -from solana_mpp._errors import PaymentError -from solana_mpp.server.mpp import Config, Mpp -from solana_mpp.store import MemoryStore +from pay_kit.protocols.mpp.core.errors import PaymentError +from pay_kit.protocols.mpp.core.store import MemoryStore +from pay_kit.protocols.mpp.server.charge import Config, Mpp class _LegacyClientLackingAwaitConfirmation: diff --git a/python/tests/test_rpc_methods.py b/python/tests/test_rpc_methods.py index 4fe5e0ed9..a9f2960bb 100644 --- a/python/tests/test_rpc_methods.py +++ b/python/tests/test_rpc_methods.py @@ -1,6 +1,6 @@ """Exhaustive coverage for SolanaRpc methods. -Hits every branch in :mod:`solana_mpp._rpc` so the JSON-RPC wrapper meets +Hits every branch in :mod:`pay_kit.protocols.mpp.core.rpc` so the JSON-RPC wrapper meets the 90 percent line coverage gate: the error branch in ``_call``, both ``get_signature_statuses`` return shapes, ``get_transaction``, ``confirm_transaction`` legacy shim (success and timeout), and @@ -11,8 +11,8 @@ import pytest -from solana_mpp._errors import PaymentError -from solana_mpp._rpc import SolanaRpc, _RpcError, _RpcResponse +from pay_kit.protocols.mpp.core.errors import PaymentError +from pay_kit.protocols.mpp.core.rpc import SolanaRpc, _RpcError, _RpcResponse class _FakeResponse: @@ -120,7 +120,7 @@ async def test_confirm_transaction_timeout(): # Always returns "processed" status so confirm_transaction loops 40x and returns timeout. rpc._client = _ScriptedClient([{"result": {"value": [{"confirmationStatus": "processed"}]}, "id": 1}]) # type: ignore[assignment] # Speed up: monkeypatch asyncio.sleep on the module - import solana_mpp._rpc as rpc_mod + import pay_kit.protocols.mpp.core.rpc as rpc_mod async def _noop_sleep(_s): return None diff --git a/python/tests/test_rpc_send_validation.py b/python/tests/test_rpc_send_validation.py index 9cfc76d64..060983e89 100644 --- a/python/tests/test_rpc_send_validation.py +++ b/python/tests/test_rpc_send_validation.py @@ -8,7 +8,7 @@ import pytest -from solana_mpp._rpc import SolanaRpc, _RpcError +from pay_kit.protocols.mpp.core.rpc import SolanaRpc, _RpcError class _FakeResponse: diff --git a/python/tests/test_server.py b/python/tests/test_server.py index 3c6bec1fa..676e65efe 100644 --- a/python/tests/test_server.py +++ b/python/tests/test_server.py @@ -11,11 +11,12 @@ from solders.system_program import TransferParams, transfer from solders.transaction import Transaction -from solana_mpp._errors import ChallengeExpiredError, ChallengeMismatchError, PaymentError, ReplayError -from solana_mpp._types import ChallengeEcho, PaymentCredential -from solana_mpp.protocol.intents import ChargeRequest -from solana_mpp.protocol.solana import MEMO_PROGRAM, TOKEN_2022_PROGRAM, MethodDetails, Split -from solana_mpp.server.mpp import ( +from pay_kit._paycore.solana import MEMO_PROGRAM, TOKEN_2022_PROGRAM, MethodDetails, Split +from pay_kit.protocols.mpp.core.errors import ChallengeExpiredError, ChallengeMismatchError, PaymentError, ReplayError +from pay_kit.protocols.mpp.core.store import MemoryStore +from pay_kit.protocols.mpp.core.types import ChallengeEcho, PaymentCredential +from pay_kit.protocols.mpp.intents.charge import ChargeRequest +from pay_kit.protocols.mpp.server.charge import ( ChargeOptions, Config, Mpp, @@ -23,7 +24,6 @@ _verify_parsed_sol_transfers, _verify_parsed_spl_transfers, ) -from solana_mpp.store import MemoryStore TEST_SECRET = "test-secret-key-that-is-long-enough-for-hmac-sha256" TEST_RECIPIENT = "11111111111111111111111111111112" @@ -135,7 +135,7 @@ async def await_confirmation(self, *_args, **_kwargs): status = (self.statuses or [{}])[0] err = status.get("err") if isinstance(status, dict) else None if err is not None: - from solana_mpp._errors import PaymentError + from pay_kit.protocols.mpp.core.errors import PaymentError raise PaymentError( f"transaction failed on-chain: {err}", @@ -863,7 +863,7 @@ def _build_tx_with_memo_v1(self) -> str: return base64.b64encode(bytes(transaction)).decode("ascii") def test_decode_rejects_memo_v1(self): - from solana_mpp.server.mpp import _decode_legacy_payment_instructions + from pay_kit.protocols.mpp.server.charge import _decode_legacy_payment_instructions tx_b64 = self._build_tx_with_memo_v1() with pytest.raises(PaymentError, match="memo v1"): @@ -937,7 +937,7 @@ async def await_confirmation(self, *_args, **_kwargs): status = (self._confirm_value or [{}])[0] err = status.get("err") if isinstance(status, dict) else None if err is not None: - from solana_mpp._errors import PaymentError + from pay_kit.protocols.mpp.core.errors import PaymentError raise PaymentError( f"transaction failed on-chain: {err}", @@ -984,7 +984,7 @@ async def test_broadcast_before_consume(self): ordering: list[str] = [] rpc = self._OrderingRPC(ordering, [{"err": None}]) store = self._RecordingStore(ordering) - from solana_mpp.store import Store # noqa: F401 ensure protocol import + from pay_kit.protocols.mpp.core.store import Store # noqa: F401 ensure protocol import handler = Mpp( Config( @@ -1077,7 +1077,7 @@ async def await_confirmation(self, *_a, **_kw): ordering.append("await_confirmation") rpc = _NoRPC() - from solana_mpp.store import MemoryStore + from pay_kit.protocols.mpp.core.store import MemoryStore handler = Mpp( Config( @@ -1156,7 +1156,7 @@ def test_fee_payer_in_readonly_unsigned_block_is_rejected(self): from solders.system_program import TransferParams, transfer from solders.transaction import Transaction - from solana_mpp.server.mpp import _co_sign_with_fee_payer + from pay_kit.protocols.mpp.server.charge import _co_sign_with_fee_payer # Build a transaction whose only signer is ``real_signer``. Then # reference ``rogue_fee_payer.pubkey()`` in a readonly-unsigned @@ -1208,7 +1208,7 @@ def test_fee_payer_at_required_slot_co_signs(self): from solders.system_program import TransferParams, transfer from solders.transaction import Transaction - from solana_mpp.server.mpp import _co_sign_with_fee_payer + from pay_kit.protocols.mpp.server.charge import _co_sign_with_fee_payer fee_payer = Keypair() recipient = Pubkey.from_string(TEST_RECIPIENT) @@ -1252,7 +1252,7 @@ def test_fee_payer_at_non_zero_signer_slot_is_rejected(self): from solders.system_program import TransferParams, transfer from solders.transaction import Transaction - from solana_mpp.server.mpp import _co_sign_with_fee_payer + from pay_kit.protocols.mpp.server.charge import _co_sign_with_fee_payer # Put a different real signer at slot 0 (the actual fee payer), # and reference the server's would-be fee-payer pubkey as the @@ -1317,7 +1317,7 @@ def _build_tx_with_compute_budget_data(data: bytes) -> str: return base64.b64encode(bytes(transaction)).decode("ascii") def test_set_compute_unit_limit_at_cap_is_accepted(self): - from solana_mpp.server.mpp import MAX_COMPUTE_UNIT_LIMIT, _decode_legacy_payment_instructions + from pay_kit.protocols.mpp.server.charge import MAX_COMPUTE_UNIT_LIMIT, _decode_legacy_payment_instructions data = bytes([2]) + MAX_COMPUTE_UNIT_LIMIT.to_bytes(4, "little") tx_b64 = self._build_tx_with_compute_budget_data(data) @@ -1328,7 +1328,7 @@ def test_set_compute_unit_limit_at_cap_is_accepted(self): assert not any(item.get("programId") == self._COMPUTE_BUDGET for item in out) def test_set_compute_unit_limit_over_cap_is_rejected(self): - from solana_mpp.server.mpp import MAX_COMPUTE_UNIT_LIMIT, _decode_legacy_payment_instructions + from pay_kit.protocols.mpp.server.charge import MAX_COMPUTE_UNIT_LIMIT, _decode_legacy_payment_instructions over = MAX_COMPUTE_UNIT_LIMIT + 1 data = bytes([2]) + over.to_bytes(4, "little") @@ -1340,7 +1340,7 @@ def test_set_compute_unit_limit_over_cap_is_rejected(self): assert str(over) in str(exc.value) def test_set_compute_unit_price_over_cap_is_rejected(self): - from solana_mpp.server.mpp import ( + from pay_kit.protocols.mpp.server.charge import ( MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS, _decode_legacy_payment_instructions, ) @@ -1355,7 +1355,7 @@ def test_set_compute_unit_price_over_cap_is_rejected(self): assert str(over) in str(exc.value) def test_unknown_compute_budget_discriminator_is_rejected(self): - from solana_mpp.server.mpp import _decode_legacy_payment_instructions + from pay_kit.protocols.mpp.server.charge import _decode_legacy_payment_instructions # Discriminator 0 (RequestUnits) is no longer a permitted shape # in the MPP allowlist; reject as invalid payload. @@ -1366,7 +1366,7 @@ def test_unknown_compute_budget_discriminator_is_rejected(self): assert exc.value.code == "compute-budget-invalid" def test_canonical_code_maps_to_payment_invalid(self): - from solana_mpp._errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code assert canonical_code("compute-budget-cap-exceeded") == CODE_PAYMENT_INVALID assert canonical_code("compute-budget-invalid") == CODE_PAYMENT_INVALID @@ -1390,7 +1390,7 @@ def _make_request_with_n_splits(n: int) -> tuple[ChargeRequest, MethodDetails]: return request, details def test_splits_at_cap_is_accepted(self): - from solana_mpp.server.mpp import MAX_SPLITS, _build_expected_transfers + from pay_kit.protocols.mpp.server.charge import MAX_SPLITS, _build_expected_transfers request, details = self._make_request_with_n_splits(MAX_SPLITS) out = _build_expected_transfers(request, details) @@ -1398,7 +1398,7 @@ def test_splits_at_cap_is_accepted(self): assert len(out) == MAX_SPLITS + 1 def test_splits_over_cap_is_rejected(self): - from solana_mpp.server.mpp import MAX_SPLITS, _build_expected_transfers + from pay_kit.protocols.mpp.server.charge import MAX_SPLITS, _build_expected_transfers observed = MAX_SPLITS + 1 request, details = self._make_request_with_n_splits(observed) @@ -1409,7 +1409,7 @@ def test_splits_over_cap_is_rejected(self): assert str(MAX_SPLITS) in str(exc.value) def test_canonical_code_maps_to_payment_invalid(self): - from solana_mpp._errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code assert canonical_code("too-many-splits") == CODE_PAYMENT_INVALID @@ -1454,7 +1454,7 @@ def test_valid_payment_with_compute_budget_is_accepted(self): """Positive control: a charge transaction with a permitted ComputeBudget SetComputeUnitLimit alongside the required transfer must pass the allowlist.""" - from solana_mpp.server.mpp import _verify_local_transaction_intent + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() request, details = self._request_and_details() @@ -1480,8 +1480,8 @@ def test_valid_payment_with_extra_system_transfer_to_attacker_is_rejected(self): attacker address. Without the allowlist this would be co-signed and broadcast, draining the fee payer. MUST be rejected with the canonical ``payment_invalid`` code before co-sign.""" - from solana_mpp._errors import CODE_PAYMENT_INVALID, canonical_code - from solana_mpp.server.mpp import _verify_local_transaction_intent + from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() request, details = self._request_and_details() @@ -1510,8 +1510,8 @@ def test_valid_payment_with_extra_spl_transfer_is_rejected(self): SPL Token transfer instruction. The native-SOL allowlist must reject any Token Program instruction since a native-SOL charge never legitimately carries one.""" - from solana_mpp._errors import CODE_PAYMENT_INVALID, canonical_code - from solana_mpp.server.mpp import _verify_local_transaction_intent + from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() request, details = self._request_and_details() @@ -1543,8 +1543,8 @@ def test_valid_payment_with_extra_spl_transfer_is_rejected(self): def test_valid_payment_with_unknown_program_is_rejected(self): """SECURITY: an arbitrary BPF program invocation alongside the valid payment is not on the allowlist and must be rejected.""" - from solana_mpp._errors import CODE_PAYMENT_INVALID, canonical_code - from solana_mpp.server.mpp import _verify_local_transaction_intent + from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() request, details = self._request_and_details() @@ -1565,7 +1565,7 @@ def test_valid_payment_with_unknown_program_is_rejected(self): def test_valid_payment_with_memo_v1_is_rejected(self): """L2 lock parity: memo v1 is rejected even when the v2 verifier would otherwise let extra memos slip past as unmatched.""" - from solana_mpp.server.mpp import _verify_local_transaction_intent + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() request, details = self._request_and_details() @@ -1592,11 +1592,11 @@ def test_valid_spl_payment_with_ata_create_for_required_split_is_accepted(self): ``allowed_ata_owners`` is the set of required-split owners; the primary recipient is never in that set. """ - from solana_mpp.protocol.solana import ( + from pay_kit._paycore.solana import ( ASSOCIATED_TOKEN_PROGRAM, Split, ) - from solana_mpp.server.mpp import _verify_local_transaction_intent + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() split_recipient = "8wXtPeU6557ETkp9WHFY1n1EcU6NxDvbAggHGsMYiHsB" @@ -1669,8 +1669,8 @@ def test_ata_create_for_primary_recipient_is_rejected(self): Without this, a malicious client could get the fee payer to spend SOL on rent for a primary-recipient ATA the route did not authorize. """ - from solana_mpp.protocol.solana import ASSOCIATED_TOKEN_PROGRAM - from solana_mpp.server.mpp import _verify_local_transaction_intent + from pay_kit._paycore.solana import ASSOCIATED_TOKEN_PROGRAM + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() request = ChargeRequest( @@ -1714,9 +1714,9 @@ def test_ata_create_for_attacker_owner_is_rejected(self): """SECURITY: an ATA create for an owner that is NOT a charge recipient must be rejected so the attacker cannot get the fee payer to fund an arbitrary ATA rent.""" - from solana_mpp._errors import CODE_PAYMENT_INVALID, canonical_code - from solana_mpp.protocol.solana import ASSOCIATED_TOKEN_PROGRAM - from solana_mpp.server.mpp import _verify_local_transaction_intent + from pay_kit._paycore.solana import ASSOCIATED_TOKEN_PROGRAM + from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() request = ChargeRequest( @@ -1813,8 +1813,8 @@ def test_sol_drain_with_fee_payer_as_source_is_rejected(self): recipient matches destination + amount, but the source IS the fee-payer; the server would otherwise co-sign and drain fee-payer SOL beyond the network fee. MUST be rejected with payment_invalid.""" - from solana_mpp._errors import CODE_PAYMENT_INVALID, canonical_code - from solana_mpp.server.mpp import _verify_local_transaction_intent + from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() request = ChargeRequest( @@ -1846,8 +1846,8 @@ def test_spl_drain_with_fee_payer_ata_as_source_is_rejected(self): check the allowlist accepts the transfer (correct mint, amount, destination), the server co-signs, and the fee-payer's token balance is drained. MUST be rejected with payment_invalid.""" - from solana_mpp._errors import CODE_PAYMENT_INVALID, canonical_code - from solana_mpp.server.mpp import _verify_local_transaction_intent + from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() request = ChargeRequest( @@ -1884,8 +1884,8 @@ def test_spl_token_2022_drain_with_fee_payer_ata_as_source_is_rejected(self): """SECURITY: same drain shape on the Token-2022 program id (PYUSD devnet mint, derived under TOKEN_2022_PROGRAM). The fee-payer source check must hold for both token program ids.""" - from solana_mpp._errors import CODE_PAYMENT_INVALID, canonical_code - from solana_mpp.server.mpp import _verify_local_transaction_intent + from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() request = ChargeRequest( @@ -1924,7 +1924,7 @@ def test_legitimate_spl_payment_with_fee_payer_cosign_is_accepted(self): ATA owned by a separate sender keypair MUST be accepted. The fee-payer source check must not over-block legitimate co-sign transfers.""" - from solana_mpp.server.mpp import _verify_local_transaction_intent + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() sender = Keypair() @@ -2017,8 +2017,8 @@ def test_sol_drain_with_tampered_echoed_fee_payer_key_is_rejected(self): fix the allowlist compares the source against ATTACKER, finds no match, and lets the transfer through; the server then co-signs and drains itself. MUST be rejected with payment_invalid.""" - from solana_mpp._errors import CODE_PAYMENT_INVALID, canonical_code - from solana_mpp.server.mpp import _verify_local_transaction_intent + from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent server_fee_payer = Keypair() attacker = Keypair() @@ -2055,8 +2055,8 @@ def test_spl_drain_with_tampered_echoed_fee_payer_key_is_rejected(self): """SPL variant: client echoes a bogus fee-payer key, the drain transfer is sourced from the real server fee-payer's ATA. MUST be rejected with payment_invalid.""" - from solana_mpp._errors import CODE_PAYMENT_INVALID, canonical_code - from solana_mpp.server.mpp import _verify_local_transaction_intent + from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent server_fee_payer = Keypair() attacker = Keypair() @@ -2101,8 +2101,8 @@ def test_echoed_fee_payer_key_mismatch_with_server_signer_is_rejected(self): canonical ``payment_invalid`` code so a tampered echoed key cannot slip through even if the rest of the transaction happens to be well-formed.""" - from solana_mpp._errors import CODE_PAYMENT_INVALID, canonical_code - from solana_mpp.server.mpp import _verify_local_transaction_intent + from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent server_fee_payer = Keypair() attacker = Keypair() @@ -2146,7 +2146,7 @@ def test_legitimate_payment_with_matching_echoed_and_server_keys_is_accepted(sel """Positive control: client echoes the correct server fee-payer pubkey, transaction is well-formed with a third-party sender. Must not raise.""" - from solana_mpp.server.mpp import _verify_local_transaction_intent + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent server_fee_payer = Keypair() sender = Keypair() diff --git a/python/tests/test_server_defaults.py b/python/tests/test_server_defaults.py index 010100c70..292543995 100644 --- a/python/tests/test_server_defaults.py +++ b/python/tests/test_server_defaults.py @@ -4,7 +4,7 @@ import pytest -from solana_mpp.server.defaults import detect_realm, detect_secret_key +from pay_kit.protocols.mpp.server.defaults import detect_realm, detect_secret_key class TestDetectRealm: diff --git a/python/tests/test_server_html.py b/python/tests/test_server_html.py index 196e172ad..1bf6b7a0e 100644 --- a/python/tests/test_server_html.py +++ b/python/tests/test_server_html.py @@ -4,9 +4,9 @@ import json -from solana_mpp._base64url import encode_json -from solana_mpp._types import PaymentChallenge -from solana_mpp.server.payment_page import ( +from pay_kit.protocols.mpp.core.base64url import encode_json +from pay_kit.protocols.mpp.core.types import PaymentChallenge +from pay_kit.protocols.mpp.server.payment_page import ( SERVICE_WORKER_PARAM, accepts_html, challenge_to_html, diff --git a/python/tests/test_server_v0_transactions.py b/python/tests/test_server_v0_transactions.py index 555b0a7f2..f1fe62b73 100644 --- a/python/tests/test_server_v0_transactions.py +++ b/python/tests/test_server_v0_transactions.py @@ -1,4 +1,4 @@ -"""V0 (versioned) transaction coverage for ``solana_mpp.server.mpp``. +"""V0 (versioned) transaction coverage for ``pay_kit.protocols.mpp.server.charge``. The legacy-transaction paths in ``_decode_legacy_payment_instructions``, ``_co_sign_with_fee_payer``, and ``_validate_instruction_allowlist`` are @@ -33,10 +33,10 @@ from solders.system_program import TransferParams, transfer from solders.transaction import VersionedTransaction -from solana_mpp._errors import PaymentError -from solana_mpp.protocol.intents import ChargeRequest -from solana_mpp.protocol.solana import MethodDetails -from solana_mpp.server import mpp as M +from pay_kit._paycore.solana import MethodDetails +from pay_kit.protocols.mpp.core.errors import PaymentError +from pay_kit.protocols.mpp.intents.charge import ChargeRequest +from pay_kit.protocols.mpp.server import charge as M TEST_BLOCKHASH = "4vJ9JU1bJJQpUgJ8V6hYz7xXKz4F2tN6aBrZEcD3xKhs" diff --git a/python/tests/test_solana_protocol.py b/python/tests/test_solana_protocol.py index 5e600e49f..01165bc9d 100644 --- a/python/tests/test_solana_protocol.py +++ b/python/tests/test_solana_protocol.py @@ -2,7 +2,7 @@ from __future__ import annotations -from solana_mpp.protocol.solana import ( +from pay_kit._paycore.solana import ( ASSOCIATED_TOKEN_PROGRAM, MEMO_PROGRAM, SYSTEM_PROGRAM, @@ -82,7 +82,7 @@ def test_no_mainnet_beta_keys(self): # L1 lock invariant: ``mainnet-beta`` must not appear as a direct key # inside KNOWN_MINTS. Drift here would make a Ruby-mainnet credential # resolve to a different mint than its Python-mainnet-beta echo. - from solana_mpp.protocol.solana import KNOWN_MINTS + from pay_kit._paycore.solana import KNOWN_MINTS for symbol, networks in KNOWN_MINTS.items(): assert "mainnet-beta" not in networks, ( diff --git a/python/tests/test_store.py b/python/tests/test_store.py index 238ca0719..19d13054f 100644 --- a/python/tests/test_store.py +++ b/python/tests/test_store.py @@ -6,7 +6,7 @@ import pytest -from solana_mpp.store import FileReplayStore, MemoryStore, Store +from pay_kit.protocols.mpp.core.store import FileReplayStore, MemoryStore, Store class TestMemoryStore: @@ -164,8 +164,8 @@ class TestMppRequiresExplicitStore: """L4 lock: ``Mpp.__init__`` MUST refuse to start without an explicit store.""" def test_missing_store_raises(self): - from solana_mpp._errors import PaymentError - from solana_mpp.server.mpp import Config, Mpp + from pay_kit.protocols.mpp.core.errors import PaymentError + from pay_kit.protocols.mpp.server.charge import Config, Mpp with pytest.raises(PaymentError, match="replay store is required"): Mpp( diff --git a/python/tests/test_types.py b/python/tests/test_types.py index aaced44ea..760427ec2 100644 --- a/python/tests/test_types.py +++ b/python/tests/test_types.py @@ -4,8 +4,8 @@ from datetime import UTC, datetime -from solana_mpp._base64url import encode_json -from solana_mpp._types import PaymentChallenge, Receipt +from pay_kit.protocols.mpp.core.base64url import encode_json +from pay_kit.protocols.mpp.core.types import PaymentChallenge, Receipt class TestPaymentChallenge: diff --git a/swift/Examples/iOSDemo/MerchantServer/serve.py b/swift/Examples/iOSDemo/MerchantServer/serve.py index d5aa9822d..e31369e6d 100644 --- a/swift/Examples/iOSDemo/MerchantServer/serve.py +++ b/swift/Examples/iOSDemo/MerchantServer/serve.py @@ -33,16 +33,10 @@ "httpx is required. Install with `pip install -e ../../../python`." ) -try: - # Stable public re-export added in PR #106. - from solana_mpp import SolanaRpc -except ImportError: # pragma: no cover - pre-#106 installs only - # Fall back to the underscore-prefixed path so the demo merchant - # boots against either tree until #106 lands. - from solana_mpp._rpc import SolanaRpc -from solana_mpp._headers import format_www_authenticate, parse_authorization -from solana_mpp.server.mpp import ChargeOptions, Config, Mpp -from solana_mpp.store import MemoryStore +from pay_kit.protocols.mpp.core.rpc import SolanaRpc +from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization +from pay_kit.protocols.mpp.server.charge import ChargeOptions, Config, Mpp +from pay_kit.protocols.mpp.core.store import MemoryStore # Merchant recipient (separate from the demo signer). Same value as # python/examples/payment-links/server.py so interop fixtures keep From 71d11b3a745505282b2844986971e48a827427bf Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 19:01:37 +0300 Subject: [PATCH 19/45] refactor(python): lift shared infra into _paycore so protocols don't 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. --- harness/python-server/server.py | 6 +- python/examples/payment-links/server.py | 4 +- python/src/pay_kit/__init__.py | 2 +- python/src/pay_kit/_paycore/__init__.py | 7 +- .../mpp/core => _paycore}/errors.py | 0 python/src/pay_kit/_paycore/mints.py | 6 +- .../mpp/server => _paycore}/network_check.py | 2 +- .../{protocols/mpp/core => _paycore}/rpc.py | 2 +- .../{protocols/mpp/core => _paycore}/store.py | 0 python/src/pay_kit/_paycore/transaction.py | 52 +++++++++++++ python/src/pay_kit/errors.py | 2 +- python/src/pay_kit/protocols/mpp/__init__.py | 6 +- .../pay_kit/protocols/mpp/server/charge.py | 73 ++++--------------- .../protocols/mpp/server/middleware.py | 2 +- python/src/pay_kit/protocols/x402/verify.py | 27 ++++--- python/tests/conftest.py | 2 +- python/tests/test_cross_route_replay.py | 4 +- python/tests/test_errors.py | 20 ++--- python/tests/test_middleware.py | 2 +- python/tests/test_mpp_helpers.py | 2 +- python/tests/test_network_check.py | 2 +- python/tests/test_rpc_contract.py | 4 +- python/tests/test_rpc_methods.py | 8 +- python/tests/test_rpc_send_validation.py | 2 +- python/tests/test_server.py | 36 ++++----- python/tests/test_server_v0_transactions.py | 15 ++-- python/tests/test_store.py | 4 +- 27 files changed, 153 insertions(+), 139 deletions(-) rename python/src/pay_kit/{protocols/mpp/core => _paycore}/errors.py (100%) rename python/src/pay_kit/{protocols/mpp/server => _paycore}/network_check.py (97%) rename python/src/pay_kit/{protocols/mpp/core => _paycore}/rpc.py (99%) rename python/src/pay_kit/{protocols/mpp/core => _paycore}/store.py (100%) create mode 100644 python/src/pay_kit/_paycore/transaction.py diff --git a/harness/python-server/server.py b/harness/python-server/server.py index faa5a8881..bd2021045 100644 --- a/harness/python-server/server.py +++ b/harness/python-server/server.py @@ -66,14 +66,14 @@ def _find_repo_root(start: Path) -> Path: ) from pay_kit.errors import InvalidProofError # noqa: E402 from pay_kit.protocols.x402 import X402Adapter # noqa: E402 -from pay_kit.protocols.mpp.core.errors import PaymentError, canonical_code # noqa: E402 +from pay_kit._paycore.errors import PaymentError, canonical_code # noqa: E402 from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization # noqa: E402 -from pay_kit.protocols.mpp.core.rpc import SolanaRpc # noqa: E402 +from pay_kit._paycore.rpc import SolanaRpc # noqa: E402 from pay_kit.protocols.mpp.intents.charge import ChargeRequest # noqa: E402 from pay_kit.protocols.mpp.server.charge import ChargeOptions # noqa: E402 from pay_kit.protocols.mpp.server.charge import Config as MppServerConfig # noqa: E402 from pay_kit.protocols.mpp.server.charge import Mpp # noqa: E402 -from pay_kit.protocols.mpp.core.store import MemoryStore # noqa: E402 +from pay_kit._paycore.store import MemoryStore # noqa: E402 def require_env(name: str) -> str: diff --git a/python/examples/payment-links/server.py b/python/examples/payment-links/server.py index 712bfec6b..79a6928de 100644 --- a/python/examples/payment-links/server.py +++ b/python/examples/payment-links/server.py @@ -12,7 +12,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization -from pay_kit.protocols.mpp.core.rpc import SolanaRpc +from pay_kit._paycore.rpc import SolanaRpc from pay_kit.protocols.mpp.server.charge import ChargeOptions, Config, Mpp from pay_kit.protocols.mpp.server.payment_page import ( accepts_html, @@ -20,7 +20,7 @@ is_service_worker_request, service_worker_js, ) -from pay_kit.protocols.mpp.core.store import MemoryStore +from pay_kit._paycore.store import MemoryStore RECIPIENT = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY" USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" diff --git a/python/src/pay_kit/__init__.py b/python/src/pay_kit/__init__.py index 1419d46ab..3ebe51c41 100644 --- a/python/src/pay_kit/__init__.py +++ b/python/src/pay_kit/__init__.py @@ -26,6 +26,7 @@ from pay_kit._paycore.network import Network from pay_kit._paycore.protocol import Protocol from pay_kit._paycore.stablecoin import Stablecoin +from pay_kit._paycore.store import FileReplayStore, MemoryStore, Store from pay_kit.config import ( Config, MppConfig, @@ -55,7 +56,6 @@ from pay_kit.price import Price from pay_kit.pricing import Pricing from pay_kit.protocols.mpp.core.expires import days, hours, minutes, seconds, weeks -from pay_kit.protocols.mpp.core.store import FileReplayStore, MemoryStore, Store from pay_kit.signer import LocalSigner, Signer __all__ = [ diff --git a/python/src/pay_kit/_paycore/__init__.py b/python/src/pay_kit/_paycore/__init__.py index 7a61c41ff..21056318e 100644 --- a/python/src/pay_kit/_paycore/__init__.py +++ b/python/src/pay_kit/_paycore/__init__.py @@ -1,4 +1,9 @@ -"""Layer A: paycore primitives (enums, mints) plus pay_kit.protocols.mpp re-exports.""" +"""Shared payment-core primitives used by both protocol packages. + +The analog of the Rust ``core`` crate: enums, mints, RPC, replay store, network +check, transaction helpers, and the wire error model. x402 and MPP both depend +on ``_paycore``; neither protocol depends on the other. +""" from __future__ import annotations diff --git a/python/src/pay_kit/protocols/mpp/core/errors.py b/python/src/pay_kit/_paycore/errors.py similarity index 100% rename from python/src/pay_kit/protocols/mpp/core/errors.py rename to python/src/pay_kit/_paycore/errors.py diff --git a/python/src/pay_kit/_paycore/mints.py b/python/src/pay_kit/_paycore/mints.py index b7b4b3d0c..15eddf2bb 100644 --- a/python/src/pay_kit/_paycore/mints.py +++ b/python/src/pay_kit/_paycore/mints.py @@ -1,8 +1,8 @@ -"""Stablecoin mint resolution and ATA derivation over pay_kit.protocols.mpp data. +"""Stablecoin mint resolution and ATA derivation over the shared Solana tables. Mirrors PHP ``PayCore/Solana/Mints.php``. All mint/program tables live in -``pay_kit._paycore.solana`` and are reused here rather than duplicated, so -pay_kit and the legacy surface always agree on wire values. +``pay_kit._paycore.solana`` and are reused here rather than duplicated, so the +x402 and MPP adapters always agree on wire values. """ from __future__ import annotations diff --git a/python/src/pay_kit/protocols/mpp/server/network_check.py b/python/src/pay_kit/_paycore/network_check.py similarity index 97% rename from python/src/pay_kit/protocols/mpp/server/network_check.py rename to python/src/pay_kit/_paycore/network_check.py index 91612ce75..abf165adb 100644 --- a/python/src/pay_kit/protocols/mpp/server/network_check.py +++ b/python/src/pay_kit/_paycore/network_check.py @@ -19,7 +19,7 @@ from __future__ import annotations -from pay_kit.protocols.mpp.core.errors import PaymentError +from pay_kit._paycore.errors import PaymentError #: Base58 prefix embedded in every blockhash returned by the Surfpool #: localnet implementation. diff --git a/python/src/pay_kit/protocols/mpp/core/rpc.py b/python/src/pay_kit/_paycore/rpc.py similarity index 99% rename from python/src/pay_kit/protocols/mpp/core/rpc.py rename to python/src/pay_kit/_paycore/rpc.py index 49aa74ccf..7a96c9aed 100644 --- a/python/src/pay_kit/protocols/mpp/core/rpc.py +++ b/python/src/pay_kit/_paycore/rpc.py @@ -25,7 +25,7 @@ import httpx -from pay_kit.protocols.mpp.core.errors import PaymentError +from pay_kit._paycore.errors import PaymentError class _RpcError(PaymentError): diff --git a/python/src/pay_kit/protocols/mpp/core/store.py b/python/src/pay_kit/_paycore/store.py similarity index 100% rename from python/src/pay_kit/protocols/mpp/core/store.py rename to python/src/pay_kit/_paycore/store.py diff --git a/python/src/pay_kit/_paycore/transaction.py b/python/src/pay_kit/_paycore/transaction.py new file mode 100644 index 000000000..168f788da --- /dev/null +++ b/python/src/pay_kit/_paycore/transaction.py @@ -0,0 +1,52 @@ +"""Shared Solana transaction-wire helpers used by both protocol adapters. + +Lives in ``_paycore`` (the shared core, mirroring the Rust ``core`` crate) so +neither protocol package depends on the other: x402 and MPP both import the v0 +detector from here rather than reaching across into each other. +""" + +from __future__ import annotations + + +def is_v0_wire_bytes(raw: bytes) -> bool: + """Best-effort detection of a v0 ``VersionedTransaction`` on the wire. + + SECURITY: ``solders.transaction.Transaction.from_bytes`` is lenient on + v0 wire bytes today: it can mis-parse a signed v0 transaction as a + degenerate legacy transaction whose ``instructions`` list points at + random ``account_keys`` entries. The downstream allowlist then rejects + a legitimate v0 payment with a misleading + ``unexpected program instruction in payment transaction: `` + error sourced from the mis-parsed junk. This helper peeks at the + message-version prefix so callers can route v0 wire bytes straight to + ``VersionedTransaction.from_bytes`` instead of trusting the lenient + legacy parser. + + Wire format: ``[shortvec sig_count] [64 * sig_count signatures] [message]``. + Legacy messages start with the header byte ``num_required_signatures`` + which is always ``< 0x80`` in practice (the MSB encodes a version + prefix on v0). v0 messages start with ``0x80 | version`` so the high + bit is set. We accept multi-byte compact-u16 lengths but cap at three + bytes (Solana hard caps signatures well below ``128 * 128``). + """ + if not raw: + return False + # Parse compact-u16 sig_count. + sig_count = 0 + shift = 0 + offset = 0 + for _ in range(3): # compact-u16 is at most 3 bytes + if offset >= len(raw): + return False + byte = raw[offset] + offset += 1 + sig_count |= (byte & 0x7F) << shift + if (byte & 0x80) == 0: + break + shift += 7 + msg_start = offset + sig_count * 64 + if msg_start >= len(raw): + return False + # MessageV0 prefix is 0x80 | version; legacy header byte + # (num_required_signatures) never sets the MSB for any realistic tx. + return (raw[msg_start] & 0x80) != 0 diff --git a/python/src/pay_kit/errors.py b/python/src/pay_kit/errors.py index 6bad5a7ed..e5855c10d 100644 --- a/python/src/pay_kit/errors.py +++ b/python/src/pay_kit/errors.py @@ -13,7 +13,7 @@ ``InvalidProofError.code`` carries the canonical cross-SDK L6 error string (e.g. ``charge_request_mismatch``, ``signature_consumed``). Adapters map the underlying ``pay_kit.protocols.mpp`` ``PaymentError.code`` to these at the boundary via -``pay_kit.protocols.mpp.core.errors.canonical_code``. +``pay_kit._paycore.errors.canonical_code``. """ from __future__ import annotations diff --git a/python/src/pay_kit/protocols/mpp/__init__.py b/python/src/pay_kit/protocols/mpp/__init__.py index c2390ce94..045d8b826 100644 --- a/python/src/pay_kit/protocols/mpp/__init__.py +++ b/python/src/pay_kit/protocols/mpp/__init__.py @@ -19,13 +19,13 @@ from decimal import Decimal from typing import TYPE_CHECKING, Any, TypedDict, cast +from pay_kit._paycore.errors import PaymentError, canonical_code from pay_kit._paycore.protocol import Protocol +from pay_kit._paycore.rpc import SolanaRpc +from pay_kit._paycore.store import MemoryStore, Store from pay_kit.errors import InvalidProofError from pay_kit.payment import Payment -from pay_kit.protocols.mpp.core.errors import PaymentError, canonical_code from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization -from pay_kit.protocols.mpp.core.rpc import SolanaRpc -from pay_kit.protocols.mpp.core.store import MemoryStore, Store from pay_kit.protocols.mpp.intents.charge import ChargeRequest from pay_kit.protocols.mpp.server.charge import ChargeOptions, Mpp from pay_kit.protocols.mpp.server.charge import Config as MppServerConfig diff --git a/python/src/pay_kit/protocols/mpp/server/charge.py b/python/src/pay_kit/protocols/mpp/server/charge.py index ad17d702e..6a6f8a736 100644 --- a/python/src/pay_kit/protocols/mpp/server/charge.py +++ b/python/src/pay_kit/protocols/mpp/server/charge.py @@ -10,6 +10,13 @@ from dataclasses import dataclass, field from typing import Any +from pay_kit._paycore.errors import ( + ChallengeExpiredError, + ChallengeMismatchError, + PaymentError, + ReplayError, +) +from pay_kit._paycore.network_check import check_network_blockhash from pay_kit._paycore.solana import ( ASSOCIATED_TOKEN_PROGRAM, MEMO_PROGRAM, @@ -23,17 +30,11 @@ resolve_mint, stablecoin_symbol, ) +from pay_kit._paycore.store import Store +from pay_kit._paycore.transaction import is_v0_wire_bytes from pay_kit.protocols.mpp.core.base64url import encode_json -from pay_kit.protocols.mpp.core.errors import ( - ChallengeExpiredError, - ChallengeMismatchError, - PaymentError, - ReplayError, -) -from pay_kit.protocols.mpp.core.store import Store from pay_kit.protocols.mpp.core.types import PaymentChallenge, PaymentCredential, Receipt from pay_kit.protocols.mpp.intents.charge import ChargeRequest, parse_units -from pay_kit.protocols.mpp.server.network_check import check_network_blockhash logger = logging.getLogger(__name__) @@ -366,50 +367,6 @@ def _validate_compute_budget_instruction(data: bytes, account_count: int) -> Non ) -def _is_v0_wire_bytes(raw: bytes) -> bool: - """Best-effort detection of a v0 ``VersionedTransaction`` on the wire. - - SECURITY: ``solders.transaction.Transaction.from_bytes`` is lenient on - v0 wire bytes today: it can mis-parse a signed v0 transaction as a - degenerate legacy transaction whose ``instructions`` list points at - random ``account_keys`` entries. The downstream allowlist then rejects - a legitimate v0 payment with a misleading - ``unexpected program instruction in payment transaction: `` - error sourced from the mis-parsed junk. This helper peeks at the - message-version prefix so callers can route v0 wire bytes straight to - ``VersionedTransaction.from_bytes`` instead of trusting the lenient - legacy parser. - - Wire format: ``[shortvec sig_count] [64 * sig_count signatures] [message]``. - Legacy messages start with the header byte ``num_required_signatures`` - which is always ``< 0x80`` in practice (the MSB encodes a version - prefix on v0). v0 messages start with ``0x80 | version`` so the high - bit is set. We accept multi-byte compact-u16 lengths but cap at three - bytes (Solana hard caps signatures well below ``128 * 128``). - """ - if not raw: - return False - # Parse compact-u16 sig_count. - sig_count = 0 - shift = 0 - offset = 0 - for _ in range(3): # compact-u16 is at most 3 bytes - if offset >= len(raw): - return False - byte = raw[offset] - offset += 1 - sig_count |= (byte & 0x7F) << shift - if (byte & 0x80) == 0: - break - shift += 7 - msg_start = offset + sig_count * 64 - if msg_start >= len(raw): - return False - # MessageV0 prefix is 0x80 | version; legacy header byte - # (num_required_signatures) never sets the MSB for any realistic tx. - return (raw[msg_start] & 0x80) != 0 - - def _decode_legacy_payment_instructions(transaction_b64: str) -> list[dict[str, Any]]: """Decode local transfer and memo instructions from a legacy or v0 transaction. @@ -426,9 +383,9 @@ def _decode_legacy_payment_instructions(transaction_b64: str) -> list[dict[str, message_instructions: list[Any] = [] # Route v0 wire bytes straight to VersionedTransaction; the legacy # parser in solders is lenient and can mis-parse a signed v0 tx as a - # degenerate legacy tx with bogus instructions (see _is_v0_wire_bytes). + # degenerate legacy tx with bogus instructions (see is_v0_wire_bytes). parsed = False - if _is_v0_wire_bytes(raw): + if is_v0_wire_bytes(raw): try: vtx = VersionedTransaction.from_bytes(raw) except Exception: @@ -839,9 +796,9 @@ def _validate_instruction_allowlist( # degenerate legacy tx whose instructions point at random account # keys. The allowlist would then reject the legitimate v0 payment # with a misleading "unexpected program instruction" error sourced - # from junk bytes. See _is_v0_wire_bytes. + # from junk bytes. See is_v0_wire_bytes. parsed = False - if _is_v0_wire_bytes(raw): + if is_v0_wire_bytes(raw): try: vtx = VersionedTransaction.from_bytes(raw) except Exception: @@ -1168,7 +1125,7 @@ class Config: fee_payer_signer: Any = None store: Store | None = None # The RPC client MUST expose at least the methods on - # :class:`pay_kit.protocols.mpp.core.rpc.SolanaRpc`: ``send_raw_transaction``, + # :class:`pay_kit._paycore.rpc.SolanaRpc`: ``send_raw_transaction``, # ``get_signature_statuses``, ``await_confirmation``, # ``get_recent_blockhash`` and ``get_transaction``. The previous # ``# solana.rpc.async_api.AsyncClient`` comment suggested the legacy @@ -1228,7 +1185,7 @@ def __init__(self, config: Config) -> None: if not callable(getattr(config.rpc, method_name, None)): raise PaymentError( f"rpc client missing required method '{method_name}'; " - "use pay_kit.protocols.mpp.core.rpc.SolanaRpc or a compatible client", + "use pay_kit._paycore.rpc.SolanaRpc or a compatible client", code="invalid-config", ) self._rpc = config.rpc diff --git a/python/src/pay_kit/protocols/mpp/server/middleware.py b/python/src/pay_kit/protocols/mpp/server/middleware.py index 72ef7c91c..a48277737 100644 --- a/python/src/pay_kit/protocols/mpp/server/middleware.py +++ b/python/src/pay_kit/protocols/mpp/server/middleware.py @@ -6,7 +6,7 @@ from collections.abc import Callable from typing import Any -from pay_kit.protocols.mpp.core.errors import PaymentError, payment_required_response +from pay_kit._paycore.errors import PaymentError, payment_required_response from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization from pay_kit.protocols.mpp.server.charge import Mpp diff --git a/python/src/pay_kit/protocols/x402/verify.py b/python/src/pay_kit/protocols/x402/verify.py index abc5a7949..a049db868 100644 --- a/python/src/pay_kit/protocols/x402/verify.py +++ b/python/src/pay_kit/protocols/x402/verify.py @@ -23,13 +23,13 @@ from typing import TYPE_CHECKING, Any, TypedDict, cast from pay_kit._paycore.mints import derive_ata, resolve, token_program_for +from pay_kit._paycore.network_check import check_network_blockhash from pay_kit._paycore.protocol import Protocol +from pay_kit._paycore.rpc import SolanaRpc from pay_kit._paycore.solana import ASSOCIATED_TOKEN_PROGRAM +from pay_kit._paycore.store import MemoryStore, Store from pay_kit.errors import InvalidProofError from pay_kit.payment import Payment -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 if TYPE_CHECKING: from pay_kit.config import Config @@ -663,18 +663,17 @@ def _caip2(self) -> str: def _co_sign(transaction_b64: str, signer: Any) -> bytes: """Splice the facilitator signature into the fee-payer slot, return wire. - Mirrors ``pay_kit.protocols.mpp.server.charge._co_sign_with_fee_payer``: legacy messages - are signed over ``bytes(msg)``, v0 over ``to_bytes_versioned(msg)`` (0x80 - prefix). The fee payer must occupy a signature slot. + Legacy messages are signed over ``bytes(msg)``, v0 over + ``to_bytes_versioned(msg)`` (0x80 prefix). The fee payer must occupy a + signature slot. The v0-wire detector lives in the shared + :mod:`pay_kit._paycore.transaction` core so neither protocol depends on the + other. """ from solders.message import to_bytes_versioned from solders.pubkey import Pubkey from solders.transaction import Transaction, VersionedTransaction - # Intentional reuse of pay_kit.protocols.mpp's v0-wire detector (see docstring above) - # rather than re-implementing parallel detection logic; private by package - # convention but a deliberate cross-module dependency. - from pay_kit.protocols.mpp.server.charge import _is_v0_wire_bytes # pyright: ignore[reportPrivateUsage] + from pay_kit._paycore.transaction import is_v0_wire_bytes raw = base64.b64decode(transaction_b64) fee_payer_pubkey = Pubkey.from_string(signer.pubkey()) @@ -684,10 +683,10 @@ def _co_sign(transaction_b64: str, signer: Any) -> bytes: # transaction (it does not raise), yielding a bogus header and garbage # account keys. The rust x402 client (and the canonical PaymentProof # builder) emit v0 messages, so we must route on the message-version - # prefix byte rather than trusting a legacy parse to fail. Mirrors - # ``pay_kit.protocols.mpp.server.charge._co_sign_with_fee_payer`` and reuses its - # ``_is_v0_wire_bytes`` guard (no parallel detection logic). - if _is_v0_wire_bytes(raw): + # prefix byte rather than trusting a legacy parse to fail. Reuses the + # shared ``is_v0_wire_bytes`` guard from ``pay_kit._paycore.transaction`` + # (no parallel detection logic; same routing as the MPP charge cosign). + if is_v0_wire_bytes(raw): try: vtx = VersionedTransaction.from_bytes(raw) except Exception as exc: # noqa: BLE001 diff --git a/python/tests/conftest.py b/python/tests/conftest.py index 322722a92..28c8aa4bf 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -4,8 +4,8 @@ import pytest +from pay_kit._paycore.store import MemoryStore from pay_kit.protocols.mpp.core.base64url import encode_json -from pay_kit.protocols.mpp.core.store import MemoryStore from pay_kit.protocols.mpp.core.types import PaymentChallenge from pay_kit.protocols.mpp.server.charge import Config, Mpp diff --git a/python/tests/test_cross_route_replay.py b/python/tests/test_cross_route_replay.py index 16c89dba4..55bc66eba 100644 --- a/python/tests/test_cross_route_replay.py +++ b/python/tests/test_cross_route_replay.py @@ -9,10 +9,10 @@ import pytest +from pay_kit._paycore.errors import PaymentError +from pay_kit._paycore.store import MemoryStore from pay_kit.protocols.mpp.core.base64url import encode_json from pay_kit.protocols.mpp.core.challenge import compute_challenge_id -from pay_kit.protocols.mpp.core.errors import PaymentError -from pay_kit.protocols.mpp.core.store import MemoryStore from pay_kit.protocols.mpp.core.types import ChallengeEcho, PaymentCredential from pay_kit.protocols.mpp.intents.charge import ChargeRequest from pay_kit.protocols.mpp.server.charge import Config, Mpp diff --git a/python/tests/test_errors.py b/python/tests/test_errors.py index 925bfad83..3b0608228 100644 --- a/python/tests/test_errors.py +++ b/python/tests/test_errors.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pay_kit.protocols.mpp.core.errors import ( +from pay_kit._paycore.errors import ( ChallengeExpiredError, ChallengeMismatchError, PaymentError, @@ -61,7 +61,7 @@ class TestCanonicalCodes: """L6 / P1 lock: every 402 path emits one of the canonical codes.""" def test_canonical_codes_set(self): - from pay_kit.protocols.mpp.core.errors import CANONICAL_CODES + from pay_kit._paycore.errors import CANONICAL_CODES assert ( frozenset( @@ -79,13 +79,13 @@ def test_canonical_codes_set(self): ) def test_canonical_code_returns_canonical_unchanged(self): - from pay_kit.protocols.mpp.core.errors import canonical_code + from pay_kit._paycore.errors import canonical_code assert canonical_code("payment_invalid") == "payment_invalid" assert canonical_code("wrong_network") == "wrong_network" def test_canonical_code_maps_legacy_kebab(self): - from pay_kit.protocols.mpp.core.errors import canonical_code + from pay_kit._paycore.errors import canonical_code assert canonical_code("challenge-expired") == "challenge_expired" assert canonical_code("signature-consumed") == "signature_consumed" @@ -100,7 +100,7 @@ def test_route_mismatch_distinguished_from_hmac_failure(self): # route/realm/method/intent/currency MUST surface as # ``challenge_route_mismatch``, not as ``challenge_verification_failed``. # Codex P2 fix. - from pay_kit.protocols.mpp.core.errors import canonical_code + from pay_kit._paycore.errors import canonical_code assert canonical_code("challenge-mismatch") == "challenge_verification_failed" assert canonical_code("currency-mismatch") == "challenge_route_mismatch" @@ -109,7 +109,7 @@ def test_route_mismatch_distinguished_from_hmac_failure(self): assert canonical_code("realm-mismatch") == "challenge_route_mismatch" def test_canonical_code_falls_back_to_payment_invalid(self): - from pay_kit.protocols.mpp.core.errors import canonical_code + from pay_kit._paycore.errors import canonical_code assert canonical_code("unknown-thing") == "payment_invalid" assert canonical_code("") == "payment_invalid" @@ -117,7 +117,7 @@ def test_canonical_code_falls_back_to_payment_invalid(self): class TestPaymentRequiredResponseBuilder: def test_emits_canonical_code(self): - from pay_kit.protocols.mpp.core.errors import payment_required_response + from pay_kit._paycore.errors import payment_required_response resp = payment_required_response("nope", code="challenge-expired") assert resp["status_code"] == 402 @@ -129,19 +129,19 @@ def test_emits_canonical_code(self): assert resp["headers"]["content-type"] == "application/problem+json" def test_includes_challenge_header_when_provided(self): - from pay_kit.protocols.mpp.core.errors import payment_required_response + from pay_kit._paycore.errors import payment_required_response resp = payment_required_response("challenge", code="payment_invalid", challenge_header='Payment id="x"') assert resp["headers"]["www-authenticate"] == 'Payment id="x"' def test_omits_challenge_header_by_default(self): - from pay_kit.protocols.mpp.core.errors import payment_required_response + from pay_kit._paycore.errors import payment_required_response resp = payment_required_response("x", code="payment_invalid") assert "www-authenticate" not in resp["headers"] def test_unknown_code_falls_back_to_payment_invalid(self): - from pay_kit.protocols.mpp.core.errors import payment_required_response + from pay_kit._paycore.errors import payment_required_response resp = payment_required_response("x", code="foo-bar-baz") assert resp["body"]["code"] == "payment_invalid" diff --git a/python/tests/test_middleware.py b/python/tests/test_middleware.py index 5a9318511..3c0ee1770 100644 --- a/python/tests/test_middleware.py +++ b/python/tests/test_middleware.py @@ -4,8 +4,8 @@ import pytest +from pay_kit._paycore.store import MemoryStore from pay_kit.protocols.mpp.core.headers import format_authorization -from pay_kit.protocols.mpp.core.store import MemoryStore from pay_kit.protocols.mpp.core.types import PaymentCredential from pay_kit.protocols.mpp.server.charge import Config, Mpp from pay_kit.protocols.mpp.server.middleware import pay diff --git a/python/tests/test_mpp_helpers.py b/python/tests/test_mpp_helpers.py index 2d05692dd..bd07afbaf 100644 --- a/python/tests/test_mpp_helpers.py +++ b/python/tests/test_mpp_helpers.py @@ -18,13 +18,13 @@ from solders.system_program import TransferParams, transfer from solders.transaction import Transaction +from pay_kit._paycore.errors import PaymentError from pay_kit._paycore.solana import ( TOKEN_2022_PROGRAM, TOKEN_PROGRAM, MethodDetails, Split, ) -from pay_kit.protocols.mpp.core.errors import PaymentError from pay_kit.protocols.mpp.server import charge as M # --------------------------------------------------------------------------- diff --git a/python/tests/test_network_check.py b/python/tests/test_network_check.py index dc31198d2..652cb2501 100644 --- a/python/tests/test_network_check.py +++ b/python/tests/test_network_check.py @@ -9,7 +9,7 @@ import pytest -from pay_kit.protocols.mpp.server.network_check import ( +from pay_kit._paycore.network_check import ( SURFPOOL_BLOCKHASH_PREFIX, WrongNetworkError, check_network_blockhash, diff --git a/python/tests/test_rpc_contract.py b/python/tests/test_rpc_contract.py index 2de48bee6..d6158a889 100644 --- a/python/tests/test_rpc_contract.py +++ b/python/tests/test_rpc_contract.py @@ -1,7 +1,7 @@ import pytest -from pay_kit.protocols.mpp.core.errors import PaymentError -from pay_kit.protocols.mpp.core.store import MemoryStore +from pay_kit._paycore.errors import PaymentError +from pay_kit._paycore.store import MemoryStore from pay_kit.protocols.mpp.server.charge import Config, Mpp diff --git a/python/tests/test_rpc_methods.py b/python/tests/test_rpc_methods.py index a9f2960bb..a07a1268d 100644 --- a/python/tests/test_rpc_methods.py +++ b/python/tests/test_rpc_methods.py @@ -1,6 +1,6 @@ """Exhaustive coverage for SolanaRpc methods. -Hits every branch in :mod:`pay_kit.protocols.mpp.core.rpc` so the JSON-RPC wrapper meets +Hits every branch in :mod:`pay_kit._paycore.rpc` so the JSON-RPC wrapper meets the 90 percent line coverage gate: the error branch in ``_call``, both ``get_signature_statuses`` return shapes, ``get_transaction``, ``confirm_transaction`` legacy shim (success and timeout), and @@ -11,8 +11,8 @@ import pytest -from pay_kit.protocols.mpp.core.errors import PaymentError -from pay_kit.protocols.mpp.core.rpc import SolanaRpc, _RpcError, _RpcResponse +from pay_kit._paycore.errors import PaymentError +from pay_kit._paycore.rpc import SolanaRpc, _RpcError, _RpcResponse class _FakeResponse: @@ -120,7 +120,7 @@ async def test_confirm_transaction_timeout(): # Always returns "processed" status so confirm_transaction loops 40x and returns timeout. rpc._client = _ScriptedClient([{"result": {"value": [{"confirmationStatus": "processed"}]}, "id": 1}]) # type: ignore[assignment] # Speed up: monkeypatch asyncio.sleep on the module - import pay_kit.protocols.mpp.core.rpc as rpc_mod + import pay_kit._paycore.rpc as rpc_mod async def _noop_sleep(_s): return None diff --git a/python/tests/test_rpc_send_validation.py b/python/tests/test_rpc_send_validation.py index 060983e89..8680f88fc 100644 --- a/python/tests/test_rpc_send_validation.py +++ b/python/tests/test_rpc_send_validation.py @@ -8,7 +8,7 @@ import pytest -from pay_kit.protocols.mpp.core.rpc import SolanaRpc, _RpcError +from pay_kit._paycore.rpc import SolanaRpc, _RpcError class _FakeResponse: diff --git a/python/tests/test_server.py b/python/tests/test_server.py index 676e65efe..c051f2572 100644 --- a/python/tests/test_server.py +++ b/python/tests/test_server.py @@ -11,9 +11,9 @@ from solders.system_program import TransferParams, transfer from solders.transaction import Transaction +from pay_kit._paycore.errors import ChallengeExpiredError, ChallengeMismatchError, PaymentError, ReplayError from pay_kit._paycore.solana import MEMO_PROGRAM, TOKEN_2022_PROGRAM, MethodDetails, Split -from pay_kit.protocols.mpp.core.errors import ChallengeExpiredError, ChallengeMismatchError, PaymentError, ReplayError -from pay_kit.protocols.mpp.core.store import MemoryStore +from pay_kit._paycore.store import MemoryStore from pay_kit.protocols.mpp.core.types import ChallengeEcho, PaymentCredential from pay_kit.protocols.mpp.intents.charge import ChargeRequest from pay_kit.protocols.mpp.server.charge import ( @@ -135,7 +135,7 @@ async def await_confirmation(self, *_args, **_kwargs): status = (self.statuses or [{}])[0] err = status.get("err") if isinstance(status, dict) else None if err is not None: - from pay_kit.protocols.mpp.core.errors import PaymentError + from pay_kit._paycore.errors import PaymentError raise PaymentError( f"transaction failed on-chain: {err}", @@ -937,7 +937,7 @@ async def await_confirmation(self, *_args, **_kwargs): status = (self._confirm_value or [{}])[0] err = status.get("err") if isinstance(status, dict) else None if err is not None: - from pay_kit.protocols.mpp.core.errors import PaymentError + from pay_kit._paycore.errors import PaymentError raise PaymentError( f"transaction failed on-chain: {err}", @@ -984,7 +984,7 @@ async def test_broadcast_before_consume(self): ordering: list[str] = [] rpc = self._OrderingRPC(ordering, [{"err": None}]) store = self._RecordingStore(ordering) - from pay_kit.protocols.mpp.core.store import Store # noqa: F401 ensure protocol import + from pay_kit._paycore.store import Store # noqa: F401 ensure protocol import handler = Mpp( Config( @@ -1077,7 +1077,7 @@ async def await_confirmation(self, *_a, **_kw): ordering.append("await_confirmation") rpc = _NoRPC() - from pay_kit.protocols.mpp.core.store import MemoryStore + from pay_kit._paycore.store import MemoryStore handler = Mpp( Config( @@ -1366,7 +1366,7 @@ def test_unknown_compute_budget_discriminator_is_rejected(self): assert exc.value.code == "compute-budget-invalid" def test_canonical_code_maps_to_payment_invalid(self): - from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit._paycore.errors import CODE_PAYMENT_INVALID, canonical_code assert canonical_code("compute-budget-cap-exceeded") == CODE_PAYMENT_INVALID assert canonical_code("compute-budget-invalid") == CODE_PAYMENT_INVALID @@ -1409,7 +1409,7 @@ def test_splits_over_cap_is_rejected(self): assert str(MAX_SPLITS) in str(exc.value) def test_canonical_code_maps_to_payment_invalid(self): - from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit._paycore.errors import CODE_PAYMENT_INVALID, canonical_code assert canonical_code("too-many-splits") == CODE_PAYMENT_INVALID @@ -1480,7 +1480,7 @@ def test_valid_payment_with_extra_system_transfer_to_attacker_is_rejected(self): attacker address. Without the allowlist this would be co-signed and broadcast, draining the fee payer. MUST be rejected with the canonical ``payment_invalid`` code before co-sign.""" - from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit._paycore.errors import CODE_PAYMENT_INVALID, canonical_code from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() @@ -1510,7 +1510,7 @@ def test_valid_payment_with_extra_spl_transfer_is_rejected(self): SPL Token transfer instruction. The native-SOL allowlist must reject any Token Program instruction since a native-SOL charge never legitimately carries one.""" - from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit._paycore.errors import CODE_PAYMENT_INVALID, canonical_code from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() @@ -1543,7 +1543,7 @@ def test_valid_payment_with_extra_spl_transfer_is_rejected(self): def test_valid_payment_with_unknown_program_is_rejected(self): """SECURITY: an arbitrary BPF program invocation alongside the valid payment is not on the allowlist and must be rejected.""" - from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit._paycore.errors import CODE_PAYMENT_INVALID, canonical_code from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() @@ -1714,8 +1714,8 @@ def test_ata_create_for_attacker_owner_is_rejected(self): """SECURITY: an ATA create for an owner that is NOT a charge recipient must be rejected so the attacker cannot get the fee payer to fund an arbitrary ATA rent.""" + from pay_kit._paycore.errors import CODE_PAYMENT_INVALID, canonical_code from pay_kit._paycore.solana import ASSOCIATED_TOKEN_PROGRAM - from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() @@ -1813,7 +1813,7 @@ def test_sol_drain_with_fee_payer_as_source_is_rejected(self): recipient matches destination + amount, but the source IS the fee-payer; the server would otherwise co-sign and drain fee-payer SOL beyond the network fee. MUST be rejected with payment_invalid.""" - from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit._paycore.errors import CODE_PAYMENT_INVALID, canonical_code from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() @@ -1846,7 +1846,7 @@ def test_spl_drain_with_fee_payer_ata_as_source_is_rejected(self): check the allowlist accepts the transfer (correct mint, amount, destination), the server co-signs, and the fee-payer's token balance is drained. MUST be rejected with payment_invalid.""" - from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit._paycore.errors import CODE_PAYMENT_INVALID, canonical_code from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() @@ -1884,7 +1884,7 @@ def test_spl_token_2022_drain_with_fee_payer_ata_as_source_is_rejected(self): """SECURITY: same drain shape on the Token-2022 program id (PYUSD devnet mint, derived under TOKEN_2022_PROGRAM). The fee-payer source check must hold for both token program ids.""" - from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit._paycore.errors import CODE_PAYMENT_INVALID, canonical_code from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent fee_payer = Keypair() @@ -2017,7 +2017,7 @@ def test_sol_drain_with_tampered_echoed_fee_payer_key_is_rejected(self): fix the allowlist compares the source against ATTACKER, finds no match, and lets the transfer through; the server then co-signs and drains itself. MUST be rejected with payment_invalid.""" - from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit._paycore.errors import CODE_PAYMENT_INVALID, canonical_code from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent server_fee_payer = Keypair() @@ -2055,7 +2055,7 @@ def test_spl_drain_with_tampered_echoed_fee_payer_key_is_rejected(self): """SPL variant: client echoes a bogus fee-payer key, the drain transfer is sourced from the real server fee-payer's ATA. MUST be rejected with payment_invalid.""" - from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit._paycore.errors import CODE_PAYMENT_INVALID, canonical_code from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent server_fee_payer = Keypair() @@ -2101,7 +2101,7 @@ def test_echoed_fee_payer_key_mismatch_with_server_signer_is_rejected(self): canonical ``payment_invalid`` code so a tampered echoed key cannot slip through even if the rest of the transaction happens to be well-formed.""" - from pay_kit.protocols.mpp.core.errors import CODE_PAYMENT_INVALID, canonical_code + from pay_kit._paycore.errors import CODE_PAYMENT_INVALID, canonical_code from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent server_fee_payer = Keypair() diff --git a/python/tests/test_server_v0_transactions.py b/python/tests/test_server_v0_transactions.py index f1fe62b73..f730d8a95 100644 --- a/python/tests/test_server_v0_transactions.py +++ b/python/tests/test_server_v0_transactions.py @@ -11,7 +11,7 @@ v0 wire bytes; it can mis-parse them as a degenerate legacy transaction with bogus instructions whose program_id_index points at random account keys. The decoder and allowlist guard against this with -``_is_v0_wire_bytes`` (peeks at the v0 message-version prefix and routes +``is_v0_wire_bytes`` (peeks at the v0 message-version prefix and routes to ``VersionedTransaction.from_bytes`` first). The tests here exercise the v0 paths reachable today: the version-prefix detector, the v0 allowlist happy path under repeated random keypairs (which used to be a @@ -33,8 +33,9 @@ from solders.system_program import TransferParams, transfer from solders.transaction import VersionedTransaction +from pay_kit._paycore.errors import PaymentError from pay_kit._paycore.solana import MethodDetails -from pay_kit.protocols.mpp.core.errors import PaymentError +from pay_kit._paycore.transaction import is_v0_wire_bytes from pay_kit.protocols.mpp.intents.charge import ChargeRequest from pay_kit.protocols.mpp.server import charge as M @@ -204,7 +205,7 @@ def test_allowlist_v0_native_transfer_accepted_no_lenient_misparse(): transaction whose instructions point at random ``account_keys`` slots. The allowlist would then reject the legitimate v0 payment with a misleading ``unexpected program instruction in payment transaction: - `` error. ``_is_v0_wire_bytes`` detects the v0 message + `` error. ``is_v0_wire_bytes`` detects the v0 message prefix and forces ``VersionedTransaction.from_bytes`` to take the parse, so the allowlist sees the real System transfer. @@ -232,17 +233,17 @@ def test_is_v0_wire_bytes_classifies_correctly(): ix = transfer(TransferParams(from_pubkey=payer.pubkey(), to_pubkey=recipient.pubkey(), lamports=1)) v0_raw = base64.b64decode(_v0_tx_b64(payer, [ix])) - assert M._is_v0_wire_bytes(v0_raw) is True + assert is_v0_wire_bytes(v0_raw) is True blockhash = Hash.from_string(TEST_BLOCKHASH) legacy_msg = Message.new_with_blockhash([ix], payer.pubkey(), blockhash) legacy_tx = Transaction.new_unsigned(legacy_msg) legacy_tx.sign([payer], blockhash) legacy_raw = bytes(legacy_tx) - assert M._is_v0_wire_bytes(legacy_raw) is False + assert is_v0_wire_bytes(legacy_raw) is False - assert M._is_v0_wire_bytes(b"") is False - assert M._is_v0_wire_bytes(b"\x01") is False + assert is_v0_wire_bytes(b"") is False + assert is_v0_wire_bytes(b"\x01") is False def test_allowlist_invalid_bytes_rejected_with_invalid_payload_type(): diff --git a/python/tests/test_store.py b/python/tests/test_store.py index 19d13054f..5d5719a56 100644 --- a/python/tests/test_store.py +++ b/python/tests/test_store.py @@ -6,7 +6,7 @@ import pytest -from pay_kit.protocols.mpp.core.store import FileReplayStore, MemoryStore, Store +from pay_kit._paycore.store import FileReplayStore, MemoryStore, Store class TestMemoryStore: @@ -164,7 +164,7 @@ class TestMppRequiresExplicitStore: """L4 lock: ``Mpp.__init__`` MUST refuse to start without an explicit store.""" def test_missing_store_raises(self): - from pay_kit.protocols.mpp.core.errors import PaymentError + from pay_kit._paycore.errors import PaymentError from pay_kit.protocols.mpp.server.charge import Config, Mpp with pytest.raises(PaymentError, match="replay store is required"): From 47b1020eed829600509f46b481296592c1e1d50e Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 19:13:41 +0300 Subject: [PATCH 20/45] fix(python): align x402 Lighthouse program id with the rust spine 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. --- python/src/pay_kit/protocols/x402/verify.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/python/src/pay_kit/protocols/x402/verify.py b/python/src/pay_kit/protocols/x402/verify.py index a049db868..578a6b8d7 100644 --- a/python/src/pay_kit/protocols/x402/verify.py +++ b/python/src/pay_kit/protocols/x402/verify.py @@ -4,10 +4,11 @@ 402 challenges, runs the structural 11-rule verifier on submitted credentials, cosigns as the facilitator fee payer, broadcasts via the configured RPC, and namespaces the consumed signature in the replay store. ``ExactVerifier`` -mirrors the Rust spine at -``rust/crates/x402/src/protocol/schemes/exact/verify.rs`` and the server -backstops at ``rust/crates/x402/src/server/exact.rs`` byte-for-byte, and the -PHP port at ``php/src/Protocols/X402/{Adapter,Exact/Verifier}.php``. +follows the Rust spine rule-for-rule and reject-code-for-reject-code +(``rust/crates/x402/src/protocol/schemes/exact/verify.rs`` and the server +backstops at ``rust/crates/x402/src/server/exact.rs``), adding only +strictly-stronger defensive rejects; cross-checked against the PHP port at +``php/src/Protocols/X402/{Adapter,Exact/Verifier}.php``. Delegated mode (``X402Config.facilitator_url`` set) is reserved in the config schema but not yet wired; the adapter raises ``NotImplementedError`` when a @@ -126,7 +127,9 @@ class X402ResponseEnvelope(TypedDict): #: SPL Memo program id (allowlisted optional instruction + memo binding). MEMO_PROGRAM = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" #: Lighthouse assertion program id (allowlisted optional instruction). -LIGHTHOUSE_PROGRAM = "L1TEVtgA75k273wWz1s6XMmDhQY5i3MwcvKb4VbZzfK" +#: Must match the rust spine constant ``LIGHTHOUSE_PROGRAM`` in +#: ``rust/crates/x402/src/protocol/schemes/exact/types.rs``. +LIGHTHOUSE_PROGRAM = "L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95" #: Token-2022 program id (accepted transfer program alongside the route's). TOKEN_2022_PROGRAM = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" #: Maximum SetComputeUnitPrice in microlamports. Matches the Rust spine From 979dd0a44b963c9729d204d9e2819f1ce5225ee5 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 19:13:41 +0300 Subject: [PATCH 21/45] feat(python): implement SPL-token MPP client charge transfers 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). --- .../pay_kit/protocols/mpp/client/charge.py | 71 ++++++++++++++++--- .../pay_kit/protocols/mpp/server/charge.py | 5 +- python/tests/test_client_charge.py | 68 +++++++++++++++++- python/tests/test_client_charge_edge.py | 35 ++++++--- 4 files changed, 156 insertions(+), 23 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/client/charge.py b/python/src/pay_kit/protocols/mpp/client/charge.py index 9458d10d6..67bd80fb6 100644 --- a/python/src/pay_kit/protocols/mpp/client/charge.py +++ b/python/src/pay_kit/protocols/mpp/client/charge.py @@ -2,22 +2,24 @@ from __future__ import annotations -import logging from typing import Any +from pay_kit._paycore.mints import derive_ata from pay_kit._paycore.solana import ( + ASSOCIATED_TOKEN_PROGRAM, MEMO_PROGRAM, + SYSTEM_PROGRAM, CredentialPayload, MethodDetails, + default_token_program_for_currency, is_native_sol, + resolve_mint, ) from pay_kit.protocols.mpp.core.base64url import decode_json from pay_kit.protocols.mpp.core.headers import format_authorization from pay_kit.protocols.mpp.core.types import PaymentChallenge, PaymentCredential from pay_kit.protocols.mpp.intents.charge import ChargeRequest -logger = logging.getLogger(__name__) - async def build_credential_header( signer: Any, @@ -138,11 +140,64 @@ def append_memo(memo: str) -> None: instructions.append(split_ix) append_memo(split.memo) else: - # SPL token transfer -- requires more complex instruction building - # This is a simplified version; full implementation would handle - # ATA creation, TransferChecked, etc. - logger.warning("SPL token transfers require full solana-py integration") - raise NotImplementedError("SPL token client transfers not yet implemented") + # SPL token transfer: one TransferChecked per recipient to their ATA, + # mirroring the Go client and what the server verifier expects. Decimals + # come from the challenge methodDetails (stablecoins are 6). An + # idempotent create-ATA is prepended only for splits that flag it. + from solders.instruction import AccountMeta + + mint = resolve_mint(currency, details.network) + token_program = details.token_program or default_token_program_for_currency(currency, details.network) + decimals = details.decimals if details.decimals is not None else 6 + token_program_key = Pubkey.from_string(token_program) + mint_key = Pubkey.from_string(mint) + system_program_key = Pubkey.from_string(SYSTEM_PROGRAM) + ata_program_key = Pubkey.from_string(ASSOCIATED_TOKEN_PROGRAM) + source_ata = Pubkey.from_string(derive_ata(str(signer.pubkey()), mint, token_program)) + + def append_transfer_checked(owner: Any, transfer_amount: int, create_ata: bool, memo: str) -> None: + dest_ata = Pubkey.from_string(derive_ata(str(owner), mint, token_program)) + if create_ata: + # Associated Token Account program CreateIdempotent (discriminator 1): + # the payer funds the recipient's ATA when it does not yet exist. + instructions.append( + Instruction( + ata_program_key, + bytes([1]), + [ + AccountMeta(signer.pubkey(), True, True), + AccountMeta(dest_ata, False, True), + AccountMeta(owner, False, False), + AccountMeta(mint_key, False, False), + AccountMeta(system_program_key, False, False), + AccountMeta(token_program_key, False, False), + ], + ) + ) + # SPL Token TransferChecked (discriminator 12): amount u64 LE + decimals u8. + data = bytes([12]) + transfer_amount.to_bytes(8, "little") + bytes([decimals]) + instructions.append( + Instruction( + token_program_key, + data, + [ + AccountMeta(source_ata, False, True), + AccountMeta(mint_key, False, False), + AccountMeta(dest_ata, False, True), + AccountMeta(signer.pubkey(), True, False), + ], + ) + ) + append_memo(memo) + + append_transfer_checked(recipient_key, primary_amount, False, external_id) + for split in details.splits: + append_transfer_checked( + Pubkey.from_string(split.recipient), + int(split.amount), + split.ata_creation_required, + split.memo, + ) # Get recent blockhash if details.recent_blockhash: diff --git a/python/src/pay_kit/protocols/mpp/server/charge.py b/python/src/pay_kit/protocols/mpp/server/charge.py index 6a6f8a736..1b4a09aff 100644 --- a/python/src/pay_kit/protocols/mpp/server/charge.py +++ b/python/src/pay_kit/protocols/mpp/server/charge.py @@ -1452,9 +1452,8 @@ async def _verify_transaction( # Reject up-front if the client signed against the wrong network # (e.g. mainnet keypair pointed at a sandbox-configured server, or - # vice versa). Cheaper and clearer than letting the broadcast fail. - # Done here in the entry path so it runs even while the rest of the - # pipeline below is still a stub. + # vice versa). Done first in the entry path so the cheap, unambiguous + # check fails fast before the full verification + broadcast pipeline. try: blockhash_b58 = _extract_recent_blockhash(payload.transaction) except Exception as exc: # noqa: BLE001 — propagate decode failures as invalid payload diff --git a/python/tests/test_client_charge.py b/python/tests/test_client_charge.py index 98af03dab..19e087b2e 100644 --- a/python/tests/test_client_charge.py +++ b/python/tests/test_client_charge.py @@ -8,12 +8,45 @@ from solders.keypair import Keypair from solders.transaction import Transaction -from pay_kit._paycore.solana import MEMO_PROGRAM, MethodDetails, Split +from pay_kit._paycore.mints import derive_ata, resolve, token_program_for +from pay_kit._paycore.solana import ( + ASSOCIATED_TOKEN_PROGRAM, + MEMO_PROGRAM, + MethodDetails, + Split, +) from pay_kit.protocols.mpp.client.charge import build_charge_transaction BLOCKHASH = "11111111111111111111111111111111" +def _spl_transfers(transaction_b64: str, token_program: str) -> list[tuple[str, int, int]]: + """Return (dest_ata, amount, decimals) for each TransferChecked (disc 12).""" + tx = Transaction.from_bytes(base64.b64decode(transaction_b64)) + keys = tx.message.account_keys + out: list[tuple[str, int, int]] = [] + for ix in tx.message.instructions: + if str(keys[ix.program_id_index]) != token_program: + continue + data = bytes(ix.data) + if not data or data[0] != 12: + continue + amount = int.from_bytes(data[1:9], "little") + decimals = data[9] + dest_ata = str(keys[ix.accounts[2]]) + out.append((dest_ata, amount, decimals)) + return out + + +def _has_create_ata(transaction_b64: str) -> bool: + tx = Transaction.from_bytes(base64.b64decode(transaction_b64)) + keys = tx.message.account_keys + return any( + str(keys[ix.program_id_index]) == ASSOCIATED_TOKEN_PROGRAM and bytes(ix.data) == bytes([1]) + for ix in tx.message.instructions + ) + + def _memo_texts(transaction_b64: str) -> list[str]: tx = Transaction.from_bytes(base64.b64decode(transaction_b64)) account_keys = tx.message.account_keys @@ -46,6 +79,39 @@ async def test_build_charge_transaction_includes_external_id_and_split_memos(): assert _memo_texts(payload.transaction) == ["order-123", "platform fee"] +async def test_build_charge_transaction_spl_token_transfers_checked_to_atas(): + signer = Keypair() + recipient = str(Keypair().pubkey()) + split_recipient = str(Keypair().pubkey()) + mint = resolve("USDC", "mainnet") + assert mint is not None + tp = token_program_for("USDC", "mainnet") + + payload = await build_charge_transaction( + signer=signer, + rpc_client=None, + amount="1000", + currency="USDC", + recipient=recipient, + external_id="order-9", + method_details=MethodDetails( + network="mainnet", + decimals=6, + recent_blockhash=BLOCKHASH, + splits=[Split(recipient=split_recipient, amount="200", ata_creation_required=True)], + ), + ) + + transfers = _spl_transfers(payload.transaction, tp) + # Primary nets 800, split 200; each TransferChecked targets the recipient ATA. + assert (derive_ata(recipient, mint, tp), 800, 6) in transfers + assert (derive_ata(split_recipient, mint, tp), 200, 6) in transfers + # The split flagged ata_creation_required, so an idempotent create-ATA is prepended. + assert _has_create_ata(payload.transaction) + # The root memo still rides along. + assert "order-9" in _memo_texts(payload.transaction) + + async def test_build_charge_transaction_rejects_long_external_id_memo(): signer = Keypair() recipient = str(Keypair().pubkey()) diff --git a/python/tests/test_client_charge_edge.py b/python/tests/test_client_charge_edge.py index 094320415..a344071d8 100644 --- a/python/tests/test_client_charge_edge.py +++ b/python/tests/test_client_charge_edge.py @@ -59,19 +59,32 @@ async def test_build_charge_transaction_fetches_blockhash_when_unset(): assert payload.type == "transaction" -async def test_build_charge_transaction_spl_raises_not_implemented(): +async def test_build_charge_transaction_spl_raw_mint_builds_transfer_checked(): + # currency given as a raw mint address (not a known symbol): resolve_mint + # passes it through and the client builds an SPL TransferChecked to the + # recipient ATA. Guards against regressing the SPL client path back to a stub. + import base64 + + from solders.transaction import Transaction + signer = Keypair() recipient = str(Keypair().pubkey()) - with pytest.raises(NotImplementedError): - await build_charge_transaction( - signer=signer, - rpc_client=None, - amount="100", - currency="EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - recipient=recipient, - external_id="", - method_details=MethodDetails(recent_blockhash=BLOCKHASH), - ) + payload = await build_charge_transaction( + signer=signer, + rpc_client=None, + amount="100", + currency="EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + recipient=recipient, + external_id="", + method_details=MethodDetails(recent_blockhash=BLOCKHASH, decimals=6), + ) + + tx = Transaction.from_bytes(base64.b64decode(payload.transaction)) + transfer_checked = [bytes(ix.data) for ix in tx.message.instructions if bytes(ix.data)[:1] == b"\x0c"] + assert len(transfer_checked) == 1 + data = transfer_checked[0] + assert int.from_bytes(data[1:9], "little") == 100 # amount + assert data[9] == 6 # decimals async def test_build_charge_transaction_splits_consume_entire_amount_raises(): From e179f5093e872d5c8004b616d6c42c87ca2717fc Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 20:10:14 +0300 Subject: [PATCH 22/45] fix(php,lua): align x402 Lighthouse program id with the rust spine 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. --- lua/pay_kit/protocols/x402/exact/verify.lua | 2 +- php/src/Protocols/X402/Exact/Verifier.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/pay_kit/protocols/x402/exact/verify.lua b/lua/pay_kit/protocols/x402/exact/verify.lua index fd641ec9a..bde285ae0 100644 --- a/lua/pay_kit/protocols/x402/exact/verify.lua +++ b/lua/pay_kit/protocols/x402/exact/verify.lua @@ -36,7 +36,7 @@ local M = {} local COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111' local MEMO_PROGRAM = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr' -local LIGHTHOUSE_PROGRAM = 'L1TEVtgA75k273wWz1s6XMmDhQY5i3MwcvKb4VbZzfK' +local LIGHTHOUSE_PROGRAM = 'L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95' local ASSOCIATED_TOKEN_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL' local TOKEN_2022_PROGRAM = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb' local MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 50000 diff --git a/php/src/Protocols/X402/Exact/Verifier.php b/php/src/Protocols/X402/Exact/Verifier.php index 9dae0e025..5661aca49 100644 --- a/php/src/Protocols/X402/Exact/Verifier.php +++ b/php/src/Protocols/X402/Exact/Verifier.php @@ -36,7 +36,7 @@ final class Verifier { public const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111'; public const MEMO_PROGRAM = 'MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr'; - public const LIGHTHOUSE_PROGRAM = 'L1TEVtgA75k273wWz1s6XMmDhQY5i3MwcvKb4VbZzfK'; + public const LIGHTHOUSE_PROGRAM = 'L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95'; public const ASSOCIATED_TOKEN_PROGRAM = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'; public const TOKEN_2022_PROGRAM = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'; public const MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 50000; From ca55dfe6ec9a43427c00e608cd32de4404326ced Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 20:56:38 +0300 Subject: [PATCH 23/45] refactor(python): split x402 into exact/{verify,types} + client packages 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. --- python/src/pay_kit/_middleware.py | 2 +- python/src/pay_kit/protocols/x402/__init__.py | 391 ++++++++- .../pay_kit/protocols/x402/client/__init__.py | 1 + .../protocols/x402/client/exact/__init__.py | 1 + .../pay_kit/protocols/x402/exact/__init__.py | 1 + .../src/pay_kit/protocols/x402/exact/types.py | 85 ++ .../pay_kit/protocols/x402/exact/verify.py | 356 ++++++++ python/src/pay_kit/protocols/x402/verify.py | 796 ------------------ python/tests/test_pk_x402_settle.py | 10 +- python/tests/test_pk_x402_verifier.py | 5 +- 10 files changed, 838 insertions(+), 810 deletions(-) create mode 100644 python/src/pay_kit/protocols/x402/client/__init__.py create mode 100644 python/src/pay_kit/protocols/x402/client/exact/__init__.py create mode 100644 python/src/pay_kit/protocols/x402/exact/__init__.py create mode 100644 python/src/pay_kit/protocols/x402/exact/types.py create mode 100644 python/src/pay_kit/protocols/x402/exact/verify.py delete mode 100644 python/src/pay_kit/protocols/x402/verify.py diff --git a/python/src/pay_kit/_middleware.py b/python/src/pay_kit/_middleware.py index a37f7f76b..ccaabf412 100644 --- a/python/src/pay_kit/_middleware.py +++ b/python/src/pay_kit/_middleware.py @@ -49,7 +49,7 @@ if TYPE_CHECKING: from pay_kit.config import Config from pay_kit.protocols.mpp import MppAcceptsEntry - from pay_kit.protocols.x402.verify import X402AcceptsEntry + from pay_kit.protocols.x402.exact.types import X402AcceptsEntry __all__ = [ "PayCore", diff --git a/python/src/pay_kit/protocols/x402/__init__.py b/python/src/pay_kit/protocols/x402/__init__.py index 25668d5b5..92917dd0a 100644 --- a/python/src/pay_kit/protocols/x402/__init__.py +++ b/python/src/pay_kit/protocols/x402/__init__.py @@ -1,13 +1,392 @@ -"""x402 ``exact`` (Solana) protocol package. +"""x402 ``exact`` (Solana) adapter package. -Public surface mirrors the former flat ``pay_kit.protocols.x402`` module: -the ``X402Adapter`` server adapter plus the ``ExactVerifier`` structural -verifier and the ``X402_VERSION`` constant. The verifier, adapter, and the -``X402*`` wire TypedDicts live in :mod:`pay_kit.protocols.x402.verify`. +Self-hosted x402 ``exact`` scheme for the Solana SVM. ``X402Adapter`` (this +module) issues 402 challenges, runs the structural 11-rule verifier on +submitted credentials, cosigns as the facilitator fee payer, broadcasts via +the configured RPC, and namespaces the consumed signature in the replay store. +The verifier, module constants, and ``X402*`` wire TypedDicts live under +:mod:`pay_kit.protocols.x402.exact`; the client builder lives under +:mod:`pay_kit.protocols.x402.client`. + +Delegated mode (``X402Config.facilitator_url`` set) is reserved in the config +schema but not yet wired; the adapter raises ``NotImplementedError`` when a +facilitator URL is configured. Self-hosted is the only x402 path that ships. """ from __future__ import annotations -from pay_kit.protocols.x402.verify import X402_VERSION, ExactVerifier, X402Adapter +import base64 +import json +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, cast + +from pay_kit._paycore.mints import resolve, token_program_for +from pay_kit._paycore.network_check import check_network_blockhash +from pay_kit._paycore.protocol import Protocol +from pay_kit._paycore.rpc import SolanaRpc +from pay_kit._paycore.store import MemoryStore, Store +from pay_kit.errors import InvalidProofError +from pay_kit.payment import Payment +from pay_kit.protocols.x402.exact.types import ( + X402AcceptsEntry, + X402Challenge, + X402Extra, + X402PayloadField, + X402ResponseEnvelope, +) +from pay_kit.protocols.x402.exact.verify import X402_VERSION, ExactVerifier + +if TYPE_CHECKING: + from pay_kit.config import Config + from pay_kit.gate import Gate __all__ = ["X402Adapter", "ExactVerifier", "X402_VERSION"] + + +_SETTLEMENT_HEADER = "x-payment-settlement-signature" +_RESPONSE_HEADER = "payment-response" +_REPLAY_PREFIX = "x402-svm-exact:consumed:" + + +class X402Adapter: + """Self-hosted server adapter for the x402 ``exact`` Solana scheme.""" + + def __init__( + self, + config: Config, + replay_store: Store | None = None, + recent_blockhash_provider: Callable[[], str | None] | None = None, + ) -> None: + """Build an adapter bound to ``config``; raise for delegated mode.""" + if config.x402.is_delegated(): + raise NotImplementedError( + "pay_kit: x402 delegated mode is not yet implemented; " + "leave X402Config.facilitator_url None for self-hosted" + ) + self._config = config + self._store: Store = replay_store if replay_store is not None else MemoryStore() + self._recent_blockhash_provider = recent_blockhash_provider + + def accepts_entry(self, gate: Gate, request: Any) -> X402AcceptsEntry: + """Build one ``accepts[]`` entry (the server x402 offer for ``gate``).""" + coin = gate.amount.primary_coin() + coin_value = coin.value if coin is not None else self._config.stablecoins[0].value + label = self._config.network.mints_label() + # x402 puts the on-chain mint pubkey on `asset`, not the ticker. + # resolve() falls back to the mainnet row when the network row is + # absent (caveat #1). + asset = resolve(coin_value, label) or coin_value + token_program = token_program_for(coin_value, label) + pay_to = gate.pay_to or self._config.effective_recipient() + amount = str(int(gate.total().amount * 1_000_000)) + signer = self._config.x402.effective_signer(self._config.operator) + extra: X402Extra = { + "feePayer": signer.pubkey() if signer is not None else "", + "decimals": 6, + "tokenProgram": token_program, + "memo": _request_path(request), + } + # caveat #5: stamp the server's recent blockhash into accepted.extra + # so pay-kit Rust clients sign against the same chain state the server + # broadcasts to. Canonical TS/Go clients ignore it; harmless on real + # networks. The provider keeps unit tests offline. + blockhash = self._fetch_recent_blockhash() + if blockhash is not None: + extra["recentBlockhash"] = blockhash + return { + "protocol": "x402", + "scheme": "exact", + "network": self._caip2(), + "asset": asset, + "amount": amount, + "maxAmountRequired": amount, + "payTo": pay_to, + "maxTimeoutSeconds": 60, + "extra": extra, + } + + def challenge_headers(self, gate: Gate, request: Any) -> dict[str, str]: + """Build the ``payment-required`` header (base64 JSON challenge).""" + challenge: X402Challenge = { + "x402Version": X402_VERSION, + "resource": {"type": "http", "url": _request_path(request)}, + "accepts": [self.accepts_entry(gate, request)], + } + payload = json.dumps(challenge, separators=(",", ":")).encode("utf-8") + return {"payment-required": base64.b64encode(payload).decode("ascii")} + + async def verify_and_settle(self, gate: Gate, request: Any) -> Payment: + """Verify the submitted x402 credential, cosign, broadcast, settle.""" + signer = self._config.x402.effective_signer(self._config.operator) + if signer is None: + raise InvalidProofError("pay_kit: x402 requires operator.signer", code="payment_invalid") + + header = _payment_signature_header(request) + if not header: + raise InvalidProofError("pay_kit: payment required", code="payment_required") + + try: + decoded = base64.b64decode(header, validate=True) + except Exception as exc: # noqa: BLE001 + raise InvalidProofError( + "invalid_exact_svm_payload_signature_base64", + code="invalid_exact_svm_payload_signature_base64", + ) from exc + try: + envelope = json.loads(decoded) + except Exception as exc: # noqa: BLE001 + raise InvalidProofError( + "invalid_exact_svm_payload_signature_json", + code="invalid_exact_svm_payload_signature_json", + ) from exc + + if not isinstance(envelope, dict): + raise InvalidProofError("unsupported_x402_version", code="unsupported_x402_version") + # The envelope is attacker-controlled; it is validated field-by-field + # below, then narrowed to the typed wire shape for the rest of the flow. + envelope_map = cast("dict[str, object]", envelope) + if envelope_map.get("x402Version") != X402_VERSION: + raise InvalidProofError("unsupported_x402_version", code="unsupported_x402_version") + accepted_raw = envelope_map.get("accepted") + payload_raw = envelope_map.get("payload") + if not isinstance(accepted_raw, dict) or not isinstance(payload_raw, dict): + raise InvalidProofError( + "invalid_exact_svm_payload_envelope", + code="invalid_exact_svm_payload_envelope", + ) + accepted = cast("dict[str, object]", accepted_raw) + payload = cast("X402PayloadField", payload_raw) + + # Tier-2 identity-key match: the credential's accepted requirement must + # match the server's freshly built offer for this route. x402 has no + # HMAC-bound challenge id, so the offer is the source of truth and the + # credential's `accepted` is never trusted for the route's parameters + # (mirrors rust verify_pinned_fields + the targeted deepEqual gate). + offer = self.accepts_entry(gate, request) + offer_map = cast("dict[str, object]", offer) + for key in ("scheme", "network", "asset", "payTo"): + if accepted.get(key) != offer_map.get(key): + raise InvalidProofError( + "pay_kit: charge_request_mismatch: accepted payment requirement does not match server challenge", + code="charge_request_mismatch", + ) + if accepted.get("amount") != offer_map.get("amount") and accepted.get("maxAmountRequired") != offer_map.get( + "maxAmountRequired" + ): + raise InvalidProofError( + "pay_kit: charge_request_mismatch (amount)", + code="charge_request_mismatch", + ) + offer_extra = cast("dict[str, object]", offer_map.get("extra") or {}) + accepted_extra_raw = accepted.get("extra") + accepted_extra = cast("dict[str, object]", accepted_extra_raw if isinstance(accepted_extra_raw, dict) else {}) + for key in ("feePayer", "tokenProgram", "memo"): + if key in offer_extra and accepted_extra.get(key) != offer_extra[key]: + raise InvalidProofError( + f"pay_kit: charge_request_mismatch (extra.{key})", + code="charge_request_mismatch", + ) + + tx_base64 = payload.get("transaction") + if not isinstance(tx_base64, str) or tx_base64 == "": + raise InvalidProofError( + "invalid_exact_svm_payload_missing_transaction", + code="invalid_exact_svm_payload_missing_transaction", + ) + + # Structural shape (11 rules) against the server offer. + ExactVerifier.verify(tx_base64, cast("dict[str, Any]", offer), [signer.pubkey()]) + + # Reject up-front if the client signed against the wrong cluster. + # Skip on a loopback RPC where a Surfpool blockhash is expected. + rpc_url = self._config.effective_rpc_url() + if not _is_loopback_rpc(rpc_url): + blockhash = _recent_blockhash_of(tx_base64) + if blockhash is not None: + check_network_blockhash(self._config.network.mints_label(), blockhash) + + # Cosign as the facilitator fee payer (slot-splice, version aware). + cosigned_wire = _co_sign(tx_base64, signer) + + rpc = SolanaRpc(rpc_url) + try: + response = await rpc.send_raw_transaction(cosigned_wire) + signature = str(response.value if hasattr(response, "value") else response) + except Exception as exc: # noqa: BLE001 + raise InvalidProofError(f"pay_kit: invalid proof: broadcast failed: {exc}", code="payment_invalid") from exc + finally: + await rpc.aclose() + if not signature: + raise InvalidProofError("pay_kit: empty broadcast result", code="payment_invalid") + + # Replay reservation. Namespace is distinct from the MPP charge key so + # an x402 signature can never satisfy an MPP route and vice versa. + if not await self._store.put_if_absent(_REPLAY_PREFIX + signature, True): + raise InvalidProofError("pay_kit: signature_consumed", code="signature_consumed") + + accepted_network = accepted.get("network") + response_body: X402ResponseEnvelope = { + "success": True, + "transaction": signature, + "network": accepted_network if isinstance(accepted_network, str) and accepted_network else self._caip2(), + "payer": payload.get("transactionHash", ""), + } + response_envelope = base64.b64encode(json.dumps(response_body, separators=(",", ":")).encode("utf-8")).decode( + "ascii" + ) + + return Payment( + protocol=Protocol.X402, + transaction=signature, + gate_name=gate.name, + settlement_headers={ + _RESPONSE_HEADER: response_envelope, + _SETTLEMENT_HEADER: signature, + }, + raw=header, + ) + + def _fetch_recent_blockhash(self) -> str | None: + if self._recent_blockhash_provider is not None: + try: + value = self._recent_blockhash_provider() + except Exception: # noqa: BLE001 - provider failures are non-fatal + return None + return value if isinstance(value, str) and value != "" else None + return None + + def _caip2(self) -> str: + return self._config.network.caip2() + + +def _co_sign(transaction_b64: str, signer: Any) -> bytes: + """Splice the facilitator signature into the fee-payer slot, return wire. + + Legacy messages are signed over ``bytes(msg)``, v0 over + ``to_bytes_versioned(msg)`` (0x80 prefix). The fee payer must occupy a + signature slot. The v0-wire detector lives in the shared + :mod:`pay_kit._paycore.transaction` core so neither protocol depends on the + other. + """ + from solders.message import to_bytes_versioned + from solders.pubkey import Pubkey + from solders.transaction import Transaction, VersionedTransaction + + from pay_kit._paycore.transaction import is_v0_wire_bytes + + raw = base64.b64decode(transaction_b64) + fee_payer_pubkey = Pubkey.from_string(signer.pubkey()) + + # SECURITY: ``solders.transaction.Transaction.from_bytes`` is lenient and + # silently MIS-PARSES v0 ``VersionedTransaction`` wire bytes as a legacy + # transaction (it does not raise), yielding a bogus header and garbage + # account keys. The rust x402 client (and the canonical PaymentProof + # builder) emit v0 messages, so we must route on the message-version + # prefix byte rather than trusting a legacy parse to fail. Reuses the + # shared ``is_v0_wire_bytes`` guard from ``pay_kit._paycore.transaction`` + # (no parallel detection logic; same routing as the MPP charge cosign). + if is_v0_wire_bytes(raw): + try: + vtx = VersionedTransaction.from_bytes(raw) + except Exception as exc: # noqa: BLE001 + raise InvalidProofError( + "invalid_exact_svm_payload_transaction_parse", + code="invalid_exact_svm_payload_transaction_parse", + ) from exc + account_keys = list(vtx.message.account_keys) + message_bytes = bytes(to_bytes_versioned(vtx.message)) + num_required = int(vtx.message.header.num_required_signatures) + else: + try: + tx = Transaction.from_bytes(raw) + except Exception: # noqa: BLE001 - fall back to versioned + try: + vtx = VersionedTransaction.from_bytes(raw) + except Exception as exc: # noqa: BLE001 + raise InvalidProofError( + "invalid_exact_svm_payload_transaction_parse", + code="invalid_exact_svm_payload_transaction_parse", + ) from exc + account_keys = list(vtx.message.account_keys) + message_bytes = bytes(to_bytes_versioned(vtx.message)) + num_required = int(vtx.message.header.num_required_signatures) + else: + account_keys = list(tx.message.account_keys) + message_bytes = bytes(tx.message) + num_required = int(tx.message.header.num_required_signatures) + + try: + idx = account_keys.index(fee_payer_pubkey) + except ValueError as exc: + raise InvalidProofError( + "pay_kit: fee payer pubkey not present in transaction accounts", + code="payment_invalid", + ) from exc + if idx >= num_required: + raise InvalidProofError("pay_kit: fee payer is not a required signer", code="payment_invalid") + + sig_bytes = bytes(signer.sign(message_bytes)) + serialized = bytearray(raw) + sig_start = 1 + idx * 64 + serialized[sig_start : sig_start + 64] = sig_bytes + return bytes(serialized) + + +def _recent_blockhash_of(transaction_b64: str) -> str | None: + """Best-effort extract of the recent blockhash for the network check.""" + from solders.transaction import VersionedTransaction + + try: + raw = base64.b64decode(transaction_b64) + tx = VersionedTransaction.from_bytes(raw) + return str(tx.message.recent_blockhash) + except Exception: # noqa: BLE001 - the verifier already validated shape + return None + + +def _is_loopback_rpc(rpc_url: str) -> bool: + """True if ``rpc_url`` points at a loopback host (mirror rust).""" + stripped = rpc_url.strip() + for prefix in ("http://", "https://", "ws://", "wss://"): + if stripped.startswith(prefix): + stripped = stripped[len(prefix) :] + break + host_and_rest = stripped.split("/", 1)[0] + host = host_and_rest[1:].split("]", 1)[0] if host_and_rest.startswith("[") else host_and_rest.split(":", 1)[0] + return host in {"127.0.0.1", "localhost", "::1", "0.0.0.0"} + + +def _request_path(request: Any) -> str: + """Resolve the request path across framework request shapes.""" + path = getattr(request, "path", None) + if isinstance(path, str): + return path + url = getattr(request, "url", None) + if url is not None: + url_path = getattr(url, "path", None) + if isinstance(url_path, str): + return url_path + if isinstance(request, dict): + candidate = cast("dict[str, object]", request).get("path") + if isinstance(candidate, str): + return candidate + return "/" + + +def _payment_signature_header(request: Any) -> str: + """Read the ``Payment-Signature`` header across framework request shapes.""" + headers = getattr(request, "headers", None) + if headers is not None: + getter = getattr(headers, "get", None) + if callable(getter): + for name in ("payment-signature", "Payment-Signature", "PAYMENT-SIGNATURE"): + value: object = getter(name) + if value: + return str(value) + if isinstance(request, dict): + raw_headers = cast("dict[str, object]", request).get("headers") + if isinstance(raw_headers, dict): + for key, header_value in cast("dict[object, object]", raw_headers).items(): + if isinstance(key, str) and key.lower() == "payment-signature" and header_value: + return str(header_value) + return "" diff --git a/python/src/pay_kit/protocols/x402/client/__init__.py b/python/src/pay_kit/protocols/x402/client/__init__.py new file mode 100644 index 000000000..eb5a163b4 --- /dev/null +++ b/python/src/pay_kit/protocols/x402/client/__init__.py @@ -0,0 +1 @@ +"""x402 ``exact`` client: challenge parsing, payment building, auto-pay transport.""" diff --git a/python/src/pay_kit/protocols/x402/client/exact/__init__.py b/python/src/pay_kit/protocols/x402/client/exact/__init__.py new file mode 100644 index 000000000..579da04df --- /dev/null +++ b/python/src/pay_kit/protocols/x402/client/exact/__init__.py @@ -0,0 +1 @@ +"""x402 ``exact`` client building blocks (payment + transport).""" diff --git a/python/src/pay_kit/protocols/x402/exact/__init__.py b/python/src/pay_kit/protocols/x402/exact/__init__.py new file mode 100644 index 000000000..1212e6204 --- /dev/null +++ b/python/src/pay_kit/protocols/x402/exact/__init__.py @@ -0,0 +1 @@ +"""x402 ``exact`` scheme internals: structural verifier, constants, wire types.""" diff --git a/python/src/pay_kit/protocols/x402/exact/types.py b/python/src/pay_kit/protocols/x402/exact/types.py new file mode 100644 index 000000000..0839a42ab --- /dev/null +++ b/python/src/pay_kit/protocols/x402/exact/types.py @@ -0,0 +1,85 @@ +"""x402 ``exact`` wire shapes. + +TypedDicts describing the exact JSON dicts the adapter builds for challenges/ +offers and parses from inbound credentials. They give the adapter precise +static types over the wire payloads and never change the serialized bytes. +Optional keys use ``total=False``. Inbound payloads are validated field-by- +field at runtime and then narrowed to these shapes with ``cast``. +""" + +from __future__ import annotations + +from typing import TypedDict + + +class X402ExtraRequired(TypedDict): + """The always-present keys of an x402 ``accepts[].extra`` block.""" + + feePayer: str + decimals: int + tokenProgram: str + memo: str + + +class X402Extra(X402ExtraRequired, total=False): + """An x402 ``accepts[].extra`` block; ``recentBlockhash`` is optional.""" + + recentBlockhash: str + + +class X402Resource(TypedDict): + """The ``resource`` block inside an x402 challenge.""" + + type: str + url: str + + +class X402AcceptsEntry(TypedDict): + """One x402 ``accepts[]`` offer entry (the server requirement).""" + + protocol: str + scheme: str + network: str + asset: str + amount: str + maxAmountRequired: str + payTo: str + maxTimeoutSeconds: int + extra: X402Extra + + +class X402Challenge(TypedDict): + """The base64-encoded ``payment-required`` challenge body.""" + + x402Version: int + resource: X402Resource + accepts: list[X402AcceptsEntry] + + +class X402PayloadField(TypedDict, total=False): + """The ``payload`` block of an inbound X-PAYMENT envelope.""" + + transaction: str + transactionHash: str + + +class X402Envelope(TypedDict, total=False): + """An x402 X-PAYMENT envelope (decoded from / built for the proof header). + + All keys optional because the inbound structure is attacker-controlled and + validated field-by-field at runtime before any value is trusted; the client + builder populates ``x402Version``, ``accepted`` and ``payload``. + """ + + x402Version: int + accepted: X402AcceptsEntry + payload: X402PayloadField + + +class X402ResponseEnvelope(TypedDict): + """The base64-encoded ``payment-response`` settlement receipt.""" + + success: bool + transaction: str + network: str + payer: str diff --git a/python/src/pay_kit/protocols/x402/exact/verify.py b/python/src/pay_kit/protocols/x402/exact/verify.py new file mode 100644 index 000000000..a65bb320d --- /dev/null +++ b/python/src/pay_kit/protocols/x402/exact/verify.py @@ -0,0 +1,356 @@ +"""x402 ``exact`` self-hosted 11-rule structural verifier and constants. + +``ExactVerifier`` follows the Rust spine rule-for-rule and reject-code-for- +reject-code (``rust/crates/x402/src/protocol/schemes/exact/verify.rs`` and the +server backstops at ``rust/crates/x402/src/server/exact.rs``), adding only +strictly-stronger defensive rejects; cross-checked against the PHP port at +``php/src/Protocols/X402/Exact/Verifier.php``. +""" + +from __future__ import annotations + +import base64 +import struct +from typing import Any, cast + +from pay_kit._paycore.mints import derive_ata +from pay_kit._paycore.solana import ASSOCIATED_TOKEN_PROGRAM +from pay_kit.errors import InvalidProofError + +__all__ = [ + "ExactVerifier", + "X402_VERSION", + "COMPUTE_BUDGET_PROGRAM", + "MEMO_PROGRAM", + "LIGHTHOUSE_PROGRAM", + "TOKEN_2022_PROGRAM", + "MAX_COMPUTE_UNIT_PRICE", +] + +#: x402 protocol version emitted in challenges and required on credentials. +X402_VERSION = 2 + +#: ComputeBudget program id (instruction[0]/[1] guard). +COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111" +#: SPL Memo program id (allowlisted optional instruction + memo binding). +MEMO_PROGRAM = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" +#: Lighthouse assertion program id (allowlisted optional instruction). +#: Must match the rust spine constant ``LIGHTHOUSE_PROGRAM`` in +#: ``rust/crates/x402/src/protocol/schemes/exact/types.rs``. +LIGHTHOUSE_PROGRAM = "L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95" +#: Token-2022 program id (accepted transfer program alongside the route's). +TOKEN_2022_PROGRAM = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" +#: Maximum SetComputeUnitPrice in microlamports. Matches the Rust spine +#: constant ``MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS`` in verify.rs. +MAX_COMPUTE_UNIT_PRICE = 5_000_000 + + +def _u64_le(data: bytes, offset: int) -> int: + """Read a little-endian u64 at ``offset``; reject on a short buffer.""" + if len(data) < offset + 8: + raise InvalidProofError( + "invalid_exact_svm_payload_no_transfer_instruction", + code="invalid_exact_svm_payload_no_transfer_instruction", + ) + return struct.unpack_from(" dict[str, Any]: + """Verify a base64 transaction against the route's x402 requirement. + + ``requirement`` is one ``accepts[]`` entry (the server offer). + ``managed_signers`` lists server-managed pubkeys (typically the + facilitator fee payer) that must never be the transfer authority. + Returns a dict describing the matched transfer on success. + """ + from solders.transaction import VersionedTransaction + + try: + raw = base64.b64decode(transaction_base64, validate=True) + except Exception as exc: # noqa: BLE001 - any decode failure is a reject + raise InvalidProofError( + "invalid_exact_svm_payload_base64", + code="invalid_exact_svm_payload_base64", + ) from exc + if not raw: + raise InvalidProofError( + "invalid_exact_svm_payload_base64", + code="invalid_exact_svm_payload_base64", + ) + + try: + tx = VersionedTransaction.from_bytes(raw) + except Exception as exc: # noqa: BLE001 + raise InvalidProofError( + "invalid_exact_svm_payload_transaction_parse", + code="invalid_exact_svm_payload_transaction_parse", + ) from exc + + message = tx.message + instructions = list(message.instructions) + account_keys = [str(key) for key in message.account_keys] + + # Rule 1: instruction count 3..=6. + n = len(instructions) + if n < 3 or n > 6: + raise InvalidProofError( + "invalid_exact_svm_payload_transaction_instructions_length", + code="invalid_exact_svm_payload_transaction_instructions_length", + ) + + # Rule 2: ix[0] = ComputeBudget SetComputeUnitLimit (disc 2, 5 bytes). + ExactVerifier._verify_compute_limit(instructions[0], account_keys) + # Rule 3: ix[1] = ComputeBudget SetComputeUnitPrice (disc 3, 9 bytes, <= MAX). + ExactVerifier._verify_compute_price(instructions[1], account_keys) + # Rules 4 + 5 + 6 + 7 + 8 + 11: transferChecked. + transfer = ExactVerifier._verify_transfer(instructions[2], account_keys, requirement, managed_signers) + + # Rule 9: ix[3:] allowlist (memo, lighthouse(<2 slots), ata-create(<2 slots)). + destination_create_ata = False + reasons = ( + "invalid_exact_svm_payload_unknown_fourth_instruction", + "invalid_exact_svm_payload_unknown_fifth_instruction", + "invalid_exact_svm_payload_unknown_sixth_instruction", + ) + for i in range(3, n): + ix = instructions[i] + program = ExactVerifier._program_of(account_keys, ix) + slot_index = i - 3 + allowed = program == MEMO_PROGRAM or (slot_index < 2 and program == LIGHTHOUSE_PROGRAM) + if ( + not allowed + and slot_index < 2 + and ExactVerifier._valid_ata_create(ix, account_keys, requirement, transfer) + ): + destination_create_ata = True + allowed = True + if not allowed: + reason = ( + reasons[slot_index] + if slot_index < len(reasons) + else "invalid_exact_svm_payload_unknown_optional_instruction" + ) + raise InvalidProofError(reason, code=reason) + + # Rule 10: memo binding (exactly one Memo == extra.memo if set). + expected_memo = ExactVerifier._string_extra(requirement, "memo", required=False) + if expected_memo: + ExactVerifier._find_memo_match(account_keys, instructions, expected_memo) + + transfer["destinationCreateAta"] = destination_create_ata + return transfer + + @staticmethod + def _verify_compute_limit(ix: Any, account_keys: list[str]) -> None: + program = ExactVerifier._program_of(account_keys, ix) + data = bytes(ix.data) + if program != COMPUTE_BUDGET_PROGRAM or len(data) != 5 or data[0] != 2: + raise InvalidProofError( + "invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction", + code="invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction", + ) + + @staticmethod + def _verify_compute_price(ix: Any, account_keys: list[str]) -> None: + program = ExactVerifier._program_of(account_keys, ix) + data = bytes(ix.data) + if program != COMPUTE_BUDGET_PROGRAM or len(data) != 9 or data[0] != 3: + raise InvalidProofError( + "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction", + code="invalid_exact_svm_payload_transaction_instructions_compute_price_instruction", + ) + micro = _u64_le(data, 1) + if micro > MAX_COMPUTE_UNIT_PRICE: + reason = "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high" + raise InvalidProofError(reason, code=reason) + + @staticmethod + def _verify_transfer( + ix: Any, + account_keys: list[str], + requirement: dict[str, Any], + managed_signers: list[str], + ) -> dict[str, Any]: + program = ExactVerifier._program_of(account_keys, ix) + # Rule 11: token program strict bind to extra.tokenProgram. + token_program_extra = ExactVerifier._string_extra(requirement, "tokenProgram", required=True) + if program != token_program_extra and program != TOKEN_2022_PROGRAM: + raise InvalidProofError( + "invalid_exact_svm_payload_no_transfer_instruction", + code="invalid_exact_svm_payload_no_transfer_instruction", + ) + data = bytes(ix.data) + # solders CompiledInstruction.accounts is a list of u8 account indices; + # solders ships no stubs, so annotate the shape explicitly at the boundary. + accounts: list[int] = [int(a) for a in ix.accounts] + # Rule 4: transferChecked shape (disc 12, 10-byte data, >= 4 accounts). + if len(accounts) < 4 or len(data) != 10 or data[0] != 12: + raise InvalidProofError( + "invalid_exact_svm_payload_no_transfer_instruction", + code="invalid_exact_svm_payload_no_transfer_instruction", + ) + + source = ExactVerifier._account_at(account_keys, ix, 0) + mint = ExactVerifier._account_at(account_keys, ix, 1) + destination = ExactVerifier._account_at(account_keys, ix, 2) + authority = ExactVerifier._account_at(account_keys, ix, 3) + + # Rule 5: authority guard (no managed signer as authority/source/account). + for managed in managed_signers: + if managed in (authority, source): + raise InvalidProofError( + "invalid_exact_svm_payload_transaction_fee_payer_transferring_funds", + code="invalid_exact_svm_payload_transaction_fee_payer_transferring_funds", + ) + for idx in accounts: + key = account_keys[idx] if 0 <= idx < len(account_keys) else None + if key is None: + continue + for managed in managed_signers: + if managed == key: + raise InvalidProofError( + "invalid_exact_svm_payload_transaction_fee_payer_in_instruction_accounts", + code="invalid_exact_svm_payload_transaction_fee_payer_in_instruction_accounts", + ) + + # Rule 6: mint match (offer carries the resolved on-chain mint on `asset`). + expected_mint = ExactVerifier._b58_field(requirement, "asset") + if mint != expected_mint: + raise InvalidProofError( + "invalid_exact_svm_payload_mint_mismatch", + code="invalid_exact_svm_payload_mint_mismatch", + ) + + # Rule 7: destination ATA match (re-derive owner+mint+token_program). + pay_to = ExactVerifier._b58_field(requirement, "payTo") + expected_destination = derive_ata(pay_to, expected_mint, program) + if destination != expected_destination: + raise InvalidProofError( + "invalid_exact_svm_payload_recipient_mismatch", + code="invalid_exact_svm_payload_recipient_mismatch", + ) + + # Rule 8: amount match (u64 LE at data[1:9]). + amount = _u64_le(data, 1) + expected_amount = ExactVerifier._amount_field(requirement) + if amount != expected_amount: + raise InvalidProofError( + "invalid_exact_svm_payload_amount_mismatch", + code="invalid_exact_svm_payload_amount_mismatch", + ) + + return { + "program": program, + "source": source, + "mint": mint, + "destination": destination, + "authority": authority, + "amount": amount, + } + + @staticmethod + def _valid_ata_create( + ix: Any, + account_keys: list[str], + requirement: dict[str, Any], + transfer: dict[str, Any], + ) -> bool: + if ExactVerifier._program_of(account_keys, ix) != ASSOCIATED_TOKEN_PROGRAM: + return False + data = bytes(ix.data) + if len(data) < 1 or (data[0] != 0 and data[0] != 1): + return False + if len(list(ix.accounts)) < 6: + return False + ata = ExactVerifier._account_at(account_keys, ix, 1) + owner = ExactVerifier._account_at(account_keys, ix, 2) + mint = ExactVerifier._account_at(account_keys, ix, 3) + if owner != requirement.get("payTo"): + return False + if mint != transfer["mint"]: + return False + return ata == transfer["destination"] + + @staticmethod + def _find_memo_match(account_keys: list[str], instructions: list[Any], expected_memo: str) -> None: + count = 0 + last_data: bytes | None = None + for i in range(3, len(instructions)): + ix = instructions[i] + if ExactVerifier._program_of(account_keys, ix) == MEMO_PROGRAM: + count += 1 + last_data = bytes(ix.data) + if count != 1: + raise InvalidProofError( + "invalid_exact_svm_payload_memo_count", + code="invalid_exact_svm_payload_memo_count", + ) + if last_data is None or last_data.decode("utf-8", "replace") != expected_memo: + raise InvalidProofError( + "invalid_exact_svm_payload_memo_mismatch", + code="invalid_exact_svm_payload_memo_mismatch", + ) + + @staticmethod + def _program_of(account_keys: list[str], ix: Any) -> str: + idx = int(ix.program_id_index) + return account_keys[idx] if 0 <= idx < len(account_keys) else "" + + @staticmethod + def _account_at(account_keys: list[str], ix: Any, slot: int) -> str: + accounts = list(ix.accounts) + if slot >= len(accounts): + raise InvalidProofError( + "invalid_exact_svm_payload_no_transfer_instruction", + code="invalid_exact_svm_payload_no_transfer_instruction", + ) + idx = int(accounts[slot]) + return account_keys[idx] if 0 <= idx < len(account_keys) else "" + + @staticmethod + def _b58_field(requirement: dict[str, Any], key: str) -> str: + value = requirement.get(key) + if not isinstance(value, str) or value == "": + raise InvalidProofError( + f"invalid_exact_svm_payload_missing_field_{key}", + code=f"invalid_exact_svm_payload_missing_field_{key}", + ) + return value + + @staticmethod + def _string_extra(requirement: dict[str, Any], key: str, *, required: bool) -> str | None: + extra = requirement.get("extra") + value = cast("dict[str, object]", extra).get(key) if isinstance(extra, dict) else None + if (value is None or value == "") and required: + raise InvalidProofError( + f"invalid_exact_svm_payload_missing_extra_{key}", + code=f"invalid_exact_svm_payload_missing_extra_{key}", + ) + return value if isinstance(value, str) else None + + @staticmethod + def _amount_field(requirement: dict[str, Any]) -> int: + value = requirement.get("amount") + if value is None: + value = requirement.get("maxAmountRequired") + if not isinstance(value, (str, int)): + raise InvalidProofError( + "invalid_exact_svm_payload_missing_field_amount", + code="invalid_exact_svm_payload_missing_field_amount", + ) + return int(value) diff --git a/python/src/pay_kit/protocols/x402/verify.py b/python/src/pay_kit/protocols/x402/verify.py deleted file mode 100644 index 578a6b8d7..000000000 --- a/python/src/pay_kit/protocols/x402/verify.py +++ /dev/null @@ -1,796 +0,0 @@ -"""x402 ``exact`` (Solana) adapter and self-hosted 11-rule verifier. - -Self-hosted x402 ``exact`` scheme for the Solana SVM. ``X402Adapter`` issues -402 challenges, runs the structural 11-rule verifier on submitted credentials, -cosigns as the facilitator fee payer, broadcasts via the configured RPC, and -namespaces the consumed signature in the replay store. ``ExactVerifier`` -follows the Rust spine rule-for-rule and reject-code-for-reject-code -(``rust/crates/x402/src/protocol/schemes/exact/verify.rs`` and the server -backstops at ``rust/crates/x402/src/server/exact.rs``), adding only -strictly-stronger defensive rejects; cross-checked against the PHP port at -``php/src/Protocols/X402/{Adapter,Exact/Verifier}.php``. - -Delegated mode (``X402Config.facilitator_url`` set) is reserved in the config -schema but not yet wired; the adapter raises ``NotImplementedError`` when a -facilitator URL is configured. Self-hosted is the only x402 path that ships. -""" - -from __future__ import annotations - -import base64 -import json -import struct -from collections.abc import Callable -from typing import TYPE_CHECKING, Any, TypedDict, cast - -from pay_kit._paycore.mints import derive_ata, resolve, token_program_for -from pay_kit._paycore.network_check import check_network_blockhash -from pay_kit._paycore.protocol import Protocol -from pay_kit._paycore.rpc import SolanaRpc -from pay_kit._paycore.solana import ASSOCIATED_TOKEN_PROGRAM -from pay_kit._paycore.store import MemoryStore, Store -from pay_kit.errors import InvalidProofError -from pay_kit.payment import Payment - -if TYPE_CHECKING: - from pay_kit.config import Config - from pay_kit.gate import Gate - -__all__ = ["X402Adapter", "ExactVerifier", "X402_VERSION"] - - -# --- x402 wire shapes ------------------------------------------------------- -# TypedDicts describing the exact JSON dicts the adapter builds for challenges/ -# offers and parses from inbound credentials. They give the adapter precise -# static types over the wire payloads and never change the serialized bytes. -# Optional keys use ``total=False``. Inbound payloads are validated field-by- -# field at runtime and then narrowed to these shapes with ``cast``. - - -class X402ExtraRequired(TypedDict): - """The always-present keys of an x402 ``accepts[].extra`` block.""" - - feePayer: str - decimals: int - tokenProgram: str - memo: str - - -class X402Extra(X402ExtraRequired, total=False): - """An x402 ``accepts[].extra`` block; ``recentBlockhash`` is optional.""" - - recentBlockhash: str - - -class X402Resource(TypedDict): - """The ``resource`` block inside an x402 challenge.""" - - type: str - url: str - - -class X402AcceptsEntry(TypedDict): - """One x402 ``accepts[]`` offer entry (the server requirement).""" - - protocol: str - scheme: str - network: str - asset: str - amount: str - maxAmountRequired: str - payTo: str - maxTimeoutSeconds: int - extra: X402Extra - - -class X402Challenge(TypedDict): - """The base64-encoded ``payment-required`` challenge body.""" - - x402Version: int - resource: X402Resource - accepts: list[X402AcceptsEntry] - - -class X402PayloadField(TypedDict, total=False): - """The ``payload`` block of an inbound X-PAYMENT envelope.""" - - transaction: str - transactionHash: str - - -class X402Envelope(TypedDict, total=False): - """An inbound X-PAYMENT envelope (decoded from the proof header). - - All keys optional because the structure is attacker-controlled and validated - field-by-field at runtime before any value is trusted. - """ - - x402Version: int - accepted: X402AcceptsEntry - payload: X402PayloadField - - -class X402ResponseEnvelope(TypedDict): - """The base64-encoded ``payment-response`` settlement receipt.""" - - success: bool - transaction: str - network: str - payer: str - - -#: x402 protocol version emitted in challenges and required on credentials. -X402_VERSION = 2 - -#: ComputeBudget program id (instruction[0]/[1] guard). -COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111" -#: SPL Memo program id (allowlisted optional instruction + memo binding). -MEMO_PROGRAM = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" -#: Lighthouse assertion program id (allowlisted optional instruction). -#: Must match the rust spine constant ``LIGHTHOUSE_PROGRAM`` in -#: ``rust/crates/x402/src/protocol/schemes/exact/types.rs``. -LIGHTHOUSE_PROGRAM = "L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95" -#: Token-2022 program id (accepted transfer program alongside the route's). -TOKEN_2022_PROGRAM = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" -#: Maximum SetComputeUnitPrice in microlamports. Matches the Rust spine -#: constant ``MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS`` in verify.rs. -MAX_COMPUTE_UNIT_PRICE = 5_000_000 - -_SETTLEMENT_HEADER = "x-payment-settlement-signature" -_RESPONSE_HEADER = "payment-response" -_REPLAY_PREFIX = "x402-svm-exact:consumed:" - - -def _u64_le(data: bytes, offset: int) -> int: - """Read a little-endian u64 at ``offset``; reject on a short buffer.""" - if len(data) < offset + 8: - raise InvalidProofError( - "invalid_exact_svm_payload_no_transfer_instruction", - code="invalid_exact_svm_payload_no_transfer_instruction", - ) - return struct.unpack_from(" dict[str, Any]: - """Verify a base64 transaction against the route's x402 requirement. - - ``requirement`` is one ``accepts[]`` entry (the server offer). - ``managed_signers`` lists server-managed pubkeys (typically the - facilitator fee payer) that must never be the transfer authority. - Returns a dict describing the matched transfer on success. - """ - from solders.transaction import VersionedTransaction - - try: - raw = base64.b64decode(transaction_base64, validate=True) - except Exception as exc: # noqa: BLE001 - any decode failure is a reject - raise InvalidProofError( - "invalid_exact_svm_payload_base64", - code="invalid_exact_svm_payload_base64", - ) from exc - if not raw: - raise InvalidProofError( - "invalid_exact_svm_payload_base64", - code="invalid_exact_svm_payload_base64", - ) - - try: - tx = VersionedTransaction.from_bytes(raw) - except Exception as exc: # noqa: BLE001 - raise InvalidProofError( - "invalid_exact_svm_payload_transaction_parse", - code="invalid_exact_svm_payload_transaction_parse", - ) from exc - - message = tx.message - instructions = list(message.instructions) - account_keys = [str(key) for key in message.account_keys] - - # Rule 1: instruction count 3..=6. - n = len(instructions) - if n < 3 or n > 6: - raise InvalidProofError( - "invalid_exact_svm_payload_transaction_instructions_length", - code="invalid_exact_svm_payload_transaction_instructions_length", - ) - - # Rule 2: ix[0] = ComputeBudget SetComputeUnitLimit (disc 2, 5 bytes). - ExactVerifier._verify_compute_limit(instructions[0], account_keys) - # Rule 3: ix[1] = ComputeBudget SetComputeUnitPrice (disc 3, 9 bytes, <= MAX). - ExactVerifier._verify_compute_price(instructions[1], account_keys) - # Rules 4 + 5 + 6 + 7 + 8 + 11: transferChecked. - transfer = ExactVerifier._verify_transfer(instructions[2], account_keys, requirement, managed_signers) - - # Rule 9: ix[3:] allowlist (memo, lighthouse(<2 slots), ata-create(<2 slots)). - destination_create_ata = False - reasons = ( - "invalid_exact_svm_payload_unknown_fourth_instruction", - "invalid_exact_svm_payload_unknown_fifth_instruction", - "invalid_exact_svm_payload_unknown_sixth_instruction", - ) - for i in range(3, n): - ix = instructions[i] - program = ExactVerifier._program_of(account_keys, ix) - slot_index = i - 3 - allowed = program == MEMO_PROGRAM or (slot_index < 2 and program == LIGHTHOUSE_PROGRAM) - if ( - not allowed - and slot_index < 2 - and ExactVerifier._valid_ata_create(ix, account_keys, requirement, transfer) - ): - destination_create_ata = True - allowed = True - if not allowed: - reason = ( - reasons[slot_index] - if slot_index < len(reasons) - else "invalid_exact_svm_payload_unknown_optional_instruction" - ) - raise InvalidProofError(reason, code=reason) - - # Rule 10: memo binding (exactly one Memo == extra.memo if set). - expected_memo = ExactVerifier._string_extra(requirement, "memo", required=False) - if expected_memo: - ExactVerifier._find_memo_match(account_keys, instructions, expected_memo) - - transfer["destinationCreateAta"] = destination_create_ata - return transfer - - @staticmethod - def _verify_compute_limit(ix: Any, account_keys: list[str]) -> None: - program = ExactVerifier._program_of(account_keys, ix) - data = bytes(ix.data) - if program != COMPUTE_BUDGET_PROGRAM or len(data) != 5 or data[0] != 2: - raise InvalidProofError( - "invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction", - code="invalid_exact_svm_payload_transaction_instructions_compute_limit_instruction", - ) - - @staticmethod - def _verify_compute_price(ix: Any, account_keys: list[str]) -> None: - program = ExactVerifier._program_of(account_keys, ix) - data = bytes(ix.data) - if program != COMPUTE_BUDGET_PROGRAM or len(data) != 9 or data[0] != 3: - raise InvalidProofError( - "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction", - code="invalid_exact_svm_payload_transaction_instructions_compute_price_instruction", - ) - micro = _u64_le(data, 1) - if micro > MAX_COMPUTE_UNIT_PRICE: - reason = "invalid_exact_svm_payload_transaction_instructions_compute_price_instruction_too_high" - raise InvalidProofError(reason, code=reason) - - @staticmethod - def _verify_transfer( - ix: Any, - account_keys: list[str], - requirement: dict[str, Any], - managed_signers: list[str], - ) -> dict[str, Any]: - program = ExactVerifier._program_of(account_keys, ix) - # Rule 11: token program strict bind to extra.tokenProgram. - token_program_extra = ExactVerifier._string_extra(requirement, "tokenProgram", required=True) - if program != token_program_extra and program != TOKEN_2022_PROGRAM: - raise InvalidProofError( - "invalid_exact_svm_payload_no_transfer_instruction", - code="invalid_exact_svm_payload_no_transfer_instruction", - ) - data = bytes(ix.data) - # solders CompiledInstruction.accounts is a list of u8 account indices; - # solders ships no stubs, so annotate the shape explicitly at the boundary. - accounts: list[int] = [int(a) for a in ix.accounts] - # Rule 4: transferChecked shape (disc 12, 10-byte data, >= 4 accounts). - if len(accounts) < 4 or len(data) != 10 or data[0] != 12: - raise InvalidProofError( - "invalid_exact_svm_payload_no_transfer_instruction", - code="invalid_exact_svm_payload_no_transfer_instruction", - ) - - source = ExactVerifier._account_at(account_keys, ix, 0) - mint = ExactVerifier._account_at(account_keys, ix, 1) - destination = ExactVerifier._account_at(account_keys, ix, 2) - authority = ExactVerifier._account_at(account_keys, ix, 3) - - # Rule 5: authority guard (no managed signer as authority/source/account). - for managed in managed_signers: - if managed in (authority, source): - raise InvalidProofError( - "invalid_exact_svm_payload_transaction_fee_payer_transferring_funds", - code="invalid_exact_svm_payload_transaction_fee_payer_transferring_funds", - ) - for idx in accounts: - key = account_keys[idx] if 0 <= idx < len(account_keys) else None - if key is None: - continue - for managed in managed_signers: - if managed == key: - raise InvalidProofError( - "invalid_exact_svm_payload_transaction_fee_payer_in_instruction_accounts", - code="invalid_exact_svm_payload_transaction_fee_payer_in_instruction_accounts", - ) - - # Rule 6: mint match (offer carries the resolved on-chain mint on `asset`). - expected_mint = ExactVerifier._b58_field(requirement, "asset") - if mint != expected_mint: - raise InvalidProofError( - "invalid_exact_svm_payload_mint_mismatch", - code="invalid_exact_svm_payload_mint_mismatch", - ) - - # Rule 7: destination ATA match (re-derive owner+mint+token_program). - pay_to = ExactVerifier._b58_field(requirement, "payTo") - expected_destination = derive_ata(pay_to, expected_mint, program) - if destination != expected_destination: - raise InvalidProofError( - "invalid_exact_svm_payload_recipient_mismatch", - code="invalid_exact_svm_payload_recipient_mismatch", - ) - - # Rule 8: amount match (u64 LE at data[1:9]). - amount = _u64_le(data, 1) - expected_amount = ExactVerifier._amount_field(requirement) - if amount != expected_amount: - raise InvalidProofError( - "invalid_exact_svm_payload_amount_mismatch", - code="invalid_exact_svm_payload_amount_mismatch", - ) - - return { - "program": program, - "source": source, - "mint": mint, - "destination": destination, - "authority": authority, - "amount": amount, - } - - @staticmethod - def _valid_ata_create( - ix: Any, - account_keys: list[str], - requirement: dict[str, Any], - transfer: dict[str, Any], - ) -> bool: - if ExactVerifier._program_of(account_keys, ix) != ASSOCIATED_TOKEN_PROGRAM: - return False - data = bytes(ix.data) - if len(data) < 1 or (data[0] != 0 and data[0] != 1): - return False - if len(list(ix.accounts)) < 6: - return False - ata = ExactVerifier._account_at(account_keys, ix, 1) - owner = ExactVerifier._account_at(account_keys, ix, 2) - mint = ExactVerifier._account_at(account_keys, ix, 3) - if owner != requirement.get("payTo"): - return False - if mint != transfer["mint"]: - return False - return ata == transfer["destination"] - - @staticmethod - def _find_memo_match(account_keys: list[str], instructions: list[Any], expected_memo: str) -> None: - count = 0 - last_data: bytes | None = None - for i in range(3, len(instructions)): - ix = instructions[i] - if ExactVerifier._program_of(account_keys, ix) == MEMO_PROGRAM: - count += 1 - last_data = bytes(ix.data) - if count != 1: - raise InvalidProofError( - "invalid_exact_svm_payload_memo_count", - code="invalid_exact_svm_payload_memo_count", - ) - if last_data is None or last_data.decode("utf-8", "replace") != expected_memo: - raise InvalidProofError( - "invalid_exact_svm_payload_memo_mismatch", - code="invalid_exact_svm_payload_memo_mismatch", - ) - - @staticmethod - def _program_of(account_keys: list[str], ix: Any) -> str: - idx = int(ix.program_id_index) - return account_keys[idx] if 0 <= idx < len(account_keys) else "" - - @staticmethod - def _account_at(account_keys: list[str], ix: Any, slot: int) -> str: - accounts = list(ix.accounts) - if slot >= len(accounts): - raise InvalidProofError( - "invalid_exact_svm_payload_no_transfer_instruction", - code="invalid_exact_svm_payload_no_transfer_instruction", - ) - idx = int(accounts[slot]) - return account_keys[idx] if 0 <= idx < len(account_keys) else "" - - @staticmethod - def _b58_field(requirement: dict[str, Any], key: str) -> str: - value = requirement.get(key) - if not isinstance(value, str) or value == "": - raise InvalidProofError( - f"invalid_exact_svm_payload_missing_field_{key}", - code=f"invalid_exact_svm_payload_missing_field_{key}", - ) - return value - - @staticmethod - def _string_extra(requirement: dict[str, Any], key: str, *, required: bool) -> str | None: - extra = requirement.get("extra") - value = cast("dict[str, object]", extra).get(key) if isinstance(extra, dict) else None - if (value is None or value == "") and required: - raise InvalidProofError( - f"invalid_exact_svm_payload_missing_extra_{key}", - code=f"invalid_exact_svm_payload_missing_extra_{key}", - ) - return value if isinstance(value, str) else None - - @staticmethod - def _amount_field(requirement: dict[str, Any]) -> int: - value = requirement.get("amount") - if value is None: - value = requirement.get("maxAmountRequired") - if not isinstance(value, (str, int)): - raise InvalidProofError( - "invalid_exact_svm_payload_missing_field_amount", - code="invalid_exact_svm_payload_missing_field_amount", - ) - return int(value) - - -class X402Adapter: - """Self-hosted server adapter for the x402 ``exact`` Solana scheme.""" - - def __init__( - self, - config: Config, - replay_store: Store | None = None, - recent_blockhash_provider: Callable[[], str | None] | None = None, - ) -> None: - """Build an adapter bound to ``config``; raise for delegated mode.""" - if config.x402.is_delegated(): - raise NotImplementedError( - "pay_kit: x402 delegated mode is not yet implemented; " - "leave X402Config.facilitator_url None for self-hosted" - ) - self._config = config - self._store: Store = replay_store if replay_store is not None else MemoryStore() - self._recent_blockhash_provider = recent_blockhash_provider - - def accepts_entry(self, gate: Gate, request: Any) -> X402AcceptsEntry: - """Build one ``accepts[]`` entry (the server x402 offer for ``gate``).""" - coin = gate.amount.primary_coin() - coin_value = coin.value if coin is not None else self._config.stablecoins[0].value - label = self._config.network.mints_label() - # x402 puts the on-chain mint pubkey on `asset`, not the ticker. - # resolve() falls back to the mainnet row when the network row is - # absent (caveat #1). - asset = resolve(coin_value, label) or coin_value - token_program = token_program_for(coin_value, label) - pay_to = gate.pay_to or self._config.effective_recipient() - amount = str(int(gate.total().amount * 1_000_000)) - signer = self._config.x402.effective_signer(self._config.operator) - extra: X402Extra = { - "feePayer": signer.pubkey() if signer is not None else "", - "decimals": 6, - "tokenProgram": token_program, - "memo": _request_path(request), - } - # caveat #5: stamp the server's recent blockhash into accepted.extra - # so pay-kit Rust clients sign against the same chain state the server - # broadcasts to. Canonical TS/Go clients ignore it; harmless on real - # networks. The provider keeps unit tests offline. - blockhash = self._fetch_recent_blockhash() - if blockhash is not None: - extra["recentBlockhash"] = blockhash - return { - "protocol": "x402", - "scheme": "exact", - "network": self._caip2(), - "asset": asset, - "amount": amount, - "maxAmountRequired": amount, - "payTo": pay_to, - "maxTimeoutSeconds": 60, - "extra": extra, - } - - def challenge_headers(self, gate: Gate, request: Any) -> dict[str, str]: - """Build the ``payment-required`` header (base64 JSON challenge).""" - challenge: X402Challenge = { - "x402Version": X402_VERSION, - "resource": {"type": "http", "url": _request_path(request)}, - "accepts": [self.accepts_entry(gate, request)], - } - payload = json.dumps(challenge, separators=(",", ":")).encode("utf-8") - return {"payment-required": base64.b64encode(payload).decode("ascii")} - - async def verify_and_settle(self, gate: Gate, request: Any) -> Payment: - """Verify the submitted x402 credential, cosign, broadcast, settle.""" - signer = self._config.x402.effective_signer(self._config.operator) - if signer is None: - raise InvalidProofError("pay_kit: x402 requires operator.signer", code="payment_invalid") - - header = _payment_signature_header(request) - if not header: - raise InvalidProofError("pay_kit: payment required", code="payment_required") - - try: - decoded = base64.b64decode(header, validate=True) - except Exception as exc: # noqa: BLE001 - raise InvalidProofError( - "invalid_exact_svm_payload_signature_base64", - code="invalid_exact_svm_payload_signature_base64", - ) from exc - try: - envelope = json.loads(decoded) - except Exception as exc: # noqa: BLE001 - raise InvalidProofError( - "invalid_exact_svm_payload_signature_json", - code="invalid_exact_svm_payload_signature_json", - ) from exc - - if not isinstance(envelope, dict): - raise InvalidProofError("unsupported_x402_version", code="unsupported_x402_version") - # The envelope is attacker-controlled; it is validated field-by-field - # below, then narrowed to the typed wire shape for the rest of the flow. - envelope_map = cast("dict[str, object]", envelope) - if envelope_map.get("x402Version") != X402_VERSION: - raise InvalidProofError("unsupported_x402_version", code="unsupported_x402_version") - accepted_raw = envelope_map.get("accepted") - payload_raw = envelope_map.get("payload") - if not isinstance(accepted_raw, dict) or not isinstance(payload_raw, dict): - raise InvalidProofError( - "invalid_exact_svm_payload_envelope", - code="invalid_exact_svm_payload_envelope", - ) - accepted = cast("dict[str, object]", accepted_raw) - payload = cast("X402PayloadField", payload_raw) - - # Tier-2 identity-key match: the credential's accepted requirement must - # match the server's freshly built offer for this route. x402 has no - # HMAC-bound challenge id, so the offer is the source of truth and the - # credential's `accepted` is never trusted for the route's parameters - # (mirrors rust verify_pinned_fields + the targeted deepEqual gate). - offer = self.accepts_entry(gate, request) - offer_map = cast("dict[str, object]", offer) - for key in ("scheme", "network", "asset", "payTo"): - if accepted.get(key) != offer_map.get(key): - raise InvalidProofError( - "pay_kit: charge_request_mismatch: accepted payment requirement does not match server challenge", - code="charge_request_mismatch", - ) - if accepted.get("amount") != offer_map.get("amount") and accepted.get("maxAmountRequired") != offer_map.get( - "maxAmountRequired" - ): - raise InvalidProofError( - "pay_kit: charge_request_mismatch (amount)", - code="charge_request_mismatch", - ) - offer_extra = cast("dict[str, object]", offer_map.get("extra") or {}) - accepted_extra_raw = accepted.get("extra") - accepted_extra = cast("dict[str, object]", accepted_extra_raw if isinstance(accepted_extra_raw, dict) else {}) - for key in ("feePayer", "tokenProgram", "memo"): - if key in offer_extra and accepted_extra.get(key) != offer_extra[key]: - raise InvalidProofError( - f"pay_kit: charge_request_mismatch (extra.{key})", - code="charge_request_mismatch", - ) - - tx_base64 = payload.get("transaction") - if not isinstance(tx_base64, str) or tx_base64 == "": - raise InvalidProofError( - "invalid_exact_svm_payload_missing_transaction", - code="invalid_exact_svm_payload_missing_transaction", - ) - - # Structural shape (11 rules) against the server offer. - ExactVerifier.verify(tx_base64, cast("dict[str, Any]", offer), [signer.pubkey()]) - - # Reject up-front if the client signed against the wrong cluster. - # Skip on a loopback RPC where a Surfpool blockhash is expected. - rpc_url = self._config.effective_rpc_url() - if not _is_loopback_rpc(rpc_url): - blockhash = _recent_blockhash_of(tx_base64) - if blockhash is not None: - check_network_blockhash(self._config.network.mints_label(), blockhash) - - # Cosign as the facilitator fee payer (slot-splice, version aware). - cosigned_wire = _co_sign(tx_base64, signer) - - rpc = SolanaRpc(rpc_url) - try: - response = await rpc.send_raw_transaction(cosigned_wire) - signature = str(response.value if hasattr(response, "value") else response) - except Exception as exc: # noqa: BLE001 - raise InvalidProofError(f"pay_kit: invalid proof: broadcast failed: {exc}", code="payment_invalid") from exc - finally: - await rpc.aclose() - if not signature: - raise InvalidProofError("pay_kit: empty broadcast result", code="payment_invalid") - - # Replay reservation. Namespace is distinct from the MPP charge key so - # an x402 signature can never satisfy an MPP route and vice versa. - if not await self._store.put_if_absent(_REPLAY_PREFIX + signature, True): - raise InvalidProofError("pay_kit: signature_consumed", code="signature_consumed") - - accepted_network = accepted.get("network") - response_body: X402ResponseEnvelope = { - "success": True, - "transaction": signature, - "network": accepted_network if isinstance(accepted_network, str) and accepted_network else self._caip2(), - "payer": payload.get("transactionHash", ""), - } - response_envelope = base64.b64encode(json.dumps(response_body, separators=(",", ":")).encode("utf-8")).decode( - "ascii" - ) - - return Payment( - protocol=Protocol.X402, - transaction=signature, - gate_name=gate.name, - settlement_headers={ - _RESPONSE_HEADER: response_envelope, - _SETTLEMENT_HEADER: signature, - }, - raw=header, - ) - - def _fetch_recent_blockhash(self) -> str | None: - if self._recent_blockhash_provider is not None: - try: - value = self._recent_blockhash_provider() - except Exception: # noqa: BLE001 - provider failures are non-fatal - return None - return value if isinstance(value, str) and value != "" else None - return None - - def _caip2(self) -> str: - return self._config.network.caip2() - - -def _co_sign(transaction_b64: str, signer: Any) -> bytes: - """Splice the facilitator signature into the fee-payer slot, return wire. - - Legacy messages are signed over ``bytes(msg)``, v0 over - ``to_bytes_versioned(msg)`` (0x80 prefix). The fee payer must occupy a - signature slot. The v0-wire detector lives in the shared - :mod:`pay_kit._paycore.transaction` core so neither protocol depends on the - other. - """ - from solders.message import to_bytes_versioned - from solders.pubkey import Pubkey - from solders.transaction import Transaction, VersionedTransaction - - from pay_kit._paycore.transaction import is_v0_wire_bytes - - raw = base64.b64decode(transaction_b64) - fee_payer_pubkey = Pubkey.from_string(signer.pubkey()) - - # SECURITY: ``solders.transaction.Transaction.from_bytes`` is lenient and - # silently MIS-PARSES v0 ``VersionedTransaction`` wire bytes as a legacy - # transaction (it does not raise), yielding a bogus header and garbage - # account keys. The rust x402 client (and the canonical PaymentProof - # builder) emit v0 messages, so we must route on the message-version - # prefix byte rather than trusting a legacy parse to fail. Reuses the - # shared ``is_v0_wire_bytes`` guard from ``pay_kit._paycore.transaction`` - # (no parallel detection logic; same routing as the MPP charge cosign). - if is_v0_wire_bytes(raw): - try: - vtx = VersionedTransaction.from_bytes(raw) - except Exception as exc: # noqa: BLE001 - raise InvalidProofError( - "invalid_exact_svm_payload_transaction_parse", - code="invalid_exact_svm_payload_transaction_parse", - ) from exc - account_keys = list(vtx.message.account_keys) - message_bytes = bytes(to_bytes_versioned(vtx.message)) - num_required = int(vtx.message.header.num_required_signatures) - else: - try: - tx = Transaction.from_bytes(raw) - except Exception: # noqa: BLE001 - fall back to versioned - try: - vtx = VersionedTransaction.from_bytes(raw) - except Exception as exc: # noqa: BLE001 - raise InvalidProofError( - "invalid_exact_svm_payload_transaction_parse", - code="invalid_exact_svm_payload_transaction_parse", - ) from exc - account_keys = list(vtx.message.account_keys) - message_bytes = bytes(to_bytes_versioned(vtx.message)) - num_required = int(vtx.message.header.num_required_signatures) - else: - account_keys = list(tx.message.account_keys) - message_bytes = bytes(tx.message) - num_required = int(tx.message.header.num_required_signatures) - - try: - idx = account_keys.index(fee_payer_pubkey) - except ValueError as exc: - raise InvalidProofError( - "pay_kit: fee payer pubkey not present in transaction accounts", - code="payment_invalid", - ) from exc - if idx >= num_required: - raise InvalidProofError("pay_kit: fee payer is not a required signer", code="payment_invalid") - - sig_bytes = bytes(signer.sign(message_bytes)) - serialized = bytearray(raw) - sig_start = 1 + idx * 64 - serialized[sig_start : sig_start + 64] = sig_bytes - return bytes(serialized) - - -def _recent_blockhash_of(transaction_b64: str) -> str | None: - """Best-effort extract of the recent blockhash for the network check.""" - from solders.transaction import VersionedTransaction - - try: - raw = base64.b64decode(transaction_b64) - tx = VersionedTransaction.from_bytes(raw) - return str(tx.message.recent_blockhash) - except Exception: # noqa: BLE001 - the verifier already validated shape - return None - - -def _is_loopback_rpc(rpc_url: str) -> bool: - """True if ``rpc_url`` points at a loopback host (mirror rust).""" - stripped = rpc_url.strip() - for prefix in ("http://", "https://", "ws://", "wss://"): - if stripped.startswith(prefix): - stripped = stripped[len(prefix) :] - break - host_and_rest = stripped.split("/", 1)[0] - host = host_and_rest[1:].split("]", 1)[0] if host_and_rest.startswith("[") else host_and_rest.split(":", 1)[0] - return host in {"127.0.0.1", "localhost", "::1", "0.0.0.0"} - - -def _request_path(request: Any) -> str: - """Resolve the request path across framework request shapes.""" - path = getattr(request, "path", None) - if isinstance(path, str): - return path - url = getattr(request, "url", None) - if url is not None: - url_path = getattr(url, "path", None) - if isinstance(url_path, str): - return url_path - if isinstance(request, dict): - candidate = cast("dict[str, object]", request).get("path") - if isinstance(candidate, str): - return candidate - return "/" - - -def _payment_signature_header(request: Any) -> str: - """Read the ``Payment-Signature`` header across framework request shapes.""" - headers = getattr(request, "headers", None) - if headers is not None: - getter = getattr(headers, "get", None) - if callable(getter): - for name in ("payment-signature", "Payment-Signature", "PAYMENT-SIGNATURE"): - value: object = getter(name) - if value: - return str(value) - if isinstance(request, dict): - raw_headers = cast("dict[str, object]", request).get("headers") - if isinstance(raw_headers, dict): - for key, header_value in cast("dict[object, object]", raw_headers).items(): - if isinstance(key, str) and key.lower() == "payment-signature" and header_value: - return str(header_value) - return "" diff --git a/python/tests/test_pk_x402_settle.py b/python/tests/test_pk_x402_settle.py index f2ed78d43..0238c2ef4 100644 --- a/python/tests/test_pk_x402_settle.py +++ b/python/tests/test_pk_x402_settle.py @@ -21,7 +21,7 @@ from solders.pubkey import Pubkey from solders.transaction import VersionedTransaction -import pay_kit.protocols.x402.verify as xmod +import pay_kit.protocols.x402 as xmod from pay_kit import Gate as GateCls from pay_kit import ( LocalSigner, @@ -35,15 +35,17 @@ from pay_kit._paycore.mints import derive_ata, resolve, token_program_for from pay_kit.config import reset from pay_kit.errors import InvalidProofError -from pay_kit.protocols.x402.verify import ( - COMPUTE_BUDGET_PROGRAM, - MEMO_PROGRAM, +from pay_kit.protocols.x402 import ( X402_VERSION, X402Adapter, _co_sign, _is_loopback_rpc, _request_path, ) +from pay_kit.protocols.x402.exact.verify import ( + COMPUTE_BUDGET_PROGRAM, + MEMO_PROGRAM, +) BH = "4vJ9JU1bJJQpUgJ8V6hYz7xXKz4F2tN6aBrZEcD3xKhs" _MINT = resolve("USDC", "mainnet") diff --git a/python/tests/test_pk_x402_verifier.py b/python/tests/test_pk_x402_verifier.py index 8f43d3e7a..d0b312438 100644 --- a/python/tests/test_pk_x402_verifier.py +++ b/python/tests/test_pk_x402_verifier.py @@ -25,12 +25,11 @@ from pay_kit._paycore.solana import ASSOCIATED_TOKEN_PROGRAM from pay_kit.config import reset from pay_kit.errors import InvalidProofError -from pay_kit.protocols.x402.verify import ( +from pay_kit.protocols.x402 import ExactVerifier, X402Adapter +from pay_kit.protocols.x402.exact.verify import ( COMPUTE_BUDGET_PROGRAM, MEMO_PROGRAM, TOKEN_2022_PROGRAM, - ExactVerifier, - X402Adapter, ) BH = "4vJ9JU1bJJQpUgJ8V6hYz7xXKz4F2tN6aBrZEcD3xKhs" From 10526415d2de8e2739a2efc7fd3f955be6c5d8e7 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 21:00:59 +0300 Subject: [PATCH 24/45] feat(python): x402 exact client (challenge parse + payment build + transport) 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. --- python/pyproject.toml | 8 +- .../pay_kit/protocols/x402/client/__init__.py | 24 ++ .../protocols/x402/client/exact/__init__.py | 26 ++ .../protocols/x402/client/exact/payment.py | 395 ++++++++++++++++++ .../protocols/x402/client/exact/transport.py | 131 ++++++ 5 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 python/src/pay_kit/protocols/x402/client/exact/payment.py create mode 100644 python/src/pay_kit/protocols/x402/client/exact/transport.py diff --git a/python/pyproject.toml b/python/pyproject.toml index e607374a4..b7f13deb9 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -73,7 +73,13 @@ strict = [ "src/pay_kit/protocols/__init__.py", "src/pay_kit/protocols/mpp/__init__.py", "src/pay_kit/protocols/x402/__init__.py", - "src/pay_kit/protocols/x402/verify.py", + "src/pay_kit/protocols/x402/exact/__init__.py", + "src/pay_kit/protocols/x402/exact/types.py", + "src/pay_kit/protocols/x402/exact/verify.py", + "src/pay_kit/protocols/x402/client/__init__.py", + "src/pay_kit/protocols/x402/client/exact/__init__.py", + "src/pay_kit/protocols/x402/client/exact/payment.py", + "src/pay_kit/protocols/x402/client/exact/transport.py", ] reportMissingTypeStubs = false include = ["src", "tests"] diff --git a/python/src/pay_kit/protocols/x402/client/__init__.py b/python/src/pay_kit/protocols/x402/client/__init__.py index eb5a163b4..a1f780e17 100644 --- a/python/src/pay_kit/protocols/x402/client/__init__.py +++ b/python/src/pay_kit/protocols/x402/client/__init__.py @@ -1 +1,25 @@ """x402 ``exact`` client: challenge parsing, payment building, auto-pay transport.""" + +from __future__ import annotations + +from pay_kit.protocols.x402.client.exact import ( + PAYMENT_SIGNATURE_HEADER, + ChallengeSelection, + PaymentTransport, + X402Client, + build_payment, + build_payment_header, + parse_x402_challenge, + x402_async_client, +) + +__all__ = [ + "ChallengeSelection", + "parse_x402_challenge", + "build_payment", + "build_payment_header", + "PaymentTransport", + "X402Client", + "x402_async_client", + "PAYMENT_SIGNATURE_HEADER", +] diff --git a/python/src/pay_kit/protocols/x402/client/exact/__init__.py b/python/src/pay_kit/protocols/x402/client/exact/__init__.py index 579da04df..2ca0dffed 100644 --- a/python/src/pay_kit/protocols/x402/client/exact/__init__.py +++ b/python/src/pay_kit/protocols/x402/client/exact/__init__.py @@ -1 +1,27 @@ """x402 ``exact`` client building blocks (payment + transport).""" + +from __future__ import annotations + +from pay_kit.protocols.x402.client.exact.payment import ( + ChallengeSelection, + build_payment, + build_payment_header, + parse_x402_challenge, +) +from pay_kit.protocols.x402.client.exact.transport import ( + PAYMENT_SIGNATURE_HEADER, + PaymentTransport, + X402Client, + x402_async_client, +) + +__all__ = [ + "ChallengeSelection", + "parse_x402_challenge", + "build_payment", + "build_payment_header", + "PaymentTransport", + "X402Client", + "x402_async_client", + "PAYMENT_SIGNATURE_HEADER", +] diff --git a/python/src/pay_kit/protocols/x402/client/exact/payment.py b/python/src/pay_kit/protocols/x402/client/exact/payment.py new file mode 100644 index 000000000..8c13a90ff --- /dev/null +++ b/python/src/pay_kit/protocols/x402/client/exact/payment.py @@ -0,0 +1,395 @@ +"""x402 ``exact`` client: challenge parsing and payment-transaction building. + +Mirrors the Rust spine client +(``rust/crates/x402/src/client/exact/payment.rs``) and the Go client +(``go/protocols/x402/client/client.go``) byte-for-behavior. The Python client +operates on the :class:`~pay_kit.protocols.x402.exact.types.X402AcceptsEntry` +wire shape the pay_kit x402 server emits and the +:class:`~pay_kit.protocols.x402.exact.verify.ExactVerifier` validates: the +offer carries the resolved on-chain mint on ``asset`` and the token program / +decimals / memo on ``extra``. + +The built transaction is a v0 ``VersionedTransaction`` whose fee payer is the +offer's ``extra.feePayer`` (the facilitator, which cosigns server-side) and +whose transfer authority is the client signer. Instructions are laid out +exactly as the verifier expects: ComputeBudget SetComputeUnitLimit(200000) + +SetComputeUnitPrice, then a ``transferChecked`` (SPL) or System ``transfer`` +(native SOL), then a Memo carrying ``extra.memo``. +""" + +from __future__ import annotations + +import base64 +import json +from collections.abc import Awaitable, Callable, Mapping, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast + +from pay_kit._paycore.mints import derive_ata, resolve_stablecoin_mint +from pay_kit._paycore.network import SOLANA_DEVNET_CAIP2, SOLANA_MAINNET_CAIP2 +from pay_kit._paycore.solana import MEMO_PROGRAM, is_native_sol +from pay_kit.protocols.x402.exact.types import X402AcceptsEntry, X402Envelope, X402PayloadField +from pay_kit.protocols.x402.exact.verify import COMPUTE_BUDGET_PROGRAM, X402_VERSION + +if TYPE_CHECKING: + from pay_kit.signer import LocalSigner + +__all__ = [ + "ChallengeSelection", + "parse_x402_challenge", + "build_payment", + "build_payment_header", +] + +#: ComputeBudget SetComputeUnitLimit value the verifier accepts (disc 2, u32 LE). +_COMPUTE_UNIT_LIMIT = 200_000 +#: ComputeBudget SetComputeUnitPrice microlamports (disc 3, u64 LE, <= MAX). +_COMPUTE_UNIT_PRICE = 1 +#: Default SPL decimals when the offer omits ``extra.decimals``. +_DEFAULT_DECIMALS = 6 + +# x402 ``exact`` CAIP-2 networks the client knows how to pay on. +_SOLANA_CAIP2 = frozenset({SOLANA_MAINNET_CAIP2, SOLANA_DEVNET_CAIP2}) + + +def _caip2_for_selection(network: str | None) -> str: + """Resolve a client network preference (slug or CAIP-2) to a CAIP-2 id. + + ``None`` defaults to mainnet, mirroring the rust + ``ChallengeSelection`` default. ``localnet`` shares the devnet CAIP-2 + (Surfpool forks mainnet state under the devnet genesis hash). + """ + if network is None: + return SOLANA_MAINNET_CAIP2 + lowered = network.strip() + if lowered in _SOLANA_CAIP2: + return lowered + return { + "mainnet": SOLANA_MAINNET_CAIP2, + "mainnet-beta": SOLANA_MAINNET_CAIP2, + "solana": SOLANA_MAINNET_CAIP2, + "devnet": SOLANA_DEVNET_CAIP2, + "solana-devnet": SOLANA_DEVNET_CAIP2, + "localnet": SOLANA_DEVNET_CAIP2, + }.get(lowered, SOLANA_MAINNET_CAIP2) + + +def _mints_label_for_caip2(caip2: str) -> str: + """Bare mints-registry label (mainnet/devnet) for a Solana CAIP-2 id.""" + return "devnet" if caip2 == SOLANA_DEVNET_CAIP2 else "mainnet" + + +@dataclass(frozen=True) +class ChallengeSelection: + """Client-side preferences for picking one offer from ``accepts``. + + Mirrors the rust ``ChallengeSelection``. + """ + + #: Solana network the client wants to pay on (cluster slug or CAIP-2). + #: ``None`` defaults to mainnet. + network: str | None = None + #: Priority-ordered currencies the client will pay in (symbols or mints, + #: interchangeable). The first server offer matching the highest-priority + #: currency wins. ``None`` falls back to cheapest amount on the preferred + #: network. + currencies: Sequence[str] | None = None + + +def parse_x402_challenge( + headers: Mapping[str, str], + body: str | None, + selection: ChallengeSelection, +) -> X402AcceptsEntry | None: + """Parse an x402 ``exact`` challenge from response headers and/or body. + + Decodes the base64 JSON ``payment-required`` header first, then falls back + to a JSON body carrying ``{"accepts": [...]}``. Filters to + ``protocol == "x402"`` / ``scheme == "exact"`` offers on the preferred + network, then picks by ``selection.currencies`` preference order, else the + cheapest ``amount``. Returns ``None`` when no supported offer matches. + Mirrors rust ``parse_x402_challenge_with_selection``. + """ + header_value = _lookup_header(headers, "payment-required") + if header_value: + offer = _select_from_header(header_value, selection) + if offer is not None: + return offer + + if body is not None: + offer = _select_from_body(body, selection) + if offer is not None: + return offer + + return None + + +def _lookup_header(headers: Mapping[str, str], name: str) -> str | None: + target = name.lower() + for key, value in headers.items(): + if key.lower() == target: + return value + return None + + +def _select_from_header(header_value: str, selection: ChallengeSelection) -> X402AcceptsEntry | None: + try: + decoded = base64.b64decode(header_value, validate=True) + envelope = json.loads(decoded) + except Exception: # noqa: BLE001 - any decode failure means "no challenge here" + return None + return _select_from_envelope(envelope, selection) + + +def _select_from_body(body: str, selection: ChallengeSelection) -> X402AcceptsEntry | None: + try: + envelope = json.loads(body) + except Exception: # noqa: BLE001 + return None + return _select_from_envelope(envelope, selection) + + +def _select_from_envelope(envelope: object, selection: ChallengeSelection) -> X402AcceptsEntry | None: + if not isinstance(envelope, dict): + return None + accepts_raw = cast("dict[str, object]", envelope).get("accepts") + if not isinstance(accepts_raw, list): + return None + entries = cast("list[object]", accepts_raw) + accepts = [cast("dict[str, object]", entry) for entry in entries if isinstance(entry, dict)] + return _select_requirement(accepts, selection) + + +def _is_solana_exact(offer: dict[str, object]) -> bool: + scheme = offer.get("scheme") + protocol = offer.get("protocol") + network = offer.get("network") + # ``protocol`` is optional in the canonical wire (x402-express omits it); + # accept the offer when it is absent but reject an explicit non-x402 value. + if protocol is not None and protocol != "x402": + return False + return scheme == "exact" and isinstance(network, str) and network in _SOLANA_CAIP2 + + +def _amount_of(offer: dict[str, object]) -> int: + raw = offer.get("amount") + if raw is None: + raw = offer.get("maxAmountRequired") + try: + return int(cast("str | int", raw)) + except (TypeError, ValueError): + # Treat an unparseable amount as maximally expensive so it never wins + # the cheapest-by-amount tiebreak (mirror rust ``u64::MAX``). + return 1 << 64 + + +def _currency_of(offer: dict[str, object]) -> str: + asset = offer.get("asset") + return asset if isinstance(asset, str) else "" + + +def _currencies_match(offered: str, accepted: str, label: str) -> bool: + """``accepted`` (symbol or mint) resolves to the same mint as ``offered``.""" + offered_mint = resolve_stablecoin_mint(offered, label) or offered + accepted_mint = resolve_stablecoin_mint(accepted, label) or accepted + return offered_mint == accepted_mint + + +def _select_requirement( + accepts: list[dict[str, object]], + selection: ChallengeSelection, +) -> X402AcceptsEntry | None: + preferred = _caip2_for_selection(selection.network) + label = _mints_label_for_caip2(preferred) + + solana = [offer for offer in accepts if _is_solana_exact(offer)] + on_preferred = [offer for offer in solana if offer.get("network") == preferred] + + if selection.currencies is not None: + for wanted in selection.currencies: + for offer in on_preferred: + if _currencies_match(_currency_of(offer), wanted, label): + return cast("X402AcceptsEntry", offer) + # The client explicitly listed currencies; do not fall back to an + # unlisted one (mirror rust). + return None + + candidates = on_preferred or solana + if not candidates: + return None + cheapest = min(candidates, key=_amount_of) + return cast("X402AcceptsEntry", cheapest) + + +def _compute_unit_limit_ix(instruction_cls: Any, pubkey_cls: Any, units: int) -> Any: + program = pubkey_cls.from_string(COMPUTE_BUDGET_PROGRAM) + data = bytes([2]) + units.to_bytes(4, "little") + return instruction_cls(program, data, []) + + +def _compute_unit_price_ix(instruction_cls: Any, pubkey_cls: Any, micro_lamports: int) -> Any: + program = pubkey_cls.from_string(COMPUTE_BUDGET_PROGRAM) + data = bytes([3]) + micro_lamports.to_bytes(8, "little") + return instruction_cls(program, data, []) + + +def _extra_of(requirement: X402AcceptsEntry) -> dict[str, object]: + extra = cast("dict[str, object]", requirement).get("extra") + return cast("dict[str, object]", extra) if isinstance(extra, dict) else {} + + +def _str_field(mapping: Mapping[str, object], key: str) -> str | None: + value = mapping.get(key) + return value if isinstance(value, str) and value != "" else None + + +async def build_payment( + signer: LocalSigner, + rpc: Any, + requirement: X402AcceptsEntry, + *, + recent_blockhash_provider: Callable[[], Awaitable[str] | str] | None = None, +) -> X402Envelope: + """Build a signed x402 ``exact`` payment transaction for ``requirement``. + + Lays out the instructions the verifier expects, compiles a v0 + ``VersionedTransaction`` with the offer's ``extra.feePayer`` as fee payer + (cosigned server-side) and the client ``signer`` as transfer authority, + signs the client's signature slot, and returns the + :class:`~pay_kit.protocols.x402.exact.types.X402Envelope` carrying the + standard-base64 transaction. Mirrors rust ``build_payment``. + + The blockhash comes from ``requirement.extra.recentBlockhash`` when present, + else ``recent_blockhash_provider`` (injected for offline unit tests), else + ``await rpc.get_latest_blockhash()``. + """ + from solders.hash import Hash + from solders.instruction import AccountMeta, Instruction + from solders.message import MessageV0, to_bytes_versioned + from solders.pubkey import Pubkey + from solders.signature import Signature + from solders.transaction import VersionedTransaction + + req = cast("dict[str, object]", requirement) + asset = _str_field(req, "asset") + if asset is None: + raise ValueError("pay_kit: x402 offer is missing `asset`") + pay_to = _str_field(req, "payTo") + if pay_to is None: + raise ValueError("pay_kit: x402 offer is missing `payTo`") + + amount_raw = req.get("amount") + if amount_raw is None: + amount_raw = req.get("maxAmountRequired") + try: + amount = int(cast("str | int", amount_raw)) + except (TypeError, ValueError) as exc: + raise ValueError(f"pay_kit: x402 offer has an invalid amount: {amount_raw!r}") from exc + + extra = _extra_of(requirement) + fee_payer = _str_field(extra, "feePayer") + fee_payer_key = Pubkey.from_string(fee_payer) if fee_payer is not None else signer.keypair.pubkey() + + instructions: list[Any] = [ + _compute_unit_limit_ix(Instruction, Pubkey, _COMPUTE_UNIT_LIMIT), + _compute_unit_price_ix(Instruction, Pubkey, _COMPUTE_UNIT_PRICE), + ] + + signer_pubkey = signer.keypair.pubkey() + recipient_key = Pubkey.from_string(pay_to) + + if is_native_sol(asset): + from solders.system_program import TransferParams, transfer + + instructions.append( + transfer(TransferParams(from_pubkey=signer_pubkey, to_pubkey=recipient_key, lamports=amount)) + ) + else: + token_program = _str_field(extra, "tokenProgram") + if token_program is None: + raise ValueError("pay_kit: x402 SPL offer is missing `extra.tokenProgram`") + decimals_raw = extra.get("decimals") + decimals = int(decimals_raw) if isinstance(decimals_raw, int) else _DEFAULT_DECIMALS + token_program_key = Pubkey.from_string(token_program) + mint_key = Pubkey.from_string(asset) + source_ata = Pubkey.from_string(derive_ata(str(signer_pubkey), asset, token_program)) + dest_ata = Pubkey.from_string(derive_ata(pay_to, asset, token_program)) + # SPL Token TransferChecked (disc 12): amount u64 LE + decimals u8. + data = bytes([12]) + amount.to_bytes(8, "little") + bytes([decimals & 0xFF]) + instructions.append( + Instruction( + token_program_key, + data, + [ + AccountMeta(source_ata, False, True), + AccountMeta(mint_key, False, False), + AccountMeta(dest_ata, False, True), + AccountMeta(signer_pubkey, True, False), + ], + ) + ) + + memo = _str_field(extra, "memo") + if memo is not None: + instructions.append(Instruction(Pubkey.from_string(MEMO_PROGRAM), memo.encode("utf-8"), [])) + + blockhash_str = _str_field(extra, "recentBlockhash") + if blockhash_str is None: + blockhash_str = await _resolve_blockhash(rpc, recent_blockhash_provider) + blockhash = Hash.from_string(blockhash_str) + + message = MessageV0.try_compile(fee_payer_key, instructions, [], blockhash) + num_signers = int(message.header.num_required_signatures) + tx = VersionedTransaction.populate(message, [Signature.default() for _ in range(num_signers)]) + + sig = Signature.from_bytes(signer.sign(bytes(to_bytes_versioned(message)))) + account_keys = list(message.account_keys) + try: + signer_index = account_keys.index(signer_pubkey) + except ValueError as exc: + raise ValueError("pay_kit: signer not found in transaction accounts") from exc + signatures = list(tx.signatures) + signatures[signer_index] = sig + tx = VersionedTransaction.populate(message, signatures) + + encoded = base64.b64encode(bytes(tx)).decode("ascii") + payload: X402PayloadField = {"transaction": encoded} + return {"x402Version": X402_VERSION, "accepted": requirement, "payload": payload} + + +async def _resolve_blockhash( + rpc: Any, + provider: Callable[[], Awaitable[str] | str] | None, +) -> str: + if provider is not None: + result = provider() + if isinstance(result, str): + return result + return await result + response = await rpc.get_latest_blockhash() + value = getattr(response, "value", response) + blockhash = getattr(value, "blockhash", value) + return str(blockhash) + + +async def build_payment_header( + signer: LocalSigner, + rpc: Any, + requirement: X402AcceptsEntry, + *, + recent_blockhash_provider: Callable[[], Awaitable[str] | str] | None = None, +) -> str: + """Build the standard-base64 ``PAYMENT-SIGNATURE`` header value. + + Wraps :func:`build_payment` and base64-encodes the + :class:`~pay_kit.protocols.x402.exact.types.X402Envelope` JSON. Mirrors rust + ``build_payment_header``. + """ + envelope = await build_payment( + signer, + rpc, + requirement, + recent_blockhash_provider=recent_blockhash_provider, + ) + payload = json.dumps(envelope, separators=(",", ":")).encode("utf-8") + return base64.b64encode(payload).decode("ascii") diff --git a/python/src/pay_kit/protocols/x402/client/exact/transport.py b/python/src/pay_kit/protocols/x402/client/exact/transport.py new file mode 100644 index 000000000..b47eb973d --- /dev/null +++ b/python/src/pay_kit/protocols/x402/client/exact/transport.py @@ -0,0 +1,131 @@ +"""Payment-aware httpx transport for automatic x402 ``exact`` 402 handling. + +Mirrors the MPP ``PaymentTransport`` (``pay_kit.protocols.mpp.client.transport``) +and the Go x402 ``PaymentTransport`` / ``NewClient`` +(``go/protocols/x402/client/client.go``): a request whose first response is a +402 with an x402 ``exact`` challenge is satisfied by building a +``PAYMENT-SIGNATURE`` header and retrying the request once. The header name is +the one the pay_kit x402 server reads (``Payment-Signature``; confirmed in +``pay_kit.protocols.x402._payment_signature_header``). +""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable, Sequence +from typing import TYPE_CHECKING, Any + +import httpx + +from pay_kit.protocols.x402.client.exact.payment import ( + ChallengeSelection, + build_payment_header, + parse_x402_challenge, +) + +if TYPE_CHECKING: + from pay_kit.signer import LocalSigner + +logger = logging.getLogger("pay_kit") + +#: Request header the pay_kit x402 server reads the credential from. +PAYMENT_SIGNATURE_HEADER = "Payment-Signature" + +__all__ = ["PaymentTransport", "X402Client", "x402_async_client", "PAYMENT_SIGNATURE_HEADER"] + + +class PaymentTransport(httpx.AsyncBaseTransport): + """httpx transport that auto-pays x402 ``exact`` 402 responses. + + Wraps an inner transport and, on a 402 carrying an x402 ``exact`` challenge + (``payment-required`` header or ``accepts[]`` JSON body), builds the + ``PAYMENT-SIGNATURE`` header and retries the request once. + """ + + def __init__( + self, + signer: LocalSigner, + rpc: Any, + *, + network: str | None = None, + currencies: Sequence[str] | None = None, + base_transport: httpx.AsyncBaseTransport | None = None, + recent_blockhash_provider: Callable[[], Awaitable[str] | str] | None = None, + ) -> None: + self._signer = signer + self._rpc = rpc + self._selection = ChallengeSelection(network=network, currencies=currencies) + self._inner = base_transport or httpx.AsyncHTTPTransport() + self._recent_blockhash_provider = recent_blockhash_provider + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + """Handle a request, retrying once with a credential on a 402 challenge.""" + response = await self._inner.handle_async_request(request) + if response.status_code != 402: + return response + + await response.aread() + body: str | None + try: + body = response.text + except Exception: # noqa: BLE001 - a non-decodable body just means "header only" + body = None + + requirement = parse_x402_challenge(dict(response.headers), body, self._selection) + if requirement is None: + return response + + try: + header_value = await build_payment_header( + self._signer, + self._rpc, + requirement, + recent_blockhash_provider=self._recent_blockhash_provider, + ) + except Exception: # noqa: BLE001 - surface the original 402 on a build failure + logger.warning("pay_kit: failed to build x402 payment credential", exc_info=True) + return response + + headers = dict(request.headers) + headers[PAYMENT_SIGNATURE_HEADER] = header_value + retry_request = httpx.Request( + method=request.method, + url=request.url, + headers=headers, + stream=request.stream, + extensions=request.extensions, + ) + return await self._inner.handle_async_request(retry_request) + + async def aclose(self) -> None: + """Close the inner transport.""" + await self._inner.aclose() + + +def X402Client( # noqa: N802 - factory named for the type it returns + signer: LocalSigner, + rpc: Any, + *, + network: str | None = None, + currencies: Sequence[str] | None = None, + recent_blockhash_provider: Callable[[], Awaitable[str] | str] | None = None, + **client_kwargs: Any, +) -> httpx.AsyncClient: + """Build an ``httpx.AsyncClient`` that auto-pays x402 ``exact`` 402s. + + Mirrors the Go ``NewClient`` ergonomics: pass a signer + RPC and get back a + ready-to-use async client. + """ + transport = PaymentTransport( + signer, + rpc, + network=network, + currencies=currencies, + recent_blockhash_provider=recent_blockhash_provider, + base_transport=client_kwargs.pop("base_transport", None), + ) + return httpx.AsyncClient(transport=transport, **client_kwargs) + + +#: snake_case alias matching the rust/go free-function ergonomics. +x402_async_client = X402Client From e810f2e189c87c33eecc4c1419faa1f10870a792 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 21:05:53 +0300 Subject: [PATCH 25/45] test(python): x402 exact client unit suite 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%. --- python/tests/test_pk_x402_client.py | 623 ++++++++++++++++++++++++++++ 1 file changed, 623 insertions(+) create mode 100644 python/tests/test_pk_x402_client.py diff --git a/python/tests/test_pk_x402_client.py b/python/tests/test_pk_x402_client.py new file mode 100644 index 000000000..c1cf2f533 --- /dev/null +++ b/python/tests/test_pk_x402_client.py @@ -0,0 +1,623 @@ +"""x402 ``exact`` client coverage: challenge parsing, payment building, transport. + +Exercises the client surface against the same ``ExactVerifier`` the server runs, +so every built transaction is asserted to round-trip through verification. The +transport test wires a stub ASGI app backed by a real ``X402Adapter`` (RPC +stubbed) to a single x402-gated route and asserts the 402 -> pay -> 200 flow, +including that the retried request carries ``PAYMENT-SIGNATURE``. +""" + +from __future__ import annotations + +import base64 +import json +from typing import Any, cast + +import httpx +import pytest +from solders.keypair import Keypair +from solders.transaction import VersionedTransaction + +from pay_kit import ( + LocalSigner, + MemoryStore, + Operator, + Price, + Protocol, + Stablecoin, + configure, +) +from pay_kit._paycore.mints import derive_ata, resolve, token_program_for +from pay_kit.config import reset +from pay_kit.gate import Gate +from pay_kit.protocols.x402 import X402Adapter +from pay_kit.protocols.x402.client.exact import ( + ChallengeSelection, + PaymentTransport, + X402Client, + build_payment, + build_payment_header, + parse_x402_challenge, +) +from pay_kit.protocols.x402.exact.types import X402AcceptsEntry +from pay_kit.protocols.x402.exact.verify import ExactVerifier +from pay_kit.signer import Signer + +# A Surfpool-style blockhash: any valid base58 hash works for offline tests. +BH = "4vJ9JU1bJJQpUgJ8V6hYz7xXKz4F2tN6aBrZEcD3xKhs" +_USDC_DEVNET = resolve("USDC", "devnet") +assert _USDC_DEVNET is not None +USDC_DEVNET: str = _USDC_DEVNET +TP_USDC = token_program_for("USDC", "devnet") +DEVNET = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" +MAINNET = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + + +@pytest.fixture(autouse=True) +def _clean(monkeypatch): + reset() + monkeypatch.setenv("PAY_KIT_DISABLE_PREFLIGHT", "1") + yield + reset() + + +async def _fixed_blockhash() -> str: + return BH + + +def _offer( + *, + asset: str = USDC_DEVNET, + amount: str = "1000", + network: str = DEVNET, + token_program: str = TP_USDC, + pay_to: str | None = None, + memo: str = "/protected", + decimals: int = 6, + blockhash: str | None = BH, + fee_payer: str | None = None, +) -> dict[str, Any]: + pay_to = pay_to or str(Keypair().pubkey()) + fee_payer = fee_payer or str(Keypair().pubkey()) + extra: dict[str, Any] = {"feePayer": fee_payer, "decimals": decimals, "tokenProgram": token_program, "memo": memo} + if blockhash is not None: + extra["recentBlockhash"] = blockhash + return { + "protocol": "x402", + "scheme": "exact", + "network": network, + "asset": asset, + "amount": amount, + "maxAmountRequired": amount, + "payTo": pay_to, + "maxTimeoutSeconds": 60, + "extra": extra, + } + + +def _entry(offer: dict[str, Any]) -> X402AcceptsEntry: + """Narrow a test-built offer dict to the wire TypedDict for the client API.""" + return cast("X402AcceptsEntry", offer) + + +def _tx(env: object) -> str: + """Pull the base64 transaction out of a built X402Envelope.""" + payload = cast("dict[str, Any]", cast("dict[str, Any]", env)["payload"]) + return cast("str", payload["transaction"]) + + +def _challenge_header(*offers: dict[str, Any]) -> str: + body = {"x402Version": 2, "resource": {"type": "http", "url": "/protected"}, "accepts": list(offers)} + return base64.b64encode(json.dumps(body).encode()).decode() + + +def _challenge_body(*offers: dict[str, Any]) -> str: + return json.dumps({"x402Version": 2, "accepts": list(offers)}) + + +# -- parse_x402_challenge ---------------------------------------------------- + + +def test_parse_from_header(): + offer = _offer() + picked = parse_x402_challenge( + {"payment-required": _challenge_header(offer)}, None, ChallengeSelection(network="devnet") + ) + assert picked is not None + assert picked["asset"] == USDC_DEVNET + + +def test_parse_header_case_insensitive_lookup(): + offer = _offer() + picked = parse_x402_challenge( + {"Payment-Required": _challenge_header(offer)}, None, ChallengeSelection(network="devnet") + ) + assert picked is not None + + +def test_parse_from_body_when_header_absent(): + offer = _offer() + picked = parse_x402_challenge({}, _challenge_body(offer), ChallengeSelection(network="devnet")) + assert picked is not None + assert picked["asset"] == USDC_DEVNET + + +def test_parse_header_preferred_over_body(): + header_offer = _offer(amount="100") + body_offer = _offer(amount="999") + picked = parse_x402_challenge( + {"payment-required": _challenge_header(header_offer)}, + _challenge_body(body_offer), + ChallengeSelection(network="devnet"), + ) + assert picked is not None + assert picked["amount"] == "100" + + +def test_parse_network_filter_prefers_matching_network(): + # Two solana offers: one on the preferred devnet, one on mainnet. With no + # currency preference the preferred-network offer wins even though it is + # not the cheapest (mirror rust: filter to preferred, then cheapest). + devnet_offer = _offer(network=DEVNET, amount="9000") + mainnet_offer = _offer(network=MAINNET, amount="1") + picked = parse_x402_challenge( + {"payment-required": _challenge_header(mainnet_offer, devnet_offer)}, + None, + ChallengeSelection(network="devnet"), + ) + assert picked is not None + assert picked["network"] == DEVNET + + +def test_parse_currency_preference_restricts_to_network(): + # Currency preference path: the wanted currency exists only on mainnet, but + # the client wants devnet -> no match on the preferred network -> None. + mainnet_offer = _offer(network=MAINNET, asset=USDC_DEVNET, amount="1000") + picked = parse_x402_challenge( + {"payment-required": _challenge_header(mainnet_offer)}, + None, + ChallengeSelection(network="devnet", currencies=["USDC"]), + ) + assert picked is None + + +def test_parse_rejects_non_solana_and_non_exact(): + bad_scheme = {**_offer(), "scheme": "upto"} + foreign = {**_offer(), "network": "ethereum:1"} + picked = parse_x402_challenge( + {"payment-required": _challenge_header(bad_scheme, foreign)}, + None, + ChallengeSelection(network="devnet"), + ) + assert picked is None + + +def test_parse_currency_preference_order(): + usdc = _offer(asset=USDC_DEVNET, amount="1000000") + pyusd_mint = resolve("PYUSD", "devnet") + assert pyusd_mint is not None + pyusd = _offer(asset=pyusd_mint, amount="1000000", token_program=token_program_for("PYUSD", "devnet")) + # Client prefers PYUSD first even though USDC is listed first. + picked = parse_x402_challenge( + {"payment-required": _challenge_header(usdc, pyusd)}, + None, + ChallengeSelection(network="devnet", currencies=["PYUSD", "USDC"]), + ) + assert picked is not None + assert picked["asset"] == pyusd_mint + + +def test_parse_currency_falls_back_to_second_choice(): + usdc = _offer(asset=USDC_DEVNET, amount="1000000") + picked = parse_x402_challenge( + {"payment-required": _challenge_header(usdc)}, + None, + ChallengeSelection(network="devnet", currencies=["USDT", "USDC"]), + ) + assert picked is not None + assert picked["asset"] == USDC_DEVNET + + +def test_parse_currency_none_match_returns_none(): + usdc = _offer(asset=USDC_DEVNET, amount="1000000") + picked = parse_x402_challenge( + {"payment-required": _challenge_header(usdc)}, + None, + ChallengeSelection(network="devnet", currencies=["USDT"]), + ) + assert picked is None + + +def test_parse_currency_accepts_mint_address_as_key(): + usdc = _offer(asset=USDC_DEVNET, amount="1000000") + picked = parse_x402_challenge( + {"payment-required": _challenge_header(usdc)}, + None, + ChallengeSelection(network="devnet", currencies=[USDC_DEVNET]), + ) + assert picked is not None + assert picked["asset"] == USDC_DEVNET + + +def test_parse_no_preference_picks_cheapest(): + expensive = _offer(asset=USDC_DEVNET, amount="1000000") + pyusd_mint = resolve("PYUSD", "devnet") + assert pyusd_mint is not None + cheap = _offer(asset=pyusd_mint, amount="5000", token_program=token_program_for("PYUSD", "devnet")) + picked = parse_x402_challenge( + {"payment-required": _challenge_header(expensive, cheap)}, + None, + ChallengeSelection(network="devnet"), + ) + assert picked is not None + assert picked["amount"] == "5000" + + +def test_parse_garbage_returns_none(): + assert parse_x402_challenge({}, None, ChallengeSelection()) is None + assert parse_x402_challenge({"payment-required": "not-base64-json!!"}, None, ChallengeSelection()) is None + assert parse_x402_challenge({}, "garbage", ChallengeSelection()) is None + + +def test_parse_default_network_is_mainnet(): + # No selection.network -> default mainnet: the mainnet offer wins over a + # devnet one even though devnet is cheaper. + mainnet_offer = _offer(network=MAINNET, amount="9000") + devnet_offer = _offer(network=DEVNET, amount="1") + picked = parse_x402_challenge( + {"payment-required": _challenge_header(devnet_offer, mainnet_offer)}, + None, + ChallengeSelection(), + ) + assert picked is not None + assert picked["network"] == MAINNET + + +def test_parse_falls_back_to_any_solana_when_no_offer_on_preferred_network(): + # No currency preference and no offer on the preferred network -> fall back + # to the overall cheapest solana offer (mirror rust ``.or_else``). + devnet_offer = _offer(network=DEVNET, amount="1234") + picked = parse_x402_challenge( + {"payment-required": _challenge_header(devnet_offer)}, + None, + ChallengeSelection(network="mainnet"), + ) + assert picked is not None + assert picked["network"] == DEVNET + + +# -- build_payment ----------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_build_payment_spl_round_trips_through_verifier(): + signer = Signer.generate() + offer = _offer() + env = await build_payment(signer, None, _entry(offer)) + assert cast("dict[str, Any]", env)["x402Version"] == 2 + assert cast("dict[str, Any]", env)["accepted"] == offer + tx_b64 = _tx(env) + # The built transaction must satisfy the structural verifier the server runs. + result = ExactVerifier.verify(tx_b64, offer, [offer["extra"]["feePayer"]]) + assert result["amount"] == 1000 + assert result["mint"] == USDC_DEVNET + assert result["destination"] == derive_ata(offer["payTo"], USDC_DEVNET, TP_USDC) + + +@pytest.mark.asyncio +async def test_build_payment_instruction_layout(): + signer = Signer.generate() + offer = _offer() + env = await build_payment(signer, None, _entry(offer)) + raw = base64.b64decode(_tx(env)) + tx = VersionedTransaction.from_bytes(raw) + instructions = list(tx.message.instructions) + keys = [str(k) for k in tx.message.account_keys] + # ComputeBudget limit (disc 2) + price (disc 3) + transferChecked + memo. + assert len(instructions) == 4 + assert bytes(instructions[0].data)[0] == 2 + assert bytes(instructions[1].data)[0] == 3 + # transferChecked: disc 12, amount u64 LE, decimals byte. + transfer_data = bytes(instructions[2].data) + assert transfer_data[0] == 12 + assert int.from_bytes(transfer_data[1:9], "little") == 1000 + assert transfer_data[9] == 6 + # fee payer (extra.feePayer) is account[0] and a required signer. + assert keys[0] == offer["extra"]["feePayer"] + assert int(tx.message.header.num_required_signatures) == 2 + + +@pytest.mark.asyncio +async def test_build_payment_uses_extra_blockhash(): + signer = Signer.generate() + offer = _offer(blockhash=BH) + env = await build_payment(signer, None, _entry(offer)) + tx = VersionedTransaction.from_bytes(base64.b64decode(_tx(env))) + assert str(tx.message.recent_blockhash) == BH + + +@pytest.mark.asyncio +async def test_build_payment_offline_via_injected_provider(): + signer = Signer.generate() + offer = _offer(blockhash=None) # no extra.recentBlockhash -> use provider + env = await build_payment(signer, None, _entry(offer), recent_blockhash_provider=_fixed_blockhash) + tx = VersionedTransaction.from_bytes(base64.b64decode(_tx(env))) + assert str(tx.message.recent_blockhash) == BH + + +@pytest.mark.asyncio +async def test_build_payment_sync_provider(): + signer = Signer.generate() + offer = _offer(blockhash=None) + env = await build_payment(signer, None, _entry(offer), recent_blockhash_provider=lambda: BH) + tx = VersionedTransaction.from_bytes(base64.b64decode(_tx(env))) + assert str(tx.message.recent_blockhash) == BH + + +@pytest.mark.asyncio +async def test_build_payment_falls_back_to_rpc_blockhash(): + class _Rpc: + async def get_latest_blockhash(self): + from solders.hash import Hash + + class _Val: + blockhash = Hash.from_string(BH) + + class _Resp: + value = _Val() + + return _Resp() + + signer = Signer.generate() + offer = _offer(blockhash=None) + env = await build_payment(signer, _Rpc(), _entry(offer)) + tx = VersionedTransaction.from_bytes(base64.b64decode(_tx(env))) + assert str(tx.message.recent_blockhash) == BH + + +@pytest.mark.asyncio +async def test_build_payment_native_sol(): + signer = Signer.generate() + offer = _offer(asset="SOL", amount="5000") + # SOL offers carry no tokenProgram; the System transfer path is taken. + offer["extra"].pop("tokenProgram", None) + env = await build_payment(signer, None, _entry(offer)) + tx = VersionedTransaction.from_bytes(base64.b64decode(_tx(env))) + instructions = list(tx.message.instructions) + # ComputeBudget x2 + System transfer + memo. + assert len(instructions) == 4 + # System transfer instruction data: discriminator 2 (u32 LE) + lamports. + transfer_data = bytes(instructions[2].data) + assert int.from_bytes(transfer_data[0:4], "little") == 2 + assert int.from_bytes(transfer_data[4:12], "little") == 5000 + + +@pytest.mark.asyncio +async def test_build_payment_rejects_invalid_amount(): + signer = Signer.generate() + offer = _offer(amount="not-a-number") + with pytest.raises(ValueError, match="invalid amount"): + await build_payment(signer, None, _entry(offer)) + + +@pytest.mark.asyncio +async def test_build_payment_rejects_missing_token_program(): + signer = Signer.generate() + offer = _offer() + del offer["extra"]["tokenProgram"] + with pytest.raises(ValueError, match="tokenProgram"): + await build_payment(signer, None, _entry(offer)) + + +@pytest.mark.asyncio +async def test_build_payment_rejects_missing_asset(): + signer = Signer.generate() + offer = _offer() + del offer["asset"] + with pytest.raises(ValueError, match="asset"): + await build_payment(signer, None, _entry(offer)) + + +@pytest.mark.asyncio +async def test_build_payment_rejects_missing_pay_to(): + signer = Signer.generate() + offer = _offer() + del offer["payTo"] + with pytest.raises(ValueError, match="payTo"): + await build_payment(signer, None, _entry(offer)) + + +# -- build_payment_header ---------------------------------------------------- + + +@pytest.mark.asyncio +async def test_build_payment_header_envelope_shape(): + signer = Signer.generate() + offer = _offer() + header = await build_payment_header(signer, None, _entry(offer)) + decoded = json.loads(base64.b64decode(header)) + assert decoded["x402Version"] == 2 + assert decoded["accepted"] == offer + assert "transaction" in decoded["payload"] + # payload.transaction is itself valid base64. + assert base64.b64decode(decoded["payload"]["transaction"]) + + +# -- transport (402 -> pay -> 200) ------------------------------------------- + + +class _FakeRpc: + """Stub for the server-side SolanaRpc broadcast surface.""" + + def __init__(self, *_a, signature: str = "SIG-client-interop", **_k): + self._signature = signature + + async def send_raw_transaction(self, _raw): + class _Resp: + value = self._signature + + return _Resp() + + async def aclose(self): + return None + + +def _server_adapter_and_gate(monkeypatch): + import pay_kit.protocols.x402 as xmod + + op = Operator(signer=LocalSigner.from_keypair(Keypair()), recipient=str(Keypair().pubkey())) + cfg = configure( + network="solana_localnet", + preflight=False, + accept=(Protocol.X402,), + operator=op, + rpc_url="http://127.0.0.1:8899", # loopback skips the blockhash net check + ) + gate = Gate.build( + name="protected", + amount=Price.usd("0.001", Stablecoin.USDC), + default_pay_to=cfg.effective_recipient(), + accept=(Protocol.X402,), + ) + # Stamp a fixed blockhash into the offer so the client signs offline against it. + adapter = X402Adapter(cfg, replay_store=MemoryStore(), recent_blockhash_provider=lambda: BH) + monkeypatch.setattr(xmod, "SolanaRpc", lambda *_a, **_k: _FakeRpc()) + return adapter, gate + + +def _asgi_app(adapter: X402Adapter, gate: Gate): + async def app(scope, receive, send): + assert scope["type"] == "http" + headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])} + request = {"headers": headers, "path": scope["path"]} + + async def respond(status: int, body: dict, extra_headers: dict): + payload = json.dumps(body).encode() + raw_headers = [(b"content-type", b"application/json")] + for name, value in extra_headers.items(): + raw_headers.append((name.lower().encode(), value.encode())) + await send({"type": "http.response.start", "status": status, "headers": raw_headers}) + await send({"type": "http.response.body", "body": payload}) + + if not headers.get("payment-signature"): + await respond( + 402, + {"error": "payment_required"}, + adapter.challenge_headers(gate, request), + ) + return + payment = await adapter.verify_and_settle(gate, request) + settle = dict(payment.settlement_headers) + settle["x-fixture-settlement"] = payment.transaction + await respond(200, {"ok": True, "transaction": payment.transaction}, settle) + + return app + + +@pytest.mark.asyncio +async def test_transport_402_then_pay_then_200(monkeypatch): + adapter, gate = _server_adapter_and_gate(monkeypatch) + app = _asgi_app(adapter, gate) + signer = Signer.generate() + + inner = httpx.ASGITransport(app=app) + transport = PaymentTransport(signer, None, network="localnet", base_transport=inner) + async with httpx.AsyncClient(transport=transport, base_url="http://server") as client: + resp = await client.get("/protected") + + assert resp.status_code == 200 + assert resp.json()["ok"] is True + assert resp.headers["x-fixture-settlement"] == "SIG-client-interop" + + +@pytest.mark.asyncio +async def test_transport_sends_payment_signature_header(monkeypatch): + adapter, gate = _server_adapter_and_gate(monkeypatch) + seen_headers: list[dict[str, str]] = [] + base_app = _asgi_app(adapter, gate) + + async def recording_app(scope, receive, send): + headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])} + seen_headers.append(headers) + await base_app(scope, receive, send) + + inner = httpx.ASGITransport(app=recording_app) + signer = Signer.generate() + transport = PaymentTransport(signer, None, network="localnet", base_transport=inner) + async with httpx.AsyncClient(transport=transport, base_url="http://server") as client: + resp = await client.get("/protected") + + assert resp.status_code == 200 + # Two requests reached the app: the unpaid GET and the retried paid GET. + assert len(seen_headers) == 2 + assert "payment-signature" not in seen_headers[0] + assert "payment-signature" in seen_headers[1] + + +@pytest.mark.asyncio +async def test_transport_passes_through_non_402(monkeypatch): + async def ok_app(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/plain")]}) + await send({"type": "http.response.body", "body": b"hi"}) + + signer = Signer.generate() + inner = httpx.ASGITransport(app=ok_app) + transport = PaymentTransport(signer, None, network="localnet", base_transport=inner) + async with httpx.AsyncClient(transport=transport, base_url="http://server") as client: + resp = await client.get("/free") + assert resp.status_code == 200 + assert resp.text == "hi" + + +@pytest.mark.asyncio +async def test_transport_returns_402_when_no_supported_challenge(monkeypatch): + async def bare_402(scope, receive, send): + await send({"type": "http.response.start", "status": 402, "headers": [(b"content-type", b"text/plain")]}) + await send({"type": "http.response.body", "body": b"nope"}) + + signer = Signer.generate() + inner = httpx.ASGITransport(app=bare_402) + transport = PaymentTransport(signer, None, network="localnet", base_transport=inner) + async with httpx.AsyncClient(transport=transport, base_url="http://server") as client: + resp = await client.get("/protected") + assert resp.status_code == 402 + + +@pytest.mark.asyncio +async def test_transport_returns_original_402_on_build_failure(monkeypatch): + # A challenge whose offer is missing tokenProgram: build_payment raises, the + # transport logs and surfaces the original 402 rather than crashing. + offer = _offer(network=DEVNET) + del offer["extra"]["tokenProgram"] + header = _challenge_header(offer) + + async def broken_app(scope, receive, send): + await send( + { + "type": "http.response.start", + "status": 402, + "headers": [(b"content-type", b"application/json"), (b"payment-required", header.encode())], + } + ) + await send({"type": "http.response.body", "body": b"{}"}) + + signer = Signer.generate() + inner = httpx.ASGITransport(app=broken_app) + transport = PaymentTransport(signer, None, network="devnet", base_transport=inner) + async with httpx.AsyncClient(transport=transport, base_url="http://server") as client: + resp = await client.get("/protected") + assert resp.status_code == 402 + + +@pytest.mark.asyncio +async def test_x402_client_factory(monkeypatch): + adapter, gate = _server_adapter_and_gate(monkeypatch) + app = _asgi_app(adapter, gate) + signer = Signer.generate() + inner = httpx.ASGITransport(app=app) + client = X402Client(signer, None, network="localnet", base_transport=inner, base_url="http://server") + try: + resp = await client.get("/protected") + finally: + await client.aclose() + assert resp.status_code == 200 From 80fc3f702c0b5283a42a0c67a58e4e67d1031cf8 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 21:08:40 +0300 Subject: [PATCH 26/45] feat(harness): register python-x402 exact interop client 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). --- harness/python-x402-client/main.py | 188 ++++++++++++++++++++++++++++ harness/src/implementations.ts | 16 +++ harness/test/x402-exact.e2e.test.ts | 9 ++ 3 files changed, 213 insertions(+) create mode 100644 harness/python-x402-client/main.py diff --git a/harness/python-x402-client/main.py b/harness/python-x402-client/main.py new file mode 100644 index 000000000..cbe55ab34 --- /dev/null +++ b/harness/python-x402-client/main.py @@ -0,0 +1,188 @@ +"""Cross-language harness adapter for the Python pay_kit x402 ``exact`` client. + +Mirrors the Rust spine interop client +(``rust/crates/x402/src/bin/interop_client.rs``): GET the target, parse the +x402 challenge with the client's network + currency-preference selection, build +the ``PAYMENT-SIGNATURE`` header, GET again with it, then print exactly one +result JSON line to stdout. All diagnostics go to stderr. + +Env contract (shared with the rust/ts clients): + +* ``X402_INTEROP_TARGET_URL`` - required, the gated resource URL. +* ``X402_INTEROP_RPC_URL`` - required, Solana RPC (blockhash fallback). +* ``X402_INTEROP_NETWORK`` - CAIP-2 / slug; default devnet CAIP-2. +* ``X402_INTEROP_CLIENT_SECRET_KEY`` - required, JSON int array (Signer.bytes). +* ``X402_INTEROP_PREFER_CURRENCIES`` - optional, comma-separated preference list. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +from pathlib import Path +from typing import Any + + +def _find_repo_root(start: Path) -> Path: + for candidate in [start, *start.parents]: + if (candidate / ".git").exists() or (candidate / "python" / "pyproject.toml").is_file(): + return candidate + return start.parents[-1] + + +_repo_root = _find_repo_root(Path(__file__).resolve()) +_python_src = _repo_root / "python" / "src" +if _python_src.is_dir(): + sys.path.insert(0, str(_python_src)) + +import httpx # noqa: E402 + +from pay_kit.signer import Signer # noqa: E402 +from pay_kit.protocols.x402.client.exact import ( # noqa: E402 + ChallengeSelection, + build_payment_header, + parse_x402_challenge, +) + +DEFAULT_NETWORK = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" +SETTLEMENT_HEADER = "x-fixture-settlement" +PAYMENT_SIGNATURE_HEADER = "Payment-Signature" + + +def _require_env(name: str) -> str: + value = os.environ.get(name) + if not value: + print(f"{name} is required", file=sys.stderr) + sys.exit(2) + return value + + +class _BlockhashRpc: + """Minimal RPC exposing ``get_latest_blockhash`` for the build fallback. + + Only used when an offer omits ``extra.recentBlockhash``; the pay_kit x402 + server stamps the blockhash so this is the rare path. + """ + + def __init__(self, endpoint: str) -> None: + self._endpoint = endpoint + + async def get_latest_blockhash(self) -> Any: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + self._endpoint, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "getLatestBlockhash", + "params": [{"commitment": "confirmed"}], + }, + ) + response.raise_for_status() + data = response.json() + blockhash = data["result"]["value"]["blockhash"] + + class _Value: + def __init__(self, bh: str) -> None: + self.blockhash = bh + + class _Resp: + def __init__(self, bh: str) -> None: + self.value = _Value(bh) + + return _Resp(blockhash) + + +def _emit(result: dict[str, Any]) -> None: + sys.stdout.write(json.dumps(result) + "\n") + sys.stdout.flush() + + +async def _run() -> None: + target_url = _require_env("X402_INTEROP_TARGET_URL") + rpc_url = _require_env("X402_INTEROP_RPC_URL") + network = os.environ.get("X402_INTEROP_NETWORK") or DEFAULT_NETWORK + secret = _require_env("X402_INTEROP_CLIENT_SECRET_KEY") + signer = Signer.json(secret) + + prefer_raw = os.environ.get("X402_INTEROP_PREFER_CURRENCIES") + currencies = None + if prefer_raw: + currencies = [entry.strip() for entry in prefer_raw.split(",") if entry.strip()] or None + + async with httpx.AsyncClient(timeout=60.0) as http: + first = await http.get(target_url) + first_headers = {k: v for k, v in first.headers.items()} + first_body = first.text + + selection = ChallengeSelection(network=network, currencies=currencies) + requirement = parse_x402_challenge(first_headers, first_body, selection) + if requirement is None: + _emit( + { + "type": "result", + "implementation": "python", + "role": "client", + "ok": False, + "status": first.status_code, + "responseHeaders": first_headers, + "responseBody": _parse_body(first_body), + "settlement": None, + "error": "server did not return a supported SVM x402 challenge", + } + ) + return + + rpc = _BlockhashRpc(rpc_url) + payment_header = await build_payment_header(signer, rpc, requirement) + + paid = await http.get(target_url, headers={PAYMENT_SIGNATURE_HEADER: payment_header}) + + paid_headers = {k: v for k, v in paid.headers.items()} + paid_headers[f"{PAYMENT_SIGNATURE_HEADER}-sent"] = payment_header + settlement = paid_headers.get(SETTLEMENT_HEADER) + + _emit( + { + "type": "result", + "implementation": "python", + "role": "client", + "ok": paid.is_success, + "status": paid.status_code, + "responseHeaders": paid_headers, + "responseBody": _parse_body(paid.text), + "settlement": settlement, + } + ) + + +def _parse_body(raw: str) -> Any: + try: + return json.loads(raw) + except (json.JSONDecodeError, ValueError): + return raw + + +def main() -> None: + try: + asyncio.run(_run()) + except Exception as exc: # noqa: BLE001 - emit a structured failure line + _emit( + { + "type": "result", + "implementation": "python", + "role": "client", + "ok": False, + "status": 0, + "responseHeaders": {}, + "responseBody": None, + "settlement": None, + "error": str(exc), + } + ) + + +if __name__ == "__main__": + main() diff --git a/harness/src/implementations.ts b/harness/src/implementations.ts index 62f2a7276..bd4e4445c 100644 --- a/harness/src/implementations.ts +++ b/harness/src/implementations.ts @@ -130,6 +130,22 @@ export const clientImplementations: ImplementationDefinition[] = [ enabled: isEnabled("go-x402", "X402_INTEROP_CLIENTS", false), intents: ["x402-exact"], }, + { + id: "python-x402", + label: "Python pay_kit x402 exact client", + role: "client", + // Drives the pay_kit x402 exact client (parse challenge -> build a signed + // v0 VersionedTransaction -> PAYMENT-SIGNATURE -> retry). Inserts python/src + // on sys.path like harness/python-server/server.py. Default OFF to match the + // go/swift/kotlin/ruby adapters: the default matrix should not require a + // Python toolchain on every contributor's machine. Opt in via + // `X402_INTEROP_CLIENTS=python-x402` (the focused python-x402 CI job sets + // this). Carries a real signed Solana transaction, so it settles end-to-end + // against the rust/ts/python x402 servers (see test/x402-exact.e2e.test.ts). + command: ["python3", "python-x402-client/main.py"], + enabled: isEnabled("python-x402", "X402_INTEROP_CLIENTS", false), + intents: ["x402-exact"], + }, ]; export const serverImplementations: ImplementationDefinition[] = [ diff --git a/harness/test/x402-exact.e2e.test.ts b/harness/test/x402-exact.e2e.test.ts index 321cb5d17..cc2d9e6b1 100644 --- a/harness/test/x402-exact.e2e.test.ts +++ b/harness/test/x402-exact.e2e.test.ts @@ -95,6 +95,15 @@ describe("x402 exact intent — cross-language matrix", () => { // mirroring the rust<->lua x402 interop pairing. The ts-x402 stub // client (no real transaction) is intentionally excluded. if (clientId === "rust-x402" && serverId === "python") return true; + // The Python pay_kit x402 client carries a real signed v0 + // VersionedTransaction, so it can only be driven against full-settling + // x402 servers (cosign + broadcast). The ts-x402 stub server expects a + // stub credential with a payload.challengeId and never broadcasts a real + // transaction, so it is intentionally excluded — same reasoning that keeps + // rust-x402 off the ts-x402 server. Drive python-x402 against the rust and + // python x402 servers, which settle end-to-end against surfpool. + if (clientId === "python-x402" && serverId === "rust-x402") return true; + if (clientId === "python-x402" && serverId === "python") return true; return false; }; From 90b442f363920f04ce3e6dfb2b6adc301e569720 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 21:10:13 +0300 Subject: [PATCH 27/45] test(harness): add x402-exact token-2022 + ATA-precreated scenarios 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. --- harness/src/intents/x402-exact.ts | 47 +++++++++++++++++++++++++++ harness/test/intent-selection.test.ts | 2 ++ 2 files changed, 49 insertions(+) diff --git a/harness/src/intents/x402-exact.ts b/harness/src/intents/x402-exact.ts index d789c89be..f617b39a6 100644 --- a/harness/src/intents/x402-exact.ts +++ b/harness/src/intents/x402-exact.ts @@ -23,8 +23,55 @@ export const x402ExactScenarios: readonly InteropScenario[] = [ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", resourcePath: "/protected", settlementHeader: "x-fixture-settlement", + // The pay_kit rust/python x402 servers stamp `extra.recentBlockhash` and + // `extra.memo` (the resource path) into the offer; the basic happy path + // therefore already exercises both the recentBlockhash-present and memo + // bindings end-to-end. The token-2022 and ATA-create variants below add + // the remaining shapes. These full-settlement scenarios only run against + // full-settling client+server pairs (see the matrix `allowedPair` + // restriction); the default x402 e2e matrix runs `x402-exact-basic` only. expectedStatus: 200, }, + { + // Token-2022 mint. PYUSD on localnet/devnet is owned by the Token-2022 + // program; the harness deploys the mint under that program. The pay_kit + // server advertises `extra.tokenProgram = TOKEN_2022_PROGRAM` and the + // client builds the transferChecked against the Token-2022 program and + // the Token-2022-derived ATA. Exercises the verifier's Rule 11 token + // program bind on the Token-2022 branch. + id: "x402-exact-token2022", + intent: "x402-exact", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + price: "0.001", + amount: "1000", + asset: "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", + tokenProgram: "TOKEN_2022_PROGRAM", + resourcePath: "/protected/token2022", + settlementHeader: "x-fixture-settlement", + expectedStatus: 200, + clientIds: ["rust-x402", "python-x402"], + serverIds: ["rust-x402", "python"], + }, + { + // ATA-create: the platform recipient's ATA is pre-created with a zero + // balance before the test, so the settled transferChecked lands in an + // already-existing destination ATA (the verifier accepts the bare + // transferChecked; no client-side create-idempotent instruction needed). + // This exercises the destination-ATA derivation + Rule 7 recipient match + // against an on-chain account that exists at settle time. + id: "x402-exact-ata-precreated", + intent: "x402-exact", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + price: "0.001", + amount: "1000", + asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + resourcePath: "/protected/ata-precreated", + settlementHeader: "x-fixture-settlement", + preCreatePlatformAta: true, + expectedStatus: 200, + clientIds: ["rust-x402", "python-x402"], + serverIds: ["rust-x402", "python"], + }, { // Network mismatch: client signs against localnet but the challenge // requires devnet (or vice versa). Server must reject the credential diff --git a/harness/test/intent-selection.test.ts b/harness/test/intent-selection.test.ts index 1dcef686f..43a399c06 100644 --- a/harness/test/intent-selection.test.ts +++ b/harness/test/intent-selection.test.ts @@ -64,6 +64,8 @@ describe("interop scenario selection", () => { ), ).toEqual([ "x402-exact-basic", + "x402-exact-token2022", + "x402-exact-ata-precreated", "x402-exact-network-mismatch", "x402-exact-cross-route-replay", "x402-exact-cross-server-portability", From bf225d404a8368f64474b117149b38c279c7dfb2 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 21:12:43 +0300 Subject: [PATCH 28/45] ci(python): wire python-x402 client into the interop matrix 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). --- .github/workflows/python.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index fe5dff080..8bfd0923f 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -100,7 +100,9 @@ jobs: run: pnpm --filter @solana/mpp build - name: Build Rust interop adapters working-directory: rust - run: cargo build -p solana-mpp --bin interop_client --bin interop_server + run: | + cargo build -p solana-mpp --bin interop_client --bin interop_server + cargo build -p solana-x402 --bin interop_client --bin interop_server - name: Install interop harness working-directory: harness run: pnpm install --frozen-lockfile @@ -116,3 +118,19 @@ jobs: MPP_INTEROP_CLIENTS: rust MPP_INTEROP_SERVERS: python run: pnpm exec vitest run test/e2e.test.ts + # x402 exact: drive the Python pay_kit x402 client (a real signed v0 + # VersionedTransaction) against the full-settling rust and python x402 + # servers. test/e2e.test.ts self-hosts surfnet and threads the funded + # client keypair into X402_INTEROP_CLIENT_SECRET_KEY; the matrix gates on + # impl.intents so only x402-exact scenarios run. The x402 client adapter + # imports pay_kit from python/src on sys.path (no extra install beyond the + # editable SDK above). ts-x402 is excluded: its stub server expects a + # payload.challengeId and never broadcasts a real transaction, so it + # cannot settle a genuine signed tx (same reason rust-x402 skips it). + - name: Focused python-x402 -> rust/python x402 servers + working-directory: harness + env: + MPP_INTEROP_INTENTS: x402-exact + X402_INTEROP_CLIENTS: python-x402 + X402_INTEROP_SERVERS: rust-x402,python + run: pnpm exec vitest run test/e2e.test.ts --testTimeout 180000 From 73d2a2398de79a40f30c6529977a8acfe5107aac Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 21:15:36 +0300 Subject: [PATCH 29/45] Revert "test(harness): add x402-exact token-2022 + ATA-precreated scenarios" This reverts commit 90b442f363920f04ce3e6dfb2b6adc301e569720. --- harness/src/intents/x402-exact.ts | 47 --------------------------- harness/test/intent-selection.test.ts | 2 -- 2 files changed, 49 deletions(-) diff --git a/harness/src/intents/x402-exact.ts b/harness/src/intents/x402-exact.ts index f617b39a6..d789c89be 100644 --- a/harness/src/intents/x402-exact.ts +++ b/harness/src/intents/x402-exact.ts @@ -23,55 +23,8 @@ export const x402ExactScenarios: readonly InteropScenario[] = [ asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", resourcePath: "/protected", settlementHeader: "x-fixture-settlement", - // The pay_kit rust/python x402 servers stamp `extra.recentBlockhash` and - // `extra.memo` (the resource path) into the offer; the basic happy path - // therefore already exercises both the recentBlockhash-present and memo - // bindings end-to-end. The token-2022 and ATA-create variants below add - // the remaining shapes. These full-settlement scenarios only run against - // full-settling client+server pairs (see the matrix `allowedPair` - // restriction); the default x402 e2e matrix runs `x402-exact-basic` only. expectedStatus: 200, }, - { - // Token-2022 mint. PYUSD on localnet/devnet is owned by the Token-2022 - // program; the harness deploys the mint under that program. The pay_kit - // server advertises `extra.tokenProgram = TOKEN_2022_PROGRAM` and the - // client builds the transferChecked against the Token-2022 program and - // the Token-2022-derived ATA. Exercises the verifier's Rule 11 token - // program bind on the Token-2022 branch. - id: "x402-exact-token2022", - intent: "x402-exact", - network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", - price: "0.001", - amount: "1000", - asset: "CXk2AMBfi3TwaEL2468s6zP8xq9NxTXjp9gjMgzeUynM", - tokenProgram: "TOKEN_2022_PROGRAM", - resourcePath: "/protected/token2022", - settlementHeader: "x-fixture-settlement", - expectedStatus: 200, - clientIds: ["rust-x402", "python-x402"], - serverIds: ["rust-x402", "python"], - }, - { - // ATA-create: the platform recipient's ATA is pre-created with a zero - // balance before the test, so the settled transferChecked lands in an - // already-existing destination ATA (the verifier accepts the bare - // transferChecked; no client-side create-idempotent instruction needed). - // This exercises the destination-ATA derivation + Rule 7 recipient match - // against an on-chain account that exists at settle time. - id: "x402-exact-ata-precreated", - intent: "x402-exact", - network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", - price: "0.001", - amount: "1000", - asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", - resourcePath: "/protected/ata-precreated", - settlementHeader: "x-fixture-settlement", - preCreatePlatformAta: true, - expectedStatus: 200, - clientIds: ["rust-x402", "python-x402"], - serverIds: ["rust-x402", "python"], - }, { // Network mismatch: client signs against localnet but the challenge // requires devnet (or vice versa). Server must reject the credential diff --git a/harness/test/intent-selection.test.ts b/harness/test/intent-selection.test.ts index 43a399c06..1dcef686f 100644 --- a/harness/test/intent-selection.test.ts +++ b/harness/test/intent-selection.test.ts @@ -64,8 +64,6 @@ describe("interop scenario selection", () => { ), ).toEqual([ "x402-exact-basic", - "x402-exact-token2022", - "x402-exact-ata-precreated", "x402-exact-network-mismatch", "x402-exact-cross-route-replay", "x402-exact-cross-server-portability", From 8a3568a70231db764716ce8ffb41b9a79b6ab3f0 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 21:16:46 +0300 Subject: [PATCH 30/45] ci(python): scope the python-x402 interop run to the basic happy path 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). --- .github/workflows/python.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 8bfd0923f..dd9a065d4 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -121,16 +121,21 @@ jobs: # x402 exact: drive the Python pay_kit x402 client (a real signed v0 # VersionedTransaction) against the full-settling rust and python x402 # servers. test/e2e.test.ts self-hosts surfnet and threads the funded - # client keypair into X402_INTEROP_CLIENT_SECRET_KEY; the matrix gates on - # impl.intents so only x402-exact scenarios run. The x402 client adapter - # imports pay_kit from python/src on sys.path (no extra install beyond the - # editable SDK above). ts-x402 is excluded: its stub server expects a + # client keypair into X402_INTEROP_CLIENT_SECRET_KEY. The matrix pairs + # every active client x active server, so the MPP_INTEROP_* selectors are + # also set to keep the default charge-enabled adapters (typescript, php, + # go) out of this x402 run. ts-x402 is excluded: its stub server expects a # payload.challengeId and never broadcasts a real transaction, so it - # cannot settle a genuine signed tx (same reason rust-x402 skips it). + # cannot settle a genuine signed tx (same reason rust-x402 skips it). The + # x402 client adapter imports pay_kit from python/src on sys.path (no + # extra install beyond the editable SDK above). - name: Focused python-x402 -> rust/python x402 servers working-directory: harness env: MPP_INTEROP_INTENTS: x402-exact + MPP_INTEROP_SCENARIOS: x402-exact-basic + MPP_INTEROP_CLIENTS: python-x402 + MPP_INTEROP_SERVERS: rust-x402,python X402_INTEROP_CLIENTS: python-x402 X402_INTEROP_SERVERS: rust-x402,python run: pnpm exec vitest run test/e2e.test.ts --testTimeout 180000 From a91c638e6ac6051601bd23a51e885853f732cf5f Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 21:50:39 +0300 Subject: [PATCH 31/45] docs(python): drop mpp module path from x402 transport docstring Keeps the 'protocols must not depend on each other' invariant grep-clean (it was only a doc cross-reference, not an import). --- python/src/pay_kit/protocols/x402/client/exact/transport.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/python/src/pay_kit/protocols/x402/client/exact/transport.py b/python/src/pay_kit/protocols/x402/client/exact/transport.py index b47eb973d..0aa3baa76 100644 --- a/python/src/pay_kit/protocols/x402/client/exact/transport.py +++ b/python/src/pay_kit/protocols/x402/client/exact/transport.py @@ -1,7 +1,6 @@ """Payment-aware httpx transport for automatic x402 ``exact`` 402 handling. -Mirrors the MPP ``PaymentTransport`` (``pay_kit.protocols.mpp.client.transport``) -and the Go x402 ``PaymentTransport`` / ``NewClient`` +Mirrors the Go x402 ``PaymentTransport`` / ``NewClient`` (``go/protocols/x402/client/client.go``): a request whose first response is a 402 with an x402 ``exact`` challenge is satisfied by building a ``PAYMENT-SIGNATURE`` header and retrying the request once. The header name is From 104099fbdd4f76156bbe623835235e19da8134d0 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 22:03:35 +0300 Subject: [PATCH 32/45] fix(python): add SolanaRpc.get_latest_blockhash for the x402 client blockhash fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- python/src/pay_kit/_paycore/rpc.py | 19 +++++++++++++++++++ python/tests/test_rpc_methods.py | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/python/src/pay_kit/_paycore/rpc.py b/python/src/pay_kit/_paycore/rpc.py index 7a96c9aed..cdbc4f74c 100644 --- a/python/src/pay_kit/_paycore/rpc.py +++ b/python/src/pay_kit/_paycore/rpc.py @@ -47,6 +47,16 @@ def __init__(self, value: Any) -> None: self.value = value +class _BlockhashValue: + """``.blockhash`` holder so ``get_latest_blockhash().value.blockhash`` + matches the ``solana-py`` / solders response shape the x402 client reads.""" + + __slots__ = ("blockhash",) + + def __init__(self, blockhash: str) -> None: + self.blockhash = blockhash + + class SolanaRpc: """Minimal async JSON-RPC client for the Solana RPC API.""" @@ -92,6 +102,15 @@ async def send_raw_transaction(self, raw_tx: bytes) -> Any: return _RpcResponse(signature) + async def get_latest_blockhash(self, commitment: str = "confirmed") -> _RpcResponse: + """Fetch the latest blockhash. Used by the x402 client when an offer + omits ``extra.recentBlockhash``. Returns ``resp.value.blockhash``.""" + result = await self._call("getLatestBlockhash", [{"commitment": commitment}]) + blockhash = ((result or {}).get("value") or {}).get("blockhash") if isinstance(result, dict) else None + if not isinstance(blockhash, str) or not blockhash: + raise _RpcError("getLatestBlockhash returned no blockhash", code="payment_invalid") + return _RpcResponse(_BlockhashValue(blockhash)) + async def get_signature_statuses(self, signatures: list[str]) -> list[Any]: result = await self._call("getSignatureStatuses", [signatures, {"searchTransactionHistory": False}]) return (result or {}).get("value") or [] diff --git a/python/tests/test_rpc_methods.py b/python/tests/test_rpc_methods.py index a07a1268d..3f647ff35 100644 --- a/python/tests/test_rpc_methods.py +++ b/python/tests/test_rpc_methods.py @@ -190,3 +190,27 @@ async def test_aclose_calls_underlying_client(): rpc = _rpc({"result": None, "id": 1}) await rpc.aclose() # Survives without error. + + +@pytest.mark.asyncio +async def test_get_latest_blockhash_returns_value_blockhash(): + # Regression: the x402 client's blockhash fallback calls + # rpc.get_latest_blockhash() and reads resp.value.blockhash. Manual DX + # caught that SolanaRpc lacked this method entirely. + payload = { + "result": { + "context": {"slot": 1}, + "value": {"blockhash": "Bh11111111111111111111111111111111111111111", "lastValidBlockHeight": 200}, + }, + "id": 1, + } + rpc = _rpc(payload) + resp = await rpc.get_latest_blockhash() + assert resp.value.blockhash == "Bh11111111111111111111111111111111111111111" + + +@pytest.mark.asyncio +async def test_get_latest_blockhash_rejects_missing_blockhash(): + rpc = _rpc({"result": {"value": {}}, "id": 1}) + with pytest.raises(_RpcError): + await rpc.get_latest_blockhash() From 533ee293f721d77252be034546e90e5898e6f876 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 22:03:35 +0300 Subject: [PATCH 33/45] docs(python): document and example the x402 exact client Add a Client column to the x402 matrix, a '### Client' section showing the auto-pay transport (x402_async_client), and examples/x402-client/. --- python/README.md | 40 ++++++++++++++++++--- python/examples/x402-client/main.py | 55 +++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 python/examples/x402-client/main.py diff --git a/python/README.md b/python/README.md index 42b09076b..88ac993fd 100644 --- a/python/README.md +++ b/python/README.md @@ -205,11 +205,41 @@ network fees, the customer's signed transaction settles funds to `pay_to`. Gates with `fee_within` or `fee_on_top` recipients auto-disable x402, because stock x402 facilitators settle to one address. -| Scheme | Status | -|---------|--------| -| `exact` | ✅ | -| `upto` | — | -| `batch` | — | +| Scheme | Client | Server | +|---------|:------:|:------:| +| `exact` | ✅ | ✅ | +| `upto` | — | — | +| `batch` | — | — | + +### Client + +Pay an x402-gated endpoint with the auto-pay transport (the Go `NewClient` +ergonomics): hand it a signer and an RPC and you get back an +`httpx.AsyncClient` that replays any `402` with a signed `PAYMENT-SIGNATURE` +payment, then returns the paid response. + +```python +import asyncio + +from pay_kit import Signer +from pay_kit._paycore.rpc import SolanaRpc +from pay_kit.protocols.x402.client import x402_async_client + +async def main(): + signer = Signer.file("payer.json") # the payer's keypair + rpc = SolanaRpc("https://api.devnet.solana.com") + async with x402_async_client(signer, rpc) as http: + resp = await http.get("https://api.example/report") # 402 -> pay -> 200 + print(resp.status_code, resp.headers.get("payment-response")) + +asyncio.run(main()) +``` + +The low-level building blocks are exposed too, mirroring the Rust/Go client: +`parse_x402_challenge(headers, body, selection)` selects an offer, and +`build_payment_header(signer, rpc, offer)` returns the base64 `PAYMENT-SIGNATURE` +value for callers that drive their own HTTP. See +[`examples/x402-client/`](examples/x402-client). ## MPP diff --git a/python/examples/x402-client/main.py b/python/examples/x402-client/main.py new file mode 100644 index 000000000..0dc9820d4 --- /dev/null +++ b/python/examples/x402-client/main.py @@ -0,0 +1,55 @@ +# examples/x402-client/main.py +"""Pay an x402-gated endpoint with the pay_kit x402 ``exact`` client. + +The auto-pay transport mirrors the Go ``NewClient`` ergonomics: give it a payer +signer and an RPC, get back an ``httpx.AsyncClient`` that turns any ``402`` into +a signed ``PAYMENT-SIGNATURE`` payment and replays the request. + +Run a server first (see examples/fastapi), then: + + pip install -e ".[fastapi]" + uvicorn app:app --app-dir examples/fastapi --port 8000 # the gated server + python examples/x402-client/main.py http://127.0.0.1:8000/report + +Env: + X402_PAYER_KEY path to the payer's Solana keypair JSON (default: demo signer) + X402_RPC_URL Solana RPC for the blockhash fallback (default: devnet) +""" + +from __future__ import annotations + +import asyncio +import os +import sys + +from pay_kit import Signer +from pay_kit._paycore.rpc import SolanaRpc +from pay_kit.protocols.x402.client import x402_async_client + + +async def main(url: str) -> int: + key_path = os.environ.get("X402_PAYER_KEY") + signer = Signer.file(key_path) if key_path else Signer.demo() + rpc = SolanaRpc(os.environ.get("X402_RPC_URL", "https://api.devnet.solana.com")) + + # High-level: one client that auto-pays the 402 and returns the paid response. + async with x402_async_client(signer, rpc) as http: + resp = await http.get(url) + settlement = resp.headers.get("payment-response") or resp.headers.get("x-payment-settlement-signature") + print(f"status : {resp.status_code}") + print(f"settlement : {settlement}") + print(f"body : {resp.text[:200]}") + + # Low-level equivalent (drive your own HTTP): parse the offer, build the header. + # async with httpx.AsyncClient() as raw: + # first = await raw.get(url) + # offer = parse_x402_challenge(dict(first.headers), first.text, ChallengeSelection()) + # header = await build_payment_header(signer, rpc, offer) + # paid = await raw.get(url, headers={"PAYMENT-SIGNATURE": header}) + + return 0 if resp.is_success else 1 + + +if __name__ == "__main__": + target = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8000/report" + raise SystemExit(asyncio.run(main(target))) From 18a5a9b70c141537042cc376fac51df7828bc3f0 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 29 May 2026 23:31:21 +0300 Subject: [PATCH 34/45] fix(python): align x402 client ComputeUnitLimit with the rust spine (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. --- .../src/pay_kit/protocols/x402/client/exact/payment.py | 9 ++++++--- python/tests/test_pk_x402_client.py | 2 ++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/python/src/pay_kit/protocols/x402/client/exact/payment.py b/python/src/pay_kit/protocols/x402/client/exact/payment.py index 8c13a90ff..8da9a5464 100644 --- a/python/src/pay_kit/protocols/x402/client/exact/payment.py +++ b/python/src/pay_kit/protocols/x402/client/exact/payment.py @@ -12,7 +12,7 @@ The built transaction is a v0 ``VersionedTransaction`` whose fee payer is the offer's ``extra.feePayer`` (the facilitator, which cosigns server-side) and whose transfer authority is the client signer. Instructions are laid out -exactly as the verifier expects: ComputeBudget SetComputeUnitLimit(200000) + +exactly as the verifier expects: ComputeBudget SetComputeUnitLimit(20000) + SetComputeUnitPrice, then a ``transferChecked`` (SPL) or System ``transfer`` (native SOL), then a Memo carrying ``extra.memo``. """ @@ -41,8 +41,11 @@ "build_payment_header", ] -#: ComputeBudget SetComputeUnitLimit value the verifier accepts (disc 2, u32 LE). -_COMPUTE_UNIT_LIMIT = 200_000 +#: ComputeBudget SetComputeUnitLimit (disc 2, u32 LE). Matches the rust spine +#: client (``rust/crates/x402/src/client/exact/payment.rs``) and the Go client; +#: the server verifier checks the instruction shape + the price cap, not this +#: value, but the SDKs emit one canonical limit. +_COMPUTE_UNIT_LIMIT = 20_000 #: ComputeBudget SetComputeUnitPrice microlamports (disc 3, u64 LE, <= MAX). _COMPUTE_UNIT_PRICE = 1 #: Default SPL decimals when the offer omits ``extra.decimals``. diff --git a/python/tests/test_pk_x402_client.py b/python/tests/test_pk_x402_client.py index c1cf2f533..4cb243ed4 100644 --- a/python/tests/test_pk_x402_client.py +++ b/python/tests/test_pk_x402_client.py @@ -316,6 +316,8 @@ async def test_build_payment_instruction_layout(): # ComputeBudget limit (disc 2) + price (disc 3) + transferChecked + memo. assert len(instructions) == 4 assert bytes(instructions[0].data)[0] == 2 + # Canonical SetComputeUnitLimit value: 20_000, matching the rust/go clients. + assert int.from_bytes(bytes(instructions[0].data)[1:5], "little") == 20_000 assert bytes(instructions[1].data)[0] == 3 # transferChecked: disc 12, amount u64 LE, decimals byte. transfer_data = bytes(instructions[2].data) From cdf4048059b8e815faf46acaa8cf6308a7566e11 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Sat, 30 May 2026 18:03:17 +0300 Subject: [PATCH 35/45] fix(python/x402): confirm before success, drop ATA-create, mandatory 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. --- README.md | 5 +- python/src/pay_kit/protocols/x402/__init__.py | 55 ++- .../protocols/x402/client/exact/payment.py | 33 +- .../pay_kit/protocols/x402/exact/verify.py | 43 +- python/tests/test_pk_x402_client.py | 58 +++ python/tests/test_pk_x402_settle.py | 111 ++++- python/tests/test_pk_x402_verifier.py | 36 +- python/uv.lock | 430 +++++++++++++++++- 8 files changed, 711 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index e51a71941..15a99be05 100644 --- a/README.md +++ b/README.md @@ -121,13 +121,16 @@ return result.withReceipt(Response.json({ data: '...' })) Python ```python -from pay_kit.protocols.mpp.server import Mpp, Config +from pay_kit import MemoryStore +from pay_kit.protocols.mpp.server import Config, Mpp mpp = Mpp(Config( recipient="RecipientPubkey...", currency="EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", decimals=6, html=True, + secret_key="...", # or set MPP_SECRET_KEY in the environment + store=MemoryStore(), # required; use FileReplayStore(path) for durable replay )) challenge = mpp.charge("1.00") # 1 USDC diff --git a/python/src/pay_kit/protocols/x402/__init__.py b/python/src/pay_kit/protocols/x402/__init__.py index 92917dd0a..4de5cca43 100644 --- a/python/src/pay_kit/protocols/x402/__init__.py +++ b/python/src/pay_kit/protocols/x402/__init__.py @@ -170,9 +170,13 @@ async def verify_and_settle(self, gate: Gate, request: Any) -> Payment: "pay_kit: charge_request_mismatch: accepted payment requirement does not match server challenge", code="charge_request_mismatch", ) - if accepted.get("amount") != offer_map.get("amount") and accepted.get("maxAmountRequired") != offer_map.get( + # Reject if EITHER the exact `amount` or the `maxAmountRequired` + # ceiling drifts from the server offer. The previous AND only tripped + # when both diverged, so one-sided drift (e.g. amount tampered while + # maxAmountRequired left intact) silently passed. + if accepted.get("amount") != offer_map.get("amount") or accepted.get( "maxAmountRequired" - ): + ) != offer_map.get("maxAmountRequired"): raise InvalidProofError( "pay_kit: charge_request_mismatch (amount)", code="charge_request_mismatch", @@ -210,19 +214,44 @@ async def verify_and_settle(self, gate: Gate, request: Any) -> Payment: rpc = SolanaRpc(rpc_url) try: - response = await rpc.send_raw_transaction(cosigned_wire) - signature = str(response.value if hasattr(response, "value") else response) - except Exception as exc: # noqa: BLE001 - raise InvalidProofError(f"pay_kit: invalid proof: broadcast failed: {exc}", code="payment_invalid") from exc + try: + response = await rpc.send_raw_transaction(cosigned_wire) + signature = str(response.value if hasattr(response, "value") else response) + except Exception as exc: # noqa: BLE001 + raise InvalidProofError( + f"pay_kit: invalid proof: broadcast failed: {exc}", code="payment_invalid" + ) from exc + if not signature: + raise InvalidProofError("pay_kit: empty broadcast result", code="payment_invalid") + + # Replay reservation. Namespace is distinct from the MPP charge key + # so an x402 signature can never satisfy an MPP route and vice + # versa. Reserve BEFORE confirmation so a concurrent resubmit of the + # same signature loses the race and is rejected as consumed. + replay_key = _REPLAY_PREFIX + signature + if not await self._store.put_if_absent(replay_key, True): + raise InvalidProofError("pay_kit: signature_consumed", code="signature_consumed") + + # Await on-chain confirmation BEFORE returning success. Without this + # the adapter returned a settlement header for a transaction that + # may have been dropped by the cluster or reverted on-chain, granting + # the client access without payment. ``await_confirmation`` raises + # ``transaction-failed`` (included but reverted) or + # ``transaction-not-found`` (never confirmed inside the window). + # + # On failure roll the reservation back: the transaction did not + # land, so the same signature must remain replayable for an honest + # retry. Mirrors the confirmation gate the MPP charge flow runs + # (protocols/mpp/server/charge.py). + try: + await rpc.await_confirmation(signature) + except Exception as exc: # noqa: BLE001 + await self._store.delete(replay_key) + raise InvalidProofError( + f"pay_kit: invalid proof: confirmation failed: {exc}", code="payment_invalid" + ) from exc finally: await rpc.aclose() - if not signature: - raise InvalidProofError("pay_kit: empty broadcast result", code="payment_invalid") - - # Replay reservation. Namespace is distinct from the MPP charge key so - # an x402 signature can never satisfy an MPP route and vice versa. - if not await self._store.put_if_absent(_REPLAY_PREFIX + signature, True): - raise InvalidProofError("pay_kit: signature_consumed", code="signature_consumed") accepted_network = accepted.get("network") response_body: X402ResponseEnvelope = { diff --git a/python/src/pay_kit/protocols/x402/client/exact/payment.py b/python/src/pay_kit/protocols/x402/client/exact/payment.py index 8da9a5464..1907b30d6 100644 --- a/python/src/pay_kit/protocols/x402/client/exact/payment.py +++ b/python/src/pay_kit/protocols/x402/client/exact/payment.py @@ -21,6 +21,7 @@ import base64 import json +import secrets from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, Any, cast @@ -50,6 +51,20 @@ _COMPUTE_UNIT_PRICE = 1 #: Default SPL decimals when the offer omits ``extra.decimals``. _DEFAULT_DECIMALS = 6 +#: Random memo nonce length in bytes when the offer omits ``extra.memo``. The +#: x402 SVM exact contract requires a Memo of at least 16 bytes; it is +#: hex-encoded to a UTF-8 string for the Memo instruction data. +_MEMO_NONCE_BYTES = 16 + + +def _default_memo_nonce() -> str: + """Generate a fresh >=16-byte memo nonce, hex-encoded for UTF-8. + + Used when the offer carries no ``extra.memo``. Injectable via + :func:`build_payment`'s ``memo_nonce`` parameter so deterministic and + golden-vector tests can pin a fixed nonce. + """ + return secrets.token_bytes(_MEMO_NONCE_BYTES).hex() # x402 ``exact`` CAIP-2 networks the client knows how to pay on. _SOLANA_CAIP2 = frozenset({SOLANA_MAINNET_CAIP2, SOLANA_DEVNET_CAIP2}) @@ -252,6 +267,7 @@ async def build_payment( requirement: X402AcceptsEntry, *, recent_blockhash_provider: Callable[[], Awaitable[str] | str] | None = None, + memo_nonce: Callable[[], str] | None = None, ) -> X402Envelope: """Build a signed x402 ``exact`` payment transaction for ``requirement``. @@ -265,6 +281,13 @@ async def build_payment( The blockhash comes from ``requirement.extra.recentBlockhash`` when present, else ``recent_blockhash_provider`` (injected for offline unit tests), else ``await rpc.get_latest_blockhash()``. + + The client ALWAYS appends exactly one Memo instruction. When the offer + carries ``extra.memo`` that value is used; otherwise a random >=16-byte + hex-encoded nonce guarantees uniqueness of otherwise-identical payments + (the Memo is what lets the facilitator distinguish concurrent identical + transfers). ``memo_nonce`` overrides the default secure RNG source so + deterministic / golden-vector tests can pin a fixed nonce. """ from solders.hash import Hash from solders.instruction import AccountMeta, Instruction @@ -332,9 +355,13 @@ async def build_payment( ) ) + # Always append exactly one Memo. Use the offer's memo when present, else a + # random >=16-byte hex nonce so two otherwise-identical payments produce + # distinct transactions. The verifier requires this slot for uniqueness. memo = _str_field(extra, "memo") - if memo is not None: - instructions.append(Instruction(Pubkey.from_string(MEMO_PROGRAM), memo.encode("utf-8"), [])) + if memo is None: + memo = (memo_nonce or _default_memo_nonce)() + instructions.append(Instruction(Pubkey.from_string(MEMO_PROGRAM), memo.encode("utf-8"), [])) blockhash_str = _str_field(extra, "recentBlockhash") if blockhash_str is None: @@ -381,6 +408,7 @@ async def build_payment_header( requirement: X402AcceptsEntry, *, recent_blockhash_provider: Callable[[], Awaitable[str] | str] | None = None, + memo_nonce: Callable[[], str] | None = None, ) -> str: """Build the standard-base64 ``PAYMENT-SIGNATURE`` header value. @@ -393,6 +421,7 @@ async def build_payment_header( rpc, requirement, recent_blockhash_provider=recent_blockhash_provider, + memo_nonce=memo_nonce, ) payload = json.dumps(envelope, separators=(",", ":")).encode("utf-8") return base64.b64encode(payload).decode("ascii") diff --git a/python/src/pay_kit/protocols/x402/exact/verify.py b/python/src/pay_kit/protocols/x402/exact/verify.py index a65bb320d..338e91852 100644 --- a/python/src/pay_kit/protocols/x402/exact/verify.py +++ b/python/src/pay_kit/protocols/x402/exact/verify.py @@ -14,7 +14,6 @@ from typing import Any, cast from pay_kit._paycore.mints import derive_ata -from pay_kit._paycore.solana import ASSOCIATED_TOKEN_PROGRAM from pay_kit.errors import InvalidProofError __all__ = [ @@ -120,8 +119,13 @@ def verify( # Rules 4 + 5 + 6 + 7 + 8 + 11: transferChecked. transfer = ExactVerifier._verify_transfer(instructions[2], account_keys, requirement, managed_signers) - # Rule 9: ix[3:] allowlist (memo, lighthouse(<2 slots), ata-create(<2 slots)). - destination_create_ata = False + # Rule 9: ix[3:] allowlist. Per the official x402 SVM exact contract + # (specs/schemes/exact/scheme_exact_svm.md), the only permitted optional + # instructions are Lighthouse (wallet-injected user-protection asserts, + # Phantom=1 / Solflare=2) and SPL Memo. A Create-ATA / Associated Token + # Program instruction is NOT allowed: the destination ATA MUST pre-exist + # (Rule 7 derives and pins the destination ATA). This matches the + # Rust/Go verifiers, which never accept ATA-create in this shape. reasons = ( "invalid_exact_svm_payload_unknown_fourth_instruction", "invalid_exact_svm_payload_unknown_fifth_instruction", @@ -132,13 +136,6 @@ def verify( program = ExactVerifier._program_of(account_keys, ix) slot_index = i - 3 allowed = program == MEMO_PROGRAM or (slot_index < 2 and program == LIGHTHOUSE_PROGRAM) - if ( - not allowed - and slot_index < 2 - and ExactVerifier._valid_ata_create(ix, account_keys, requirement, transfer) - ): - destination_create_ata = True - allowed = True if not allowed: reason = ( reasons[slot_index] @@ -152,7 +149,8 @@ def verify( if expected_memo: ExactVerifier._find_memo_match(account_keys, instructions, expected_memo) - transfer["destinationCreateAta"] = destination_create_ata + # The destination ATA must pre-exist; ATA-create is never accepted. + transfer["destinationCreateAta"] = False return transfer @staticmethod @@ -263,29 +261,6 @@ def _verify_transfer( "amount": amount, } - @staticmethod - def _valid_ata_create( - ix: Any, - account_keys: list[str], - requirement: dict[str, Any], - transfer: dict[str, Any], - ) -> bool: - if ExactVerifier._program_of(account_keys, ix) != ASSOCIATED_TOKEN_PROGRAM: - return False - data = bytes(ix.data) - if len(data) < 1 or (data[0] != 0 and data[0] != 1): - return False - if len(list(ix.accounts)) < 6: - return False - ata = ExactVerifier._account_at(account_keys, ix, 1) - owner = ExactVerifier._account_at(account_keys, ix, 2) - mint = ExactVerifier._account_at(account_keys, ix, 3) - if owner != requirement.get("payTo"): - return False - if mint != transfer["mint"]: - return False - return ata == transfer["destination"] - @staticmethod def _find_memo_match(account_keys: list[str], instructions: list[Any], expected_memo: str) -> None: count = 0 diff --git a/python/tests/test_pk_x402_client.py b/python/tests/test_pk_x402_client.py index 4cb243ed4..3020f1820 100644 --- a/python/tests/test_pk_x402_client.py +++ b/python/tests/test_pk_x402_client.py @@ -329,6 +329,61 @@ async def test_build_payment_instruction_layout(): assert int(tx.message.header.num_required_signatures) == 2 +@pytest.mark.asyncio +async def test_build_payment_appends_random_memo_when_offer_has_none(): + """Decision 2: the client ALWAYS appends a memo. + + When the offer carries no ``extra.memo`` the client must still emit exactly + one Memo instruction holding a >=16-byte hex nonce, so two otherwise + identical payments are distinct on-chain. + """ + from pay_kit.protocols.x402.exact.verify import MEMO_PROGRAM + + signer = Signer.generate() + offer = _offer() + del offer["extra"]["memo"] + env = await build_payment(signer, None, _entry(offer)) + tx = VersionedTransaction.from_bytes(base64.b64decode(_tx(env))) + instructions = list(tx.message.instructions) + keys = [str(k) for k in tx.message.account_keys] + assert len(instructions) == 4 # compute x2 + transfer + memo + memo_ix = instructions[3] + assert keys[int(memo_ix.program_id_index)] == MEMO_PROGRAM + memo_text = bytes(memo_ix.data).decode("utf-8") + # 16 bytes hex-encoded == 32 hex chars; bytes.fromhex validates it is hex. + assert len(memo_text) >= 32 + bytes.fromhex(memo_text) + + +@pytest.mark.asyncio +async def test_build_payment_memo_nonce_is_injectable(): + """The nonce source is injectable so golden-vector tests stay deterministic.""" + from pay_kit.protocols.x402.exact.verify import MEMO_PROGRAM + + signer = Signer.generate() + offer = _offer() + del offer["extra"]["memo"] + fixed = "00112233445566778899aabbccddeeff" + env = await build_payment(signer, None, _entry(offer), memo_nonce=lambda: fixed) + tx = VersionedTransaction.from_bytes(base64.b64decode(_tx(env))) + instructions = list(tx.message.instructions) + keys = [str(k) for k in tx.message.account_keys] + memo_ix = instructions[3] + assert keys[int(memo_ix.program_id_index)] == MEMO_PROGRAM + assert bytes(memo_ix.data).decode("utf-8") == fixed + + +@pytest.mark.asyncio +async def test_build_payment_two_no_memo_payments_differ(): + """Two payments for the same offer must produce distinct transactions.""" + signer = Signer.generate() + offer = _offer() + del offer["extra"]["memo"] + env1 = await build_payment(signer, None, _entry(offer)) + env2 = await build_payment(signer, None, _entry(offer)) + assert _tx(env1) != _tx(env2) + + @pytest.mark.asyncio async def test_build_payment_uses_extra_blockhash(): signer = Signer.generate() @@ -460,6 +515,9 @@ class _Resp: return _Resp() + async def await_confirmation(self, _signature, *_a, **_k): + return None + async def aclose(self): return None diff --git a/python/tests/test_pk_x402_settle.py b/python/tests/test_pk_x402_settle.py index 0238c2ef4..c8fb2209c 100644 --- a/python/tests/test_pk_x402_settle.py +++ b/python/tests/test_pk_x402_settle.py @@ -57,9 +57,19 @@ class _FakeRpc: """Stub matching pay_kit.protocols.mpp.SolanaRpc's async send/close surface.""" - def __init__(self, *_a, signature: str = "SIG-broadcast", fail: bool = False, **_k): + def __init__( + self, + *_a, + signature: str = "SIG-broadcast", + fail: bool = False, + confirm_error: Exception | None = None, + **_k, + ): self._signature = signature self._fail = fail + self._confirm_error = confirm_error + self.confirm_calls = 0 + self.aclose_calls = 0 async def send_raw_transaction(self, _raw): if self._fail: @@ -71,7 +81,14 @@ class _Resp: _Resp.value = self._signature return _Resp() + async def await_confirmation(self, _signature, *_a, **_k): + self.confirm_calls += 1 + if self._confirm_error is not None: + raise self._confirm_error + return None + async def aclose(self): + self.aclose_calls += 1 return None @@ -83,7 +100,7 @@ def _clean(monkeypatch): reset() -def _adapter(store=None, signature="SIG-broadcast", fail=False, monkeypatch=None): +def _adapter(store=None, signature="SIG-broadcast", fail=False, confirm_error=None, monkeypatch=None, rpcs=None): op_kp = Keypair() op = Operator(signer=LocalSigner.from_keypair(op_kp), recipient=str(Keypair().pubkey())) cfg = configure( @@ -102,7 +119,10 @@ def _adapter(store=None, signature="SIG-broadcast", fail=False, monkeypatch=None adapter = X402Adapter(cfg, replay_store=store or MemoryStore()) def _factory(*_a, **_k): - return _FakeRpc(signature=signature, fail=fail) + rpc = _FakeRpc(signature=signature, fail=fail, confirm_error=confirm_error) + if rpcs is not None: + rpcs.append(rpc) + return rpc if monkeypatch is not None: monkeypatch.setattr(xmod, "SolanaRpc", _factory) @@ -184,6 +204,91 @@ async def test_broadcast_failure_is_invalid_proof(monkeypatch): await adapter.verify_and_settle(gate, _Req(header)) +# -- confirmation gate (149-2 BLOCKER) --------------------------------------- + + +@pytest.mark.asyncio +async def test_success_path_awaits_confirmation_before_returning(monkeypatch): + rpcs: list = [] + adapter, gate, op_kp = _adapter(signature="SIG-ok", monkeypatch=monkeypatch, rpcs=rpcs) + header = _build_envelope(adapter, gate, op_kp) + payment = await adapter.verify_and_settle(gate, _Req(header)) + assert payment.transaction == "SIG-ok" + # The adapter must poll confirmation, then close the RPC after the poll. + assert rpcs[0].confirm_calls == 1 + assert rpcs[0].aclose_calls == 1 + + +@pytest.mark.asyncio +async def test_confirmation_timeout_raises_and_does_not_return_success(monkeypatch): + from pay_kit._paycore.errors import PaymentError + + store = MemoryStore() + adapter, gate, op_kp = _adapter( + store=store, + signature="SIG-timeout", + confirm_error=PaymentError("timed out", code="transaction-not-found"), + monkeypatch=monkeypatch, + ) + header = _build_envelope(adapter, gate, op_kp) + with pytest.raises(InvalidProofError) as exc: + await adapter.verify_and_settle(gate, _Req(header)) + assert exc.value.code == "payment_invalid" + assert "confirmation failed" in str(exc.value) + # Reservation must be rolled back so an honest retry can replay. + assert await store.get("x402-svm-exact:consumed:SIG-timeout") is None + + +@pytest.mark.asyncio +async def test_confirmation_onchain_failure_rolls_back_reservation(monkeypatch): + from pay_kit._paycore.errors import PaymentError + + store = MemoryStore() + adapter, gate, op_kp = _adapter( + store=store, + signature="SIG-revert", + confirm_error=PaymentError("reverted", code="transaction-failed"), + monkeypatch=monkeypatch, + ) + header = _build_envelope(adapter, gate, op_kp) + with pytest.raises(InvalidProofError): + await adapter.verify_and_settle(gate, _Req(header)) + assert await store.get("x402-svm-exact:consumed:SIG-revert") is None + + +# -- accepted-echo amount drift (149-1) -------------------------------------- + + +@pytest.mark.asyncio +async def test_amount_drift_one_sided_rejected(monkeypatch): + """One-sided amount drift must be rejected (AND -> OR fix). + + Tamper only `amount`, leaving `maxAmountRequired` intact. The previous + AND check passed because maxAmountRequired still matched; the OR check + rejects on either field drifting. + """ + adapter, gate, op_kp = _adapter(monkeypatch=monkeypatch) + header = _build_envelope(adapter, gate, op_kp) + decoded = json.loads(base64.b64decode(header)) + decoded["accepted"]["amount"] = str(int(decoded["accepted"]["amount"]) + 1) + tampered = base64.b64encode(json.dumps(decoded).encode()).decode() + with pytest.raises(InvalidProofError) as exc: + await adapter.verify_and_settle(gate, _Req(tampered)) + assert exc.value.code == "charge_request_mismatch" + + +@pytest.mark.asyncio +async def test_max_amount_required_one_sided_drift_rejected(monkeypatch): + adapter, gate, op_kp = _adapter(monkeypatch=monkeypatch) + header = _build_envelope(adapter, gate, op_kp) + decoded = json.loads(base64.b64decode(header)) + decoded["accepted"]["maxAmountRequired"] = str(int(decoded["accepted"]["maxAmountRequired"]) + 5) + tampered = base64.b64encode(json.dumps(decoded).encode()).decode() + with pytest.raises(InvalidProofError) as exc: + await adapter.verify_and_settle(gate, _Req(tampered)) + assert exc.value.code == "charge_request_mismatch" + + # -- envelope reject branches ------------------------------------------------ diff --git a/python/tests/test_pk_x402_verifier.py b/python/tests/test_pk_x402_verifier.py index d0b312438..ea958fc3a 100644 --- a/python/tests/test_pk_x402_verifier.py +++ b/python/tests/test_pk_x402_verifier.py @@ -2,9 +2,10 @@ Builds real ``VersionedTransaction`` payloads with solders and exercises each of the verifier's reject branches by name, plus the happy path with an optional -memo, an ATA-create instruction, and the Token-2022 program. Also covers the -adapter's ``accepts_entry`` / ``challenge_headers`` and the caveat #5 -``recentBlockhash`` injection via the offline ``recent_blockhash_provider``. +memo, a Lighthouse optional instruction, and the Token-2022 program. ATA-create +is explicitly rejected (149-3). Also covers the adapter's ``accepts_entry`` / +``challenge_headers`` and the caveat #5 ``recentBlockhash`` injection via the +offline ``recent_blockhash_provider``. """ from __future__ import annotations @@ -166,7 +167,14 @@ def test_verify_happy_with_token_2022_program(): assert out["program"] == TOKEN_2022_PROGRAM -def test_verify_happy_with_ata_create(): +def test_verify_rejects_ata_create_instruction(): + """149-3: ATA-create is NOT an allowed optional instruction. + + Per the official x402 SVM exact contract the destination ATA MUST + pre-exist; only Lighthouse and Memo are permitted optional slots. A + transaction carrying an Associated-Token-Program create instruction must + be rejected, matching the Rust/Go verifiers. + """ fee_payer, authority, pay_to, src, dest = _scenario() ixs = [ _compute_limit_ix(), @@ -175,8 +183,26 @@ def test_verify_happy_with_ata_create(): _ata_create_ix(payer=fee_payer.pubkey(), ata=dest, owner=pay_to, mint=MINT, program=TOKEN_PROGRAM), ] tx = _tx_b64(fee_payer, ixs, [fee_payer, authority]) + with pytest.raises(InvalidProofError) as e: + ExactVerifier.verify(tx, _requirement(pay_to), [str(fee_payer.pubkey())]) + assert e.value.code == "invalid_exact_svm_payload_unknown_fourth_instruction" + + +def test_verify_allows_lighthouse_optional_instruction(): + """Lighthouse asserts are wallet-injected and MUST be allowed.""" + from pay_kit.protocols.x402.exact.verify import LIGHTHOUSE_PROGRAM + + fee_payer, authority, pay_to, src, dest = _scenario() + lighthouse = Instruction(Pubkey.from_string(LIGHTHOUSE_PROGRAM), b"\x00", []) + ixs = [ + _compute_limit_ix(), + _compute_price_ix(), + _transfer_checked_ix(source=src, mint=MINT, destination=dest, authority=authority.pubkey()), + lighthouse, + ] + tx = _tx_b64(fee_payer, ixs, [fee_payer, authority]) out = ExactVerifier.verify(tx, _requirement(pay_to), [str(fee_payer.pubkey())]) - assert out["destinationCreateAta"] is True + assert out["destinationCreateAta"] is False # -- rule 0: payload decode -------------------------------------------------- diff --git a/python/uv.lock b/python/uv.lock index 4702ddb7a..81a818fe3 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,6 +1,28 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] [[package]] name = "anyio" @@ -15,6 +37,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -24,6 +64,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -159,6 +211,73 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "django" +version = "5.2.14" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version < '3.12'" }, + { name = "sqlparse", marker = "python_full_version < '3.12'" }, + { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" }, +] + +[[package]] +name = "django" +version = "6.0.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version >= '3.12'" }, + { name = "sqlparse", marker = "python_full_version >= '3.12'" }, + { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f1/bf85f0d29ef76abf901f193fe8fef4769d3da7794197832bc30151c071d8/django-6.0.5.tar.gz", hash = "sha256:bc6d6872e98a2864c836e42edd644b362db311147dd5aa8d5b82ba7a032f5269", size = 10924131, upload-time = "2026-05-05T13:54:39.329Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/5b/1328f8b84fce040c404f76822bf8c57d254e368e8cbd8bd67ec2b26d75f5/django-6.0.5-py3-none-any.whl", hash = "sha256:9d58a7cb49244e74c8e161d5e403a46d6209f1009ba40f5a66d6aa0d0786a8f0", size = 8368680, upload-time = "2026-05-05T13:54:33.532Z" }, +] + +[[package]] +name = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -214,6 +333,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jsonalias" version = "0.1.1" @@ -223,6 +363,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/ed/05aebce69f78c104feff2ffcdd5a6f9d668a208aba3a8bf56e3750809fd8/jsonalias-0.1.1-py3-none-any.whl", hash = "sha256:a56d2888e6397812c606156504e861e8ec00e188005af149f003c787db3d3f18", size = 1312, upload-time = "2022-10-28T22:57:54.763Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -250,6 +464,137 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -315,6 +660,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "ruff" version = "0.15.14" @@ -357,11 +711,13 @@ wheels = [ ] [[package]] -name = "solana-mpp" +name = "solana-pay-kit" version = "0.1.0" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "solana" }, { name = "solders" }, ] @@ -374,10 +730,25 @@ dev = [ { name = "pytest-cov" }, { name = "ruff" }, ] +django = [ + { name = "django", version = "5.2.14", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +fastapi = [ + { name = "fastapi" }, +] +flask = [ + { name = "flask" }, +] [package.metadata] requires-dist = [ + { name = "django", marker = "extra == 'django'", specifier = ">=4.2" }, + { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.110" }, + { name = "flask", marker = "extra == 'flask'", specifier = ">=3" }, { name = "httpx", specifier = ">=0.27" }, + { name = "pydantic", specifier = ">=2" }, + { name = "pydantic-settings", specifier = ">=2" }, { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, @@ -386,7 +757,7 @@ requires-dist = [ { name = "solana", specifier = ">=0.35" }, { name = "solders", specifier = ">=0.22" }, ] -provides-extras = ["dev"] +provides-extras = ["fastapi", "flask", "django", "dev"] [[package]] name = "solders" @@ -408,6 +779,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/f3/14ed12d8d5047ababaca3271f82ebbf500ff74b6358f283962232103a12d/solders-0.27.1-cp38-abi3-win_amd64.whl", hash = "sha256:f3b787c29570a46d219c7a67543d8b0fadc73abda346653aa20e8eccd839e78b", size = 5295092, upload-time = "2025-11-15T07:50:50.517Z" }, ] +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "starlette" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/bf/616a066c2760f6c2b1ae3437cc28149734d069fbb46511712beae118a68c/starlette-1.2.0.tar.gz", hash = "sha256:3c5a6b23fff42492914e93890bb80cbfea72dbf37de268eec06185d62a4ca553", size = 2668923, upload-time = "2026-05-28T11:42:50.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/85/492183764d5d01d4514be3730fdb8e228a80605783099551c51627578b5d/starlette-1.2.0-py3-none-any.whl", hash = "sha256:36e0c76ac59157e75dc4b3bdeafba97fb04eaf1878045f15dbef666a6f092ed7", size = 73213, upload-time = "2026-05-28T11:42:48.801Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -471,6 +864,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + [[package]] name = "websockets" version = "15.0.1" @@ -512,3 +926,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] From 1f7606106571ce0690590593b372fc118ff3d42a Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Sat, 30 May 2026 18:36:17 +0300 Subject: [PATCH 36/45] fix(python): share replay store per config, charge fee-on-top total, 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. --- python/src/pay_kit/_middleware.py | 44 +++++++++++-- python/src/pay_kit/django.py | 4 +- python/src/pay_kit/fastapi.py | 2 +- python/src/pay_kit/flask.py | 2 +- python/src/pay_kit/protocols/mpp/__init__.py | 24 ++++++- python/tests/test_pk_middleware.py | 67 ++++++++++++++++++-- python/tests/test_pk_mpp_adapter.py | 67 ++++++++++++++++++++ 7 files changed, 190 insertions(+), 20 deletions(-) diff --git a/python/src/pay_kit/_middleware.py b/python/src/pay_kit/_middleware.py index ccaabf412..75b1cbfb4 100644 --- a/python/src/pay_kit/_middleware.py +++ b/python/src/pay_kit/_middleware.py @@ -30,6 +30,7 @@ from __future__ import annotations +import weakref from collections.abc import Callable, Mapping from typing import TYPE_CHECKING, Any, cast @@ -66,6 +67,15 @@ #: Gate reference shapes accepted by the middleware. GateRef = "Gate | DynamicGate | Price | str | Callable[[Any], Gate]" +#: One ``PayCore`` (and its adapters + shared replay store) per Config. The +#: framework shims construct a gate per request, but a fresh adapter would mean +#: a fresh in-memory replay store, so a settled MPP signature could be replayed. +#: Caching the core per Config keeps the consumed-signature marker durable for +#: the lifetime of that config. Weak keys let a dropped config (e.g. a test +#: ``reset()``) and its cached core be collected. The Config is frozen, so a +#: cached core never observes stale settings. +_CORE_CACHE: weakref.WeakKeyDictionary[Config, PayCore] = weakref.WeakKeyDictionary() + class PayCore: """Host-neutral payment-gating core shared by every framework shim. @@ -95,6 +105,23 @@ def __init__( else: self._x402 = None + @classmethod + def for_config(cls, config: Config) -> PayCore: + """Return the cached per-Config core, building (and caching) one on miss. + + The framework shims call this once per request; reusing one core per + Config keeps the MPP/x402 adapters and their shared in-memory replay + store alive across requests so a settled signature stays consumed and + cannot be replayed. A fresh ``PayCore(config)`` per request (the prior + behaviour) reset that store on every call. + """ + cached = _CORE_CACHE.get(config) + if cached is not None: + return cached + core = cls(config) + _CORE_CACHE[config] = core + return core + @property def config(self) -> Config: """The frozen configuration this core gates against.""" @@ -119,7 +146,7 @@ def resolve_gate( return gate_ref.resolve(request) if not isinstance(gate_ref, (Gate, Price, str)) and callable(gate_ref): return self._resolve_callable(gate_ref, request) - return self._coerce_static(gate_ref, pricing) + return self._coerce_static(gate_ref, pricing, request) def detect_adapter( self, @@ -222,7 +249,7 @@ def _resolve_callable(self, builder: Callable[[Any], object], request: Any) -> G if isinstance(result, Gate): return result if isinstance(result, Price): - return self._coerce_static(result, None) + return self._coerce_static(result, None, request) raise ProtocolNotSupportedError( f"pay_kit: gate builder returned {type(result).__name__}, expected Gate or Price" ) @@ -231,14 +258,19 @@ def _coerce_static( self, gate_ref: Gate | DynamicGate | Price | str, pricing: Pricing | None, + request: Any, ) -> Gate: - """Coerce a non-callable reference; resolve a DynamicGate against defaults.""" + """Coerce a non-callable reference; resolve a DynamicGate against the request. + + A registered name may resolve (via the pricing registry) to a + :class:`DynamicGate`. Such a gate still needs the current request to + evaluate its builder, so inject the Config defaults and resolve it here + rather than rejecting it; ``resolve_gate`` always has the request. + """ coerced = coerce(gate_ref, registry=pricing, config=self._config) if isinstance(coerced, DynamicGate): self._inject_dynamic_defaults(coerced) - # A DynamicGate from a registry still needs a request to resolve; - # callers must pass it through process() (which has the request). - raise ProtocolNotSupportedError(f"pay_kit: dynamic gate {coerced.name!r} requires a request to resolve") + return coerced.resolve(request) return coerced def _inject_dynamic_defaults(self, gate: DynamicGate) -> None: diff --git a/python/src/pay_kit/django.py b/python/src/pay_kit/django.py index 522f815bf..386e90b2a 100644 --- a/python/src/pay_kit/django.py +++ b/python/src/pay_kit/django.py @@ -74,7 +74,7 @@ def require_payment( def decorator(view: Callable[..., Any]) -> Callable[..., Any]: @wraps(view) def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: - core = PayCore(config if config is not None else _config()) + core = PayCore.for_config(config if config is not None else _config()) try: payment = _run(core.process(gate_ref, pricing, request)) except PayKitError as exc: @@ -110,7 +110,7 @@ def __call__(self, request: HttpRequest) -> HttpResponse: if gate_ref is None: return self._passthrough(request) - core = PayCore(_config()) + core = PayCore.for_config(_config()) try: payment = _run(core.process(gate_ref, _request_pricing(request), request)) except PayKitError as exc: diff --git a/python/src/pay_kit/fastapi.py b/python/src/pay_kit/fastapi.py index d1e835a5c..172b193a2 100644 --- a/python/src/pay_kit/fastapi.py +++ b/python/src/pay_kit/fastapi.py @@ -70,7 +70,7 @@ def RequirePayment( # noqa: N802 - factory reads as a dependency constructor """ async def dependency(request: Request) -> Payment: - core = PayCore(config if config is not None else _config()) + core = PayCore.for_config(config if config is not None else _config()) try: payment = await core.process(gate_ref, pricing, request) except PaymentRequiredError as exc: diff --git a/python/src/pay_kit/flask.py b/python/src/pay_kit/flask.py index f31bd8037..30c0f4bb2 100644 --- a/python/src/pay_kit/flask.py +++ b/python/src/pay_kit/flask.py @@ -67,7 +67,7 @@ def decorator(view: _F) -> _F: @wraps(view) def wrapper(*args: Any, **kwargs: Any) -> Any: request = flask.request - core = PayCore(config if config is not None else _global_config()) + core = PayCore.for_config(config if config is not None else _global_config()) try: payment_obj = _run(core.process(gate_ref, pricing, request)) except PaymentRequiredError as exc: diff --git a/python/src/pay_kit/protocols/mpp/__init__.py b/python/src/pay_kit/protocols/mpp/__init__.py index 045d8b826..8c8198486 100644 --- a/python/src/pay_kit/protocols/mpp/__init__.py +++ b/python/src/pay_kit/protocols/mpp/__init__.py @@ -287,7 +287,11 @@ def _charge_request_for(self, gate: Gate) -> ChargeRequest: """Build the route's expected charge request from the gate.""" coin = self._settlement_coin(gate) pay_to = gate.pay_to or self._config.effective_recipient() - amount = str(self._price_units(gate.amount)) + # Top-level amount is the total the customer pays (base + on-top fees), + # matching accepts_entry()'s advertised gate.total(). The MPP wire + # subtracts sum(splits) to get the primary recipient's share, so using + # the bare base here would let a fee_on_top gate accept an underpayment. + amount = str(self._price_units(gate.total())) # Pay's MPP client filters challenges by the short network slug # ("mainnet"/"devnet"/"localnet") in request.methodDetails.network # (rust/crates/core/src/client/mpp.rs). Advertise the same slug @@ -321,9 +325,15 @@ def _charge_request_for(self, gate: Gate) -> ChargeRequest: def _charge_options(self, gate: Gate) -> ChargeOptions: """Build ChargeOptions mirroring the route's charge request.""" + from pay_kit.protocols.mpp.core.expires import seconds + + # Derive the challenge expiry from MppConfig.expires_in; without this + # the wire layer falls back to its hard-coded 5-minute default and + # MppConfig(expires_in=...) is silently ignored. options = ChargeOptions( description=gate.description or "", external_id=gate.external_id or "", + expires=seconds(self._config.mpp.expires_in), ) if gate.has_fees(): # ChargeOptions.splits is the untyped pay_kit.protocols.mpp list[dict]; build the @@ -380,8 +390,16 @@ def _settlement_coin(self, gate: Gate) -> str: return self._config.stablecoins[0].value def _human_amount(self, gate: Gate) -> str: - """Charge amount as a human decimal string the wire re-parses.""" - return gate.amount.amount_string() + """Charge amount as a human decimal string the wire re-parses. + + Uses ``gate.total()`` (base + on-top fees) so the issued challenge's + top-level ``request.amount`` is the total the customer pays. The MPP + wire derives the primary recipient's share as ``amount - sum(splits)`` + (rust client charge.rs), so advertising the base here would let a + fee_on_top gate accept an underpayment that matched ``accepts_entry``'s + advertised total. + """ + return gate.total().amount_string() def _price_units(self, price: Price) -> int: """Convert a Decimal price to 6-decimal base units (no float).""" diff --git a/python/tests/test_pk_middleware.py b/python/tests/test_pk_middleware.py index 1ea8c8611..2407f69e5 100644 --- a/python/tests/test_pk_middleware.py +++ b/python/tests/test_pk_middleware.py @@ -73,6 +73,51 @@ def __init__(self, headers=None, path="/report"): self.path = path +# -- per-config core cache (replay-store persistence) ------------------------ + + +def test_for_config_returns_same_core_per_config(): + """Regression: shims built a fresh PayCore per request, so each request got + a fresh in-memory replay store and a settled MPP signature could be + replayed. for_config() must hand back the same core (and thus the same + replay store) for a given Config.""" + cfg = _cfg() + first = PayCore.for_config(cfg) + second = PayCore.for_config(cfg) + assert first is second + # The MPP adapter (and its replay store) is shared, not rebuilt per call. + assert first._mpp is second._mpp + assert first._mpp._replay_store is second._mpp._replay_store + + +def test_for_config_distinct_cores_for_distinct_configs(): + """Configs that differ get distinct cores (and thus distinct replay stores).""" + cfg_a = _cfg(accept=(Protocol.MPP,)) + reset() + import os + + os.environ["PAY_KIT_DISABLE_PREFLIGHT"] = "1" + cfg_b = _cfg(accept=(Protocol.X402, Protocol.MPP)) + assert cfg_a != cfg_b + assert PayCore.for_config(cfg_a) is not PayCore.for_config(cfg_b) + + +@pytest.mark.asyncio +async def test_settled_signature_not_replayable_across_requests(monkeypatch): + """End-to-end of the cache: a signature consumed on the shared replay store + by one request's core stays consumed for the next request's core.""" + cfg = _cfg(accept=(Protocol.MPP,)) + store = PayCore.for_config(cfg)._mpp._replay_store + key = "solana-charge:consumed:sig-xyz" + # First request settles the signature (marks it consumed). + assert await store.put_if_absent(key, True) is True + # A later request resolves the SAME core/store, so the marker persists and + # a replay of the same signature is rejected (put_if_absent returns False). + store_again = PayCore.for_config(cfg)._mpp._replay_store + assert store_again is store + assert await store_again.put_if_absent(key, True) is False + + # -- gate resolution --------------------------------------------------------- @@ -251,14 +296,20 @@ async def fake_verify(gate, request): assert out is sentinel -@pytest.mark.asyncio -async def test_process_inline_dynamic_from_registry_raises(): +def test_resolve_registry_dynamic_gate_uses_request(): + """A registry-returned DynamicGate resolves against the request. + + Regression: a prior round raised ProtocolNotSupportedError here, so a + registered name pointing at a @dynamic gate was unusable. resolve_gate() + has the request, so it must inject the Config defaults and resolve the + dynamic gate instead of rejecting it. + """ from pay_kit import gate as dynamic - cfg = _cfg() + cfg = _cfg(accept=(Protocol.MPP,)) core = PayCore(cfg) - @dynamic("by_units") # type: ignore[arg-type] + @dynamic("by_units", accept=(Protocol.MPP,)) # type: ignore[arg-type] def builder(request): return Price.usd("0.10", Stablecoin.USDC) @@ -266,9 +317,11 @@ class Catalog(Pricing): def __init__(self): self.by_units = builder - # Resolving a DynamicGate through the static coercion path needs a request. - with pytest.raises(ProtocolNotSupportedError, match="requires a request"): - core._coerce_static("by_units", Catalog()) + g = core.resolve_gate("by_units", Catalog(), _Req()) + assert isinstance(g, Gate) + assert g.name == "by_units" + assert g.amount.amount_string() == "0.10" + assert g.pay_to == cfg.effective_recipient() # -- request-scoped trio ----------------------------------------------------- diff --git a/python/tests/test_pk_mpp_adapter.py b/python/tests/test_pk_mpp_adapter.py index 0840b9f46..72e1043bc 100644 --- a/python/tests/test_pk_mpp_adapter.py +++ b/python/tests/test_pk_mpp_adapter.py @@ -111,6 +111,73 @@ def test_challenge_headers_emit_www_authenticate(): assert headers["www-authenticate"].lower().startswith("payment") +# -- on-top fees: challenge + expected amount track gate.total() ------------- + + +def test_fee_on_top_expected_amount_is_total_not_base(): + """Regression: a fee_on_top gate's expected charge request must pin the + total (base + on-top), not the bare base. + + accepts_entry() advertises gate.total(); if the verifier's expected amount + were the base, the MPP binding (which compares credential.amount to + expected.amount) would accept a challenge worth only the base, letting a + paying client underpay by the on-top fee while the 402 advertised the total. + """ + cfg = _cfg() + gate = _gate(cfg, fee_on_top={FEE_A: Price.usd("0.02", Stablecoin.USDC)}) + expected = MppAdapter(cfg)._charge_request_for(gate) + # base 0.10 + on-top 0.02 = 0.12 -> 120000 base units, NOT 100000. + assert expected.amount == "120000" + assert expected.method_details is not None + assert expected.method_details["splits"] == [{"recipient": FEE_A, "amount": "20000"}] + + +def test_fee_on_top_issued_challenge_amount_matches_advertised_total(): + """The issued WWW-Authenticate challenge's request.amount must equal the + gate total advertised in accepts_entry().""" + cfg = _cfg() + adapter = MppAdapter(cfg) + gate = _gate(cfg, fee_on_top={FEE_A: Price.usd("0.02", Stablecoin.USDC)}) + + advertised = adapter.accepts_entry(gate, {"path": "/report"})["amount"] + + mpp = adapter._server_for(gate) + challenge = mpp.charge_with_options(adapter._human_amount(gate), adapter._charge_options(gate)) + request = challenge.decode_request() + assert str(request["amount"]) == advertised == "120000" + + +def test_fee_within_amount_unchanged_by_total_switch(): + """A fee_within gate's customer-paid total equals the base, so the expected + amount stays the base (guards against the on-top fix over-charging here).""" + cfg = _cfg() + gate = _gate(cfg, fee_within={FEE_A: Price.usd("0.03", Stablecoin.USDC)}) + expected = MppAdapter(cfg)._charge_request_for(gate) + assert expected.amount == "100000" # base 0.10, within fee comes out of it + + +# -- challenge expiry tracks MppConfig.expires_in (regression) --------------- + + +def test_charge_options_expiry_derived_from_config(): + """MppConfig(expires_in=...) must drive the challenge expiry rather than the + wire layer's hard-coded 5-minute fallback.""" + from datetime import UTC, datetime + + cfg = _cfg(mpp=MppConfig(challenge_binding_secret=SECRET, expires_in=30)) + adapter = MppAdapter(cfg) + gate = _gate(cfg) + + options = adapter._charge_options(gate) + assert options.expires != "" # round-1 left this blank -> 5min fallback + + challenge = adapter._server_for(gate).charge_with_options(adapter._human_amount(gate), options) + expires_at = datetime.fromisoformat(challenge.expires.replace("Z", "+00:00")) + delta = (expires_at - datetime.now(UTC)).total_seconds() + # ~30s window, comfortably under the 300s hard-coded default. + assert 20 <= delta <= 40 + + # -- verify: missing / malformed proof --------------------------------------- From 0b861e417e917a32adc278b082c8eaaab2ac2830 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Sat, 30 May 2026 19:22:45 +0300 Subject: [PATCH 37/45] fix(python): close mainnet x402 demo-signer bypass, reject bad amounts 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. --- python/src/pay_kit/config.py | 19 ++++++- python/src/pay_kit/price.py | 20 +++++--- python/src/pay_kit/protocols/x402/__init__.py | 16 +++++- python/tests/test_pk_config.py | 50 +++++++++++++++++++ python/tests/test_pk_value_objects.py | 15 ++++++ python/tests/test_pk_x402_settle.py | 40 +++++++++++++++ 6 files changed, 151 insertions(+), 9 deletions(-) diff --git a/python/src/pay_kit/config.py b/python/src/pay_kit/config.py index 64900bde9..2a495e4d1 100644 --- a/python/src/pay_kit/config.py +++ b/python/src/pay_kit/config.py @@ -229,7 +229,15 @@ def _resolve_mpp_secret_if_needed(cfg: Config) -> Config: def _enforce_demo_signer_on_mainnet(cfg: Config) -> None: - """Refuse to boot the shipped demo signer against solana_mainnet.""" + """Refuse to boot the shipped demo signer against solana_mainnet. + + Checks both the operator signer and, when x402 is an accepted protocol, the + x402 cosigner. The x402 adapter signs as the facilitator fee payer with + ``cfg.x402.effective_signer(cfg.operator)``, which can carry its own + ``X402Config(signer=Signer.demo())`` override while the operator runs a real + key. Without the x402 leg the shipped demo public key could still be the + mainnet facilitator signer, bypassing the documented refusal. + """ if cfg.network is not Network.SOLANA_MAINNET: return signer = cfg.operator.signer @@ -239,6 +247,15 @@ def _enforce_demo_signer_on_mainnet(cfg: Config) -> None: f"({signer.pubkey()}) refuses to start on solana_mainnet. " "Load a real keypair via Signer.file() or Signer.env()." ) + if Protocol.X402 in cfg.accept: + x402_signer = cfg.effective_x402_signer() + if x402_signer is not None and x402_signer.is_demo(): + raise DemoSignerOnMainnetError( + "pay_kit: the package-shipped demo signer " + f"({x402_signer.pubkey()}) refuses to start as the x402 facilitator " + "signer on solana_mainnet. Load a real keypair via " + "x402=X402Config(signer=Signer.file()/Signer.env()) or operator=Operator(signer=...)." + ) def _warn_about_public_mainnet_rpc(cfg: Config) -> None: diff --git a/python/src/pay_kit/price.py b/python/src/pay_kit/price.py index 71958ef91..5c2b156ab 100644 --- a/python/src/pay_kit/price.py +++ b/python/src/pay_kit/price.py @@ -39,17 +39,25 @@ def _to_decimal(amount: object) -> Decimal: if isinstance(amount, float): raise ConfigurationError("pay_kit: Price amount must be str | int | Decimal, not float") if isinstance(amount, Decimal): - return amount - if isinstance(amount, int): - return Decimal(amount) - if isinstance(amount, str): + coerced = amount + elif isinstance(amount, int): + coerced = Decimal(amount) + elif isinstance(amount, str): if not _AMOUNT_RE.match(amount): raise ConfigurationError(f"pay_kit: invalid Price amount: {amount!r}") try: - return Decimal(amount) + coerced = Decimal(amount) except InvalidOperation as exc: raise ConfigurationError(f"pay_kit: invalid Price amount: {amount!r}") from exc - raise ConfigurationError("pay_kit: Price amount must be str | int | Decimal") + else: + raise ConfigurationError("pay_kit: Price amount must be str | int | Decimal") + # The str path rejects negatives via _AMOUNT_RE, but the int and Decimal + # paths reach here unguarded; validate the sign uniformly so usd(-1) and + # usd(Decimal("-0.01")) raise instead of building an invalid Price. Zero is + # allowed (a free gate). + if coerced < 0: + raise ConfigurationError(f"pay_kit: Price amount must not be negative: {amount!r}") + return coerced class Settlement(pydantic.BaseModel): diff --git a/python/src/pay_kit/protocols/x402/__init__.py b/python/src/pay_kit/protocols/x402/__init__.py index 4de5cca43..4e0b2daf7 100644 --- a/python/src/pay_kit/protocols/x402/__init__.py +++ b/python/src/pay_kit/protocols/x402/__init__.py @@ -25,8 +25,9 @@ from pay_kit._paycore.protocol import Protocol from pay_kit._paycore.rpc import SolanaRpc from pay_kit._paycore.store import MemoryStore, Store -from pay_kit.errors import InvalidProofError +from pay_kit.errors import ConfigurationError, InvalidProofError from pay_kit.payment import Payment +from pay_kit.protocols.mpp.intents.charge import parse_units from pay_kit.protocols.x402.exact.types import ( X402AcceptsEntry, X402Challenge, @@ -78,7 +79,18 @@ def accepts_entry(self, gate: Gate, request: Any) -> X402AcceptsEntry: asset = resolve(coin_value, label) or coin_value token_program = token_program_for(coin_value, label) pay_to = gate.pay_to or self._config.effective_recipient() - amount = str(int(gate.total().amount * 1_000_000)) + # Exact 6-decimal base-unit conversion. ``int(amount * 1_000_000)`` + # silently truncated sub-microunit precision (usd("0.0000009") -> "0"), + # which would have the verifier accept a zero-amount transfer. Reuse the + # MPP ``parse_units`` helper so over-precision is rejected the same way + # MPP rejects it; surface it as a ConfigurationError at offer-build time. + try: + amount = parse_units(gate.total().amount_string(), 6) + except ValueError as exc: + raise ConfigurationError( + f"pay_kit: x402 price {gate.total().amount_string()!r} exceeds 6-decimal (micro-unit) precision; " + "USDC settles in micro-units" + ) from exc signer = self._config.x402.effective_signer(self._config.operator) extra: X402Extra = { "feePayer": signer.pubkey() if signer is not None else "", diff --git a/python/tests/test_pk_config.py b/python/tests/test_pk_config.py index 22af42ed7..0fb4939cf 100644 --- a/python/tests/test_pk_config.py +++ b/python/tests/test_pk_config.py @@ -127,6 +127,56 @@ def test_real_signer_on_mainnet_allowed(): assert cfg.network is Network.SOLANA_MAINNET +def test_x402_demo_signer_on_mainnet_refused_even_with_real_operator(): + # Regression: the operator signer is real, but the x402 override is the + # shipped demo signer. The adapter cosigns with the x402 effective signer, + # so booting must refuse the demo facilitator key on mainnet. + op = Operator(signer=Signer.generate(), recipient="R1111111111111111111111111111111111111111") + with pytest.raises(DemoSignerOnMainnetError, match="x402 facilitator"): + configure( + network="solana_mainnet", + operator=op, + rpc_url="https://helius", + x402=X402Config(signer=Signer.demo()), + ) + + +def test_x402_demo_signer_allowed_on_devnet(): + # The same config must NOT raise off mainnet. + op = Operator(signer=Signer.generate(), recipient="R1111111111111111111111111111111111111111") + cfg = configure( + network="solana_devnet", + operator=op, + x402=X402Config(signer=Signer.demo()), + ) + assert cfg.network is Network.SOLANA_DEVNET + + +def test_real_x402_signer_on_mainnet_allowed(): + # A real x402 override on mainnet must NOT raise. + op = Operator(signer=Signer.generate(), recipient="R1111111111111111111111111111111111111111") + cfg = configure( + network="solana_mainnet", + operator=op, + rpc_url="https://helius", + x402=X402Config(signer=Signer.generate()), + ) + assert cfg.network is Network.SOLANA_MAINNET + + +def test_x402_demo_signer_on_mainnet_allowed_when_x402_not_accepted(): + # When x402 is not an accepted protocol, the x402 leg must not gate boot. + op = Operator(signer=Signer.generate(), recipient="R1111111111111111111111111111111111111111") + cfg = configure( + network="solana_mainnet", + operator=op, + rpc_url="https://helius", + accept=Protocol.MPP, + x402=X402Config(signer=Signer.demo()), + ) + assert cfg.network is Network.SOLANA_MAINNET + + def test_public_mainnet_rpc_warns(caplog): op = Operator(signer=Signer.generate(), recipient="R1111111111111111111111111111111111111111") with caplog.at_level("WARNING", logger="pay_kit"): diff --git a/python/tests/test_pk_value_objects.py b/python/tests/test_pk_value_objects.py index 8e0b12a3d..59285c8fb 100644 --- a/python/tests/test_pk_value_objects.py +++ b/python/tests/test_pk_value_objects.py @@ -55,6 +55,21 @@ def test_price_rejects_malformed_string(): Price.usd("abc") +def test_price_rejects_negative_int_and_decimal(): + # Regression: the str path rejected negatives via _AMOUNT_RE, but the int + # and Decimal paths returned unguarded, building an invalid Price. + with pytest.raises(ConfigurationError, match="must not be negative"): + Price.usd(-1) + with pytest.raises(ConfigurationError, match="must not be negative"): + Price.usd(Decimal("-0.01")) + + +def test_price_allows_zero(): + # Zero is a valid (free) gate amount and must still build. + assert Price.usd(0).amount == Decimal(0) + assert Price.usd("0").amount == Decimal("0") + + def test_price_currency_factories(): assert Price.usd("1").currency is Currency.USD assert Price.eur("1").currency is Currency.EUR diff --git a/python/tests/test_pk_x402_settle.py b/python/tests/test_pk_x402_settle.py index c8fb2209c..37df124ae 100644 --- a/python/tests/test_pk_x402_settle.py +++ b/python/tests/test_pk_x402_settle.py @@ -256,6 +256,46 @@ async def test_confirmation_onchain_failure_rolls_back_reservation(monkeypatch): assert await store.get("x402-svm-exact:consumed:SIG-revert") is None +# -- sub-microunit price truncation (149-2) ---------------------------------- + + +def _x402_adapter_for_price(price): + op = Operator(signer=LocalSigner.from_keypair(Keypair()), recipient=str(Keypair().pubkey())) + cfg = configure( + network="solana_localnet", + preflight=False, + accept=(Protocol.X402,), + operator=op, + rpc_url="http://127.0.0.1:8899", + ) + gate = GateCls.build( + name="report", + amount=price, + default_pay_to=cfg.effective_recipient(), + accept=(Protocol.X402,), + ) + return X402Adapter(cfg, replay_store=MemoryStore()), gate + + +def test_x402_sub_microunit_price_rejected(): + # Regression: usd("0.0000009") truncated to "0" via int(amount * 1e6), + # which would have the verifier accept a zero-amount transfer. It must now + # raise instead of producing "0". + from pay_kit.errors import ConfigurationError + + adapter, gate = _x402_adapter_for_price(Price.usd("0.0000009", Stablecoin.USDC)) + with pytest.raises(ConfigurationError, match="precision"): + adapter.accepts_entry(gate, {"path": "/report"}) + + +def test_x402_six_decimal_price_yields_micro_units(): + # A normal 6-dp price still converts exactly. + adapter, gate = _x402_adapter_for_price(Price.usd("0.10", Stablecoin.USDC)) + offer = adapter.accepts_entry(gate, {"path": "/report"}) + assert offer["amount"] == "100000" + assert offer["maxAmountRequired"] == "100000" + + # -- accepted-echo amount drift (149-1) -------------------------------------- From 02811a3614b4c84e60564d3471463f5e589727b4 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Sat, 30 May 2026 20:21:35 +0300 Subject: [PATCH 38/45] fix(python): accept x402 Lighthouse guard in any optional slot 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. --- .../pay_kit/protocols/x402/exact/verify.py | 7 +++- python/tests/test_pk_x402_verifier.py | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/python/src/pay_kit/protocols/x402/exact/verify.py b/python/src/pay_kit/protocols/x402/exact/verify.py index 338e91852..43fef0e23 100644 --- a/python/src/pay_kit/protocols/x402/exact/verify.py +++ b/python/src/pay_kit/protocols/x402/exact/verify.py @@ -125,7 +125,10 @@ def verify( # Phantom=1 / Solflare=2) and SPL Memo. A Create-ATA / Associated Token # Program instruction is NOT allowed: the destination ATA MUST pre-exist # (Rule 7 derives and pins the destination ATA). This matches the - # Rust/Go verifiers, which never accept ATA-create in this shape. + # Rust/Go verifiers, which accept Lighthouse or Memo in ANY optional + # slot (rust verify.rs iter().skip(3); go verify.go case Memo/Lighthouse + # for all i>=3) and never accept ATA-create in this shape. Lighthouse is + # not slot-restricted because wallets inject a variable number of guards. reasons = ( "invalid_exact_svm_payload_unknown_fourth_instruction", "invalid_exact_svm_payload_unknown_fifth_instruction", @@ -135,7 +138,7 @@ def verify( ix = instructions[i] program = ExactVerifier._program_of(account_keys, ix) slot_index = i - 3 - allowed = program == MEMO_PROGRAM or (slot_index < 2 and program == LIGHTHOUSE_PROGRAM) + allowed = program in (MEMO_PROGRAM, LIGHTHOUSE_PROGRAM) if not allowed: reason = ( reasons[slot_index] diff --git a/python/tests/test_pk_x402_verifier.py b/python/tests/test_pk_x402_verifier.py index ea958fc3a..7029a38f5 100644 --- a/python/tests/test_pk_x402_verifier.py +++ b/python/tests/test_pk_x402_verifier.py @@ -205,6 +205,45 @@ def test_verify_allows_lighthouse_optional_instruction(): assert out["destinationCreateAta"] is False +def test_verify_allows_lighthouse_in_last_optional_slot(): + """Regression: Lighthouse MUST be accepted in ANY optional slot, not just the first. + + Exercises the maximum-slot layout [ComputeUnitLimit, ComputeUnitPrice, + transferChecked, Memo, Lighthouse, Lighthouse] so that Lighthouse sits at + instruction index 4 (slot_index 1) and instruction index 5 (slot_index 2). + The old ``slot_index < 2`` guard wrongly rejected Lighthouse at slot_index 2. + """ + from pay_kit.protocols.x402.exact.verify import LIGHTHOUSE_PROGRAM + + fee_payer, authority, pay_to, src, dest = _scenario() + lighthouse = Instruction(Pubkey.from_string(LIGHTHOUSE_PROGRAM), b"\x00", []) + + # Lighthouse at i=4 (slot_index 1) + ixs_slot1 = [ + _compute_limit_ix(), + _compute_price_ix(), + _transfer_checked_ix(source=src, mint=MINT, destination=dest, authority=authority.pubkey()), + _memo_ix("/pay"), + lighthouse, + ] + tx_slot1 = _tx_b64(fee_payer, ixs_slot1, [fee_payer, authority]) + out1 = ExactVerifier.verify(tx_slot1, _requirement(pay_to, memo="/pay"), [str(fee_payer.pubkey())]) + assert out1["destinationCreateAta"] is False + + # Lighthouse at i=5 (slot_index 2) — the last permitted optional slot. + ixs_slot2 = [ + _compute_limit_ix(), + _compute_price_ix(), + _transfer_checked_ix(source=src, mint=MINT, destination=dest, authority=authority.pubkey()), + _memo_ix("/pay"), + lighthouse, + lighthouse, + ] + tx_slot2 = _tx_b64(fee_payer, ixs_slot2, [fee_payer, authority]) + out2 = ExactVerifier.verify(tx_slot2, _requirement(pay_to, memo="/pay"), [str(fee_payer.pubkey())]) + assert out2["destinationCreateAta"] is False + + # -- rule 0: payload decode -------------------------------------------------- From 8631f6e96eaeeea41d420249595657c72fd4f86a Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Sun, 31 May 2026 00:02:41 +0300 Subject: [PATCH 39/45] refactor(python): split mpp server charge into decode and verify modules 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. --- .../protocols/mpp/server/_tx_decode.py | 495 ++++++++ .../pay_kit/protocols/mpp/server/_verify.py | 633 +++++++++ .../pay_kit/protocols/mpp/server/charge.py | 1126 +---------------- 3 files changed, 1191 insertions(+), 1063 deletions(-) create mode 100644 python/src/pay_kit/protocols/mpp/server/_tx_decode.py create mode 100644 python/src/pay_kit/protocols/mpp/server/_verify.py diff --git a/python/src/pay_kit/protocols/mpp/server/_tx_decode.py b/python/src/pay_kit/protocols/mpp/server/_tx_decode.py new file mode 100644 index 000000000..2b2f72487 --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/server/_tx_decode.py @@ -0,0 +1,495 @@ +"""Transaction decoding and parsed-instruction verification for MPP charge. + +Pure, RPC-free helpers shared by the server charge flow: module constants +mirrored from the Rust spine, the local transfer/memo decoders for legacy and +v0 transactions, the lossy parsed-instruction verifiers, and the small +RPC-response coercion utilities. These never touch the replay store or the +network; the orchestration and the strict no-leftovers allowlist live in +:mod:`pay_kit.protocols.mpp.server._verify` and +:mod:`pay_kit.protocols.mpp.server.charge`. +""" + +from __future__ import annotations + +import base64 +import json +from typing import Any + +from pay_kit._paycore.errors import PaymentError +from pay_kit._paycore.solana import ( + ASSOCIATED_TOKEN_PROGRAM, + MEMO_PROGRAM, + TOKEN_2022_PROGRAM, + TOKEN_PROGRAM, + MethodDetails, + default_token_program_for_currency, + resolve_mint, +) +from pay_kit._paycore.transaction import is_v0_wire_bytes +from pay_kit.protocols.mpp.intents.charge import ChargeRequest + +_SYSTEM_PROGRAM = "11111111111111111111111111111111" +_SYSTEM_TRANSFER_INSTRUCTION = 2 +_TOKEN_TRANSFER_CHECKED_INSTRUCTION = 12 + +# Compute-budget program allowlist caps. These must stay in sync with the +# canonical Rust reference at ``rust/src/server/charge.rs`` constants +# ``MAX_COMPUTE_UNIT_LIMIT`` and ``MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS``, +# and the mirrored caps on Ruby, PHP, Lua, Go server SDKs. A challenge +# carrying a SetComputeUnitLimit / SetComputeUnitPrice instruction over +# these caps is rejected before broadcast so the payer cannot drain the +# fee payer with an unbounded priority fee. +_COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111" +_COMPUTE_BUDGET_SET_LIMIT_DISCRIMINATOR = 2 +_COMPUTE_BUDGET_SET_PRICE_DISCRIMINATOR = 3 +MAX_COMPUTE_UNIT_LIMIT = 200_000 +MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 5_000_000 + +# Maximum number of additional split recipients on a single charge. +# Matches Rust ``splits.len() > 8`` guard in +# ``rust/src/server/charge.rs::verify_versioned_transaction_pre_broadcast`` +# and the equivalent ``count($splits) > 8`` / ``splits.length > 8`` guards +# in PHP and Ruby. A high split count balloons the transaction size and +# the per-recipient ATA verification cost, so we reject early at the +# pre-broadcast stage. +MAX_SPLITS = 8 + +# Legacy Solana memo program (v1). MPP charge transactions MUST use memo v2 +# (``MEMO_PROGRAM`` from :mod:`pay_kit._paycore.solana`). v1 had a different +# instruction shape and is rejected to match the L2 lock landed on PHP fde0efb +# and mirrored in Ruby, Rust, Lua. +_MEMO_V1_PROGRAM = "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo" + + +def _build_expected_transfers(request: ChargeRequest, details: MethodDetails) -> list[tuple[str, int]]: + # Reject over-bound splits up-front. Mirrors the Rust pre-broadcast + # guard at ``rust/src/server/charge.rs::verify_versioned_transaction_pre_broadcast`` + # (``splits.len() > 8``) and the equivalent PHP / Ruby guards. A + # high split count balloons transaction size and per-recipient ATA + # verification cost, so we surface the limit + observed count in + # the error so the client can repair the challenge. + if len(details.splits) > MAX_SPLITS: + raise PaymentError( + f"too many splits: {len(details.splits)} exceeds limit {MAX_SPLITS}", + code="too-many-splits", + ) + + total_amount = int(request.amount) + split_total = sum(int(split.amount) for split in details.splits) + primary_amount = total_amount - split_total + if primary_amount <= 0: + raise PaymentError( + "splits consume the entire amount — primary recipient must receive a positive amount", + code="splits-exceed-amount", + ) + + expected = [(request.recipient, primary_amount)] + for split in details.splits: + expected.append((split.recipient, int(split.amount))) + return expected + + +def _verify_parsed_sol_transfers( + instructions: list[dict[str, Any]], + request: ChargeRequest, + details: MethodDetails, +) -> None: + expected = _build_expected_transfers(request, details) + transfers = [ + instruction + for instruction in instructions + if instruction.get("program") == "system" and (instruction.get("parsed") or {}).get("type") == "transfer" + ] + + for recipient, amount in expected: + match_index = next( + ( + index + for index, transfer in enumerate(transfers) + if ((transfer.get("parsed") or {}).get("info") or {}).get("destination") == recipient + and str(((transfer.get("parsed") or {}).get("info") or {}).get("lamports")) == str(amount) + ), + -1, + ) + if match_index == -1: + raise PaymentError(f"no matching SOL transfer for {recipient}", code="no-transfer") + transfers.pop(match_index) + + +def _verify_parsed_spl_transfers( + instructions: list[dict[str, Any]], + request: ChargeRequest, + details: MethodDetails, +) -> None: + expected = _build_expected_transfers(request, details) + program_id = details.token_program or default_token_program_for_currency(request.currency, details.network) + mint = resolve_mint(request.currency, details.network) + transfers = [ + instruction + for instruction in instructions + if instruction.get("programId") == program_id + and (instruction.get("parsed") or {}).get("type") == "transferChecked" + ] + + for recipient, amount in expected: + match_index = next( + ( + index + for index, transfer in enumerate(transfers) + if ((transfer.get("parsed") or {}).get("info") or {}).get("mint") == mint + and str((((transfer.get("parsed") or {}).get("info") or {}).get("tokenAmount") or {}).get("amount")) + == str(amount) + and _verify_ata_owner( + ((transfer.get("parsed") or {}).get("info") or {}).get("destination", ""), + recipient, + mint, + program_id, + ) + ), + -1, + ) + if match_index == -1: + raise PaymentError(f"no matching token transfer for {recipient}", code="no-transfer") + transfers.pop(match_index) + + +def _verify_ata_owner(ata_address: str, expected_owner: str, mint: str, token_program: str) -> bool: + """Verify that an ATA address belongs to the expected owner by deriving it.""" + try: + from solders.pubkey import Pubkey + + owner_pk = Pubkey.from_string(expected_owner) + mint_pk = Pubkey.from_string(mint) + tp_pk = Pubkey.from_string(token_program) + ata_program = Pubkey.from_string(ASSOCIATED_TOKEN_PROGRAM) + expected_ata, _bump = Pubkey.find_program_address( + [bytes(owner_pk), bytes(tp_pk), bytes(mint_pk)], + ata_program, + ) + return str(expected_ata) == ata_address + except Exception: + return False + + +def _parsed_program_id(instruction: dict[str, Any]) -> str: + program_id = instruction.get("programId") or instruction.get("program_id") + if isinstance(program_id, str): + return program_id + if instruction.get("program") == "spl-memo": + return MEMO_PROGRAM + return "" + + +def _parsed_memo_text(instruction: dict[str, Any]) -> str | None: + parsed = instruction.get("parsed") + if isinstance(parsed, str): + return parsed + if isinstance(parsed, dict): + info = parsed.get("info") + if isinstance(info, dict): + memo = info.get("memo") + if isinstance(memo, str): + return memo + data = info.get("data") + if isinstance(data, str): + return data + return None + + +def _expected_memos(request: ChargeRequest, details: MethodDetails) -> list[tuple[str, str]]: + expected: list[tuple[str, str]] = [] + if request.external_id: + expected.append(("externalId", request.external_id)) + for split in details.splits: + if split.memo: + expected.append(("split", split.memo)) + return expected + + +def _verify_parsed_memo_instructions( + instructions: list[dict[str, Any]], + request: ChargeRequest, + details: MethodDetails, +) -> None: + matched: set[int] = set() + for label, memo in _expected_memos(request, details): + if len(memo.encode("utf-8")) > 566: + raise PaymentError("memo cannot exceed 566 bytes", code="invalid-payload") + + match_index = next( + ( + index + for index, instruction in enumerate(instructions) + if index not in matched + and _parsed_program_id(instruction) == MEMO_PROGRAM + and _parsed_memo_text(instruction) == memo + ), + -1, + ) + if match_index == -1: + raise PaymentError(f'No memo instruction found for {label} memo "{memo}"', code="invalid-payload") + matched.add(match_index) + + for index, instruction in enumerate(instructions): + program_id = _parsed_program_id(instruction) + if index not in matched and program_id == MEMO_PROGRAM: + raise PaymentError("unexpected Memo Program instruction in payment transaction", code="invalid-payload") + # L2 lock parity with the pull-mode pre-broadcast decoder + # (_decode_legacy_payment_instructions). Push-mode signature + # credentials reach this verifier without going through + # _decode_legacy_payment_instructions; without an explicit Memo + # v1 program-id check here, a confirmed on-chain transaction + # carrying a Memo v1 instruction would slip past the v2-only + # matcher above, leaving the L2 guard partial. Reject the + # credential so push-mode matches pull-mode behaviour. + if program_id == _MEMO_V1_PROGRAM: + raise PaymentError( + "memo v1 program is not supported (use Memo v2)", + code="invalid-payload", + ) + + +def _rpc_value(response: Any) -> Any: + if response is None: + return None + if isinstance(response, dict): + return response.get("value", response) + return getattr(response, "value", response) + + +def _json_like(value: Any) -> Any: + if isinstance(value, (str, int, float, bool)) or value is None: + return value + if isinstance(value, dict): + return {k: _json_like(v) for k, v in value.items()} + if isinstance(value, list): + return [_json_like(item) for item in value] + if hasattr(value, "to_json"): + return json.loads(value.to_json()) + if hasattr(value, "__dict__"): + return {key: _json_like(val) for key, val in vars(value).items()} + return value + + +def _transaction_dict(response: Any) -> dict[str, Any] | None: + value = _rpc_value(response) + if value is None: + return None + data = _json_like(value) + if isinstance(data, dict) and "transaction" in data: + return data + return None + + +def _status_ok(response: Any) -> bool: + value = _rpc_value(response) + data = _json_like(value) + if isinstance(data, list): + return any(entry and entry.get("err") is None for entry in data) + return data is not None + + +def _extract_recent_blockhash(transaction_b64: str) -> str: + """Decode a base64 transaction and return its recent blockhash (base58). + + Tries the legacy ``Transaction`` first (the most common shape from our + SDK clients) and falls back to ``VersionedTransaction``. Kept thin so + the surrounding network check can be exercised by tests without a full + verification pipeline in place. + """ + from solders.transaction import Transaction, VersionedTransaction + + raw = base64.b64decode(transaction_b64) + try: + tx = Transaction.from_bytes(raw) + return str(tx.message.recent_blockhash) + except Exception: + vtx = VersionedTransaction.from_bytes(raw) + return str(vtx.message.recent_blockhash) + + +def _validate_compute_budget_instruction(data: bytes, account_count: int) -> None: + """Validate a single ComputeBudget program instruction. + + Mirrors ``validate_compute_budget_instruction`` in + ``rust/src/server/charge.rs``: SetComputeUnitLimit (discriminator 2, + u32 LE units in ``data[1..5]``) and SetComputeUnitPrice (discriminator + 3, u64 LE microlamports in ``data[1..9]``) are the only accepted + shapes, both must carry zero account references, and each value is + capped at the per-instruction maximum. Anything else is rejected as + an invalid payload to keep the on-wire allowlist tight. + """ + if account_count != 0: + raise PaymentError( + "compute budget instruction must not have accounts", + code="compute-budget-invalid", + ) + if not data: + raise PaymentError( + "compute budget instruction has empty data", + code="compute-budget-invalid", + ) + discriminator = data[0] + if discriminator == _COMPUTE_BUDGET_SET_LIMIT_DISCRIMINATOR and len(data) == 5: + units = int.from_bytes(data[1:5], "little") + if units > MAX_COMPUTE_UNIT_LIMIT: + raise PaymentError( + f"compute unit limit {units} exceeds cap {MAX_COMPUTE_UNIT_LIMIT}", + code="compute-budget-cap-exceeded", + ) + return + if discriminator == _COMPUTE_BUDGET_SET_PRICE_DISCRIMINATOR and len(data) == 9: + price = int.from_bytes(data[1:9], "little") + if price > MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS: + raise PaymentError( + f"compute unit price {price} exceeds cap {MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS}", + code="compute-budget-cap-exceeded", + ) + return + raise PaymentError( + "unsupported compute budget instruction", + code="compute-budget-invalid", + ) + + +def _decode_legacy_payment_instructions(transaction_b64: str) -> list[dict[str, Any]]: + """Decode local transfer and memo instructions from a legacy or v0 transaction. + + Accepts both legacy ``Transaction`` and ``VersionedTransaction``. For v0 + we only inspect the static account keys; address lookup tables are + rejected up-front (a v0 tx with a non-empty ALT list would let an + instruction reference accounts the verifier cannot see). Mirrors the + Rust spine's ``verify_versioned_transaction_pre_broadcast`` policy. + """ + from solders.transaction import Transaction, VersionedTransaction + + raw = base64.b64decode(transaction_b64) + message: Any = None + message_instructions: list[Any] = [] + # Route v0 wire bytes straight to VersionedTransaction; the legacy + # parser in solders is lenient and can mis-parse a signed v0 tx as a + # degenerate legacy tx with bogus instructions (see is_v0_wire_bytes). + parsed = False + if is_v0_wire_bytes(raw): + try: + vtx = VersionedTransaction.from_bytes(raw) + except Exception: + vtx = None + if vtx is not None: + lookups = getattr(vtx.message, "address_table_lookups", None) + if lookups: + raise PaymentError( + "v0 transactions with address lookup tables are not supported", + code="invalid-payload", + ) from None + message = vtx.message + message_instructions = list(vtx.message.instructions) + parsed = True + if not parsed: + try: + tx = Transaction.from_bytes(raw) + message = tx.message + message_instructions = list(tx.message.instructions) + except Exception: + try: + vtx = VersionedTransaction.from_bytes(raw) + except Exception as exc: + raise PaymentError( + "unsupported transaction shape for pre-broadcast verification", + code="invalid-payload-type", + ) from exc + # Reject v0 transactions that reference address lookup tables; the + # pre-broadcast verifier only sees static account keys. + lookups = getattr(vtx.message, "address_table_lookups", None) + if lookups: + raise PaymentError( + "v0 transactions with address lookup tables are not supported", + code="invalid-payload", + ) from None + message = vtx.message + message_instructions = list(vtx.message.instructions) + + account_keys = [str(key) for key in message.account_keys] + instructions: list[dict[str, Any]] = [] + for instruction in message_instructions: + try: + program_id = account_keys[int(instruction.program_id_index)] + except IndexError as exc: + raise PaymentError("transaction instruction references an unknown program", code="invalid-payload") from exc + data = bytes(instruction.data) + if program_id == _SYSTEM_PROGRAM: + if len(data) < 12: + continue + kind = int.from_bytes(data[:4], "little") + if kind != _SYSTEM_TRANSFER_INSTRUCTION or len(instruction.accounts) < 2: + continue + try: + destination = account_keys[int(instruction.accounts[1])] + except IndexError as exc: + raise PaymentError( + "transaction transfer references an unknown account", code="invalid-payload" + ) from exc + lamports = int.from_bytes(data[4:12], "little") + instructions.append( + { + "program": "system", + "parsed": { + "type": "transfer", + "info": { + "destination": destination, + "lamports": str(lamports), + }, + }, + } + ) + elif program_id in {TOKEN_PROGRAM, TOKEN_2022_PROGRAM}: + if len(data) < 10: + continue + kind = data[0] + if kind != _TOKEN_TRANSFER_CHECKED_INSTRUCTION or len(instruction.accounts) < 3: + continue + try: + mint = account_keys[int(instruction.accounts[1])] + destination = account_keys[int(instruction.accounts[2])] + except IndexError as exc: + raise PaymentError( + "transaction token transfer references an unknown account", code="invalid-payload" + ) from exc + amount = int.from_bytes(data[1:9], "little") + instructions.append( + { + "programId": program_id, + "parsed": { + "type": "transferChecked", + "info": { + "destination": destination, + "mint": mint, + "tokenAmount": {"amount": str(amount)}, + }, + }, + } + ) + elif program_id == MEMO_PROGRAM: + try: + memo = data.decode("utf-8") + except UnicodeDecodeError as exc: + raise PaymentError("memo instruction is not valid UTF-8", code="invalid-payload") from exc + instructions.append({"programId": MEMO_PROGRAM, "parsed": memo}) + elif program_id == _COMPUTE_BUDGET_PROGRAM: + # Validate compute-budget instructions inline so an over-cap + # SetComputeUnitLimit / SetComputeUnitPrice is rejected with a + # structured error before broadcast. The instruction itself + # carries no transfer semantics, so we do not append it to + # the parsed instruction list consumed downstream. + _validate_compute_budget_instruction(data, len(instruction.accounts)) + elif program_id == _MEMO_V1_PROGRAM: + # L2 lock: MPP charge requires memo v2. Memo v1 has a different + # instruction shape (UTF-8 directly in data with no signer check) + # and would let a tampered transaction slip past the v2-only + # ``_verify_parsed_memo_instructions`` matcher. + raise PaymentError( + "memo v1 program is not supported (use Memo v2)", + code="invalid-payload", + ) + + return instructions diff --git a/python/src/pay_kit/protocols/mpp/server/_verify.py b/python/src/pay_kit/protocols/mpp/server/_verify.py new file mode 100644 index 000000000..f47e6eebf --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/server/_verify.py @@ -0,0 +1,633 @@ +"""Fee-payer cosign and the strict pre-broadcast instruction allowlist. + +The security-critical half of the server charge flow: splicing the fee-payer +signature into the canonical slot, the ATA-creation policy, and the +no-leftovers allowlist that protects the fee-payer keypair from being +co-opted into signing attacker-supplied transfers. Builds on the pure +decoders in :mod:`pay_kit.protocols.mpp.server._tx_decode`; the ``Mpp`` +orchestration that drives broadcast / confirmation lives in +:mod:`pay_kit.protocols.mpp.server.charge`. +""" + +from __future__ import annotations + +import base64 +from typing import Any + +from pay_kit._paycore.errors import PaymentError +from pay_kit._paycore.solana import ( + ASSOCIATED_TOKEN_PROGRAM, + MEMO_PROGRAM, + TOKEN_2022_PROGRAM, + TOKEN_PROGRAM, + MethodDetails, + default_token_program_for_currency, + is_native_sol, + resolve_mint, +) +from pay_kit._paycore.transaction import is_v0_wire_bytes +from pay_kit.protocols.mpp.intents.charge import ChargeRequest +from pay_kit.protocols.mpp.server._tx_decode import ( + _COMPUTE_BUDGET_PROGRAM, + _MEMO_V1_PROGRAM, + _SYSTEM_PROGRAM, + _SYSTEM_TRANSFER_INSTRUCTION, + _TOKEN_TRANSFER_CHECKED_INSTRUCTION, + _build_expected_transfers, + _decode_legacy_payment_instructions, + _expected_memos, + _validate_compute_budget_instruction, + _verify_ata_owner, + _verify_parsed_memo_instructions, + _verify_parsed_sol_transfers, + _verify_parsed_spl_transfers, +) + + +def _co_sign_with_fee_payer(transaction_b64: str, fee_payer: Any) -> str: + """Co-sign a client transaction with the server's fee payer keypair. + + The fee payer occupies the first signer slot in Solana transactions. We + serialize the message in the correct shape for its version (legacy uses + ``bytes(msg)``; v0 uses ``to_bytes_versioned(msg)`` which prepends the + ``0x80`` version tag), sign with the fee-payer private key, and splice + the resulting signature into the signature array at the slot matching + the fee-payer pubkey. + + Mirrors the cosign step in rust/src/server/charge.rs verify_pull. + """ + from solders.message import to_bytes_versioned + from solders.transaction import Transaction, VersionedTransaction + + raw = base64.b64decode(transaction_b64) + fee_payer_pubkey = fee_payer.pubkey() + + # Try legacy transaction first (the common path); fall back to versioned. + try: + tx = Transaction.from_bytes(raw) + except Exception: + try: + vtx = VersionedTransaction.from_bytes(raw) + except Exception as exc: + raise PaymentError( + f"could not decode transaction for fee payer co-sign: {exc}", + code="invalid-payload-type", + ) from exc + account_keys = list(vtx.message.account_keys) + try: + idx = account_keys.index(fee_payer_pubkey) + except ValueError as exc: + raise PaymentError( + "fee payer pubkey not present in transaction accounts", + code="invalid-payload", + ) from exc + num_required = int(vtx.message.header.num_required_signatures) + _assert_signature_slot(idx, num_required) + # v0 messages are signed over ``to_bytes_versioned(msg)`` which + # prepends the 0x80 version byte. + message_bytes = bytes(to_bytes_versioned(vtx.message)) + sig_bytes = bytes(fee_payer.sign_message(message_bytes)) + # Manual splice in the on-wire bytes preserves the rest of the + # transaction exactly. Wire format: [num_sigs (compact-u16)] [sigs] + # [message...]. num_sigs < 128 so it is a 1-byte prefix. + serialized = bytearray(raw) + sig_start = 1 + idx * 64 + serialized[sig_start : sig_start + 64] = sig_bytes + return base64.b64encode(bytes(serialized)).decode("ascii") + + account_keys = list(tx.message.account_keys) + try: + idx = account_keys.index(fee_payer_pubkey) + except ValueError as exc: + raise PaymentError( + "fee payer pubkey not present in transaction accounts", + code="invalid-payload", + ) from exc + num_required = int(tx.message.header.num_required_signatures) + _assert_signature_slot(idx, num_required) + + # Legacy Transaction: sign ``bytes(msg)`` directly. + message_bytes = bytes(tx.message) + sig_bytes = bytes(fee_payer.sign_message(message_bytes)) + serialized = bytearray(raw) + sig_start = 1 + idx * 64 + serialized[sig_start : sig_start + 64] = sig_bytes + return base64.b64encode(bytes(serialized)).decode("ascii") + + +def _assert_signature_slot(idx: int, num_required: int) -> None: + """Validate that the fee payer occupies the canonical slot 0. + + The Solana protocol requires the fee payer to be ``account_keys[0]``: + the runtime debits the first required signer for transaction fees. If + we accepted a fee-payer pubkey at any slot inside the required-signers + block, a client could craft a transaction that includes a benign + payment transfer plus an extra instruction that *also* needs the + server's key as a required signer (for example, at slot 1). The + pre-broadcast decoder would still accept the transfer half, and the + server would happily produce its signature, letting the client + co-opt the server's private key to authorize arbitrary on-chain + intents. Enforcing ``idx == 0`` matches the Rust spine's + ``expected_fee_payer`` invariant (``account_keys.first() == fee_payer``) + and closes that escalation path before any sign call is made. + """ + if idx < 0 or idx >= num_required: + raise PaymentError( + f"fee payer pubkey at account index {idx} is outside the " + f"required-signers block (num_required_signatures={num_required}); " + "a client must place the fee payer inside the signer header", + code="invalid-payload", + ) + if idx != 0: + raise PaymentError( + "fee payer pubkey must occupy account index 0 (the transaction " + f"fee-payer slot); found at index {idx}. The Solana runtime " + "always debits the first required signer for fees, so any other " + "placement would cause the server's key to sign for an " + "instruction outside the fee-payment role.", + code="invalid-payload", + ) + + +def _expected_ata_creation_policy( + details: MethodDetails, + fee_payer_pubkey: str | None, +) -> tuple[set[str], set[str]]: + """Return ``(allowed_ata_owners, required_ata_owners)`` per Rust spine. + + Mirrors ``expected_ata_creation_policy`` in + ``rust/src/server/charge.rs``: + + - ``required_ata_owners`` is the set of split recipients with + ``ataCreationRequired=true``. + - ``allowed_ata_owners`` is ``required_ata_owners`` when the route + advertises ``feePayer=true`` (the server only sponsors ATA creates + that the route explicitly demanded), and the set of every split + recipient when no fee-payer co-sign is in play (client pays its + own ATA rent so it may opportunistically create ATAs for any + declared split). + + The primary recipient is NEVER in ``allowed_ata_owners``. Including + it would let a sponsored route co-sign an ATA create for the top-level + recipient even though no split asked for it, spending fee-payer SOL + on rent the route did not authorize. + """ + required_owners: set[str] = set() + split_owners: set[str] = set() + for split in details.splits: + split_owners.add(split.recipient) + if split.ata_creation_required: + required_owners.add(split.recipient) + + allowed_owners = set(required_owners) if fee_payer_pubkey is not None else split_owners + return allowed_owners, required_owners + + +def _validate_ata_create_idempotent( + instruction: Any, + account_keys: list[str], + expected_mint: str | None, + allowed_ata_owners: set[str], + expected_token_program: str | None, + expected_payer: str, +) -> None: + """Validate an AssociatedTokenAccount create-idempotent instruction. + + Mirrors ``validate_create_ata_idempotent_instruction`` in + ``rust/src/server/charge.rs``. The only ATA program instruction the + fee-payer co-sign path may include is the idempotent create variant + (discriminator byte ``0x01``) and only for an ATA whose payer is the + transaction fee payer, whose owner is a recipient declared by the + charge, whose mint matches the challenge currency, and whose token + program is the one the challenge selected. Any deviation is rejected + so an attacker cannot trick the server into co-signing an ATA create + that funds an attacker-controlled mint or owner with fee-payer SOL. + """ + if expected_mint is None: + raise PaymentError( + "ATA creation is not allowed for native SOL payments", + code="invalid-payload", + ) + data = bytes(instruction.data) + if data != b"\x01": + raise PaymentError( + "only idempotent ATA creation is allowed", + code="invalid-payload", + ) + accounts = list(instruction.accounts) + if len(accounts) != 6: + raise PaymentError( + "unexpected ATA creation account layout", + code="invalid-payload", + ) + try: + payer = account_keys[int(accounts[0])] + ata = account_keys[int(accounts[1])] + owner = account_keys[int(accounts[2])] + mint = account_keys[int(accounts[3])] + sys_program = account_keys[int(accounts[4])] + token_program = account_keys[int(accounts[5])] + except IndexError as exc: + raise PaymentError( + "ATA creation references an unknown account index", + code="invalid-payload", + ) from exc + + if payer != expected_payer: + raise PaymentError( + "ATA payer must match the transaction fee payer", + code="invalid-payload", + ) + if mint != expected_mint: + raise PaymentError( + "ATA creation mint does not match the charge currency", + code="invalid-payload", + ) + if owner not in allowed_ata_owners: + raise PaymentError( + "ATA creation owner is not authorized by the challenge", + code="invalid-payload", + ) + if sys_program != _SYSTEM_PROGRAM: + raise PaymentError( + "ATA creation must reference the System Program", + code="invalid-payload", + ) + if token_program not in {TOKEN_PROGRAM, TOKEN_2022_PROGRAM}: + raise PaymentError( + "ATA creation uses an unsupported token program", + code="invalid-payload", + ) + if expected_token_program is not None and token_program != expected_token_program: + raise PaymentError( + "ATA creation token program does not match methodDetails.tokenProgram", + code="invalid-payload", + ) + # Verify the derived ATA matches owner/mint/token_program so a caller + # cannot funnel the create to an attacker-controlled address. + try: + from solders.pubkey import Pubkey + + owner_pk = Pubkey.from_string(owner) + mint_pk = Pubkey.from_string(mint) + tp_pk = Pubkey.from_string(token_program) + ata_program = Pubkey.from_string(ASSOCIATED_TOKEN_PROGRAM) + derived, _ = Pubkey.find_program_address( + [bytes(owner_pk), bytes(tp_pk), bytes(mint_pk)], + ata_program, + ) + if str(derived) != ata: + raise PaymentError( + "ATA creation address does not match owner/mint/token program", + code="invalid-payload", + ) + except PaymentError: + raise + except Exception as exc: # noqa: BLE001 + raise PaymentError( + f"could not validate ATA creation address: {exc}", + code="invalid-payload", + ) from exc + + +def _validate_instruction_allowlist( + transaction_b64: str, + request: ChargeRequest, + details: MethodDetails, + expected_fee_payer_pubkey: str | None = None, +) -> None: + """Reject any instruction not on the strict fee-payer co-sign allowlist. + + SECURITY: this is the no-leftovers check that protects the server's + fee-payer keypair from being co-opted into signing attacker-supplied + transfers. The lossy parsed-instruction verifier + (``_verify_parsed_sol_transfers`` / + ``_verify_parsed_spl_transfers`` / ``_verify_parsed_memo_instructions``) + only checks that the required transfers / memos are present; it does + not reject extra instructions. Without this allowlist a malicious + client could include the expected payment plus a System Program + transfer from the fee payer to the attacker, and the server would + co-sign the entire transaction. + + The allowlist mirrors ``validate_instruction_allowlist`` in + ``rust/src/server/charge.rs``: only ComputeBudget (validated), + Memo v2 (must match an expected memo), System Program transfer (must + match an expected payment transfer), SPL Token / Token-2022 + transferChecked (must match an expected payment transfer), and + AssociatedTokenAccount create-idempotent (validated) are accepted. + Anything else (including SOL transfers that do not match a required + transfer, SPL transfers to unrelated mints, raw token approve / + burn, BPF program calls, sysvar reads, etc.) is rejected before + broadcast with a ``payment-invalid`` canonical code. + """ + from solders.transaction import Transaction, VersionedTransaction + + raw = base64.b64decode(transaction_b64) + message: Any = None + message_instructions: list[Any] = [] + # Route v0 wire bytes straight to VersionedTransaction; the legacy + # parser in solders is lenient and can mis-parse a signed v0 tx as a + # degenerate legacy tx whose instructions point at random account + # keys. The allowlist would then reject the legitimate v0 payment + # with a misleading "unexpected program instruction" error sourced + # from junk bytes. See is_v0_wire_bytes. + parsed = False + if is_v0_wire_bytes(raw): + try: + vtx = VersionedTransaction.from_bytes(raw) + except Exception: + vtx = None + if vtx is not None: + if getattr(vtx.message, "address_table_lookups", None): + raise PaymentError( + "v0 transactions with address lookup tables are not supported", + code="invalid-payload", + ) from None + message = vtx.message + message_instructions = list(vtx.message.instructions) + parsed = True + if not parsed: + try: + tx = Transaction.from_bytes(raw) + message = tx.message + message_instructions = list(tx.message.instructions) + except Exception: + try: + vtx = VersionedTransaction.from_bytes(raw) + except Exception as exc: + raise PaymentError( + "unsupported transaction shape for instruction allowlist", + code="invalid-payload-type", + ) from exc + if getattr(vtx.message, "address_table_lookups", None): + raise PaymentError( + "v0 transactions with address lookup tables are not supported", + code="invalid-payload", + ) from None + message = vtx.message + message_instructions = list(vtx.message.instructions) + + account_keys = [str(key) for key in message.account_keys] + if not account_keys: + raise PaymentError("transaction has no accounts", code="invalid-payload") + fee_payer_account = account_keys[0] + # SECURITY: when the charge advertises feePayer=true the protective + # pubkey used for drain detection MUST come from the server-side + # signing context (``Mpp._fee_payer_signer.pubkey()``), NOT from + # client-echoed ``methodDetails.feePayerKey``. A malicious client can + # tamper the echoed key to a pubkey it controls, pass the source-account + # checks below (because they compare against the tampered value), and + # still get the real server keypair to co-sign and broadcast a transfer + # sourced from the actual server fee-payer. + # + # The client-echoed ``details.fee_payer_key`` is cross-checked against + # the server pubkey above this allowlist (in ``_verify_local_transaction_intent``) + # so a mismatch is rejected up-front with ``payment_invalid``. Here we + # only consume the server-supplied pubkey. If no server pubkey was + # threaded (e.g. unit tests that call the helper directly), we fall + # back to the echoed value for backward compatibility; production + # callers always thread the server pubkey. + fee_payer_pubkey: str | None + if expected_fee_payer_pubkey is not None: + fee_payer_pubkey = expected_fee_payer_pubkey + elif details.fee_payer and details.fee_payer_key: + fee_payer_pubkey = details.fee_payer_key + else: + fee_payer_pubkey = None + + expected_transfers = _build_expected_transfers(request, details) + native = is_native_sol(request.currency) + expected_mint = None if native else resolve_mint(request.currency, details.network) + expected_token_program: str | None = None + if not native: + expected_token_program = details.token_program or default_token_program_for_currency( + request.currency, details.network + ) + allowed_ata_owners, _required_ata_owners = _expected_ata_creation_policy(details, fee_payer_pubkey) + expected_memos = {memo for _label, memo in _expected_memos(request, details)} + + # Track which required transfers / memos have been satisfied so each + # required entry can only be matched once; an attacker cannot replay + # a single transfer to cover two required legs. + remaining_transfers: list[tuple[str, int]] = list(expected_transfers) + remaining_memos: set[str] = set(expected_memos) + + for instruction in message_instructions: + try: + program_id = account_keys[int(instruction.program_id_index)] + except IndexError as exc: + raise PaymentError( + "instruction references an unknown program index", + code="invalid-payload", + ) from exc + data = bytes(instruction.data) + accounts = list(instruction.accounts) + + if program_id == _COMPUTE_BUDGET_PROGRAM: + _validate_compute_budget_instruction(data, len(accounts)) + continue + + if program_id == MEMO_PROGRAM: + try: + memo_text = data.decode("utf-8") + except UnicodeDecodeError as exc: + raise PaymentError( + "memo instruction is not valid UTF-8", + code="invalid-payload", + ) from exc + if memo_text not in remaining_memos: + raise PaymentError( + "unexpected Memo Program instruction in payment transaction", + code="invalid-payload", + ) + remaining_memos.discard(memo_text) + continue + + if program_id == _MEMO_V1_PROGRAM: + raise PaymentError( + "memo v1 program is not supported (use Memo v2)", + code="invalid-payload", + ) + + if program_id == _SYSTEM_PROGRAM: + if not native: + raise PaymentError( + "unexpected System Program instruction in token payment transaction", + code="invalid-payload", + ) + if len(data) < 12 or len(accounts) < 2: + raise PaymentError( + "unexpected System Program instruction in payment transaction", + code="invalid-payload", + ) + kind = int.from_bytes(data[:4], "little") + if kind != _SYSTEM_TRANSFER_INSTRUCTION: + raise PaymentError( + "unexpected System Program instruction in payment transaction", + code="invalid-payload", + ) + try: + source = account_keys[int(accounts[0])] + destination = account_keys[int(accounts[1])] + except IndexError as exc: + raise PaymentError( + "transfer references an unknown account", + code="invalid-payload", + ) from exc + # SECURITY: reject any System transfer that sources lamports from + # the configured fee-payer (mirrors rust spine ``verify_sol_transfer_instructions``). + # Without this guard a malicious client can satisfy the required + # payment with a transfer FROM the fee-payer, draining server SOL + # on top of the network fee already debited from account_keys[0]. + if fee_payer_pubkey is not None and source == fee_payer_pubkey: + raise PaymentError( + "fee payer cannot fund the SOL payment transfer", + code="invalid-payload", + ) + lamports = int.from_bytes(data[4:12], "little") + match_idx = next( + (i for i, (rcpt, amt) in enumerate(remaining_transfers) if rcpt == destination and amt == lamports), + -1, + ) + if match_idx == -1: + raise PaymentError( + "unexpected System Program transfer in payment transaction", + code="invalid-payload", + ) + remaining_transfers.pop(match_idx) + continue + + if program_id in {TOKEN_PROGRAM, TOKEN_2022_PROGRAM}: + if native: + raise PaymentError( + "unexpected Token Program instruction in native SOL payment", + code="invalid-payload", + ) + if expected_token_program is not None and program_id != expected_token_program: + raise PaymentError( + "token program does not match methodDetails.tokenProgram", + code="invalid-payload", + ) + if len(data) < 10 or len(accounts) < 4: + raise PaymentError( + "unexpected Token Program instruction in payment transaction", + code="invalid-payload", + ) + if data[0] != _TOKEN_TRANSFER_CHECKED_INSTRUCTION: + raise PaymentError( + "unexpected Token Program instruction in payment transaction", + code="invalid-payload", + ) + try: + source_ata = account_keys[int(accounts[0])] + mint = account_keys[int(accounts[1])] + destination = account_keys[int(accounts[2])] + authority = account_keys[int(accounts[3])] + except IndexError as exc: + raise PaymentError( + "token transfer references an unknown account", + code="invalid-payload", + ) from exc + if expected_mint is not None and mint != expected_mint: + raise PaymentError( + "token transfer mint does not match the charge currency", + code="invalid-payload", + ) + # SECURITY: reject any SPL transferChecked authorized by the + # configured fee-payer or sourced from the fee-payer's ATA for + # this mint / token program. Mirrors rust spine + # ``verify_spl_transfer_instructions``. Without these checks a + # malicious client can present a transferChecked FROM the + # fee-payer ATA TO the recipient ATA matching the required + # amount; the allowlist would pass and the server would + # co-sign, draining fee-payer tokens. + if fee_payer_pubkey is not None: + if authority == fee_payer_pubkey: + raise PaymentError( + "fee payer cannot authorize the SPL payment transfer", + code="invalid-payload", + ) + if _verify_ata_owner(source_ata, fee_payer_pubkey, mint, program_id): + raise PaymentError( + "fee payer token account cannot fund the SPL payment transfer", + code="invalid-payload", + ) + amount = int.from_bytes(data[1:9], "little") + match_idx = next( + ( + i + for i, (rcpt, amt) in enumerate(remaining_transfers) + if amt == amount and _verify_ata_owner(destination, rcpt, mint, program_id) + ), + -1, + ) + if match_idx == -1: + raise PaymentError( + "unexpected Token Program transfer in payment transaction", + code="invalid-payload", + ) + remaining_transfers.pop(match_idx) + continue + + if program_id == ASSOCIATED_TOKEN_PROGRAM: + _validate_ata_create_idempotent( + instruction, + account_keys, + expected_mint, + allowed_ata_owners, + expected_token_program, + fee_payer_account, + ) + continue + + raise PaymentError( + f"unexpected program instruction in payment transaction: {program_id}", + code="invalid-payload", + ) + + +def _verify_local_transaction_intent( + transaction_b64: str, + request: ChargeRequest, + details: MethodDetails, + expected_fee_payer_pubkey: str | None = None, +) -> None: + """Verify locally-decodable payment intent before broadcasting. + + ``expected_fee_payer_pubkey`` is the AUTHORITATIVE server-side fee-payer + pubkey (``Mpp._fee_payer_signer.pubkey()``). It is threaded by + ``_verify_transaction`` so the no-leftovers allowlist can detect drain + attempts against the real server key, not against a client-echoed + ``methodDetails.feePayerKey`` value (which an attacker controls). When + both are present and ``details.fee_payer`` is true we also reject any + mismatch up-front with the canonical ``payment_invalid`` code so a + tampered echoed key cannot silently slip through. + """ + if ( + expected_fee_payer_pubkey is not None + and details.fee_payer + and details.fee_payer_key + and details.fee_payer_key != expected_fee_payer_pubkey + ): + raise PaymentError( + "methodDetails.feePayerKey does not match the server fee-payer signer", + code="invalid-payload", + ) + instructions = _decode_legacy_payment_instructions(transaction_b64) + if is_native_sol(request.currency): + _verify_parsed_sol_transfers(instructions, request, details) + else: + _verify_parsed_spl_transfers(instructions, request, details) + _verify_parsed_memo_instructions(instructions, request, details) + # SECURITY: strict no-leftovers allowlist. Runs after the parsed + # verifiers so a missing-required-transfer fails with the canonical + # ``no-transfer`` code; this final pass rejects ANY extra instruction + # (especially System Program transfers from the fee payer) so the + # fee-payer co-sign path cannot be tricked into draining the + # server's SOL. + _validate_instruction_allowlist( + transaction_b64, + request, + details, + expected_fee_payer_pubkey=expected_fee_payer_pubkey, + ) diff --git a/python/src/pay_kit/protocols/mpp/server/charge.py b/python/src/pay_kit/protocols/mpp/server/charge.py index 1b4a09aff..1a22723a6 100644 --- a/python/src/pay_kit/protocols/mpp/server/charge.py +++ b/python/src/pay_kit/protocols/mpp/server/charge.py @@ -1,11 +1,19 @@ -"""Main server-side Solana charge handler.""" +"""Main server-side Solana charge handler. + +The ``Mpp`` orchestration lives here. The pre-broadcast transaction decoders +and parsed-instruction verifiers live in +:mod:`pay_kit.protocols.mpp.server._tx_decode`; the fee-payer cosign and the +strict no-leftovers instruction allowlist live in +:mod:`pay_kit.protocols.mpp.server._verify`. Both are re-exported below so the +``pay_kit.protocols.mpp.server.charge`` import path stays stable for callers +and tests that reach into the helpers directly. +""" from __future__ import annotations import asyncio import base64 import contextlib -import json import logging from dataclasses import dataclass, field from typing import Any @@ -18,1085 +26,77 @@ ) from pay_kit._paycore.network_check import check_network_blockhash from pay_kit._paycore.solana import ( - ASSOCIATED_TOKEN_PROGRAM, - MEMO_PROGRAM, - TOKEN_2022_PROGRAM, - TOKEN_PROGRAM, CredentialPayload, MethodDetails, default_rpc_url, default_token_program_for_currency, is_native_sol, - resolve_mint, stablecoin_symbol, ) from pay_kit._paycore.store import Store -from pay_kit._paycore.transaction import is_v0_wire_bytes from pay_kit.protocols.mpp.core.base64url import encode_json from pay_kit.protocols.mpp.core.types import PaymentChallenge, PaymentCredential, Receipt from pay_kit.protocols.mpp.intents.charge import ChargeRequest, parse_units +from pay_kit.protocols.mpp.server._tx_decode import ( + _SYSTEM_PROGRAM, + MAX_COMPUTE_UNIT_LIMIT, + MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS, + MAX_SPLITS, + _build_expected_transfers, + _decode_legacy_payment_instructions, + _extract_recent_blockhash, + _json_like, + _rpc_value, + _status_ok, + _transaction_dict, + _validate_compute_budget_instruction, + _verify_parsed_memo_instructions, + _verify_parsed_sol_transfers, + _verify_parsed_spl_transfers, +) +from pay_kit.protocols.mpp.server._verify import ( + _assert_signature_slot, + _co_sign_with_fee_payer, + _expected_ata_creation_policy, + _validate_ata_create_idempotent, + _validate_instruction_allowlist, + _verify_local_transaction_intent, +) logger = logging.getLogger(__name__) _DEFAULT_REALM = "MPP Payment" _SECRET_KEY_ENV_VAR = "MPP_SECRET_KEY" _CONSUMED_PREFIX = "solana-charge:consumed:" -_SYSTEM_PROGRAM = "11111111111111111111111111111111" -_SYSTEM_TRANSFER_INSTRUCTION = 2 -_TOKEN_TRANSFER_CHECKED_INSTRUCTION = 12 - -# Compute-budget program allowlist caps. These must stay in sync with the -# canonical Rust reference at ``rust/src/server/charge.rs`` constants -# ``MAX_COMPUTE_UNIT_LIMIT`` and ``MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS``, -# and the mirrored caps on Ruby, PHP, Lua, Go server SDKs. A challenge -# carrying a SetComputeUnitLimit / SetComputeUnitPrice instruction over -# these caps is rejected before broadcast so the payer cannot drain the -# fee payer with an unbounded priority fee. -_COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111" -_COMPUTE_BUDGET_SET_LIMIT_DISCRIMINATOR = 2 -_COMPUTE_BUDGET_SET_PRICE_DISCRIMINATOR = 3 -MAX_COMPUTE_UNIT_LIMIT = 200_000 -MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 5_000_000 - -# Maximum number of additional split recipients on a single charge. -# Matches Rust ``splits.len() > 8`` guard in -# ``rust/src/server/charge.rs::verify_versioned_transaction_pre_broadcast`` -# and the equivalent ``count($splits) > 8`` / ``splits.length > 8`` guards -# in PHP and Ruby. A high split count balloons the transaction size and -# the per-recipient ATA verification cost, so we reject early at the -# pre-broadcast stage. -MAX_SPLITS = 8 - -# Legacy Solana memo program (v1). MPP charge transactions MUST use memo v2 -# (``MEMO_PROGRAM`` from :mod:`pay_kit._paycore.solana`). v1 had a different -# instruction shape and is rejected to match the L2 lock landed on PHP fde0efb -# and mirrored in Ruby, Rust, Lua. -_MEMO_V1_PROGRAM = "Memo1UhkJRfHyvLMcVucJwxXeuD728EqVDDwQDxFMNo" - - -def _build_expected_transfers(request: ChargeRequest, details: MethodDetails) -> list[tuple[str, int]]: - # Reject over-bound splits up-front. Mirrors the Rust pre-broadcast - # guard at ``rust/src/server/charge.rs::verify_versioned_transaction_pre_broadcast`` - # (``splits.len() > 8``) and the equivalent PHP / Ruby guards. A - # high split count balloons transaction size and per-recipient ATA - # verification cost, so we surface the limit + observed count in - # the error so the client can repair the challenge. - if len(details.splits) > MAX_SPLITS: - raise PaymentError( - f"too many splits: {len(details.splits)} exceeds limit {MAX_SPLITS}", - code="too-many-splits", - ) - - total_amount = int(request.amount) - split_total = sum(int(split.amount) for split in details.splits) - primary_amount = total_amount - split_total - if primary_amount <= 0: - raise PaymentError( - "splits consume the entire amount — primary recipient must receive a positive amount", - code="splits-exceed-amount", - ) - - expected = [(request.recipient, primary_amount)] - for split in details.splits: - expected.append((split.recipient, int(split.amount))) - return expected - - -def _verify_parsed_sol_transfers( - instructions: list[dict[str, Any]], - request: ChargeRequest, - details: MethodDetails, -) -> None: - expected = _build_expected_transfers(request, details) - transfers = [ - instruction - for instruction in instructions - if instruction.get("program") == "system" and (instruction.get("parsed") or {}).get("type") == "transfer" - ] - - for recipient, amount in expected: - match_index = next( - ( - index - for index, transfer in enumerate(transfers) - if ((transfer.get("parsed") or {}).get("info") or {}).get("destination") == recipient - and str(((transfer.get("parsed") or {}).get("info") or {}).get("lamports")) == str(amount) - ), - -1, - ) - if match_index == -1: - raise PaymentError(f"no matching SOL transfer for {recipient}", code="no-transfer") - transfers.pop(match_index) - - -def _verify_parsed_spl_transfers( - instructions: list[dict[str, Any]], - request: ChargeRequest, - details: MethodDetails, -) -> None: - expected = _build_expected_transfers(request, details) - program_id = details.token_program or default_token_program_for_currency(request.currency, details.network) - mint = resolve_mint(request.currency, details.network) - transfers = [ - instruction - for instruction in instructions - if instruction.get("programId") == program_id - and (instruction.get("parsed") or {}).get("type") == "transferChecked" - ] - - for recipient, amount in expected: - match_index = next( - ( - index - for index, transfer in enumerate(transfers) - if ((transfer.get("parsed") or {}).get("info") or {}).get("mint") == mint - and str((((transfer.get("parsed") or {}).get("info") or {}).get("tokenAmount") or {}).get("amount")) - == str(amount) - and _verify_ata_owner( - ((transfer.get("parsed") or {}).get("info") or {}).get("destination", ""), - recipient, - mint, - program_id, - ) - ), - -1, - ) - if match_index == -1: - raise PaymentError(f"no matching token transfer for {recipient}", code="no-transfer") - transfers.pop(match_index) - - -def _verify_ata_owner(ata_address: str, expected_owner: str, mint: str, token_program: str) -> bool: - """Verify that an ATA address belongs to the expected owner by deriving it.""" - try: - from solders.pubkey import Pubkey - - owner_pk = Pubkey.from_string(expected_owner) - mint_pk = Pubkey.from_string(mint) - tp_pk = Pubkey.from_string(token_program) - ata_program = Pubkey.from_string(ASSOCIATED_TOKEN_PROGRAM) - expected_ata, _bump = Pubkey.find_program_address( - [bytes(owner_pk), bytes(tp_pk), bytes(mint_pk)], - ata_program, - ) - return str(expected_ata) == ata_address - except Exception: - return False - - -def _parsed_program_id(instruction: dict[str, Any]) -> str: - program_id = instruction.get("programId") or instruction.get("program_id") - if isinstance(program_id, str): - return program_id - if instruction.get("program") == "spl-memo": - return MEMO_PROGRAM - return "" - - -def _parsed_memo_text(instruction: dict[str, Any]) -> str | None: - parsed = instruction.get("parsed") - if isinstance(parsed, str): - return parsed - if isinstance(parsed, dict): - info = parsed.get("info") - if isinstance(info, dict): - memo = info.get("memo") - if isinstance(memo, str): - return memo - data = info.get("data") - if isinstance(data, str): - return data - return None - - -def _expected_memos(request: ChargeRequest, details: MethodDetails) -> list[tuple[str, str]]: - expected: list[tuple[str, str]] = [] - if request.external_id: - expected.append(("externalId", request.external_id)) - for split in details.splits: - if split.memo: - expected.append(("split", split.memo)) - return expected - - -def _verify_parsed_memo_instructions( - instructions: list[dict[str, Any]], - request: ChargeRequest, - details: MethodDetails, -) -> None: - matched: set[int] = set() - for label, memo in _expected_memos(request, details): - if len(memo.encode("utf-8")) > 566: - raise PaymentError("memo cannot exceed 566 bytes", code="invalid-payload") - - match_index = next( - ( - index - for index, instruction in enumerate(instructions) - if index not in matched - and _parsed_program_id(instruction) == MEMO_PROGRAM - and _parsed_memo_text(instruction) == memo - ), - -1, - ) - if match_index == -1: - raise PaymentError(f'No memo instruction found for {label} memo "{memo}"', code="invalid-payload") - matched.add(match_index) - - for index, instruction in enumerate(instructions): - program_id = _parsed_program_id(instruction) - if index not in matched and program_id == MEMO_PROGRAM: - raise PaymentError("unexpected Memo Program instruction in payment transaction", code="invalid-payload") - # L2 lock parity with the pull-mode pre-broadcast decoder - # (_decode_legacy_payment_instructions). Push-mode signature - # credentials reach this verifier without going through - # _decode_legacy_payment_instructions; without an explicit Memo - # v1 program-id check here, a confirmed on-chain transaction - # carrying a Memo v1 instruction would slip past the v2-only - # matcher above, leaving the L2 guard partial. Reject the - # credential so push-mode matches pull-mode behaviour. - if program_id == _MEMO_V1_PROGRAM: - raise PaymentError( - "memo v1 program is not supported (use Memo v2)", - code="invalid-payload", - ) - - -def _rpc_value(response: Any) -> Any: - if response is None: - return None - if isinstance(response, dict): - return response.get("value", response) - return getattr(response, "value", response) - - -def _json_like(value: Any) -> Any: - if isinstance(value, (str, int, float, bool)) or value is None: - return value - if isinstance(value, dict): - return {k: _json_like(v) for k, v in value.items()} - if isinstance(value, list): - return [_json_like(item) for item in value] - if hasattr(value, "to_json"): - return json.loads(value.to_json()) - if hasattr(value, "__dict__"): - return {key: _json_like(val) for key, val in vars(value).items()} - return value - - -def _transaction_dict(response: Any) -> dict[str, Any] | None: - value = _rpc_value(response) - if value is None: - return None - data = _json_like(value) - if isinstance(data, dict) and "transaction" in data: - return data - return None - - -def _status_ok(response: Any) -> bool: - value = _rpc_value(response) - data = _json_like(value) - if isinstance(data, list): - return any(entry and entry.get("err") is None for entry in data) - return data is not None - - -def _extract_recent_blockhash(transaction_b64: str) -> str: - """Decode a base64 transaction and return its recent blockhash (base58). - - Tries the legacy ``Transaction`` first (the most common shape from our - SDK clients) and falls back to ``VersionedTransaction``. Kept thin so - the surrounding network check can be exercised by tests without a full - verification pipeline in place. - """ - import base64 - - from solders.transaction import Transaction, VersionedTransaction - - raw = base64.b64decode(transaction_b64) - try: - tx = Transaction.from_bytes(raw) - return str(tx.message.recent_blockhash) - except Exception: - vtx = VersionedTransaction.from_bytes(raw) - return str(vtx.message.recent_blockhash) - - -def _validate_compute_budget_instruction(data: bytes, account_count: int) -> None: - """Validate a single ComputeBudget program instruction. - - Mirrors ``validate_compute_budget_instruction`` in - ``rust/src/server/charge.rs``: SetComputeUnitLimit (discriminator 2, - u32 LE units in ``data[1..5]``) and SetComputeUnitPrice (discriminator - 3, u64 LE microlamports in ``data[1..9]``) are the only accepted - shapes, both must carry zero account references, and each value is - capped at the per-instruction maximum. Anything else is rejected as - an invalid payload to keep the on-wire allowlist tight. - """ - if account_count != 0: - raise PaymentError( - "compute budget instruction must not have accounts", - code="compute-budget-invalid", - ) - if not data: - raise PaymentError( - "compute budget instruction has empty data", - code="compute-budget-invalid", - ) - discriminator = data[0] - if discriminator == _COMPUTE_BUDGET_SET_LIMIT_DISCRIMINATOR and len(data) == 5: - units = int.from_bytes(data[1:5], "little") - if units > MAX_COMPUTE_UNIT_LIMIT: - raise PaymentError( - f"compute unit limit {units} exceeds cap {MAX_COMPUTE_UNIT_LIMIT}", - code="compute-budget-cap-exceeded", - ) - return - if discriminator == _COMPUTE_BUDGET_SET_PRICE_DISCRIMINATOR and len(data) == 9: - price = int.from_bytes(data[1:9], "little") - if price > MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS: - raise PaymentError( - f"compute unit price {price} exceeds cap {MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS}", - code="compute-budget-cap-exceeded", - ) - return - raise PaymentError( - "unsupported compute budget instruction", - code="compute-budget-invalid", - ) - - -def _decode_legacy_payment_instructions(transaction_b64: str) -> list[dict[str, Any]]: - """Decode local transfer and memo instructions from a legacy or v0 transaction. - - Accepts both legacy ``Transaction`` and ``VersionedTransaction``. For v0 - we only inspect the static account keys; address lookup tables are - rejected up-front (a v0 tx with a non-empty ALT list would let an - instruction reference accounts the verifier cannot see). Mirrors the - Rust spine's ``verify_versioned_transaction_pre_broadcast`` policy. - """ - from solders.transaction import Transaction, VersionedTransaction - - raw = base64.b64decode(transaction_b64) - message: Any = None - message_instructions: list[Any] = [] - # Route v0 wire bytes straight to VersionedTransaction; the legacy - # parser in solders is lenient and can mis-parse a signed v0 tx as a - # degenerate legacy tx with bogus instructions (see is_v0_wire_bytes). - parsed = False - if is_v0_wire_bytes(raw): - try: - vtx = VersionedTransaction.from_bytes(raw) - except Exception: - vtx = None - if vtx is not None: - lookups = getattr(vtx.message, "address_table_lookups", None) - if lookups: - raise PaymentError( - "v0 transactions with address lookup tables are not supported", - code="invalid-payload", - ) from None - message = vtx.message - message_instructions = list(vtx.message.instructions) - parsed = True - if not parsed: - try: - tx = Transaction.from_bytes(raw) - message = tx.message - message_instructions = list(tx.message.instructions) - except Exception: - try: - vtx = VersionedTransaction.from_bytes(raw) - except Exception as exc: - raise PaymentError( - "unsupported transaction shape for pre-broadcast verification", - code="invalid-payload-type", - ) from exc - # Reject v0 transactions that reference address lookup tables; the - # pre-broadcast verifier only sees static account keys. - lookups = getattr(vtx.message, "address_table_lookups", None) - if lookups: - raise PaymentError( - "v0 transactions with address lookup tables are not supported", - code="invalid-payload", - ) from None - message = vtx.message - message_instructions = list(vtx.message.instructions) - - account_keys = [str(key) for key in message.account_keys] - instructions: list[dict[str, Any]] = [] - for instruction in message_instructions: - try: - program_id = account_keys[int(instruction.program_id_index)] - except IndexError as exc: - raise PaymentError("transaction instruction references an unknown program", code="invalid-payload") from exc - data = bytes(instruction.data) - if program_id == _SYSTEM_PROGRAM: - if len(data) < 12: - continue - kind = int.from_bytes(data[:4], "little") - if kind != _SYSTEM_TRANSFER_INSTRUCTION or len(instruction.accounts) < 2: - continue - try: - destination = account_keys[int(instruction.accounts[1])] - except IndexError as exc: - raise PaymentError( - "transaction transfer references an unknown account", code="invalid-payload" - ) from exc - lamports = int.from_bytes(data[4:12], "little") - instructions.append( - { - "program": "system", - "parsed": { - "type": "transfer", - "info": { - "destination": destination, - "lamports": str(lamports), - }, - }, - } - ) - elif program_id in {TOKEN_PROGRAM, TOKEN_2022_PROGRAM}: - if len(data) < 10: - continue - kind = data[0] - if kind != _TOKEN_TRANSFER_CHECKED_INSTRUCTION or len(instruction.accounts) < 3: - continue - try: - mint = account_keys[int(instruction.accounts[1])] - destination = account_keys[int(instruction.accounts[2])] - except IndexError as exc: - raise PaymentError( - "transaction token transfer references an unknown account", code="invalid-payload" - ) from exc - amount = int.from_bytes(data[1:9], "little") - instructions.append( - { - "programId": program_id, - "parsed": { - "type": "transferChecked", - "info": { - "destination": destination, - "mint": mint, - "tokenAmount": {"amount": str(amount)}, - }, - }, - } - ) - elif program_id == MEMO_PROGRAM: - try: - memo = data.decode("utf-8") - except UnicodeDecodeError as exc: - raise PaymentError("memo instruction is not valid UTF-8", code="invalid-payload") from exc - instructions.append({"programId": MEMO_PROGRAM, "parsed": memo}) - elif program_id == _COMPUTE_BUDGET_PROGRAM: - # Validate compute-budget instructions inline so an over-cap - # SetComputeUnitLimit / SetComputeUnitPrice is rejected with a - # structured error before broadcast. The instruction itself - # carries no transfer semantics, so we do not append it to - # the parsed instruction list consumed downstream. - _validate_compute_budget_instruction(data, len(instruction.accounts)) - elif program_id == _MEMO_V1_PROGRAM: - # L2 lock: MPP charge requires memo v2. Memo v1 has a different - # instruction shape (UTF-8 directly in data with no signer check) - # and would let a tampered transaction slip past the v2-only - # ``_verify_parsed_memo_instructions`` matcher. - raise PaymentError( - "memo v1 program is not supported (use Memo v2)", - code="invalid-payload", - ) - - return instructions - - -def _co_sign_with_fee_payer(transaction_b64: str, fee_payer: Any) -> str: - """Co-sign a client transaction with the server's fee payer keypair. - - The fee payer occupies the first signer slot in Solana transactions. We - serialize the message in the correct shape for its version (legacy uses - ``bytes(msg)``; v0 uses ``to_bytes_versioned(msg)`` which prepends the - ``0x80`` version tag), sign with the fee-payer private key, and splice - the resulting signature into the signature array at the slot matching - the fee-payer pubkey. - - Mirrors the cosign step in rust/src/server/charge.rs verify_pull. - """ - from solders.message import to_bytes_versioned - from solders.transaction import Transaction, VersionedTransaction - raw = base64.b64decode(transaction_b64) - fee_payer_pubkey = fee_payer.pubkey() - - # Try legacy transaction first (the common path); fall back to versioned. - try: - tx = Transaction.from_bytes(raw) - except Exception: - try: - vtx = VersionedTransaction.from_bytes(raw) - except Exception as exc: - raise PaymentError( - f"could not decode transaction for fee payer co-sign: {exc}", - code="invalid-payload-type", - ) from exc - account_keys = list(vtx.message.account_keys) - try: - idx = account_keys.index(fee_payer_pubkey) - except ValueError as exc: - raise PaymentError( - "fee payer pubkey not present in transaction accounts", - code="invalid-payload", - ) from exc - num_required = int(vtx.message.header.num_required_signatures) - _assert_signature_slot(idx, num_required) - # v0 messages are signed over ``to_bytes_versioned(msg)`` which - # prepends the 0x80 version byte. - message_bytes = bytes(to_bytes_versioned(vtx.message)) - sig_bytes = bytes(fee_payer.sign_message(message_bytes)) - # Manual splice in the on-wire bytes preserves the rest of the - # transaction exactly. Wire format: [num_sigs (compact-u16)] [sigs] - # [message...]. num_sigs < 128 so it is a 1-byte prefix. - serialized = bytearray(raw) - sig_start = 1 + idx * 64 - serialized[sig_start : sig_start + 64] = sig_bytes - return base64.b64encode(bytes(serialized)).decode("ascii") - - account_keys = list(tx.message.account_keys) - try: - idx = account_keys.index(fee_payer_pubkey) - except ValueError as exc: - raise PaymentError( - "fee payer pubkey not present in transaction accounts", - code="invalid-payload", - ) from exc - num_required = int(tx.message.header.num_required_signatures) - _assert_signature_slot(idx, num_required) - - # Legacy Transaction: sign ``bytes(msg)`` directly. - message_bytes = bytes(tx.message) - sig_bytes = bytes(fee_payer.sign_message(message_bytes)) - serialized = bytearray(raw) - sig_start = 1 + idx * 64 - serialized[sig_start : sig_start + 64] = sig_bytes - return base64.b64encode(bytes(serialized)).decode("ascii") - - -def _assert_signature_slot(idx: int, num_required: int) -> None: - """Validate that the fee payer occupies the canonical slot 0. - - The Solana protocol requires the fee payer to be ``account_keys[0]``: - the runtime debits the first required signer for transaction fees. If - we accepted a fee-payer pubkey at any slot inside the required-signers - block, a client could craft a transaction that includes a benign - payment transfer plus an extra instruction that *also* needs the - server's key as a required signer (for example, at slot 1). The - pre-broadcast decoder would still accept the transfer half, and the - server would happily produce its signature, letting the client - co-opt the server's private key to authorize arbitrary on-chain - intents. Enforcing ``idx == 0`` matches the Rust spine's - ``expected_fee_payer`` invariant (``account_keys.first() == fee_payer``) - and closes that escalation path before any sign call is made. - """ - if idx < 0 or idx >= num_required: - raise PaymentError( - f"fee payer pubkey at account index {idx} is outside the " - f"required-signers block (num_required_signatures={num_required}); " - "a client must place the fee payer inside the signer header", - code="invalid-payload", - ) - if idx != 0: - raise PaymentError( - "fee payer pubkey must occupy account index 0 (the transaction " - f"fee-payer slot); found at index {idx}. The Solana runtime " - "always debits the first required signer for fees, so any other " - "placement would cause the server's key to sign for an " - "instruction outside the fee-payment role.", - code="invalid-payload", - ) - - -def _expected_ata_creation_policy( - details: MethodDetails, - fee_payer_pubkey: str | None, -) -> tuple[set[str], set[str]]: - """Return ``(allowed_ata_owners, required_ata_owners)`` per Rust spine. - - Mirrors ``expected_ata_creation_policy`` in - ``rust/src/server/charge.rs``: - - - ``required_ata_owners`` is the set of split recipients with - ``ataCreationRequired=true``. - - ``allowed_ata_owners`` is ``required_ata_owners`` when the route - advertises ``feePayer=true`` (the server only sponsors ATA creates - that the route explicitly demanded), and the set of every split - recipient when no fee-payer co-sign is in play (client pays its - own ATA rent so it may opportunistically create ATAs for any - declared split). - - The primary recipient is NEVER in ``allowed_ata_owners``. Including - it would let a sponsored route co-sign an ATA create for the top-level - recipient even though no split asked for it, spending fee-payer SOL - on rent the route did not authorize. - """ - required_owners: set[str] = set() - split_owners: set[str] = set() - for split in details.splits: - split_owners.add(split.recipient) - if split.ata_creation_required: - required_owners.add(split.recipient) - - allowed_owners = set(required_owners) if fee_payer_pubkey is not None else split_owners - return allowed_owners, required_owners - - -def _validate_ata_create_idempotent( - instruction: Any, - account_keys: list[str], - expected_mint: str | None, - allowed_ata_owners: set[str], - expected_token_program: str | None, - expected_payer: str, -) -> None: - """Validate an AssociatedTokenAccount create-idempotent instruction. - - Mirrors ``validate_create_ata_idempotent_instruction`` in - ``rust/src/server/charge.rs``. The only ATA program instruction the - fee-payer co-sign path may include is the idempotent create variant - (discriminator byte ``0x01``) and only for an ATA whose payer is the - transaction fee payer, whose owner is a recipient declared by the - charge, whose mint matches the challenge currency, and whose token - program is the one the challenge selected. Any deviation is rejected - so an attacker cannot trick the server into co-signing an ATA create - that funds an attacker-controlled mint or owner with fee-payer SOL. - """ - if expected_mint is None: - raise PaymentError( - "ATA creation is not allowed for native SOL payments", - code="invalid-payload", - ) - data = bytes(instruction.data) - if data != b"\x01": - raise PaymentError( - "only idempotent ATA creation is allowed", - code="invalid-payload", - ) - accounts = list(instruction.accounts) - if len(accounts) != 6: - raise PaymentError( - "unexpected ATA creation account layout", - code="invalid-payload", - ) - try: - payer = account_keys[int(accounts[0])] - ata = account_keys[int(accounts[1])] - owner = account_keys[int(accounts[2])] - mint = account_keys[int(accounts[3])] - sys_program = account_keys[int(accounts[4])] - token_program = account_keys[int(accounts[5])] - except IndexError as exc: - raise PaymentError( - "ATA creation references an unknown account index", - code="invalid-payload", - ) from exc - - if payer != expected_payer: - raise PaymentError( - "ATA payer must match the transaction fee payer", - code="invalid-payload", - ) - if mint != expected_mint: - raise PaymentError( - "ATA creation mint does not match the charge currency", - code="invalid-payload", - ) - if owner not in allowed_ata_owners: - raise PaymentError( - "ATA creation owner is not authorized by the challenge", - code="invalid-payload", - ) - if sys_program != _SYSTEM_PROGRAM: - raise PaymentError( - "ATA creation must reference the System Program", - code="invalid-payload", - ) - if token_program not in {TOKEN_PROGRAM, TOKEN_2022_PROGRAM}: - raise PaymentError( - "ATA creation uses an unsupported token program", - code="invalid-payload", - ) - if expected_token_program is not None and token_program != expected_token_program: - raise PaymentError( - "ATA creation token program does not match methodDetails.tokenProgram", - code="invalid-payload", - ) - # Verify the derived ATA matches owner/mint/token_program so a caller - # cannot funnel the create to an attacker-controlled address. - try: - from solders.pubkey import Pubkey - - owner_pk = Pubkey.from_string(owner) - mint_pk = Pubkey.from_string(mint) - tp_pk = Pubkey.from_string(token_program) - ata_program = Pubkey.from_string(ASSOCIATED_TOKEN_PROGRAM) - derived, _ = Pubkey.find_program_address( - [bytes(owner_pk), bytes(tp_pk), bytes(mint_pk)], - ata_program, - ) - if str(derived) != ata: - raise PaymentError( - "ATA creation address does not match owner/mint/token program", - code="invalid-payload", - ) - except PaymentError: - raise - except Exception as exc: # noqa: BLE001 - raise PaymentError( - f"could not validate ATA creation address: {exc}", - code="invalid-payload", - ) from exc - - -def _validate_instruction_allowlist( - transaction_b64: str, - request: ChargeRequest, - details: MethodDetails, - expected_fee_payer_pubkey: str | None = None, -) -> None: - """Reject any instruction not on the strict fee-payer co-sign allowlist. - - SECURITY: this is the no-leftovers check that protects the server's - fee-payer keypair from being co-opted into signing attacker-supplied - transfers. The lossy parsed-instruction verifier - (``_verify_parsed_sol_transfers`` / - ``_verify_parsed_spl_transfers`` / ``_verify_parsed_memo_instructions``) - only checks that the required transfers / memos are present; it does - not reject extra instructions. Without this allowlist a malicious - client could include the expected payment plus a System Program - transfer from the fee payer to the attacker, and the server would - co-sign the entire transaction. - - The allowlist mirrors ``validate_instruction_allowlist`` in - ``rust/src/server/charge.rs``: only ComputeBudget (validated), - Memo v2 (must match an expected memo), System Program transfer (must - match an expected payment transfer), SPL Token / Token-2022 - transferChecked (must match an expected payment transfer), and - AssociatedTokenAccount create-idempotent (validated) are accepted. - Anything else (including SOL transfers that do not match a required - transfer, SPL transfers to unrelated mints, raw token approve / - burn, BPF program calls, sysvar reads, etc.) is rejected before - broadcast with a ``payment-invalid`` canonical code. - """ - from solders.transaction import Transaction, VersionedTransaction - - raw = base64.b64decode(transaction_b64) - message: Any = None - message_instructions: list[Any] = [] - # Route v0 wire bytes straight to VersionedTransaction; the legacy - # parser in solders is lenient and can mis-parse a signed v0 tx as a - # degenerate legacy tx whose instructions point at random account - # keys. The allowlist would then reject the legitimate v0 payment - # with a misleading "unexpected program instruction" error sourced - # from junk bytes. See is_v0_wire_bytes. - parsed = False - if is_v0_wire_bytes(raw): - try: - vtx = VersionedTransaction.from_bytes(raw) - except Exception: - vtx = None - if vtx is not None: - if getattr(vtx.message, "address_table_lookups", None): - raise PaymentError( - "v0 transactions with address lookup tables are not supported", - code="invalid-payload", - ) from None - message = vtx.message - message_instructions = list(vtx.message.instructions) - parsed = True - if not parsed: - try: - tx = Transaction.from_bytes(raw) - message = tx.message - message_instructions = list(tx.message.instructions) - except Exception: - try: - vtx = VersionedTransaction.from_bytes(raw) - except Exception as exc: - raise PaymentError( - "unsupported transaction shape for instruction allowlist", - code="invalid-payload-type", - ) from exc - if getattr(vtx.message, "address_table_lookups", None): - raise PaymentError( - "v0 transactions with address lookup tables are not supported", - code="invalid-payload", - ) from None - message = vtx.message - message_instructions = list(vtx.message.instructions) - - account_keys = [str(key) for key in message.account_keys] - if not account_keys: - raise PaymentError("transaction has no accounts", code="invalid-payload") - fee_payer_account = account_keys[0] - # SECURITY: when the charge advertises feePayer=true the protective - # pubkey used for drain detection MUST come from the server-side - # signing context (``Mpp._fee_payer_signer.pubkey()``), NOT from - # client-echoed ``methodDetails.feePayerKey``. A malicious client can - # tamper the echoed key to a pubkey it controls, pass the source-account - # checks below (because they compare against the tampered value), and - # still get the real server keypair to co-sign and broadcast a transfer - # sourced from the actual server fee-payer. - # - # The client-echoed ``details.fee_payer_key`` is cross-checked against - # the server pubkey above this allowlist (in ``_verify_local_transaction_intent``) - # so a mismatch is rejected up-front with ``payment_invalid``. Here we - # only consume the server-supplied pubkey. If no server pubkey was - # threaded (e.g. unit tests that call the helper directly), we fall - # back to the echoed value for backward compatibility; production - # callers always thread the server pubkey. - fee_payer_pubkey: str | None - if expected_fee_payer_pubkey is not None: - fee_payer_pubkey = expected_fee_payer_pubkey - elif details.fee_payer and details.fee_payer_key: - fee_payer_pubkey = details.fee_payer_key - else: - fee_payer_pubkey = None - - expected_transfers = _build_expected_transfers(request, details) - native = is_native_sol(request.currency) - expected_mint = None if native else resolve_mint(request.currency, details.network) - expected_token_program: str | None = None - if not native: - expected_token_program = details.token_program or default_token_program_for_currency( - request.currency, details.network - ) - allowed_ata_owners, _required_ata_owners = _expected_ata_creation_policy(details, fee_payer_pubkey) - expected_memos = {memo for _label, memo in _expected_memos(request, details)} - - # Track which required transfers / memos have been satisfied so each - # required entry can only be matched once; an attacker cannot replay - # a single transfer to cover two required legs. - remaining_transfers: list[tuple[str, int]] = list(expected_transfers) - remaining_memos: set[str] = set(expected_memos) - - for instruction in message_instructions: - try: - program_id = account_keys[int(instruction.program_id_index)] - except IndexError as exc: - raise PaymentError( - "instruction references an unknown program index", - code="invalid-payload", - ) from exc - data = bytes(instruction.data) - accounts = list(instruction.accounts) - - if program_id == _COMPUTE_BUDGET_PROGRAM: - _validate_compute_budget_instruction(data, len(accounts)) - continue - - if program_id == MEMO_PROGRAM: - try: - memo_text = data.decode("utf-8") - except UnicodeDecodeError as exc: - raise PaymentError( - "memo instruction is not valid UTF-8", - code="invalid-payload", - ) from exc - if memo_text not in remaining_memos: - raise PaymentError( - "unexpected Memo Program instruction in payment transaction", - code="invalid-payload", - ) - remaining_memos.discard(memo_text) - continue - - if program_id == _MEMO_V1_PROGRAM: - raise PaymentError( - "memo v1 program is not supported (use Memo v2)", - code="invalid-payload", - ) - - if program_id == _SYSTEM_PROGRAM: - if not native: - raise PaymentError( - "unexpected System Program instruction in token payment transaction", - code="invalid-payload", - ) - if len(data) < 12 or len(accounts) < 2: - raise PaymentError( - "unexpected System Program instruction in payment transaction", - code="invalid-payload", - ) - kind = int.from_bytes(data[:4], "little") - if kind != _SYSTEM_TRANSFER_INSTRUCTION: - raise PaymentError( - "unexpected System Program instruction in payment transaction", - code="invalid-payload", - ) - try: - source = account_keys[int(accounts[0])] - destination = account_keys[int(accounts[1])] - except IndexError as exc: - raise PaymentError( - "transfer references an unknown account", - code="invalid-payload", - ) from exc - # SECURITY: reject any System transfer that sources lamports from - # the configured fee-payer (mirrors rust spine ``verify_sol_transfer_instructions``). - # Without this guard a malicious client can satisfy the required - # payment with a transfer FROM the fee-payer, draining server SOL - # on top of the network fee already debited from account_keys[0]. - if fee_payer_pubkey is not None and source == fee_payer_pubkey: - raise PaymentError( - "fee payer cannot fund the SOL payment transfer", - code="invalid-payload", - ) - lamports = int.from_bytes(data[4:12], "little") - match_idx = next( - (i for i, (rcpt, amt) in enumerate(remaining_transfers) if rcpt == destination and amt == lamports), - -1, - ) - if match_idx == -1: - raise PaymentError( - "unexpected System Program transfer in payment transaction", - code="invalid-payload", - ) - remaining_transfers.pop(match_idx) - continue - - if program_id in {TOKEN_PROGRAM, TOKEN_2022_PROGRAM}: - if native: - raise PaymentError( - "unexpected Token Program instruction in native SOL payment", - code="invalid-payload", - ) - if expected_token_program is not None and program_id != expected_token_program: - raise PaymentError( - "token program does not match methodDetails.tokenProgram", - code="invalid-payload", - ) - if len(data) < 10 or len(accounts) < 4: - raise PaymentError( - "unexpected Token Program instruction in payment transaction", - code="invalid-payload", - ) - if data[0] != _TOKEN_TRANSFER_CHECKED_INSTRUCTION: - raise PaymentError( - "unexpected Token Program instruction in payment transaction", - code="invalid-payload", - ) - try: - source_ata = account_keys[int(accounts[0])] - mint = account_keys[int(accounts[1])] - destination = account_keys[int(accounts[2])] - authority = account_keys[int(accounts[3])] - except IndexError as exc: - raise PaymentError( - "token transfer references an unknown account", - code="invalid-payload", - ) from exc - if expected_mint is not None and mint != expected_mint: - raise PaymentError( - "token transfer mint does not match the charge currency", - code="invalid-payload", - ) - # SECURITY: reject any SPL transferChecked authorized by the - # configured fee-payer or sourced from the fee-payer's ATA for - # this mint / token program. Mirrors rust spine - # ``verify_spl_transfer_instructions``. Without these checks a - # malicious client can present a transferChecked FROM the - # fee-payer ATA TO the recipient ATA matching the required - # amount; the allowlist would pass and the server would - # co-sign, draining fee-payer tokens. - if fee_payer_pubkey is not None: - if authority == fee_payer_pubkey: - raise PaymentError( - "fee payer cannot authorize the SPL payment transfer", - code="invalid-payload", - ) - if _verify_ata_owner(source_ata, fee_payer_pubkey, mint, program_id): - raise PaymentError( - "fee payer token account cannot fund the SPL payment transfer", - code="invalid-payload", - ) - amount = int.from_bytes(data[1:9], "little") - match_idx = next( - ( - i - for i, (rcpt, amt) in enumerate(remaining_transfers) - if amt == amount and _verify_ata_owner(destination, rcpt, mint, program_id) - ), - -1, - ) - if match_idx == -1: - raise PaymentError( - "unexpected Token Program transfer in payment transaction", - code="invalid-payload", - ) - remaining_transfers.pop(match_idx) - continue - - if program_id == ASSOCIATED_TOKEN_PROGRAM: - _validate_ata_create_idempotent( - instruction, - account_keys, - expected_mint, - allowed_ata_owners, - expected_token_program, - fee_payer_account, - ) - continue - - raise PaymentError( - f"unexpected program instruction in payment transaction: {program_id}", - code="invalid-payload", - ) - - -def _verify_local_transaction_intent( - transaction_b64: str, - request: ChargeRequest, - details: MethodDetails, - expected_fee_payer_pubkey: str | None = None, -) -> None: - """Verify locally-decodable payment intent before broadcasting. - - ``expected_fee_payer_pubkey`` is the AUTHORITATIVE server-side fee-payer - pubkey (``Mpp._fee_payer_signer.pubkey()``). It is threaded by - ``_verify_transaction`` so the no-leftovers allowlist can detect drain - attempts against the real server key, not against a client-echoed - ``methodDetails.feePayerKey`` value (which an attacker controls). When - both are present and ``details.fee_payer`` is true we also reject any - mismatch up-front with the canonical ``payment_invalid`` code so a - tampered echoed key cannot silently slip through. - """ - if ( - expected_fee_payer_pubkey is not None - and details.fee_payer - and details.fee_payer_key - and details.fee_payer_key != expected_fee_payer_pubkey - ): - raise PaymentError( - "methodDetails.feePayerKey does not match the server fee-payer signer", - code="invalid-payload", - ) - instructions = _decode_legacy_payment_instructions(transaction_b64) - if is_native_sol(request.currency): - _verify_parsed_sol_transfers(instructions, request, details) - else: - _verify_parsed_spl_transfers(instructions, request, details) - _verify_parsed_memo_instructions(instructions, request, details) - # SECURITY: strict no-leftovers allowlist. Runs after the parsed - # verifiers so a missing-required-transfer fails with the canonical - # ``no-transfer`` code; this final pass rejects ANY extra instruction - # (especially System Program transfers from the fee payer) so the - # fee-payer co-sign path cannot be tricked into draining the - # server's SOL. - _validate_instruction_allowlist( - transaction_b64, - request, - details, - expected_fee_payer_pubkey=expected_fee_payer_pubkey, - ) +# Re-exported from the decoder / verifier modules so the historical +# ``pay_kit.protocols.mpp.server.charge`` import surface stays intact. +__all__ = [ + "ChargeOptions", + "Config", + "Mpp", + "MAX_COMPUTE_UNIT_LIMIT", + "MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS", + "MAX_SPLITS", + "_assert_signature_slot", + "_build_expected_transfers", + "_co_sign_with_fee_payer", + "_decode_legacy_payment_instructions", + "_expected_ata_creation_policy", + "_extract_recent_blockhash", + "_json_like", + "_rpc_value", + "_status_ok", + "_SYSTEM_PROGRAM", + "_transaction_dict", + "_validate_ata_create_idempotent", + "_validate_compute_budget_instruction", + "_validate_instruction_allowlist", + "_verify_local_transaction_intent", + "_verify_parsed_memo_instructions", + "_verify_parsed_sol_transfers", + "_verify_parsed_spl_transfers", +] @dataclass From adc457f646db5ec848f2a52034d25592142b443f Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Sun, 31 May 2026 23:06:00 +0300 Subject: [PATCH 40/45] fix(python-x402): match rust client field precedence and fee-payer toggle 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::() (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) --- .../protocols/x402/client/exact/payment.py | 147 ++++++++++++++-- python/tests/test_pk_x402_client.py | 164 +++++++++++++++++- 2 files changed, 294 insertions(+), 17 deletions(-) diff --git a/python/src/pay_kit/protocols/x402/client/exact/payment.py b/python/src/pay_kit/protocols/x402/client/exact/payment.py index 1907b30d6..56ba5b30d 100644 --- a/python/src/pay_kit/protocols/x402/client/exact/payment.py +++ b/python/src/pay_kit/protocols/x402/client/exact/payment.py @@ -28,7 +28,11 @@ from pay_kit._paycore.mints import derive_ata, resolve_stablecoin_mint from pay_kit._paycore.network import SOLANA_DEVNET_CAIP2, SOLANA_MAINNET_CAIP2 -from pay_kit._paycore.solana import MEMO_PROGRAM, is_native_sol +from pay_kit._paycore.solana import ( + MEMO_PROGRAM, + default_token_program_for_currency, + is_native_sol, +) from pay_kit.protocols.x402.exact.types import X402AcceptsEntry, X402Envelope, X402PayloadField from pay_kit.protocols.x402.exact.verify import COMPUTE_BUDGET_PROGRAM, X402_VERSION @@ -170,14 +174,42 @@ def _select_from_body(body: str, selection: ChallengeSelection) -> X402AcceptsEn def _select_from_envelope(envelope: object, selection: ChallengeSelection) -> X402AcceptsEntry | None: if not isinstance(envelope, dict): return None - accepts_raw = cast("dict[str, object]", envelope).get("accepts") + envelope_dict = cast("dict[str, object]", envelope) + accepts_raw = envelope_dict.get("accepts") if not isinstance(accepts_raw, list): return None entries = cast("list[object]", accepts_raw) accepts = [cast("dict[str, object]", entry) for entry in entries if isinstance(entry, dict)] + _attach_envelope_resource(envelope_dict, accepts) return _select_requirement(accepts, selection) +def _attach_envelope_resource( + envelope: Mapping[str, object], + accepts: list[dict[str, object]], +) -> None: + """Copy the envelope-level v2 ``resource`` object onto each accept. + + Mirrors rust ``PaymentRequiredEnvelope::with_resource_on_accepts`` + (types.rs:463-476): the canonical v2 challenge carries ``resource`` at the + envelope level; the rust deserializer attaches it to every parsed + requirement so the client can echo it back. Only fills the entry's + ``resource``/``description`` when absent so a per-offer override wins. + """ + resource = envelope.get("resource") + if not isinstance(resource, dict): + return + url = resource.get("url") + if not isinstance(url, str) or url == "": + return + description = resource.get("description") + for accept in accepts: + if not _str_field(accept, "resource"): + accept["resource"] = url + if "description" not in accept and isinstance(description, str): + accept["description"] = description + + def _is_solana_exact(offer: dict[str, object]) -> bool: scheme = offer.get("scheme") protocol = offer.get("protocol") @@ -261,6 +293,30 @@ def _str_field(mapping: Mapping[str, object], key: str) -> str | None: return value if isinstance(value, str) and value != "" else None +#: Exclusive upper bound for a Solana u64 amount (lamports / token base units). +_U64_BOUND = 1 << 64 + + +def _str_top_then_extra( + req: Mapping[str, object], + extra: Mapping[str, object], + key: str, +) -> str | None: + """Read a string field top-level first, then ``extra.*``. + + Mirrors the rust ``PaymentRequirements`` deserializer field precedence + (``rust/crates/x402/src/protocol/schemes/exact/types.rs:344-351``) where + canonical-wire fields (``tokenProgram``/``recentBlockhash``) are read at the + top level before falling back to ``extra``. + """ + return _str_field(req, key) or _str_field(extra, key) + + +def _bool_field(mapping: Mapping[str, object], key: str) -> bool | None: + value = mapping.get(key) + return value if isinstance(value, bool) else None + + async def build_payment( signer: LocalSigner, rpc: Any, @@ -297,10 +353,15 @@ async def build_payment( from solders.transaction import VersionedTransaction req = cast("dict[str, object]", requirement) - asset = _str_field(req, "asset") + extra = _extra_of(requirement) + + # Field precedence mirrors the rust ``PaymentRequirements`` deserializer + # (types.rs:334-353): top-level ``currency``/``recipient`` win over the + # canonical-wire ``asset``/``payTo`` aliases. + asset = _str_field(req, "currency") or _str_field(req, "asset") if asset is None: raise ValueError("pay_kit: x402 offer is missing `asset`") - pay_to = _str_field(req, "payTo") + pay_to = _str_field(req, "recipient") or _str_field(req, "payTo") if pay_to is None: raise ValueError("pay_kit: x402 offer is missing `payTo`") @@ -311,10 +372,25 @@ async def build_payment( amount = int(cast("str | int", amount_raw)) except (TypeError, ValueError) as exc: raise ValueError(f"pay_kit: x402 offer has an invalid amount: {amount_raw!r}") from exc - - extra = _extra_of(requirement) - fee_payer = _str_field(extra, "feePayer") - fee_payer_key = Pubkey.from_string(fee_payer) if fee_payer is not None else signer.keypair.pubkey() + # Amount must fit an unsigned u64, matching rust ``amount.parse::()`` + # (client/exact/payment.rs:33-36). Reject out-of-range here rather than + # deferring to a later ``int.to_bytes(8, ...)`` OverflowError. + if amount < 0 or amount >= _U64_BOUND: + raise ValueError(f"pay_kit: x402 offer has an invalid amount: {amount_raw!r}") + + # Fee-payer toggle + precedence (types.rs:350-353, payment.rs:43-51): + # key comes from top-level ``feePayerKey`` first, else ``extra.feePayer``; + # ``use_fee_payer`` is the explicit ``feePayer`` bool when present, else true + # when a key is present. An explicit ``feePayer: false`` opts OUT even with a + # key, in which case the client signer is the message fee payer. + fee_payer = _str_field(req, "feePayerKey") or _str_field(extra, "feePayer") + fee_payer_bool = _bool_field(req, "feePayer") + use_fee_payer = (fee_payer_bool if fee_payer_bool is not None else fee_payer is not None) and ( + fee_payer is not None + ) + fee_payer_key = ( + Pubkey.from_string(cast("str", fee_payer)) if use_fee_payer else signer.keypair.pubkey() + ) instructions: list[Any] = [ _compute_unit_limit_ix(Instruction, Pubkey, _COMPUTE_UNIT_LIMIT), @@ -331,11 +407,23 @@ async def build_payment( transfer(TransferParams(from_pubkey=signer_pubkey, to_pubkey=recipient_key, lamports=amount)) ) else: - token_program = _str_field(extra, "tokenProgram") + # tokenProgram: top-level first, then extra (types.rs:346-347). When the + # offer omits it entirely, default by currency/cluster like rust + # ``default_token_program_for_currency`` (payment.rs:445-452) instead of + # erroring, so a canonical offer that elides tokenProgram still builds. + token_program = _str_top_then_extra(req, extra, "tokenProgram") if token_program is None: - raise ValueError("pay_kit: x402 SPL offer is missing `extra.tokenProgram`") - decimals_raw = extra.get("decimals") - decimals = int(decimals_raw) if isinstance(decimals_raw, int) else _DEFAULT_DECIMALS + cluster_label = _mints_label_for_caip2(_caip2_for_selection(_str_field(req, "network"))) + token_program = default_token_program_for_currency(asset, cluster_label) + # decimals: top-level first, then extra (types.rs:344-345); default 6. + decimals_raw = req.get("decimals") + if not isinstance(decimals_raw, int) or isinstance(decimals_raw, bool): + decimals_raw = extra.get("decimals") + decimals = ( + int(decimals_raw) + if isinstance(decimals_raw, int) and not isinstance(decimals_raw, bool) + else _DEFAULT_DECIMALS + ) token_program_key = Pubkey.from_string(token_program) mint_key = Pubkey.from_string(asset) source_ata = Pubkey.from_string(derive_ata(str(signer_pubkey), asset, token_program)) @@ -363,7 +451,8 @@ async def build_payment( memo = (memo_nonce or _default_memo_nonce)() instructions.append(Instruction(Pubkey.from_string(MEMO_PROGRAM), memo.encode("utf-8"), [])) - blockhash_str = _str_field(extra, "recentBlockhash") + # recentBlockhash: top-level first, then extra (types.rs:348-349). + blockhash_str = _str_top_then_extra(req, extra, "recentBlockhash") if blockhash_str is None: blockhash_str = await _resolve_blockhash(rpc, recent_blockhash_provider) blockhash = Hash.from_string(blockhash_str) @@ -384,7 +473,37 @@ async def build_payment( encoded = base64.b64encode(bytes(tx)).decode("ascii") payload: X402PayloadField = {"transaction": encoded} - return {"x402Version": X402_VERSION, "accepted": requirement, "payload": payload} + envelope: dict[str, object] = { + "x402Version": X402_VERSION, + "accepted": requirement, + "payload": payload, + } + # Echo the offer's resource info at the envelope top level, mirroring rust + # ``build_payment_header`` (payment.rs:131-138) which sets + # ``resource: requirements.resource_info()``. Omit when the offer carries no + # resource (rust ``skip_serializing_if = Option::is_none``). + resource_info = _resource_info_of(req) + if resource_info is not None: + envelope["resource"] = resource_info + return cast("X402Envelope", envelope) + + +def _resource_info_of(req: Mapping[str, object]) -> dict[str, object] | None: + """Build the canonical v2 ``resource`` object from an offer. + + Mirrors rust ``PaymentRequirements::resource_info`` (types.rs:253-265): + derive ``{url, description?}`` from the offer's ``resource`` URL string and + optional ``description``. Returns ``None`` when the offer carries no + resource URL. + """ + url = _str_field(req, "resource") + if url is None: + return None + info: dict[str, object] = {"url": url} + description = _str_field(req, "description") + if description is not None: + info["description"] = description + return info async def _resolve_blockhash( diff --git a/python/tests/test_pk_x402_client.py b/python/tests/test_pk_x402_client.py index 3020f1820..77dc15198 100644 --- a/python/tests/test_pk_x402_client.py +++ b/python/tests/test_pk_x402_client.py @@ -458,12 +458,24 @@ async def test_build_payment_rejects_invalid_amount(): @pytest.mark.asyncio -async def test_build_payment_rejects_missing_token_program(): +async def test_build_payment_defaults_token_program_when_offer_omits_it(): + # Rust ``build_spl_instructions`` defaults the token program via + # ``default_token_program_for_currency`` when the offer omits it + # (client/exact/payment.rs:445-452); the client must not error. USDC -> + # classic Token program, so the built transferChecked uses TP_USDC. signer = Signer.generate() offer = _offer() del offer["extra"]["tokenProgram"] - with pytest.raises(ValueError, match="tokenProgram"): - await build_payment(signer, None, _entry(offer)) + env = await build_payment(signer, None, _entry(offer)) + tx = VersionedTransaction.from_bytes(base64.b64decode(_tx(env))) + instructions = list(tx.message.instructions) + keys = [str(k) for k in tx.message.account_keys] + transfer_ix = instructions[2] + assert keys[int(transfer_ix.program_id_index)] == TP_USDC + # The transfer's source/dest ATAs are derived off the same defaulted + # program, matching what a server that pins extra.tokenProgram=TP_USDC + # would re-derive. + assert keys[int(transfer_ix.accounts[2])] == derive_ata(offer["payTo"], USDC_DEVNET, TP_USDC) @pytest.mark.asyncio @@ -484,6 +496,152 @@ async def test_build_payment_rejects_missing_pay_to(): await build_payment(signer, None, _entry(offer)) +# -- rust-parity regressions ------------------------------------------------- + + +@pytest.mark.asyncio +async def test_build_payment_fee_payer_explicit_false_opts_out(): + # Rust ``use_fee_payer = feePayer.unwrap_or(false) && fee_payer_key.is_some()`` + # (payment.rs:43-44): an explicit ``feePayer: false`` opts out even when a + # key is present, so the client signer becomes the message fee payer + # (account[0]) and the only required signer. + signer = Signer.generate() + offer = _offer() + offer["feePayer"] = False + env = await build_payment(signer, None, _entry(offer)) + tx = VersionedTransaction.from_bytes(base64.b64decode(_tx(env))) + keys = [str(k) for k in tx.message.account_keys] + assert keys[0] == str(signer.keypair.pubkey()) + assert int(tx.message.header.num_required_signatures) == 1 + + +@pytest.mark.asyncio +async def test_build_payment_fee_payer_key_from_top_level(): + # Rust sources the fee-payer key from top-level ``feePayerKey`` first + # (types.rs:350-351). A top-level key with no extra.feePayer must still be + # used as account[0]. + signer = Signer.generate() + fee_payer = str(Keypair().pubkey()) + offer = _offer() + del offer["extra"]["feePayer"] + offer["feePayerKey"] = fee_payer + env = await build_payment(signer, None, _entry(offer)) + tx = VersionedTransaction.from_bytes(base64.b64decode(_tx(env))) + keys = [str(k) for k in tx.message.account_keys] + assert keys[0] == fee_payer + assert int(tx.message.header.num_required_signatures) == 2 + + +@pytest.mark.asyncio +async def test_build_payment_reads_token_program_and_decimals_top_level_first(): + # Rust reads tokenProgram/decimals/recentBlockhash top-level before extra + # (types.rs:344-349). A top-level tokenProgram/decimals must win over extra. + signer = Signer.generate() + pyusd_mint = resolve("PYUSD", "devnet") + assert pyusd_mint is not None + tp_pyusd = token_program_for("PYUSD", "devnet") + offer = _offer(asset=pyusd_mint, token_program=tp_pyusd) + # Wrong values in extra; correct values at top level must override. + offer["extra"]["tokenProgram"] = TP_USDC + offer["extra"]["decimals"] = 9 + offer["tokenProgram"] = tp_pyusd + offer["decimals"] = 6 + env = await build_payment(signer, None, _entry(offer)) + tx = VersionedTransaction.from_bytes(base64.b64decode(_tx(env))) + instructions = list(tx.message.instructions) + keys = [str(k) for k in tx.message.account_keys] + transfer_ix = instructions[2] + assert keys[int(transfer_ix.program_id_index)] == tp_pyusd + assert bytes(transfer_ix.data)[9] == 6 + + +@pytest.mark.asyncio +async def test_build_payment_reads_recent_blockhash_top_level_first(): + signer = Signer.generate() + offer = _offer(blockhash=None) + offer["recentBlockhash"] = BH + env = await build_payment(signer, None, _entry(offer)) + tx = VersionedTransaction.from_bytes(base64.b64decode(_tx(env))) + assert str(tx.message.recent_blockhash) == BH + + +@pytest.mark.asyncio +async def test_build_payment_currency_and_recipient_aliases_win(): + # Rust resolves currency/recipient top-level first, then asset/payTo + # (types.rs:334-342). A top-level currency/recipient must override the + # canonical asset/payTo aliases. + signer = Signer.generate() + real_pay_to = str(Keypair().pubkey()) + offer = _offer(asset="SOL", amount="5000") + offer["extra"].pop("tokenProgram", None) + offer["payTo"] = str(Keypair().pubkey()) + offer["recipient"] = real_pay_to + env = await build_payment(signer, None, _entry(offer)) + tx = VersionedTransaction.from_bytes(base64.b64decode(_tx(env))) + instructions = list(tx.message.instructions) + keys = [str(k) for k in tx.message.account_keys] + # System transfer destination is account index 1 of the transfer ix. + transfer_ix = instructions[2] + assert keys[int(transfer_ix.accounts[1])] == real_pay_to + + +@pytest.mark.asyncio +async def test_build_payment_rejects_negative_amount(): + # Rust ``amount.parse::()`` rejects a negative amount up front + # (payment.rs:33-36); python must reject at parse, not at to_bytes. + signer = Signer.generate() + offer = _offer(amount="-1") + with pytest.raises(ValueError, match="invalid amount"): + await build_payment(signer, None, _entry(offer)) + + +@pytest.mark.asyncio +async def test_build_payment_rejects_amount_above_u64(): + signer = Signer.generate() + offer = _offer(amount=str(1 << 64)) + with pytest.raises(ValueError, match="invalid amount"): + await build_payment(signer, None, _entry(offer)) + + +@pytest.mark.asyncio +async def test_build_payment_echoes_resource_in_envelope(): + # Rust ``build_payment_header`` sets ``resource = requirements.resource_info()`` + # (payment.rs:131-138). When the offer carries resource info the client must + # echo it at the envelope top level. + signer = Signer.generate() + offer = _offer() + offer["resource"] = "https://api.example.test/data" + offer["description"] = "Test data" + env = await build_payment(signer, None, _entry(offer)) + resource = cast("dict[str, Any]", env)["resource"] + assert resource == {"url": "https://api.example.test/data", "description": "Test data"} + + +@pytest.mark.asyncio +async def test_build_payment_omits_resource_when_offer_has_none(): + signer = Signer.generate() + offer = _offer() + env = await build_payment(signer, None, _entry(offer)) + assert "resource" not in cast("dict[str, Any]", env) + + +def test_parse_attaches_envelope_resource_to_selected_offer(): + # Rust ``with_resource_on_accepts`` (types.rs:463-476) copies the envelope's + # v2 resource onto each parsed accept so the client can echo it. + offer = _offer() + body = { + "x402Version": 2, + "resource": {"url": "https://api.example.test/joke", "description": "A joke"}, + "accepts": [offer], + } + header = base64.b64encode(json.dumps(body).encode()).decode() + picked = parse_x402_challenge( + {"payment-required": header}, None, ChallengeSelection(network="devnet") + ) + assert picked is not None + assert cast("dict[str, Any]", picked)["resource"] == "https://api.example.test/joke" + + # -- build_payment_header ---------------------------------------------------- From 7a24050585043e1b198b058841ef59340561cc3a Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Sun, 31 May 2026 23:13:50 +0300 Subject: [PATCH 41/45] fix(python-mpp): match rust charge client tx layout and fee-payer slot 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) --- python/src/pay_kit/_paycore/solana.py | 1 + .../pay_kit/protocols/mpp/client/charge.py | 99 ++++++++++- python/tests/test_client_charge.py | 165 +++++++++++++++++- 3 files changed, 259 insertions(+), 6 deletions(-) diff --git a/python/src/pay_kit/_paycore/solana.py b/python/src/pay_kit/_paycore/solana.py index d15297e2c..d445c9a96 100644 --- a/python/src/pay_kit/_paycore/solana.py +++ b/python/src/pay_kit/_paycore/solana.py @@ -10,6 +10,7 @@ TOKEN_2022_PROGRAM = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb" ASSOCIATED_TOKEN_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" MEMO_PROGRAM = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr" +COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111" # Mint addresses keyed by currency symbol, then by network. diff --git a/python/src/pay_kit/protocols/mpp/client/charge.py b/python/src/pay_kit/protocols/mpp/client/charge.py index 67bd80fb6..5e9fed9e6 100644 --- a/python/src/pay_kit/protocols/mpp/client/charge.py +++ b/python/src/pay_kit/protocols/mpp/client/charge.py @@ -7,8 +7,11 @@ from pay_kit._paycore.mints import derive_ata from pay_kit._paycore.solana import ( ASSOCIATED_TOKEN_PROGRAM, + COMPUTE_BUDGET_PROGRAM, MEMO_PROGRAM, SYSTEM_PROGRAM, + TOKEN_2022_PROGRAM, + TOKEN_PROGRAM, CredentialPayload, MethodDetails, default_token_program_for_currency, @@ -97,15 +100,37 @@ async def build_charge_transaction( details = method_details or MethodDetails() amount_int = int(amount) + # Cap split count, matching rust ``if splits.len() > 8`` (charge.rs:76-78); + # the server enforces MAX_SPLITS=8 too, but the client must fail fast. + if len(details.splits) > 8: + raise ValueError("too many splits: maximum is 8") split_total = sum(int(split.amount) for split in details.splits) primary_amount = amount_int - split_total if primary_amount <= 0: raise ValueError("splits consume the entire amount") recipient_key = Pubkey.from_string(recipient) + # Fee-payer toggle mirrors rust (charge.rs:96-104): a sponsored route uses + # the server fee payer as the message fee payer (account[0]); the client + # signs only its own signature slot and the server cosigns slot 0. + use_fee_payer = details.fee_payer and bool(details.fee_payer_key) + fee_payer_key = Pubkey.from_string(details.fee_payer_key) if use_fee_payer else None + instructions = [] memo_program = Pubkey.from_string(MEMO_PROGRAM) + # ComputeBudget prelude, matching rust charge.rs:108-110: SetComputeUnitPrice(1) + # (program ComputeBudget111..., disc 3, u64 LE) THEN SetComputeUnitLimit(200_000) + # (disc 2, u32 LE), both with zero accounts. Restores byte-level instruction + # order parity with the rust/cross-impl clients for an identical challenge. + compute_budget_program = Pubkey.from_string(COMPUTE_BUDGET_PROGRAM) + instructions.append( + Instruction(compute_budget_program, bytes([3]) + (1).to_bytes(8, "little"), []) + ) + instructions.append( + Instruction(compute_budget_program, bytes([2]) + (200_000).to_bytes(4, "little"), []) + ) + def append_memo(memo: str) -> None: if not memo: return @@ -114,6 +139,20 @@ def append_memo(memo: str) -> None: raise ValueError("memo cannot exceed 566 bytes") instructions.append(Instruction(memo_program, data, [])) + # ataCreationRequired gate, matching rust charge.rs:113-128: any split that + # flags ata_creation_required requires the charge currency to be an SPL token + # mint address (not native SOL and not a symbol). resolve_mint returns "" for + # SOL and the raw mint for an SPL mint address; for a known symbol it returns + # a mint that differs from the symbol input, which we reject here. + if any(split.ata_creation_required for split in details.splits): + resolved = resolve_mint(currency, details.network) + if is_native_sol(currency) or not resolved: + raise ValueError("ataCreationRequired requires an SPL token charge") + if resolved != currency: + raise ValueError( + "ataCreationRequired requires currency to be an SPL token mint address" + ) + if is_native_sol(currency): # SOL transfer ix = transfer( @@ -147,13 +186,17 @@ def append_memo(memo: str) -> None: from solders.instruction import AccountMeta mint = resolve_mint(currency, details.network) - token_program = details.token_program or default_token_program_for_currency(currency, details.network) + token_program = await _resolve_token_program(rpc_client, mint, details) decimals = details.decimals if details.decimals is not None else 6 token_program_key = Pubkey.from_string(token_program) mint_key = Pubkey.from_string(mint) system_program_key = Pubkey.from_string(SYSTEM_PROGRAM) ata_program_key = Pubkey.from_string(ASSOCIATED_TOKEN_PROGRAM) source_ata = Pubkey.from_string(derive_ata(str(signer.pubkey()), mint, token_program)) + # The create-ATA payer is the fee payer when sponsored, else the signer, + # matching rust ``let payer = fee_payer.copied().unwrap_or(*signer)`` + # (charge.rs:368). + ata_payer = fee_payer_key if fee_payer_key is not None else signer.pubkey() def append_transfer_checked(owner: Any, transfer_amount: int, create_ata: bool, memo: str) -> None: dest_ata = Pubkey.from_string(derive_ata(str(owner), mint, token_program)) @@ -165,7 +208,7 @@ def append_transfer_checked(owner: Any, transfer_amount: int, create_ata: bool, ata_program_key, bytes([1]), [ - AccountMeta(signer.pubkey(), True, True), + AccountMeta(ata_payer, True, True), AccountMeta(dest_ata, False, True), AccountMeta(owner, False, False), AccountMeta(mint_key, False, False), @@ -206,10 +249,15 @@ def append_transfer_checked(owner: Any, transfer_amount: int, create_ata: bool, resp = await rpc_client.get_latest_blockhash() blockhash = resp.value.blockhash - # Build and sign transaction - msg = Message.new_with_blockhash(instructions, signer.pubkey(), blockhash) + # Build and sign transaction. The message fee payer (account[0]) is the + # server fee payer when sponsored, else the signer, matching rust + # ``actual_fee_payer = fee_payer_pubkey.unwrap_or(signer_pubkey)`` + # (charge.rs:162-163). The client signs ONLY its own slot via partial_sign; + # when sponsored the server cosigns the fee-payer slot at account[0]. + actual_fee_payer = fee_payer_key if fee_payer_key is not None else signer.pubkey() + msg = Message.new_with_blockhash(instructions, actual_fee_payer, blockhash) tx = Transaction.new_unsigned(msg) - tx.sign([signer], blockhash) + tx.partial_sign([signer], blockhash) # Encode transaction import base64 as b64 @@ -218,3 +266,44 @@ def append_transfer_checked(owner: Any, transfer_amount: int, create_ata: bool, tx_b64 = b64.b64encode(tx_bytes).decode("ascii") return CredentialPayload(type="transaction", transaction=tx_b64) + + +async def _resolve_token_program(rpc_client: Any, mint: str, details: MethodDetails) -> str: + """Resolve the SPL token program for ``mint``, matching rust resolve_token_program. + + Mirrors rust ``resolve_token_program`` (charge.rs:442-466): use + ``methodDetails.tokenProgram`` when present; otherwise fetch the mint + account owner via RPC; then reject any program that is not the classic SPL + Token program or Token-2022. Without this, an unknown mint that omits + ``tokenProgram`` silently defaults to the classic program where rust + consults the chain, building the wrong program id / ATA derivation. + """ + if details.token_program: + token_program = details.token_program + else: + owner = await _fetch_mint_owner(rpc_client, mint) + token_program = owner if owner is not None else default_token_program_for_currency( + mint, details.network + ) + if token_program not in (TOKEN_PROGRAM, TOKEN_2022_PROGRAM): + raise ValueError(f"Unsupported token program: {token_program}") + return token_program + + +async def _fetch_mint_owner(rpc_client: Any, mint: str) -> str | None: + """Return the on-chain owner program of ``mint`` via RPC, or None when unavailable. + + Mirrors the rust ``rpc.get_account(mint).owner`` lookup. Tolerates an absent + or stubbed RPC client (offline tests pass ``None``) by returning None so the + caller falls back to the symbol-derived default. + """ + if rpc_client is None: + return None + from solders.pubkey import Pubkey + + resp = await rpc_client.get_account(Pubkey.from_string(mint)) + value = getattr(resp, "value", resp) + if value is None: + return None + owner = getattr(value, "owner", None) + return str(owner) if owner is not None else None diff --git a/python/tests/test_client_charge.py b/python/tests/test_client_charge.py index 19e087b2e..7f1d10056 100644 --- a/python/tests/test_client_charge.py +++ b/python/tests/test_client_charge.py @@ -87,16 +87,19 @@ async def test_build_charge_transaction_spl_token_transfers_checked_to_atas(): assert mint is not None tp = token_program_for("USDC", "mainnet") + # ataCreationRequired requires the charge currency to be a raw mint address + # (rust charge.rs:113-128); pass the resolved mint rather than the symbol. payload = await build_charge_transaction( signer=signer, rpc_client=None, amount="1000", - currency="USDC", + currency=mint, recipient=recipient, external_id="order-9", method_details=MethodDetails( network="mainnet", decimals=6, + token_program=tp, recent_blockhash=BLOCKHASH, splits=[Split(recipient=split_recipient, amount="200", ata_creation_required=True)], ), @@ -145,3 +148,163 @@ async def test_build_charge_transaction_rejects_splits_that_exhaust_total(): splits=[Split(recipient=split_recipient, amount="1000")], ), ) + + +# -- rust-parity regressions ------------------------------------------------- + +COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111" + + +def _instructions(transaction_b64: str) -> tuple[Transaction, list]: + tx = Transaction.from_bytes(base64.b64decode(transaction_b64)) + return tx, list(tx.message.instructions) + + +async def test_build_charge_transaction_prepends_compute_budget_prelude(): + # Rust charge.rs:108-110 prepends SetComputeUnitPrice(1) (disc 3, u64 LE) + # then SetComputeUnitLimit(200_000) (disc 2, u32 LE), both zero-account. + signer = Keypair() + recipient = str(Keypair().pubkey()) + payload = await build_charge_transaction( + signer=signer, + rpc_client=None, + amount="1000", + currency="sol", + recipient=recipient, + method_details=MethodDetails(recent_blockhash=BLOCKHASH), + ) + tx, ixs = _instructions(payload.transaction) + keys = tx.message.account_keys + assert str(keys[ixs[0].program_id_index]) == COMPUTE_BUDGET_PROGRAM + assert str(keys[ixs[1].program_id_index]) == COMPUTE_BUDGET_PROGRAM + price_data = bytes(ixs[0].data) + assert price_data[0] == 3 + assert int.from_bytes(price_data[1:9], "little") == 1 + limit_data = bytes(ixs[1].data) + assert limit_data[0] == 2 + assert int.from_bytes(limit_data[1:5], "little") == 200_000 + assert len(ixs[0].accounts) == 0 + assert len(ixs[1].accounts) == 0 + + +async def test_build_charge_transaction_sponsored_fee_payer_is_message_slot_zero(): + # Rust charge.rs:96-104,162-163: when feePayer+feePayerKey are set, the + # server fee payer is the message fee payer (account[0]) and the client + # signs only its own slot, leaving the fee-payer slot for the server cosign. + signer = Keypair() + fee_payer = str(Keypair().pubkey()) + recipient = str(Keypair().pubkey()) + payload = await build_charge_transaction( + signer=signer, + rpc_client=None, + amount="1000", + currency="sol", + recipient=recipient, + method_details=MethodDetails( + recent_blockhash=BLOCKHASH, + fee_payer=True, + fee_payer_key=fee_payer, + ), + ) + tx = Transaction.from_bytes(base64.b64decode(payload.transaction)) + keys = [str(k) for k in tx.message.account_keys] + assert keys[0] == fee_payer + # Two required signers (fee payer slot + client); the client slot is signed, + # the fee-payer slot (account[0]) is left blank for the server cosign. + header = tx.message.header + assert int(header.num_required_signatures) == 2 + sigs = list(tx.signatures) + fee_payer_index = keys.index(fee_payer) + client_index = keys.index(str(signer.pubkey())) + assert sigs[fee_payer_index] == sigs[fee_payer_index].default() + assert sigs[client_index] != sigs[client_index].default() + + +async def test_build_charge_transaction_unsponsored_signs_signer_at_slot_zero(): + # No feePayer toggle: the client signer is the message fee payer (slot 0). + signer = Keypair() + recipient = str(Keypair().pubkey()) + payload = await build_charge_transaction( + signer=signer, + rpc_client=None, + amount="1000", + currency="sol", + recipient=recipient, + method_details=MethodDetails(recent_blockhash=BLOCKHASH), + ) + tx = Transaction.from_bytes(base64.b64decode(payload.transaction)) + keys = [str(k) for k in tx.message.account_keys] + assert keys[0] == str(signer.pubkey()) + assert int(tx.message.header.num_required_signatures) == 1 + + +async def test_build_charge_transaction_rejects_more_than_eight_splits(): + # Rust charge.rs:76-78 rejects > 8 splits with TooManySplits. + signer = Keypair() + recipient = str(Keypair().pubkey()) + splits = [Split(recipient=str(Keypair().pubkey()), amount="1") for _ in range(9)] + with pytest.raises(ValueError, match="too many splits"): + await build_charge_transaction( + signer=signer, + rpc_client=None, + amount="1000", + currency="sol", + recipient=recipient, + method_details=MethodDetails(recent_blockhash=BLOCKHASH, splits=splits), + ) + + +async def test_build_charge_transaction_rejects_unsupported_token_program(): + # Rust resolve_token_program (charge.rs:457-463) rejects any token program + # outside the {TOKEN, TOKEN_2022} allowlist. + signer = Keypair() + recipient = str(Keypair().pubkey()) + mint = resolve("USDC", "mainnet") + assert mint is not None + with pytest.raises(ValueError, match="Unsupported token program"): + await build_charge_transaction( + signer=signer, + rpc_client=None, + amount="1000", + currency=mint, + recipient=recipient, + method_details=MethodDetails( + network="mainnet", + decimals=6, + token_program=str(Keypair().pubkey()), + recent_blockhash=BLOCKHASH, + ), + ) + + +async def test_build_charge_transaction_resolves_token_program_via_rpc_owner(): + # Rust resolve_token_program fetches the mint account owner via RPC when + # methodDetails.tokenProgram is absent (charge.rs:450-454). An unknown mint + # owned by Token-2022 must build with the Token-2022 program. + from pay_kit._paycore.solana import TOKEN_2022_PROGRAM + + class _Owner: + owner = TOKEN_2022_PROGRAM + + class _Resp: + value = _Owner() + + class _Rpc: + async def get_account(self, _pubkey): + return _Resp() + + signer = Keypair() + recipient = str(Keypair().pubkey()) + unknown_mint = str(Keypair().pubkey()) + payload = await build_charge_transaction( + signer=signer, + rpc_client=_Rpc(), + amount="1000", + currency=unknown_mint, + recipient=recipient, + method_details=MethodDetails(network="mainnet", decimals=6, recent_blockhash=BLOCKHASH), + ) + tx, ixs = _instructions(payload.transaction) + keys = tx.message.account_keys + transfer_ix = next(ix for ix in ixs if bytes(ix.data)[:1] == bytes([12])) + assert str(keys[transfer_ix.program_id_index]) == TOKEN_2022_PROGRAM From 37e48b5a94f8923c8a21e178d7a543a24440e807 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Sun, 31 May 2026 23:13:58 +0300 Subject: [PATCH 42/45] fix(python-mpp): enforce required ATA-creation set in charge allowlist 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). --- .../pay_kit/protocols/mpp/server/_verify.py | 26 ++++++++-- python/tests/test_server.py | 52 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/server/_verify.py b/python/src/pay_kit/protocols/mpp/server/_verify.py index f47e6eebf..30a3af447 100644 --- a/python/src/pay_kit/protocols/mpp/server/_verify.py +++ b/python/src/pay_kit/protocols/mpp/server/_verify.py @@ -190,9 +190,13 @@ def _validate_ata_create_idempotent( allowed_ata_owners: set[str], expected_token_program: str | None, expected_payer: str, -) -> None: +) -> str: """Validate an AssociatedTokenAccount create-idempotent instruction. + Returns the validated ATA ``owner`` so the caller can confirm every + ``ataCreationRequired`` split recipient actually had its ATA created, + mirroring rust ``validate_create_ata_idempotent_instruction``. + Mirrors ``validate_create_ata_idempotent_instruction`` in ``rust/src/server/charge.rs``. The only ATA program instruction the fee-payer co-sign path may include is the idempotent create variant @@ -289,6 +293,8 @@ def _validate_ata_create_idempotent( code="invalid-payload", ) from exc + return owner + def _validate_instruction_allowlist( transaction_b64: str, @@ -403,7 +409,8 @@ def _validate_instruction_allowlist( expected_token_program = details.token_program or default_token_program_for_currency( request.currency, details.network ) - allowed_ata_owners, _required_ata_owners = _expected_ata_creation_policy(details, fee_payer_pubkey) + allowed_ata_owners, required_ata_owners = _expected_ata_creation_policy(details, fee_payer_pubkey) + created_ata_owners: set[str] = set() expected_memos = {memo for _label, memo in _expected_memos(request, details)} # Track which required transfers / memos have been satisfied so each @@ -570,7 +577,7 @@ def _validate_instruction_allowlist( continue if program_id == ASSOCIATED_TOKEN_PROGRAM: - _validate_ata_create_idempotent( + created_owner = _validate_ata_create_idempotent( instruction, account_keys, expected_mint, @@ -578,6 +585,7 @@ def _validate_instruction_allowlist( expected_token_program, fee_payer_account, ) + created_ata_owners.add(created_owner) continue raise PaymentError( @@ -585,6 +593,18 @@ def _validate_instruction_allowlist( code="invalid-payload", ) + # SECURITY: every split recipient flagged ``ataCreationRequired`` must have + # a matching create-ATA-idempotent instruction, mirroring rust + # ``validate_instruction_allowlist`` (server/charge.rs:1362-1368). Without + # this a sponsored credential that omits a demanded create is accepted and + # the server cosigns/broadcasts, so settlement under-creates the recipient ATA. + for owner in required_ata_owners: + if owner not in created_ata_owners: + raise PaymentError( + f"missing required ATA creation instruction for split recipient {owner}", + code="invalid-payload", + ) + def _verify_local_transaction_intent( transaction_b64: str, diff --git a/python/tests/test_server.py b/python/tests/test_server.py index c051f2572..88e8ea03e 100644 --- a/python/tests/test_server.py +++ b/python/tests/test_server.py @@ -1662,6 +1662,58 @@ def test_valid_spl_payment_with_ata_create_for_required_split_is_accepted(self): # Must not raise. _verify_local_transaction_intent(tx_b64, request, details) + def test_missing_required_ata_create_is_rejected(self): + """SECURITY: a split flagged ``ataCreationRequired=true`` whose + create-ATA-idempotent instruction is omitted must be rejected. + + Mirrors rust ``validate_instruction_allowlist`` tail + (server/charge.rs:1362-1368): the required-ATA-owner set must be fully + covered by create-ATA instructions. Without the enforcement a sponsored + credential that drops the demanded create is cosigned and broadcast, + under-creating the recipient ATA. Same transaction as the positive + control above MINUS the ata_create instruction. + """ + from pay_kit._paycore.solana import Split + from pay_kit.protocols.mpp.server.charge import _verify_local_transaction_intent + + fee_payer = Keypair() + split_recipient = "8wXtPeU6557ETkp9WHFY1n1EcU6NxDvbAggHGsMYiHsB" + request = ChargeRequest(amount="1000000", currency="USDC", recipient=TEST_RECIPIENT) + details = MethodDetails( + network="devnet", + token_program=TOKEN_PROGRAM, + decimals=6, + splits=[Split(recipient=split_recipient, amount="100000", ata_creation_required=True)], + ) + mint = USDC_DEVNET + recipient_ata = _derive_ata(TEST_RECIPIENT, mint, TOKEN_PROGRAM) + split_ata = _derive_ata(split_recipient, mint, TOKEN_PROGRAM) + source = Pubkey.new_unique() + primary_transfer = Instruction( + Pubkey.from_string(TOKEN_PROGRAM), + bytes([12]) + (900_000).to_bytes(8, "little") + bytes([6]), + [ + AccountMeta(source, False, True), + AccountMeta(Pubkey.from_string(mint), False, False), + AccountMeta(Pubkey.from_string(recipient_ata), False, True), + AccountMeta(fee_payer.pubkey(), True, False), + ], + ) + split_transfer = Instruction( + Pubkey.from_string(TOKEN_PROGRAM), + bytes([12]) + (100_000).to_bytes(8, "little") + bytes([6]), + [ + AccountMeta(source, False, True), + AccountMeta(Pubkey.from_string(mint), False, False), + AccountMeta(Pubkey.from_string(split_ata), False, True), + AccountMeta(fee_payer.pubkey(), True, False), + ], + ) + # NOTE: the demanded create-ATA for the required split is intentionally absent. + tx_b64 = self._build_tx([primary_transfer, split_transfer], fee_payer) + with pytest.raises(PaymentError, match="missing required ATA creation"): + _verify_local_transaction_intent(tx_b64, request, details) + def test_ata_create_for_primary_recipient_is_rejected(self): """SECURITY: even the top-level recipient is NOT a valid ATA-create owner under fee-payer sponsorship. Only splits with From 627005dd32785f4d2e26d8fd72a1d1b47c97dee6 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Mon, 1 Jun 2026 01:37:30 +0300 Subject: [PATCH 43/45] fix(python): cast envelope resource to typed dict for pyright 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. --- python/src/pay_kit/protocols/x402/client/exact/payment.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/src/pay_kit/protocols/x402/client/exact/payment.py b/python/src/pay_kit/protocols/x402/client/exact/payment.py index 56ba5b30d..f600763cc 100644 --- a/python/src/pay_kit/protocols/x402/client/exact/payment.py +++ b/python/src/pay_kit/protocols/x402/client/exact/payment.py @@ -196,9 +196,10 @@ def _attach_envelope_resource( requirement so the client can echo it back. Only fills the entry's ``resource``/``description`` when absent so a per-offer override wins. """ - resource = envelope.get("resource") - if not isinstance(resource, dict): + resource_value = envelope.get("resource") + if not isinstance(resource_value, dict): return + resource = cast("dict[str, object]", resource_value) url = resource.get("url") if not isinstance(url, str) or url == "": return From 53a24f5025bf4a39b5ff71d0bf4da077da4e0f94 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Mon, 1 Jun 2026 01:56:45 +0300 Subject: [PATCH 44/45] fix(python-x402): keep echoed accepted clean so rust verifier matches 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. --- .../protocols/x402/client/exact/payment.py | 59 ++++++++++++++----- python/tests/test_pk_x402_client.py | 29 +++++++-- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/python/src/pay_kit/protocols/x402/client/exact/payment.py b/python/src/pay_kit/protocols/x402/client/exact/payment.py index f600763cc..1f3e1e410 100644 --- a/python/src/pay_kit/protocols/x402/client/exact/payment.py +++ b/python/src/pay_kit/protocols/x402/client/exact/payment.py @@ -184,17 +184,34 @@ def _select_from_envelope(envelope: object, selection: ChallengeSelection) -> X4 return _select_requirement(accepts, selection) +#: Private (non-wire) key under which the envelope-level v2 ``resource`` info is +#: stashed on a parsed accept. Stripped before the offer is echoed back as the +#: ``accepted`` body so it never reaches the wire. See ``_attach_envelope_resource``. +_RESOURCE_INFO_KEY = "__pay_kit_resource_info__" + + def _attach_envelope_resource( envelope: Mapping[str, object], accepts: list[dict[str, object]], ) -> None: - """Copy the envelope-level v2 ``resource`` object onto each accept. + """Stash the envelope-level v2 ``resource`` object on each accept. Mirrors rust ``PaymentRequiredEnvelope::with_resource_on_accepts`` (types.rs:463-476): the canonical v2 challenge carries ``resource`` at the - envelope level; the rust deserializer attaches it to every parsed - requirement so the client can echo it back. Only fills the entry's - ``resource``/``description`` when absent so a per-offer override wins. + envelope level and the rust deserializer attaches it to every parsed + requirement so the client can echo it at the *envelope* top level. + + The rust client echoes the offer back as ``accepted`` via + ``PaymentRequirements::to_accepted_value`` (types.rs:235-249), which for a + parsed offer returns the original received JSON verbatim — it never folds + ``resource``/``description`` into the ``accepted`` body. The server's + structural ``deepEqual`` compares that echoed ``accepted`` against its own + freshly built requirements, which carry no top-level ``resource``; adding + those fields to the echo breaks the match (HTTP 402 ``payment_invalid``). + + So we stash the resolved resource info under a private (non-wire) key the + echo path strips, instead of mutating the offer's wire fields. A per-offer + ``resource``/``description`` already present on the accept still wins. """ resource_value = envelope.get("resource") if not isinstance(resource_value, dict): @@ -205,10 +222,13 @@ def _attach_envelope_resource( return description = resource.get("description") for accept in accepts: - if not _str_field(accept, "resource"): - accept["resource"] = url - if "description" not in accept and isinstance(description, str): - accept["description"] = description + info: dict[str, object] = {"url": _str_field(accept, "resource") or url} + offer_description = _str_field(accept, "description") + if offer_description is not None: + info["description"] = offer_description + elif isinstance(description, str): + info["description"] = description + accept.setdefault(_RESOURCE_INFO_KEY, info) def _is_solana_exact(offer: dict[str, object]) -> bool: @@ -472,31 +492,42 @@ async def build_payment( signatures[signer_index] = sig tx = VersionedTransaction.populate(message, signatures) + # Derive the envelope-level resource BEFORE building the echoed ``accepted`` + # body, then strip the private resource-info key so the echo carries only + # the offer's wire fields. The rust client echoes the offer verbatim via + # ``to_accepted_value`` and the rust server's structural compare rejects any + # extra top-level field; mirror that exactly. + resource_info = _resource_info_of(req) + accepted = {key: value for key, value in req.items() if key != _RESOURCE_INFO_KEY} + encoded = base64.b64encode(bytes(tx)).decode("ascii") payload: X402PayloadField = {"transaction": encoded} envelope: dict[str, object] = { "x402Version": X402_VERSION, - "accepted": requirement, + "accepted": accepted, "payload": payload, } # Echo the offer's resource info at the envelope top level, mirroring rust # ``build_payment_header`` (payment.rs:131-138) which sets # ``resource: requirements.resource_info()``. Omit when the offer carries no # resource (rust ``skip_serializing_if = Option::is_none``). - resource_info = _resource_info_of(req) if resource_info is not None: envelope["resource"] = resource_info return cast("X402Envelope", envelope) def _resource_info_of(req: Mapping[str, object]) -> dict[str, object] | None: - """Build the canonical v2 ``resource`` object from an offer. + """Build the canonical v2 ``resource`` object for the envelope top level. Mirrors rust ``PaymentRequirements::resource_info`` (types.rs:253-265): - derive ``{url, description?}`` from the offer's ``resource`` URL string and - optional ``description``. Returns ``None`` when the offer carries no - resource URL. + prefer the resource info stashed from the envelope-level v2 ``resource`` + (``_attach_envelope_resource``), then fall back to a per-offer top-level + ``resource`` URL string with optional ``description``. Returns ``None`` when + neither is present. """ + stashed = req.get(_RESOURCE_INFO_KEY) + if isinstance(stashed, dict): + return cast("dict[str, object]", stashed) url = _str_field(req, "resource") if url is None: return None diff --git a/python/tests/test_pk_x402_client.py b/python/tests/test_pk_x402_client.py index 77dc15198..030df4c2e 100644 --- a/python/tests/test_pk_x402_client.py +++ b/python/tests/test_pk_x402_client.py @@ -625,9 +625,16 @@ async def test_build_payment_omits_resource_when_offer_has_none(): assert "resource" not in cast("dict[str, Any]", env) -def test_parse_attaches_envelope_resource_to_selected_offer(): - # Rust ``with_resource_on_accepts`` (types.rs:463-476) copies the envelope's - # v2 resource onto each parsed accept so the client can echo it. +@pytest.mark.asyncio +async def test_parse_stashes_envelope_resource_without_polluting_accepted(): + # Rust ``with_resource_on_accepts`` (types.rs:463-476) attaches the + # envelope's v2 resource to each parsed requirement so the client can echo + # it at the *envelope* top level. The echoed ``accepted`` body must NOT gain + # ``resource``/``description`` wire fields: the rust server's structural + # compare (server/exact.rs verify_envelope_payload) rejects any top-level + # field its own freshly built requirements do not carry, returning HTTP 402 + # ``payment_invalid``. Mirror rust ``to_accepted_value`` echoing the offer + # verbatim while ``resource_info`` rides only the envelope. offer = _offer() body = { "x402Version": 2, @@ -639,7 +646,21 @@ def test_parse_attaches_envelope_resource_to_selected_offer(): {"payment-required": header}, None, ChallengeSelection(network="devnet") ) assert picked is not None - assert cast("dict[str, Any]", picked)["resource"] == "https://api.example.test/joke" + # The wire-visible offer is untouched: no top-level resource/description. + assert "resource" not in cast("dict[str, Any]", picked) + assert "description" not in cast("dict[str, Any]", picked) + + signer = Signer.generate() + env = await build_payment(signer, None, picked) + decoded = cast("dict[str, Any]", env) + # Envelope echoes the resource; the accepted body stays clean. + assert decoded["resource"] == { + "url": "https://api.example.test/joke", + "description": "A joke", + } + assert "resource" not in decoded["accepted"] + assert "description" not in decoded["accepted"] + assert decoded["accepted"] == offer # -- build_payment_header ---------------------------------------------------- From c69acfb24d2a9294550a071bf39f187d9b2bfa5c Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Mon, 1 Jun 2026 11:58:39 +0300 Subject: [PATCH 45/45] fix(python): enforce transferChecked decimals and add compute-budget 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. --- .../pay_kit/protocols/mpp/client/charge.py | 16 +++++++-- .../protocols/mpp/server/_tx_decode.py | 35 ++++++++++++++++++- .../pay_kit/protocols/mpp/server/_verify.py | 10 +++++- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/client/charge.py b/python/src/pay_kit/protocols/mpp/client/charge.py index 5e9fed9e6..96518990a 100644 --- a/python/src/pay_kit/protocols/mpp/client/charge.py +++ b/python/src/pay_kit/protocols/mpp/client/charge.py @@ -72,6 +72,8 @@ async def build_charge_transaction( recipient: str, method_details: MethodDetails | None = None, external_id: str = "", + compute_unit_limit: int | None = None, + compute_unit_price: int | None = None, ) -> CredentialPayload: """Build a Solana transaction for a charge intent. @@ -86,6 +88,11 @@ async def build_charge_transaction( recipient: Recipient public key (base58). external_id: Optional root payment memo requested by the server. method_details: Optional Solana-specific method details. + compute_unit_limit: Optional override for the SetComputeUnitLimit + prelude (defaults to 200_000), mirroring the Go ``BuildOptions`` + and the TS ``buildChargeTransaction`` compute overrides. + compute_unit_price: Optional override for the SetComputeUnitPrice + prelude (defaults to 1 micro-lamport). Returns: A CredentialPayload with the signed transaction. @@ -123,12 +130,17 @@ async def build_charge_transaction( # (program ComputeBudget111..., disc 3, u64 LE) THEN SetComputeUnitLimit(200_000) # (disc 2, u32 LE), both with zero accounts. Restores byte-level instruction # order parity with the rust/cross-impl clients for an identical challenge. + # The price / limit are overridable so a caller can build a transaction + # carrying values the server cap rejects (parity with the Go BuildOptions + # and the TS compute overrides). + price = compute_unit_price if compute_unit_price is not None else 1 + limit = compute_unit_limit if compute_unit_limit is not None else 200_000 compute_budget_program = Pubkey.from_string(COMPUTE_BUDGET_PROGRAM) instructions.append( - Instruction(compute_budget_program, bytes([3]) + (1).to_bytes(8, "little"), []) + Instruction(compute_budget_program, bytes([3]) + price.to_bytes(8, "little"), []) ) instructions.append( - Instruction(compute_budget_program, bytes([2]) + (200_000).to_bytes(4, "little"), []) + Instruction(compute_budget_program, bytes([2]) + limit.to_bytes(4, "little"), []) ) def append_memo(memo: str) -> None: diff --git a/python/src/pay_kit/protocols/mpp/server/_tx_decode.py b/python/src/pay_kit/protocols/mpp/server/_tx_decode.py index 2b2f72487..e93981121 100644 --- a/python/src/pay_kit/protocols/mpp/server/_tx_decode.py +++ b/python/src/pay_kit/protocols/mpp/server/_tx_decode.py @@ -124,6 +124,15 @@ def _verify_parsed_spl_transfers( expected = _build_expected_transfers(request, details) program_id = details.token_program or default_token_program_for_currency(request.currency, details.network) mint = resolve_mint(request.currency, details.network) + # transferChecked carries the token decimals inline; the challenge pins + # the expected decimals (6 for stablecoins). A transfer that encodes a + # different decimals byte targets a different on-chain mint precision and + # must not match, mirroring the TS reference verifier + # (server/Charge.ts verifySplTransferPreBroadcast: ``data[9] !== decimals`` + # skips the instruction) and the Rust spine. Without this an attacker can + # encode decimals=9 against a decimals=6 challenge and the lossy matcher + # would accept it. + expected_decimals = details.decimals transfers = [ instruction for instruction in instructions @@ -139,6 +148,10 @@ def _verify_parsed_spl_transfers( if ((transfer.get("parsed") or {}).get("info") or {}).get("mint") == mint and str((((transfer.get("parsed") or {}).get("info") or {}).get("tokenAmount") or {}).get("amount")) == str(amount) + and _decimals_match( + (((transfer.get("parsed") or {}).get("info") or {}).get("tokenAmount") or {}).get("decimals"), + expected_decimals, + ) and _verify_ata_owner( ((transfer.get("parsed") or {}).get("info") or {}).get("destination", ""), recipient, @@ -153,6 +166,22 @@ def _verify_parsed_spl_transfers( transfers.pop(match_index) +def _decimals_match(actual: Any, expected: int | None) -> bool: + """Return True unless a present transfer decimals contradicts the challenge. + + transferChecked encodes the token decimals inline; the pre-broadcast + decoder and the Solana jsonParsed RPC format both surface it under + ``tokenAmount.decimals``. We reject only a *present* decimals that + disagrees with the challenge so a decimals=9 transfer cannot satisfy a + decimals=6 challenge (mirrors the TS reference verifier). When either + side is absent we do not constrain on decimals, so confirmed-transaction + fixtures that omit the field still match on mint / amount / destination. + """ + if expected is None or actual is None: + return True + return int(actual) == int(expected) + + def _verify_ata_owner(ata_address: str, expected_owner: str, mint: str, token_program: str) -> bool: """Verify that an ATA address belongs to the expected owner by deriving it.""" try: @@ -456,6 +485,10 @@ def _decode_legacy_payment_instructions(transaction_b64: str) -> list[dict[str, "transaction token transfer references an unknown account", code="invalid-payload" ) from exc amount = int.from_bytes(data[1:9], "little") + # transferChecked encodes the token decimals as the trailing + # byte (data[9]); surface it so the verifier can reject a + # decimals mismatch against the challenge. + decimals = data[9] instructions.append( { "programId": program_id, @@ -464,7 +497,7 @@ def _decode_legacy_payment_instructions(transaction_b64: str) -> list[dict[str, "info": { "destination": destination, "mint": mint, - "tokenAmount": {"amount": str(amount)}, + "tokenAmount": {"amount": str(amount), "decimals": decimals}, }, }, } diff --git a/python/src/pay_kit/protocols/mpp/server/_verify.py b/python/src/pay_kit/protocols/mpp/server/_verify.py index 30a3af447..9bcd62126 100644 --- a/python/src/pay_kit/protocols/mpp/server/_verify.py +++ b/python/src/pay_kit/protocols/mpp/server/_verify.py @@ -560,11 +560,19 @@ def _validate_instruction_allowlist( code="invalid-payload", ) amount = int.from_bytes(data[1:9], "little") + # transferChecked encodes the token decimals as the trailing byte + # (data[9]); reject a mismatch against the challenge decimals so a + # transfer targeting a different mint precision cannot satisfy a + # required leg. Mirrors the parsed-transfer matcher and the TS + # reference verifier (server/Charge.ts verifySplTransferPreBroadcast). + decimals = data[9] match_idx = next( ( i for i, (rcpt, amt) in enumerate(remaining_transfers) - if amt == amount and _verify_ata_owner(destination, rcpt, mint, program_id) + if amt == amount + and (details.decimals is None or decimals == details.decimals) + and _verify_ata_owner(destination, rcpt, mint, program_id) ), -1, )