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 d04251b93..dd9a065d4 100644
--- a/.github/workflows/python.yml
+++ b/.github/workflows/python.yml
@@ -18,21 +18,28 @@ 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 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=solana_mpp \
+ --cov=pay_kit \
--cov-report=term-missing \
--cov-report=json:coverage.json \
--cov-fail-under=90 \
@@ -93,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
@@ -109,3 +118,24 @@ 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 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). 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
diff --git a/README.md b/README.md
index a8113948e..15a99be05 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,13 +121,16 @@ return result.withReceipt(Response.json({ data: '...' }))
Python
```python
-from solana_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/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/python-server/server.py b/harness/python-server/server.py
new file mode 100644
index 000000000..bd2021045
--- /dev/null
+++ b/harness/python-server/server.py
@@ -0,0 +1,468 @@
+"""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.
+
+This adapter routes every request through the unified ``pay_kit`` surface:
+
+ * x402 exact -> ``pay_kit.protocols.x402.X402Adapter`` (the umbrella adapter)
+ * 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 ``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 ``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
+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 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._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._paycore.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 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.
+ 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 = "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 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": "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/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/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 f6c530a4b..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[] = [
@@ -216,15 +232,26 @@ export const serverImplementations: ImplementationDefinition[] = [
},
{
id: "python",
- label: "Python HTTP server",
+ label: "Python pay_kit server (dual protocol)",
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"],
+ // One adapter binary, two settle paths. The dual-protocol Python
+ // 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"],
},
{
id: "go",
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 03aeb262e..cc2d9e6b1 100644
--- a/harness/test/x402-exact.e2e.test.ts
+++ b/harness/test/x402-exact.e2e.test.ts
@@ -88,6 +88,22 @@ 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 === "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;
};
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/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;
diff --git a/python/README.md b/python/README.md
index d4e74a983..88ac993fd 100644
--- a/python/README.md
+++ b/python/README.md
@@ -8,204 +8,427 @@
# 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.
[]()
-[]()
+[]()
[]()
+---
+
## 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")
+
+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)
+
+catalog = Catalog()
+app = Flask(__name__)
-@app.get("/paid")
-@mpp_charge(mpp, amount=settings.amount, description="Paid endpoint")
-def paid():
- return jsonify(ok=True, message="thanks for paying!")
+@app.get("/report")
+@require_payment("report", pricing=catalog)
+def report():
+ return jsonify(content="premium content")
-if __name__ == "__main__":
- app.run(host=settings.host, port=settings.port)
+@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:
+
+- `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.
-Two runnable examples ship with this package:
+---
-- [`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 example
-### Run the Flask 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
+git clone https://github.com/solana-foundation/pay-kit
+cd pay-kit/python
+pip install -e ".[flask]"
python examples/flask/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.
+---
-## Solana dependencies
+## x402
-| 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` |
+[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.
-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.
+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.
-## Coding convention
+| Scheme | Client | Server |
+|---------|:------:|:------:|
+| `exact` | ✅ | ✅ |
+| `upto` | — | — |
+| `batch` | — | — |
-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.
+### 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 repo-level `pay-sdk-implementation` skill remains the protocol source
-of truth: Rust / spec wire format first, Python idioms second.
+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
+
+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:
-## Test, lint, coverage
+```python
+@app.get("/oneoff")
+@require_payment(usd("0.25"))
+def oneoff():
+ return jsonify(ok=True)
+```
+
+The bare `Price` is wrapped into an inline `Gate` using the configured
+default recipient and accept list.
+
+## Gate DSL
+
+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.
+
+```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):
+
+- `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
+ ...
+```
+
+---
+
+## Examples
+
+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/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.
+- [`examples/payment-links/server.py`](examples/payment-links/server.py),
+ 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 lower-level payment-links 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-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).
-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
@@ -215,35 +438,50 @@ 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
+│ ├── 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 / 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/ 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
```
+## 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/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..79a6928de 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._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,
challenge_to_html,
is_service_worker_request,
service_worker_js,
)
-from solana_mpp.store import MemoryStore
+from pay_kit._paycore.store import MemoryStore
RECIPIENT = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY"
USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
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)))
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 8b07a8a6a..b7f13deb9 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"
@@ -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/pay_kit"]
[tool.ruff]
target-version = "py311"
@@ -36,17 +41,63 @@ select = ["E", "F", "W", "I", "UP", "B", "SIM"]
[tool.pyright]
pythonVersion = "3.11"
typeCheckingMode = "standard"
+# 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/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"]
+exclude = ["**/__pycache__"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.coverage.run]
-source = ["solana_mpp"]
+source = ["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
diff --git a/python/src/pay_kit/__init__.py b/python/src/pay_kit/__init__.py
new file mode 100644
index 000000000..3ebe51c41
--- /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:`pay_kit.protocols.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._paycore.store import FileReplayStore, MemoryStore, Store
+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.protocols.mpp.core.expires import days, hours, minutes, seconds, weeks
+from pay_kit.signer import LocalSigner, Signer
+
+__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 pay_kit.protocols.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/_middleware.py b/python/src/pay_kit/_middleware.py
new file mode 100644
index 000000000..75b1cbfb4
--- /dev/null
+++ b/python/src/pay_kit/_middleware.py
@@ -0,0 +1,417 @@
+"""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
+
+import weakref
+from collections.abc import Callable, Mapping
+from typing import TYPE_CHECKING, Any, cast
+
+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
+ from pay_kit.protocols.mpp import MppAcceptsEntry
+ from pay_kit.protocols.x402.exact.types import X402AcceptsEntry
+
+__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]"
+
+#: 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.
+
+ 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
+
+ @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."""
+ 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, request)
+
+ 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[X402AcceptsEntry | MppAcceptsEntry] = []
+ 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], 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
+ if isinstance(result, Price):
+ return self._coerce_static(result, None, request)
+ 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,
+ request: Any,
+ ) -> Gate:
+ """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)
+ return coerced.resolve(request)
+ 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: object = getattr(request, "headers", None)
+ if headers is None and isinstance(request, Mapping):
+ request_map = cast("Mapping[str, object]", request)
+ headers = request_map.get("headers", request_map)
+ if headers is None:
+ return {}
+ if isinstance(headers, Mapping):
+ 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)
+ 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) -> 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):
+ return getattr(state, name)
+ if hasattr(request, name):
+ return getattr(request, name)
+ if isinstance(request, Mapping):
+ return cast("Mapping[str, object]", 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):
+ 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 "/"
+
+
+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/_paycore/__init__.py b/python/src/pay_kit/_paycore/__init__.py
new file mode 100644
index 000000000..21056318e
--- /dev/null
+++ b/python/src/pay_kit/_paycore/__init__.py
@@ -0,0 +1,48 @@
+"""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
+
+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/solana_mpp/_errors.py b/python/src/pay_kit/_paycore/errors.py
similarity index 100%
rename from python/src/solana_mpp/_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
new file mode 100644
index 000000000..15eddf2bb
--- /dev/null
+++ b/python/src/pay_kit/_paycore/mints.py
@@ -0,0 +1,78 @@
+"""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 the
+x402 and MPP adapters always agree on wire values.
+"""
+
+from __future__ import annotations
+
+from solders.pubkey import Pubkey
+
+from pay_kit._paycore.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/solana_mpp/server/network_check.py b/python/src/pay_kit/_paycore/network_check.py
similarity index 97%
rename from python/src/solana_mpp/server/network_check.py
rename to python/src/pay_kit/_paycore/network_check.py
index 4cb6f61ec..abf165adb 100644
--- a/python/src/solana_mpp/server/network_check.py
+++ b/python/src/pay_kit/_paycore/network_check.py
@@ -19,7 +19,7 @@
from __future__ import annotations
-from solana_mpp._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/_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/solana_mpp/_rpc.py b/python/src/pay_kit/_paycore/rpc.py
similarity index 86%
rename from python/src/solana_mpp/_rpc.py
rename to python/src/pay_kit/_paycore/rpc.py
index 11a2f34f8..cdbc4f74c 100644
--- a/python/src/solana_mpp/_rpc.py
+++ b/python/src/pay_kit/_paycore/rpc.py
@@ -25,7 +25,7 @@
import httpx
-from solana_mpp._errors import PaymentError
+from pay_kit._paycore.errors import PaymentError
class _RpcError(PaymentError):
@@ -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/src/solana_mpp/protocol/solana.py b/python/src/pay_kit/_paycore/solana.py
similarity index 92%
rename from python/src/solana_mpp/protocol/solana.py
rename to python/src/pay_kit/_paycore/solana.py
index b2e212abf..d445c9a96 100644
--- a/python/src/solana_mpp/protocol/solana.py
+++ b/python/src/pay_kit/_paycore/solana.py
@@ -3,12 +3,14 @@
from __future__ import annotations
from dataclasses import dataclass, field
+from typing import Any
SYSTEM_PROGRAM = "11111111111111111111111111111111"
TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
TOKEN_2022_PROGRAM = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
ASSOCIATED_TOKEN_PROGRAM = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"
MEMO_PROGRAM = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"
+COMPUTE_BUDGET_PROGRAM = "ComputeBudget111111111111111111111111111111"
# Mint addresses keyed by currency symbol, then by network.
@@ -124,9 +126,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 +146,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 +170,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 +181,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 +199,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 +208,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/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"
diff --git a/python/src/solana_mpp/store.py b/python/src/pay_kit/_paycore/store.py
similarity index 100%
rename from python/src/solana_mpp/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/config.py b/python/src/pay_kit/config.py
new file mode 100644
index 000000000..2a495e4d1
--- /dev/null
+++ b/python/src/pay_kit/config.py
@@ -0,0 +1,431 @@
+"""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 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
+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, extra="forbid")
+
+ 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, extra="forbid")
+
+ realm: str = "App"
+ challenge_binding_secret: str | None = None
+ # 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
+ 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, extra="forbid")
+
+ 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.
+
+ 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
+ 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()."
+ )
+ 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:
+ """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/django.py b/python/src/pay_kit/django.py
new file mode 100644
index 000000000..386e90b2a
--- /dev/null
+++ b/python/src/pay_kit/django.py
@@ -0,0 +1,211 @@
+"""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, cast
+
+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 ( # 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
+ 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.for_config(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.for_config(_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 # pyright: ignore[reportMissingTypeStubs] # django ships no type stubs
+
+ status = getattr(exc, "http_status", 500)
+ 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):
+ challenge_headers = cast("dict[str, str]", getattr(exc, "challenge_headers", {}))
+ for key, value in 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/errors.py b/python/src/pay_kit/errors.py
new file mode 100644
index 000000000..e5855c10d
--- /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 ``pay_kit.protocols.mpp`` ``PaymentError.code`` to these at the boundary via
+``pay_kit._paycore.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/fastapi.py b/python/src/pay_kit/fastapi.py
new file mode 100644
index 000000000..172b193a2
--- /dev/null
+++ b/python/src/pay_kit/fastapi.py
@@ -0,0 +1,151 @@
+"""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 Awaitable, Callable
+from typing import TYPE_CHECKING, Any, cast
+
+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.for_config(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( # pyright: ignore[reportUnusedFunction] # registered via @app.exception_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( # 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 cast("dict[str, str]", 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: dict[str, Any]
+ if isinstance(body, dict):
+ detail = cast("dict[str, Any]", body)
+ else:
+ code = getattr(exc, "code", None)
+ detail = {"error": code or "payment_error", "message": str(exc)}
+
+ return HTTPException(
+ status_code=status,
+ detail=detail,
+ 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
new file mode 100644
index 000000000..c9f956f43
--- /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, extra="forbid")
+
+ 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/flask.py b/python/src/pay_kit/flask.py
new file mode 100644
index 000000000..30c0f4bb2
--- /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.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:
+ _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()
diff --git a/python/src/pay_kit/gate.py b/python/src/pay_kit/gate.py
new file mode 100644
index 000000000..d161f390c
--- /dev/null
+++ b/python/src/pay_kit/gate.py
@@ -0,0 +1,328 @@
+"""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, Literal, cast
+
+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: object,
+ fee_on_top: object,
+) -> tuple[Fee, ...]:
+ """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] = []
+ 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}}")
+ 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))
+ 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, extra="forbid")
+
+ 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.
+ """
+ # 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): # 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
+ 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], object],
+ 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], object]], 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], object]) -> DynamicGate:
+ return DynamicGate(
+ name=name,
+ accept=accept,
+ description=description,
+ builder=builder,
+ )
+
+ return _wrap
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..039d5b53c
--- /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, extra="forbid")
+
+ 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/payment.py b/python/src/pay_kit/payment.py
new file mode 100644
index 000000000..4ebca1b36
--- /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, extra="forbid")
+
+ 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/preflight.py b/python/src/pay_kit/preflight.py
new file mode 100644
index 000000000..436354667
--- /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, cast
+
+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(cast("dict[str, Any]", 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 = cast("dict[str, Any]", 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 cast("dict[str, Any]", decoded).get("result")
diff --git a/python/src/pay_kit/price.py b/python/src/pay_kit/price.py
new file mode 100644
index 000000000..5c2b156ab
--- /dev/null
+++ b/python/src/pay_kit/price.py
@@ -0,0 +1,126 @@
+"""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: 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):
+ raise ConfigurationError("pay_kit: Price amount must be str | int | Decimal, not float")
+ if isinstance(amount, Decimal):
+ 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:
+ coerced = Decimal(amount)
+ except InvalidOperation as exc:
+ raise ConfigurationError(f"pay_kit: invalid Price amount: {amount!r}") from exc
+ 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):
+ """A single settlement preference: pay ``amount`` denominated in ``coin``."""
+
+ model_config = pydantic.ConfigDict(frozen=True, extra="forbid")
+
+ 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, extra="forbid")
+
+ 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)
+
+ @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/pricing.py b/python/src/pay_kit/pricing.py
new file mode 100644
index 000000000..be271e4a2
--- /dev/null
+++ b/python/src/pay_kit/pricing.py
@@ -0,0 +1,123 @@
+"""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: object,
+ *,
+ 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. ``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
+ 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
diff --git a/python/src/pay_kit/protocols/__init__.py b/python/src/pay_kit/protocols/__init__.py
new file mode 100644
index 000000000..8fb20a264
--- /dev/null
+++ b/python/src/pay_kit/protocols/__init__.py
@@ -0,0 +1,7 @@
+"""Protocol adapters that bridge gates to the pay_kit.protocols.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/__init__.py b/python/src/pay_kit/protocols/mpp/__init__.py
new file mode 100644
index 000000000..8c8198486
--- /dev/null
+++ b/python/src/pay_kit/protocols/mpp/__init__.py
@@ -0,0 +1,423 @@
+"""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:`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
+``pay_kit.protocols.mpp.server.charge.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, 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.headers import format_www_authenticate, parse_authorization
+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
+ from pay_kit.gate import Gate
+ from pay_kit.price import Price
+
+__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.
+# 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 ``pay_kit.protocols.mpp.server.charge.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 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()
+
+ 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) -> 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: MppAcceptsEntry = {
+ "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"] = [
+ MppSplit(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 ``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``
+ # 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()
+ # 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
+ # Mints::resolve uses so `pay --sandbox --mpp curl` matches.
+ method_details: MppMethodDetails = {"network": self._config.network.mints_label()}
+ if gate.has_fees():
+ method_details["splits"] = [
+ 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:
+ 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
+ # 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,
+ currency=coin,
+ recipient=pay_to,
+ description=gate.description or "",
+ external_id=gate.external_id or "",
+ method_details=cast("dict[str, Any]", method_details) or None,
+ )
+
+ 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
+ # 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
+ return options
+
+ def _server_for(self, gate: Gate) -> Mpp:
+ """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}"
+ 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.
+
+ 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)."""
+ 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: object = getattr(request, "headers", None)
+ if headers is None and isinstance(request, dict):
+ 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: object = getter(name)
+ if value is None:
+ value = getter(name.title())
+ return str(value) if value else ""
+ return ""
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/pay_kit/protocols/mpp/client/charge.py b/python/src/pay_kit/protocols/mpp/client/charge.py
new file mode 100644
index 000000000..96518990a
--- /dev/null
+++ b/python/src/pay_kit/protocols/mpp/client/charge.py
@@ -0,0 +1,321 @@
+"""Client-side transaction building for charge intent."""
+
+from __future__ import annotations
+
+from typing import Any
+
+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,
+ 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
+
+
+async def build_credential_header(
+ signer: Any,
+ rpc_client: Any,
+ challenge: PaymentChallenge,
+) -> str:
+ """Create an Authorization header value from a challenge.
+
+ Args:
+ signer: A Solana keypair (solders.Keypair) for signing transactions.
+ rpc_client: A solana.rpc.async_api.AsyncClient for RPC calls.
+ challenge: The payment challenge to satisfy.
+
+ Returns:
+ The formatted Authorization header value.
+ """
+ request_data = decode_json(challenge.request)
+ request = ChargeRequest.from_dict(request_data)
+
+ details = MethodDetails()
+ if request.method_details:
+ details = MethodDetails.from_dict(request.method_details)
+
+ payload = await build_charge_transaction(
+ signer=signer,
+ rpc_client=rpc_client,
+ amount=request.amount,
+ currency=request.currency,
+ recipient=request.recipient,
+ external_id=request.external_id,
+ method_details=details,
+ )
+
+ credential = PaymentCredential(
+ challenge=challenge.to_echo(),
+ payload=payload.to_dict(),
+ )
+
+ return format_authorization(credential)
+
+
+async def build_charge_transaction(
+ signer: Any,
+ rpc_client: Any,
+ amount: str,
+ currency: str,
+ 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.
+
+ This creates the appropriate transfer instructions (SOL or SPL token),
+ signs the transaction, and returns a credential payload.
+
+ Args:
+ signer: A Solana keypair for signing.
+ rpc_client: An async Solana RPC client.
+ amount: Amount in base units.
+ currency: Currency symbol or mint address.
+ 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.
+ """
+ # Lazy imports so the module can be imported without solana/solders installed
+ from solders.hash import Hash # type: ignore[import-untyped]
+ from solders.instruction import Instruction # type: ignore[import-untyped]
+ from solders.message import Message # type: ignore[import-untyped]
+ from solders.pubkey import Pubkey # type: ignore[import-untyped]
+ from solders.system_program import TransferParams, transfer # type: ignore[import-untyped]
+ from solders.transaction import Transaction # type: ignore[import-untyped]
+
+ 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.
+ # 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]) + price.to_bytes(8, "little"), [])
+ )
+ instructions.append(
+ Instruction(compute_budget_program, bytes([2]) + limit.to_bytes(4, "little"), [])
+ )
+
+ def append_memo(memo: str) -> None:
+ if not memo:
+ return
+ data = memo.encode("utf-8")
+ if len(data) > 566:
+ 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(
+ TransferParams(
+ from_pubkey=signer.pubkey(),
+ to_pubkey=recipient_key,
+ lamports=primary_amount,
+ )
+ )
+ instructions.append(ix)
+ append_memo(external_id)
+
+ # Add split transfers
+ for split in details.splits:
+ split_key = Pubkey.from_string(split.recipient)
+ split_amount = int(split.amount)
+ split_ix = transfer(
+ TransferParams(
+ from_pubkey=signer.pubkey(),
+ to_pubkey=split_key,
+ lamports=split_amount,
+ )
+ )
+ instructions.append(split_ix)
+ append_memo(split.memo)
+ else:
+ # 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 = 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))
+ 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(ata_payer, 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:
+ blockhash = Hash.from_string(details.recent_blockhash)
+ else:
+ resp = await rpc_client.get_latest_blockhash()
+ blockhash = resp.value.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.partial_sign([signer], blockhash)
+
+ # Encode transaction
+ import base64 as b64
+
+ tx_bytes = bytes(tx)
+ 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/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/_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/_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/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..e93981121
--- /dev/null
+++ b/python/src/pay_kit/protocols/mpp/server/_tx_decode.py
@@ -0,0 +1,528 @@
+"""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)
+ # 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
+ 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 _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,
+ 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 _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:
+ 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")
+ # 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,
+ "parsed": {
+ "type": "transferChecked",
+ "info": {
+ "destination": destination,
+ "mint": mint,
+ "tokenAmount": {"amount": str(amount), "decimals": decimals},
+ },
+ },
+ }
+ )
+ 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..9bcd62126
--- /dev/null
+++ b/python/src/pay_kit/protocols/mpp/server/_verify.py
@@ -0,0 +1,661 @@
+"""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,
+) -> 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
+ (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
+
+ return owner
+
+
+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)
+ 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
+ # 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")
+ # 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 (details.decimals is None or decimals == details.decimals)
+ 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:
+ created_owner = _validate_ata_create_idempotent(
+ instruction,
+ account_keys,
+ expected_mint,
+ allowed_ata_owners,
+ expected_token_program,
+ fee_payer_account,
+ )
+ created_ata_owners.add(created_owner)
+ continue
+
+ raise PaymentError(
+ f"unexpected program instruction in payment transaction: {program_id}",
+ 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,
+ 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
new file mode 100644
index 000000000..1a22723a6
--- /dev/null
+++ b/python/src/pay_kit/protocols/mpp/server/charge.py
@@ -0,0 +1,612 @@
+"""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 logging
+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 (
+ CredentialPayload,
+ MethodDetails,
+ default_rpc_url,
+ default_token_program_for_currency,
+ is_native_sol,
+ stablecoin_symbol,
+)
+from pay_kit._paycore.store import Store
+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:"
+
+# 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
+class ChargeOptions:
+ """Options for charge challenge generation."""
+
+ description: str = ""
+ external_id: str = ""
+ expires: str = ""
+ fee_payer: bool = False
+ splits: list[dict] = field(default_factory=list)
+
+
+@dataclass
+class Config:
+ """Server-side configuration."""
+
+ recipient: str = ""
+ currency: str = "USDC"
+ decimals: int = 6
+ network: str = "mainnet"
+ rpc_url: str = ""
+ secret_key: str = ""
+ realm: str = ""
+ html: bool = False
+ fee_payer_signer: Any = None
+ store: Store | None = None
+ # The RPC client MUST expose at least the methods on
+ # :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
+ # solana-py client was a drop-in replacement; it is not, because it
+ # lacks ``await_confirmation`` and would AttributeError between the
+ # broadcast and the confirmation poll, AFTER the consume marker is
+ # durable. ``Mpp.__init__`` validates the contract at config time so
+ # the failure surfaces before any 402 traffic.
+ rpc: Any = None
+
+
+class Mpp:
+ """Server-side Solana charge handler.
+
+ Follows the same logic as the Go server.go implementation.
+ """
+
+ def __init__(self, config: Config) -> None:
+ if not config.recipient or not config.recipient.strip():
+ raise PaymentError("recipient is required", code="invalid-config")
+
+ import os
+
+ secret_key = config.secret_key or os.environ.get(_SECRET_KEY_ENV_VAR, "")
+ if not secret_key:
+ raise PaymentError("missing secret key", code="invalid-config")
+
+ self._secret_key = secret_key
+ self._realm = config.realm or _DEFAULT_REALM
+ self._recipient = config.recipient
+ self._currency = config.currency or "USDC"
+ self._decimals = config.decimals or 6
+ 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)
+ self._html = config.html
+ self._fee_payer_signer = config.fee_payer_signer
+ if config.store is None:
+ # L4 lock: a missing replay store is a server misconfiguration.
+ # Silently falling back to MemoryStore() used to leave a window
+ # where a credential could replay after restart. Mirrors the
+ # required-explicit-store contract on Ruby and PHP after #96 / #102.
+ raise PaymentError(
+ "replay store is required; pass MemoryStore() or FileReplayStore(path) explicitly",
+ code="invalid-config",
+ )
+ self._store: Store = config.store
+ # Validate the RPC client contract up-front. The settlement path
+ # calls ``send_raw_transaction``, ``await_confirmation`` and
+ # ``get_transaction`` after the durable consume marker is
+ # written; a missing method on the rpc instance would surface
+ # only after that consume, stranding the user. Reject at config
+ # time instead.
+ if config.rpc is not None:
+ for method_name in ("send_raw_transaction", "await_confirmation", "get_transaction"):
+ if not callable(getattr(config.rpc, method_name, None)):
+ raise PaymentError(
+ f"rpc client missing required method '{method_name}'; "
+ "use pay_kit._paycore.rpc.SolanaRpc or a compatible client",
+ code="invalid-config",
+ )
+ self._rpc = config.rpc
+ # Held by ``using_rpc`` to serialize per-request RPC swaps when
+ # the interop adapter (or any embedder) wants a fresh client
+ # bound to the current event loop. The async lock is created
+ # lazily on first use so construction does not require a
+ # running loop.
+ self._rpc_swap_lock = asyncio.Lock()
+
+ @contextlib.asynccontextmanager
+ async def using_rpc(self, rpc: Any):
+ """Scope an RPC client to the surrounding async block.
+
+ Swaps ``self._rpc`` for the duration of the body and always
+ restores the prior value on exit, even if the body raises.
+
+ Concurrency caveat: the underlying lock is an ``asyncio.Lock``,
+ which serialises only coroutines running on the SAME event
+ loop. Embedders that share one ``Mpp`` instance across multiple
+ OS threads (each running its own ``asyncio.run`` loop) MUST
+ provide their own thread-level coordination. The interop
+ adapter ships a sequential ``HTTPServer`` (not ThreadingMixIn),
+ so this lock is sufficient there; a ThreadingHTTPServer or
+ Gunicorn-style worker pool would require either thread-local
+ ``Mpp`` instances or a ``threading.Lock`` wrapping the swap.
+ """
+ async with self._rpc_swap_lock:
+ previous = self._rpc
+ self._rpc = rpc
+ try:
+ yield
+ finally:
+ self._rpc = previous
+
+ @property
+ def realm(self) -> str:
+ return self._realm
+
+ @property
+ def rpc_url(self) -> str:
+ return self._rpc_url
+
+ @property
+ def html_enabled(self) -> bool:
+ return self._html
+
+ def charge(self, amount: str) -> PaymentChallenge:
+ """Create a charge challenge from a human-readable amount."""
+ return self.charge_with_options(amount, ChargeOptions())
+
+ def charge_with_options(self, amount: str, options: ChargeOptions) -> PaymentChallenge:
+ """Create a charge challenge with optional fields."""
+ base_units = parse_units(amount, self._decimals)
+
+ details: dict[str, Any] = {"network": self._network}
+ if not is_native_sol(self._currency):
+ details["decimals"] = self._decimals
+ if stablecoin_symbol(self._currency):
+ details["tokenProgram"] = default_token_program_for_currency(self._currency, self._network)
+ if options.fee_payer or self._fee_payer_signer is not None:
+ details["feePayer"] = True
+ if self._fee_payer_signer is not None:
+ details["feePayerKey"] = str(self._fee_payer_signer.pubkey())
+ if options.splits:
+ details["splits"] = options.splits
+
+ request_obj: dict[str, Any] = {
+ "amount": base_units,
+ "currency": self._currency,
+ "recipient": self._recipient,
+ }
+ if options.description:
+ request_obj["description"] = options.description
+ if options.external_id:
+ request_obj["externalId"] = options.external_id
+ if details:
+ request_obj["methodDetails"] = details
+
+ request_b64 = encode_json(request_obj)
+
+ from pay_kit.protocols.mpp.core.expires import minutes
+
+ default_expires = minutes(5)
+ return PaymentChallenge.with_secret_key(
+ secret_key=self._secret_key,
+ realm=self._realm,
+ method="solana",
+ intent="charge",
+ request=request_b64,
+ expires=options.expires or default_expires,
+ description=options.description,
+ )
+
+ async def verify_credential(self, credential: PaymentCredential) -> Receipt:
+ """Verify either a transaction or signature credential payload.
+
+ This is the simple API and is appropriate for servers that only gate a
+ single route. Servers that gate multiple routes at different prices on
+ the same secret key MUST use ``verify_credential_with_expected`` so the
+ route's expected amount is compared to the credential's claimed amount;
+ otherwise a credential issued for a cheaper route can be replayed at
+ an expensive one.
+
+ Even on the simple API, a Tier-2 pinned-field check enforces that the
+ credential's method/intent/realm/currency/recipient match this Mpp's
+ configuration, so cross-route replay across instances with different
+ recipients/currencies is blocked.
+ """
+ request, details, payload = self._verify_challenge_and_decode(credential)
+ return await self._verify_payload(credential, request, details, payload)
+
+ async def verify_credential_with_expected(
+ self,
+ credential: PaymentCredential,
+ expected: ChargeRequest,
+ ) -> Receipt:
+ """Verify a credential against the route's expected charge request.
+
+ The amount, currency, and recipient on the credential's claimed
+ challenge must match ``expected``. Settlement (transaction broadcast,
+ on-chain checks) then runs against ``expected`` — not the credential's
+ claims — so a credential built for a different route's request cannot
+ succeed even if its other fields line up.
+ """
+ cred_request, _details, payload = self._verify_challenge_and_decode(credential)
+
+ if cred_request.amount != expected.amount:
+ raise PaymentError(
+ f"amount mismatch: credential has {cred_request.amount} but endpoint expects {expected.amount}",
+ code="amount-mismatch",
+ )
+ if cred_request.currency != expected.currency:
+ raise PaymentError(
+ f"currency mismatch: credential has {cred_request.currency} but endpoint expects {expected.currency}",
+ code="currency-mismatch",
+ )
+ if cred_request.recipient != expected.recipient:
+ raise PaymentError(
+ "recipient mismatch: credential was issued for a different recipient",
+ code="recipient-mismatch",
+ )
+
+ expected_details = MethodDetails()
+ if expected.method_details:
+ expected_details = MethodDetails.from_dict(expected.method_details)
+
+ return await self._verify_payload(credential, expected, expected_details, payload)
+
+ def _verify_challenge_and_decode(
+ self, credential: PaymentCredential
+ ) -> tuple[ChargeRequest, MethodDetails, CredentialPayload]:
+ """Run Tier-1 (HMAC + expiry) and Tier-2 (pinned-field) checks.
+
+ Returns the credential-decoded request, parsed method details, and the
+ credential payload for downstream settlement.
+ """
+ challenge = PaymentChallenge(
+ id=credential.challenge.id,
+ realm=credential.challenge.realm,
+ method=credential.challenge.method,
+ intent=credential.challenge.intent,
+ request=credential.challenge.request,
+ expires=credential.challenge.expires,
+ digest=credential.challenge.digest,
+ opaque=credential.challenge.opaque,
+ )
+
+ if not challenge.verify(self._secret_key):
+ raise ChallengeMismatchError()
+
+ if challenge.is_expired():
+ raise ChallengeExpiredError(f"challenge expired at {challenge.expires}")
+
+ request = ChargeRequest.from_dict(challenge.decode_request())
+
+ # Tier-2: pinned-field backstop. Even if the simple verify_credential
+ # path is used, fields that are fixed at Mpp construction time must
+ # match the credential.
+ self._verify_pinned_fields(credential, request)
+
+ details = MethodDetails()
+ if request.method_details:
+ details = MethodDetails.from_dict(request.method_details)
+
+ payload = CredentialPayload.from_dict(credential.payload)
+ return request, details, payload
+
+ def _verify_pinned_fields(self, credential: PaymentCredential, request: ChargeRequest) -> None:
+ # L6 lock: pinned-field mismatches are route mismatches, NOT HMAC
+ # verification failures. A validly signed credential for a different
+ # route or with a tampered echoed field reaches this path. Emitting
+ # ``challenge_route_mismatch`` lets clients distinguish a bad HMAC
+ # (``challenge_verification_failed``) from a signed credential
+ # replayed against the wrong endpoint.
+ method_name = "solana"
+ if credential.challenge.method != method_name:
+ raise PaymentError(
+ f"credential method '{credential.challenge.method}' does not match this server "
+ f"(expected '{method_name}')",
+ code="method-mismatch",
+ )
+ # IntentName equivalent: case-insensitive "charge" comparison.
+ if credential.challenge.intent.lower() != "charge":
+ raise PaymentError(
+ f"credential intent '{credential.challenge.intent}' is not a charge",
+ code="intent-mismatch",
+ )
+ # The HMAC ID is computed using the server's own realm (not the echoed
+ # one), so a tampered echoed realm passes HMAC unless re-signed. Pin it.
+ if credential.challenge.realm != self._realm:
+ raise PaymentError(
+ f"credential realm '{credential.challenge.realm}' does not match this server "
+ f"(expected '{self._realm}')",
+ code="realm-mismatch",
+ )
+ if request.currency != self._currency:
+ raise PaymentError(
+ f"credential currency '{request.currency}' does not match this server (expected '{self._currency}')",
+ code="currency-mismatch",
+ )
+ if request.recipient != self._recipient:
+ raise PaymentError(
+ "credential recipient does not match this server",
+ code="recipient-mismatch",
+ )
+
+ async def _verify_payload(
+ self,
+ credential: PaymentCredential,
+ request: ChargeRequest,
+ details: MethodDetails,
+ payload: CredentialPayload,
+ ) -> Receipt:
+ if payload.type == "transaction":
+ return await self._verify_transaction(credential, request, details, payload)
+ elif payload.type == "signature":
+ if details.fee_payer:
+ raise PaymentError(
+ 'type="signature" credentials cannot be used with fee sponsorship',
+ code="invalid-payload-type",
+ )
+ return await self._verify_signature(credential, request, details, payload)
+ else:
+ raise PaymentError("missing or invalid payload type", code="invalid-payload-type")
+
+ async def _verify_transaction(
+ self,
+ credential: PaymentCredential,
+ request: ChargeRequest,
+ details: MethodDetails,
+ payload: CredentialPayload,
+ ) -> Receipt:
+ """Verify a pull-mode transaction credential."""
+ if not payload.transaction:
+ raise PaymentError("missing transaction data in credential payload", code="missing-transaction")
+ if self._rpc is None:
+ raise PaymentError("rpc client is required for transaction verification", code="invalid-config")
+ if details.fee_payer and self._fee_payer_signer is None:
+ raise PaymentError(
+ "challenge advertises feePayer=true but server has no fee payer configured",
+ code="invalid-config",
+ )
+
+ # Reject up-front if the client signed against the wrong network
+ # (e.g. mainnet keypair pointed at a sandbox-configured server, or
+ # 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
+ raise PaymentError(
+ f"could not decode transaction to read blockhash: {exc}",
+ code="invalid-payload-type",
+ ) from exc
+ check_network_blockhash(self._network, blockhash_b58)
+ # SECURITY: pass the SERVER-side fee-payer pubkey (not the
+ # client-echoed ``details.fee_payer_key``) so the allowlist's
+ # drain-detection check matches against the actual signing key.
+ # A tampered echoed key is rejected up-front by
+ # ``_verify_local_transaction_intent``.
+ server_fee_payer_pubkey: str | None = None
+ if self._fee_payer_signer is not None:
+ server_fee_payer_pubkey = str(self._fee_payer_signer.pubkey())
+ _verify_local_transaction_intent(
+ payload.transaction,
+ request,
+ details,
+ expected_fee_payer_pubkey=server_fee_payer_pubkey,
+ )
+
+ # If the challenge advertises a server-side fee payer, co-sign the
+ # client's transaction now (after pre-broadcast verification, before
+ # broadcast). Mirrors rust/src/server/charge.rs verify_pull cosign
+ # step. The fee payer signature occupies the slot for the fee-payer
+ # account in the wire transaction.
+ signed_b64 = payload.transaction
+ if details.fee_payer:
+ signed_b64 = _co_sign_with_fee_payer(payload.transaction, self._fee_payer_signer)
+
+ # L8 lock: broadcast first, then consume_signature, then await
+ # confirmation. The previous order (consume → broadcast → await,
+ # with a rollback in the except block) had a fatal flaw: a
+ # confirmation timeout after a successful broadcast triggered the
+ # rollback path which DELETED the consume marker, so a retry of the
+ # same credential could re-broadcast the same signed transaction
+ # and re-issue a receipt for it. Mirrors the canonical L8 order
+ # documented in lua/mpp/server/charge_handler.lua and the fix that
+ # landed on Ruby + PHP + Rust in PR #96 / #102. This is the same
+ # confirmation-timeout double-pay window Ludo found on the Rust
+ # spine; closing it here brings Python into parity.
+
+ raw_tx = base64.b64decode(signed_b64)
+ send_resp = await self._rpc.send_raw_transaction(raw_tx)
+ signature = str(_rpc_value(send_resp))
+
+ # CONSUME the signature now that we know it has been accepted by the
+ # cluster. Keying by signature (not by the credential bytes) means a
+ # retry of the same credential always tries to insert the same key,
+ # so the second attempt fails fast and the network is never asked
+ # to settle the same transaction twice.
+ consumed_key = _CONSUMED_PREFIX + signature
+ inserted = await self._store.put_if_absent(consumed_key, True)
+ if not inserted:
+ raise ReplayError()
+
+ # AWAIT confirmation. A timeout here MUST NOT roll back the consume:
+ # the signature is on the wire and may finalize asynchronously.
+ # Use ``await_confirmation`` (not ``confirm_transaction``) so an
+ # on-chain failure surfaces as ``transaction-failed`` while a
+ # polling timeout surfaces as ``transaction-not-found``; the
+ # canonical code mapping in ``_errors`` collapses both to the
+ # same client-facing 402 body, so the discrimination is purely
+ # diagnostic.
+ # Pass the raw signature string straight through. The previous
+ # ``Signature.from_string(signature)`` call sat between the
+ # durable consume marker (above) and the get_transaction call;
+ # if that parse ever raised (malformed RPC response, future
+ # solders API change), the consume would be durable but no
+ # receipt would be issued, stranding the user. ``get_transaction``
+ # already calls ``str(signature)`` internally on the wire, so the
+ # conversion is redundant work on the post-consume critical path.
+ await self._rpc.await_confirmation(signature)
+
+ tx_resp = await self._rpc.get_transaction(signature, encoding="jsonParsed", max_supported_transaction_version=0)
+ tx = _transaction_dict(tx_resp)
+ if tx is None:
+ raise PaymentError("transaction not found or not yet confirmed", code="transaction-not-found")
+ self._verify_confirmed_transaction(tx, request, details)
+ return Receipt.success(
+ method="solana",
+ reference=signature,
+ challenge_id=credential.challenge.id,
+ external_id=request.external_id,
+ )
+
+ async def _verify_signature(
+ self,
+ credential: PaymentCredential,
+ request: ChargeRequest,
+ details: MethodDetails,
+ payload: CredentialPayload,
+ ) -> Receipt:
+ """Verify a push-mode signature credential."""
+ if not payload.signature:
+ raise PaymentError("missing signature in credential payload", code="missing-signature")
+ if self._rpc is None:
+ raise PaymentError("rpc client is required for signature verification", code="invalid-config")
+
+ # L8 push-mode lock: fetch the on-chain transaction and verify its
+ # shape BEFORE consuming the signature. If the client lied about the
+ # signature (or sent a signature that does not match the route), we
+ # do not want a permanent replay-store entry for it. Only after the
+ # on-chain shape is known to be correct do we mark the signature
+ # consumed. Mirrors lua/mpp/server/charge_handler.lua push-mode
+ # steps 2-4 and the cross-SDK lock from PR #96 / #102.
+ from solders.signature import Signature
+
+ sig = Signature.from_string(payload.signature)
+ tx_resp = await self._rpc.get_transaction(sig, encoding="jsonParsed", max_supported_transaction_version=0)
+ tx = _transaction_dict(tx_resp)
+ if tx is None:
+ raise PaymentError("transaction not found or not yet confirmed", code="transaction-not-found")
+ self._verify_confirmed_transaction(tx, request, details)
+
+ consumed_key = _CONSUMED_PREFIX + payload.signature
+ inserted = await self._store.put_if_absent(consumed_key, True)
+ if not inserted:
+ raise ReplayError()
+
+ return Receipt.success(
+ method="solana",
+ reference=payload.signature,
+ challenge_id=credential.challenge.id,
+ external_id=request.external_id,
+ )
+
+ def _verify_confirmed_transaction(self, tx: dict[str, Any], request: ChargeRequest, details: MethodDetails) -> None:
+ """Post-confirmation verification of the on-chain transaction
+ shape (transfers, memos, instruction allowlist).
+
+ L8 contract: this runs AFTER the durable replay marker is
+ written by ``_verify_transaction`` (broadcast → consume →
+ await → verify). The pre-broadcast verifier
+ ``_verify_local_transaction_intent`` already enforces the same
+ invariants on the raw signed bytes before any RPC call, so a
+ malicious credential never broadcasts; this confirmed-tx
+ verifier is defense-in-depth that re-checks the artifact the
+ cluster actually accepted, catching any cluster-side
+ rewriting / replay-attack the pre-broadcast verifier could
+ not see. Both layers must accept the same shape, otherwise the
+ receipt is rejected and the consume marker stays written
+ (the credential is single-use either way).
+ """
+ meta = tx.get("meta") or {}
+ if meta.get("err") is not None:
+ raise PaymentError(f"transaction failed on-chain: {meta['err']}", code="transaction-failed")
+
+ instructions = ((tx.get("transaction") or {}).get("message") or {}).get("instructions") or []
+ 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)
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..a48277737 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._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
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/payment_page.py b/python/src/pay_kit/protocols/mpp/server/payment_page.py
similarity index 88%
rename from python/src/solana_mpp/server/payment_page.py
rename to python/src/pay_kit/protocols/mpp/server/payment_page.py
index 5e9e10a21..456df9e8e 100644
--- a/python/src/solana_mpp/server/payment_page.py
+++ b/python/src/pay_kit/protocols/mpp/server/payment_page.py
@@ -10,11 +10,12 @@
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
-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"
@@ -40,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:
@@ -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":
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..4e0b2daf7
--- /dev/null
+++ b/python/src/pay_kit/protocols/x402/__init__.py
@@ -0,0 +1,433 @@
+"""x402 ``exact`` (Solana) adapter package.
+
+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
+
+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 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,
+ 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()
+ # 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 "",
+ "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",
+ )
+ # 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",
+ )
+ 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:
+ 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()
+
+ 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..a1f780e17
--- /dev/null
+++ b/python/src/pay_kit/protocols/x402/client/__init__.py
@@ -0,0 +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
new file mode 100644
index 000000000..2ca0dffed
--- /dev/null
+++ b/python/src/pay_kit/protocols/x402/client/exact/__init__.py
@@ -0,0 +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..1f3e1e410
--- /dev/null
+++ b/python/src/pay_kit/protocols/x402/client/exact/payment.py
@@ -0,0 +1,578 @@
+"""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(20000) +
+SetComputeUnitPrice, then a ``transferChecked`` (SPL) or System ``transfer``
+(native SOL), then a Memo carrying ``extra.memo``.
+"""
+
+from __future__ import annotations
+
+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
+
+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,
+ 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
+
+if TYPE_CHECKING:
+ from pay_kit.signer import LocalSigner
+
+__all__ = [
+ "ChallengeSelection",
+ "parse_x402_challenge",
+ "build_payment",
+ "build_payment_header",
+]
+
+#: 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``.
+_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})
+
+
+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
+ 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)
+
+
+#: 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:
+ """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 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):
+ return
+ resource = cast("dict[str, object]", resource_value)
+ url = resource.get("url")
+ if not isinstance(url, str) or url == "":
+ return
+ description = resource.get("description")
+ for accept in accepts:
+ 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:
+ 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
+
+
+#: 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,
+ 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``.
+
+ 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()``.
+
+ 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
+ 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)
+ 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, "recipient") or _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
+ # 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),
+ _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:
+ # 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:
+ 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))
+ 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),
+ ],
+ )
+ )
+
+ # 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 None:
+ memo = (memo_nonce or _default_memo_nonce)()
+ instructions.append(Instruction(Pubkey.from_string(MEMO_PROGRAM), memo.encode("utf-8"), []))
+
+ # 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)
+
+ 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)
+
+ # 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": 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``).
+ 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 for the envelope top level.
+
+ Mirrors rust ``PaymentRequirements::resource_info`` (types.rs:253-265):
+ 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
+ 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(
+ 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,
+ memo_nonce: Callable[[], 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,
+ 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/client/exact/transport.py b/python/src/pay_kit/protocols/x402/client/exact/transport.py
new file mode 100644
index 000000000..0aa3baa76
--- /dev/null
+++ b/python/src/pay_kit/protocols/x402/client/exact/transport.py
@@ -0,0 +1,130 @@
+"""Payment-aware httpx transport for automatic x402 ``exact`` 402 handling.
+
+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
+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
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..43fef0e23
--- /dev/null
+++ b/python/src/pay_kit/protocols/x402/exact/verify.py
@@ -0,0 +1,334 @@
+"""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.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. 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 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",
+ "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 in (MEMO_PROGRAM, LIGHTHOUSE_PROGRAM)
+ 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)
+
+ # The destination ATA must pre-exist; ATA-create is never accepted.
+ transfer["destinationCreateAta"] = False
+ 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 _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/signer.py b/python/src/pay_kit/signer.py
new file mode 100644
index 000000000..bdcab0fd7
--- /dev/null
+++ b/python/src/pay_kit/signer.py
@@ -0,0 +1,364 @@
+"""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, cast
+
+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)."""
+ # 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)
+ 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)."""
+ # 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")
+ 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]"``."""
+ # 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 == "":
+ 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")
+ # 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:
+ """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."""
+ # 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:
+ 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.
+ """
+ # 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 == "":
+ 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):
+ # 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)
+
+
+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: # pyright: ignore[reportUnusedFunction] # external test hook (test_pk_signer_operator)
+ """Reset the cached demo singleton + warning guard so the next call rebuilds.
+
+ @internal
+ """
+ global _demo_instance, _demo_warned
+ _demo_instance = None
+ _demo_warned = False
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/client/charge.py b/python/src/solana_mpp/client/charge.py
deleted file mode 100644
index 6a306a57a..000000000
--- a/python/src/solana_mpp/client/charge.py
+++ /dev/null
@@ -1,165 +0,0 @@
-"""Client-side transaction building for charge intent."""
-
-from __future__ import annotations
-
-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 (
- MEMO_PROGRAM,
- CredentialPayload,
- MethodDetails,
- is_native_sol,
-)
-
-logger = logging.getLogger(__name__)
-
-
-async def build_credential_header(
- signer: Any,
- rpc_client: Any,
- challenge: PaymentChallenge,
-) -> str:
- """Create an Authorization header value from a challenge.
-
- Args:
- signer: A Solana keypair (solders.Keypair) for signing transactions.
- rpc_client: A solana.rpc.async_api.AsyncClient for RPC calls.
- challenge: The payment challenge to satisfy.
-
- Returns:
- The formatted Authorization header value.
- """
- request_data = decode_json(challenge.request)
- request = ChargeRequest.from_dict(request_data)
-
- details = MethodDetails()
- if request.method_details:
- details = MethodDetails.from_dict(request.method_details)
-
- payload = await build_charge_transaction(
- signer=signer,
- rpc_client=rpc_client,
- amount=request.amount,
- currency=request.currency,
- recipient=request.recipient,
- external_id=request.external_id,
- method_details=details,
- )
-
- credential = PaymentCredential(
- challenge=challenge.to_echo(),
- payload=payload.to_dict(),
- )
-
- return format_authorization(credential)
-
-
-async def build_charge_transaction(
- signer: Any,
- rpc_client: Any,
- amount: str,
- currency: str,
- recipient: str,
- method_details: MethodDetails | None = None,
- external_id: str = "",
-) -> CredentialPayload:
- """Build a Solana transaction for a charge intent.
-
- This creates the appropriate transfer instructions (SOL or SPL token),
- signs the transaction, and returns a credential payload.
-
- Args:
- signer: A Solana keypair for signing.
- rpc_client: An async Solana RPC client.
- amount: Amount in base units.
- currency: Currency symbol or mint address.
- recipient: Recipient public key (base58).
- external_id: Optional root payment memo requested by the server.
- method_details: Optional Solana-specific method details.
-
- Returns:
- A CredentialPayload with the signed transaction.
- """
- # Lazy imports so the module can be imported without solana/solders installed
- from solders.hash import Hash # type: ignore[import-untyped]
- from solders.instruction import Instruction # type: ignore[import-untyped]
- from solders.message import Message # type: ignore[import-untyped]
- from solders.pubkey import Pubkey # type: ignore[import-untyped]
- from solders.system_program import TransferParams, transfer # type: ignore[import-untyped]
- from solders.transaction import Transaction # type: ignore[import-untyped]
-
- details = method_details or MethodDetails()
- amount_int = int(amount)
- 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)
-
- instructions = []
- memo_program = Pubkey.from_string(MEMO_PROGRAM)
-
- def append_memo(memo: str) -> None:
- if not memo:
- return
- data = memo.encode("utf-8")
- if len(data) > 566:
- raise ValueError("memo cannot exceed 566 bytes")
- instructions.append(Instruction(memo_program, data, []))
-
- if is_native_sol(currency):
- # SOL transfer
- ix = transfer(
- TransferParams(
- from_pubkey=signer.pubkey(),
- to_pubkey=recipient_key,
- lamports=primary_amount,
- )
- )
- instructions.append(ix)
- append_memo(external_id)
-
- # Add split transfers
- for split in details.splits:
- split_key = Pubkey.from_string(split.recipient)
- split_amount = int(split.amount)
- split_ix = transfer(
- TransferParams(
- from_pubkey=signer.pubkey(),
- to_pubkey=split_key,
- lamports=split_amount,
- )
- )
- 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")
-
- # Get recent blockhash
- if details.recent_blockhash:
- blockhash = Hash.from_string(details.recent_blockhash)
- else:
- resp = await rpc_client.get_latest_blockhash()
- blockhash = resp.value.blockhash
-
- # Build and sign transaction
- msg = Message.new_with_blockhash(instructions, signer.pubkey(), blockhash)
- tx = Transaction.new_unsigned(msg)
- tx.sign([signer], blockhash)
-
- # Encode transaction
- import base64 as b64
-
- tx_bytes = bytes(tx)
- tx_b64 = b64.b64encode(tx_bytes).decode("ascii")
-
- return CredentialPayload(type="transaction", transaction=tx_b64)
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/src/solana_mpp/server/mpp.py b/python/src/solana_mpp/server/mpp.py
deleted file mode 100644
index 7aaffb6fa..000000000
--- a/python/src/solana_mpp/server/mpp.py
+++ /dev/null
@@ -1,1656 +0,0 @@
-"""Main server-side Solana charge handler."""
-
-from __future__ import annotations
-
-import asyncio
-import base64
-import contextlib
-import json
-import logging
-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 (
- 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 solana_mpp.server.network_check import check_network_blockhash
-from solana_mpp.store import Store
-
-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:`solana_mpp.protocol.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 _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.
-
- 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,
- )
-
-
-@dataclass
-class ChargeOptions:
- """Options for charge challenge generation."""
-
- description: str = ""
- external_id: str = ""
- expires: str = ""
- fee_payer: bool = False
- splits: list[dict] = field(default_factory=list)
-
-
-@dataclass
-class Config:
- """Server-side configuration."""
-
- recipient: str = ""
- currency: str = "USDC"
- decimals: int = 6
- network: str = "mainnet"
- rpc_url: str = ""
- secret_key: str = ""
- realm: str = ""
- html: bool = False
- 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``,
- # ``get_signature_statuses``, ``await_confirmation``,
- # ``get_recent_blockhash`` and ``get_transaction``. The previous
- # ``# solana.rpc.async_api.AsyncClient`` comment suggested the legacy
- # solana-py client was a drop-in replacement; it is not, because it
- # lacks ``await_confirmation`` and would AttributeError between the
- # broadcast and the confirmation poll, AFTER the consume marker is
- # durable. ``Mpp.__init__`` validates the contract at config time so
- # the failure surfaces before any 402 traffic.
- rpc: Any = None
-
-
-class Mpp:
- """Server-side Solana charge handler.
-
- Follows the same logic as the Go server.go implementation.
- """
-
- def __init__(self, config: Config) -> None:
- if not config.recipient or not config.recipient.strip():
- raise PaymentError("recipient is required", code="invalid-config")
-
- import os
-
- secret_key = config.secret_key or os.environ.get(_SECRET_KEY_ENV_VAR, "")
- if not secret_key:
- raise PaymentError("missing secret key", code="invalid-config")
-
- self._secret_key = secret_key
- self._realm = config.realm or _DEFAULT_REALM
- 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
-
- self._network = _canonical_net(config.network or "mainnet")
- self._rpc_url = config.rpc_url or default_rpc_url(self._network)
- self._html = config.html
- self._fee_payer_signer = config.fee_payer_signer
- if config.store is None:
- # L4 lock: a missing replay store is a server misconfiguration.
- # Silently falling back to MemoryStore() used to leave a window
- # where a credential could replay after restart. Mirrors the
- # required-explicit-store contract on Ruby and PHP after #96 / #102.
- raise PaymentError(
- "replay store is required; pass MemoryStore() or FileReplayStore(path) explicitly",
- code="invalid-config",
- )
- self._store: Store = config.store
- # Validate the RPC client contract up-front. The settlement path
- # calls ``send_raw_transaction``, ``await_confirmation`` and
- # ``get_transaction`` after the durable consume marker is
- # written; a missing method on the rpc instance would surface
- # only after that consume, stranding the user. Reject at config
- # time instead.
- if config.rpc is not None:
- for method_name in ("send_raw_transaction", "await_confirmation", "get_transaction"):
- 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",
- code="invalid-config",
- )
- self._rpc = config.rpc
- # Held by ``using_rpc`` to serialize per-request RPC swaps when
- # the interop adapter (or any embedder) wants a fresh client
- # bound to the current event loop. The async lock is created
- # lazily on first use so construction does not require a
- # running loop.
- self._rpc_swap_lock = asyncio.Lock()
-
- @contextlib.asynccontextmanager
- async def using_rpc(self, rpc: Any):
- """Scope an RPC client to the surrounding async block.
-
- Swaps ``self._rpc`` for the duration of the body and always
- restores the prior value on exit, even if the body raises.
-
- Concurrency caveat: the underlying lock is an ``asyncio.Lock``,
- which serialises only coroutines running on the SAME event
- loop. Embedders that share one ``Mpp`` instance across multiple
- OS threads (each running its own ``asyncio.run`` loop) MUST
- provide their own thread-level coordination. The interop
- adapter ships a sequential ``HTTPServer`` (not ThreadingMixIn),
- so this lock is sufficient there; a ThreadingHTTPServer or
- Gunicorn-style worker pool would require either thread-local
- ``Mpp`` instances or a ``threading.Lock`` wrapping the swap.
- """
- async with self._rpc_swap_lock:
- previous = self._rpc
- self._rpc = rpc
- try:
- yield
- finally:
- self._rpc = previous
-
- @property
- def realm(self) -> str:
- return self._realm
-
- @property
- def rpc_url(self) -> str:
- return self._rpc_url
-
- @property
- def html_enabled(self) -> bool:
- return self._html
-
- def charge(self, amount: str) -> PaymentChallenge:
- """Create a charge challenge from a human-readable amount."""
- return self.charge_with_options(amount, ChargeOptions())
-
- def charge_with_options(self, amount: str, options: ChargeOptions) -> PaymentChallenge:
- """Create a charge challenge with optional fields."""
- base_units = parse_units(amount, self._decimals)
-
- details: dict[str, Any] = {"network": self._network}
- if not is_native_sol(self._currency):
- details["decimals"] = self._decimals
- if stablecoin_symbol(self._currency):
- details["tokenProgram"] = default_token_program_for_currency(self._currency, self._network)
- if options.fee_payer or self._fee_payer_signer is not None:
- details["feePayer"] = True
- if self._fee_payer_signer is not None:
- details["feePayerKey"] = str(self._fee_payer_signer.pubkey())
- if options.splits:
- details["splits"] = options.splits
-
- request_obj: dict[str, Any] = {
- "amount": base_units,
- "currency": self._currency,
- "recipient": self._recipient,
- }
- if options.description:
- request_obj["description"] = options.description
- if options.external_id:
- request_obj["externalId"] = options.external_id
- if details:
- request_obj["methodDetails"] = details
-
- request_b64 = encode_json(request_obj)
-
- from solana_mpp._expires import minutes
-
- default_expires = minutes(5)
- return PaymentChallenge.with_secret_key(
- secret_key=self._secret_key,
- realm=self._realm,
- method="solana",
- intent="charge",
- request=request_b64,
- expires=options.expires or default_expires,
- description=options.description,
- )
-
- async def verify_credential(self, credential: PaymentCredential) -> Receipt:
- """Verify either a transaction or signature credential payload.
-
- This is the simple API and is appropriate for servers that only gate a
- single route. Servers that gate multiple routes at different prices on
- the same secret key MUST use ``verify_credential_with_expected`` so the
- route's expected amount is compared to the credential's claimed amount;
- otherwise a credential issued for a cheaper route can be replayed at
- an expensive one.
-
- Even on the simple API, a Tier-2 pinned-field check enforces that the
- credential's method/intent/realm/currency/recipient match this Mpp's
- configuration, so cross-route replay across instances with different
- recipients/currencies is blocked.
- """
- request, details, payload = self._verify_challenge_and_decode(credential)
- return await self._verify_payload(credential, request, details, payload)
-
- async def verify_credential_with_expected(
- self,
- credential: PaymentCredential,
- expected: ChargeRequest,
- ) -> Receipt:
- """Verify a credential against the route's expected charge request.
-
- The amount, currency, and recipient on the credential's claimed
- challenge must match ``expected``. Settlement (transaction broadcast,
- on-chain checks) then runs against ``expected`` — not the credential's
- claims — so a credential built for a different route's request cannot
- succeed even if its other fields line up.
- """
- cred_request, _details, payload = self._verify_challenge_and_decode(credential)
-
- if cred_request.amount != expected.amount:
- raise PaymentError(
- f"amount mismatch: credential has {cred_request.amount} but endpoint expects {expected.amount}",
- code="amount-mismatch",
- )
- if cred_request.currency != expected.currency:
- raise PaymentError(
- f"currency mismatch: credential has {cred_request.currency} but endpoint expects {expected.currency}",
- code="currency-mismatch",
- )
- if cred_request.recipient != expected.recipient:
- raise PaymentError(
- "recipient mismatch: credential was issued for a different recipient",
- code="recipient-mismatch",
- )
-
- expected_details = MethodDetails()
- if expected.method_details:
- expected_details = MethodDetails.from_dict(expected.method_details)
-
- return await self._verify_payload(credential, expected, expected_details, payload)
-
- def _verify_challenge_and_decode(
- self, credential: PaymentCredential
- ) -> tuple[ChargeRequest, MethodDetails, CredentialPayload]:
- """Run Tier-1 (HMAC + expiry) and Tier-2 (pinned-field) checks.
-
- Returns the credential-decoded request, parsed method details, and the
- credential payload for downstream settlement.
- """
- challenge = PaymentChallenge(
- id=credential.challenge.id,
- realm=credential.challenge.realm,
- method=credential.challenge.method,
- intent=credential.challenge.intent,
- request=credential.challenge.request,
- expires=credential.challenge.expires,
- digest=credential.challenge.digest,
- opaque=credential.challenge.opaque,
- )
-
- if not challenge.verify(self._secret_key):
- raise ChallengeMismatchError()
-
- if challenge.is_expired():
- raise ChallengeExpiredError(f"challenge expired at {challenge.expires}")
-
- request = ChargeRequest.from_dict(challenge.decode_request())
-
- # Tier-2: pinned-field backstop. Even if the simple verify_credential
- # path is used, fields that are fixed at Mpp construction time must
- # match the credential.
- self._verify_pinned_fields(credential, request)
-
- details = MethodDetails()
- if request.method_details:
- details = MethodDetails.from_dict(request.method_details)
-
- payload = CredentialPayload.from_dict(credential.payload)
- return request, details, payload
-
- def _verify_pinned_fields(self, credential: PaymentCredential, request: ChargeRequest) -> None:
- # L6 lock: pinned-field mismatches are route mismatches, NOT HMAC
- # verification failures. A validly signed credential for a different
- # route or with a tampered echoed field reaches this path. Emitting
- # ``challenge_route_mismatch`` lets clients distinguish a bad HMAC
- # (``challenge_verification_failed``) from a signed credential
- # replayed against the wrong endpoint.
- method_name = "solana"
- if credential.challenge.method != method_name:
- raise PaymentError(
- f"credential method '{credential.challenge.method}' does not match this server "
- f"(expected '{method_name}')",
- code="method-mismatch",
- )
- # IntentName equivalent: case-insensitive "charge" comparison.
- if credential.challenge.intent.lower() != "charge":
- raise PaymentError(
- f"credential intent '{credential.challenge.intent}' is not a charge",
- code="intent-mismatch",
- )
- # The HMAC ID is computed using the server's own realm (not the echoed
- # one), so a tampered echoed realm passes HMAC unless re-signed. Pin it.
- if credential.challenge.realm != self._realm:
- raise PaymentError(
- f"credential realm '{credential.challenge.realm}' does not match this server "
- f"(expected '{self._realm}')",
- code="realm-mismatch",
- )
- if request.currency != self._currency:
- raise PaymentError(
- f"credential currency '{request.currency}' does not match this server (expected '{self._currency}')",
- code="currency-mismatch",
- )
- if request.recipient != self._recipient:
- raise PaymentError(
- "credential recipient does not match this server",
- code="recipient-mismatch",
- )
-
- async def _verify_payload(
- self,
- credential: PaymentCredential,
- request: ChargeRequest,
- details: MethodDetails,
- payload: CredentialPayload,
- ) -> Receipt:
- if payload.type == "transaction":
- return await self._verify_transaction(credential, request, details, payload)
- elif payload.type == "signature":
- if details.fee_payer:
- raise PaymentError(
- 'type="signature" credentials cannot be used with fee sponsorship',
- code="invalid-payload-type",
- )
- return await self._verify_signature(credential, request, details, payload)
- else:
- raise PaymentError("missing or invalid payload type", code="invalid-payload-type")
-
- async def _verify_transaction(
- self,
- credential: PaymentCredential,
- request: ChargeRequest,
- details: MethodDetails,
- payload: CredentialPayload,
- ) -> Receipt:
- """Verify a pull-mode transaction credential."""
- if not payload.transaction:
- raise PaymentError("missing transaction data in credential payload", code="missing-transaction")
- if self._rpc is None:
- raise PaymentError("rpc client is required for transaction verification", code="invalid-config")
- if details.fee_payer and self._fee_payer_signer is None:
- raise PaymentError(
- "challenge advertises feePayer=true but server has no fee payer configured",
- code="invalid-config",
- )
-
- # 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.
- try:
- blockhash_b58 = _extract_recent_blockhash(payload.transaction)
- except Exception as exc: # noqa: BLE001 — propagate decode failures as invalid payload
- raise PaymentError(
- f"could not decode transaction to read blockhash: {exc}",
- code="invalid-payload-type",
- ) from exc
- check_network_blockhash(self._network, blockhash_b58)
- # SECURITY: pass the SERVER-side fee-payer pubkey (not the
- # client-echoed ``details.fee_payer_key``) so the allowlist's
- # drain-detection check matches against the actual signing key.
- # A tampered echoed key is rejected up-front by
- # ``_verify_local_transaction_intent``.
- server_fee_payer_pubkey: str | None = None
- if self._fee_payer_signer is not None:
- server_fee_payer_pubkey = str(self._fee_payer_signer.pubkey())
- _verify_local_transaction_intent(
- payload.transaction,
- request,
- details,
- expected_fee_payer_pubkey=server_fee_payer_pubkey,
- )
-
- # If the challenge advertises a server-side fee payer, co-sign the
- # client's transaction now (after pre-broadcast verification, before
- # broadcast). Mirrors rust/src/server/charge.rs verify_pull cosign
- # step. The fee payer signature occupies the slot for the fee-payer
- # account in the wire transaction.
- signed_b64 = payload.transaction
- if details.fee_payer:
- signed_b64 = _co_sign_with_fee_payer(payload.transaction, self._fee_payer_signer)
-
- # L8 lock: broadcast first, then consume_signature, then await
- # confirmation. The previous order (consume → broadcast → await,
- # with a rollback in the except block) had a fatal flaw: a
- # confirmation timeout after a successful broadcast triggered the
- # rollback path which DELETED the consume marker, so a retry of the
- # same credential could re-broadcast the same signed transaction
- # and re-issue a receipt for it. Mirrors the canonical L8 order
- # documented in lua/mpp/server/charge_handler.lua and the fix that
- # landed on Ruby + PHP + Rust in PR #96 / #102. This is the same
- # confirmation-timeout double-pay window Ludo found on the Rust
- # spine; closing it here brings Python into parity.
-
- raw_tx = base64.b64decode(signed_b64)
- send_resp = await self._rpc.send_raw_transaction(raw_tx)
- signature = str(_rpc_value(send_resp))
-
- # CONSUME the signature now that we know it has been accepted by the
- # cluster. Keying by signature (not by the credential bytes) means a
- # retry of the same credential always tries to insert the same key,
- # so the second attempt fails fast and the network is never asked
- # to settle the same transaction twice.
- consumed_key = _CONSUMED_PREFIX + signature
- inserted = await self._store.put_if_absent(consumed_key, True)
- if not inserted:
- raise ReplayError()
-
- # AWAIT confirmation. A timeout here MUST NOT roll back the consume:
- # the signature is on the wire and may finalize asynchronously.
- # Use ``await_confirmation`` (not ``confirm_transaction``) so an
- # on-chain failure surfaces as ``transaction-failed`` while a
- # polling timeout surfaces as ``transaction-not-found``; the
- # canonical code mapping in ``_errors`` collapses both to the
- # same client-facing 402 body, so the discrimination is purely
- # diagnostic.
- # Pass the raw signature string straight through. The previous
- # ``Signature.from_string(signature)`` call sat between the
- # durable consume marker (above) and the get_transaction call;
- # if that parse ever raised (malformed RPC response, future
- # solders API change), the consume would be durable but no
- # receipt would be issued, stranding the user. ``get_transaction``
- # already calls ``str(signature)`` internally on the wire, so the
- # conversion is redundant work on the post-consume critical path.
- await self._rpc.await_confirmation(signature)
-
- tx_resp = await self._rpc.get_transaction(signature, encoding="jsonParsed", max_supported_transaction_version=0)
- tx = _transaction_dict(tx_resp)
- if tx is None:
- raise PaymentError("transaction not found or not yet confirmed", code="transaction-not-found")
- self._verify_confirmed_transaction(tx, request, details)
- return Receipt.success(
- method="solana",
- reference=signature,
- challenge_id=credential.challenge.id,
- external_id=request.external_id,
- )
-
- async def _verify_signature(
- self,
- credential: PaymentCredential,
- request: ChargeRequest,
- details: MethodDetails,
- payload: CredentialPayload,
- ) -> Receipt:
- """Verify a push-mode signature credential."""
- if not payload.signature:
- raise PaymentError("missing signature in credential payload", code="missing-signature")
- if self._rpc is None:
- raise PaymentError("rpc client is required for signature verification", code="invalid-config")
-
- # L8 push-mode lock: fetch the on-chain transaction and verify its
- # shape BEFORE consuming the signature. If the client lied about the
- # signature (or sent a signature that does not match the route), we
- # do not want a permanent replay-store entry for it. Only after the
- # on-chain shape is known to be correct do we mark the signature
- # consumed. Mirrors lua/mpp/server/charge_handler.lua push-mode
- # steps 2-4 and the cross-SDK lock from PR #96 / #102.
- from solders.signature import Signature
-
- sig = Signature.from_string(payload.signature)
- tx_resp = await self._rpc.get_transaction(sig, encoding="jsonParsed", max_supported_transaction_version=0)
- tx = _transaction_dict(tx_resp)
- if tx is None:
- raise PaymentError("transaction not found or not yet confirmed", code="transaction-not-found")
- self._verify_confirmed_transaction(tx, request, details)
-
- consumed_key = _CONSUMED_PREFIX + payload.signature
- inserted = await self._store.put_if_absent(consumed_key, True)
- if not inserted:
- raise ReplayError()
-
- return Receipt.success(
- method="solana",
- reference=payload.signature,
- challenge_id=credential.challenge.id,
- external_id=request.external_id,
- )
-
- def _verify_confirmed_transaction(self, tx: dict[str, Any], request: ChargeRequest, details: MethodDetails) -> None:
- """Post-confirmation verification of the on-chain transaction
- shape (transfers, memos, instruction allowlist).
-
- L8 contract: this runs AFTER the durable replay marker is
- written by ``_verify_transaction`` (broadcast → consume →
- await → verify). The pre-broadcast verifier
- ``_verify_local_transaction_intent`` already enforces the same
- invariants on the raw signed bytes before any RPC call, so a
- malicious credential never broadcasts; this confirmed-tx
- verifier is defense-in-depth that re-checks the artifact the
- cluster actually accepted, catching any cluster-side
- rewriting / replay-attack the pre-broadcast verifier could
- not see. Both layers must accept the same shape, otherwise the
- receipt is rejected and the consume marker stays written
- (the credential is single-use either way).
- """
- meta = tx.get("meta") or {}
- if meta.get("err") is not None:
- raise PaymentError(f"transaction failed on-chain: {meta['err']}", code="transaction-failed")
-
- instructions = ((tx.get("transaction") or {}).get("message") or {}).get("instructions") or []
- 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)
diff --git a/python/tests/conftest.py b/python/tests/conftest.py
index 4e3acf002..28c8aa4bf 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._paycore.store import MemoryStore
+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.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..7f1d10056 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 solana_mpp.client.charge import build_charge_transaction
-from solana_mpp.protocol.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,42 @@ 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")
+
+ # 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=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)],
+ ),
+ )
+
+ 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())
@@ -79,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
diff --git a/python/tests/test_client_charge_edge.py b/python/tests/test_client_charge_edge.py
index 6c73f9f15..a344071d8 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"
@@ -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():
@@ -122,9 +135,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 +151,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_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..55bc66eba 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._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.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..3b0608228 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._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 solana_mpp._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 solana_mpp._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 solana_mpp._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 solana_mpp._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 solana_mpp._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 solana_mpp._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 solana_mpp._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 solana_mpp._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 solana_mpp._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_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 f7a832595..3c0ee1770 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._paycore.store import MemoryStore
+from pay_kit.protocols.mpp.core.headers import format_authorization
+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,
@@ -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_mpp_helpers.py b/python/tests/test_mpp_helpers.py
index b15569e6f..bd07afbaf 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.errors import PaymentError
+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.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..652cb2501 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._paycore.network_check import (
SURFPOOL_BLOCKHASH_PREFIX,
WrongNetworkError,
check_network_blockhash,
diff --git a/python/tests/test_pk_config.py b/python/tests/test_pk_config.py
new file mode 100644
index 000000000..0fb4939cf
--- /dev/null
+++ b/python/tests/test_pk_config.py
@@ -0,0 +1,368 @@
+"""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_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"):
+ 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..2407f69e5
--- /dev/null
+++ b/python/tests/test_pk_middleware.py
@@ -0,0 +1,442 @@
+"""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
+
+
+# -- 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 ---------------------------------------------------------
+
+
+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
+
+
+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(accept=(Protocol.MPP,))
+ core = PayCore(cfg)
+
+ @dynamic("by_units", accept=(Protocol.MPP,)) # type: ignore[arg-type]
+ def builder(request):
+ return Price.usd("0.10", Stablecoin.USDC)
+
+ class Catalog(Pricing):
+ def __init__(self):
+ self.by_units = builder
+
+ 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 -----------------------------------------------------
+
+
+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..72e1043bc
--- /dev/null
+++ b/python/tests/test_pk_mpp_adapter.py
@@ -0,0 +1,337 @@
+"""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 ``pay_kit.protocols.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 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"
+
+
+@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.get("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")
+
+
+# -- 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 ---------------------------------------
+
+
+@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..59285c8fb
--- /dev/null
+++ b/python/tests/test_pk_value_objects.py
@@ -0,0 +1,381 @@
+"""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_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
+ 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_client.py b/python/tests/test_pk_x402_client.py
new file mode 100644
index 000000000..030df4c2e
--- /dev/null
+++ b/python/tests/test_pk_x402_client.py
@@ -0,0 +1,862 @@
+"""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
+ # 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)
+ 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_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()
+ 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_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"]
+ 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
+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))
+
+
+# -- 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)
+
+
+@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,
+ "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
+ # 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 ----------------------------------------------------
+
+
+@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 await_confirmation(self, _signature, *_a, **_k):
+ return None
+
+ 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
diff --git a/python/tests/test_pk_x402_settle.py b/python/tests/test_pk_x402_settle.py
new file mode 100644
index 000000000..37df124ae
--- /dev/null
+++ b/python/tests/test_pk_x402_settle.py
@@ -0,0 +1,468 @@
+"""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 (
+ 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")
+assert _MINT is not None
+MINT: str = _MINT
+TP = token_program_for("USDC", "mainnet")
+
+
+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,
+ 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:
+ raise RuntimeError("broadcast boom")
+
+ class _Resp:
+ value = self._signature # type: ignore[assignment]
+
+ _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
+
+
+@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, confirm_error=None, monkeypatch=None, rpcs=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):
+ 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)
+ 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(" 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 ------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_missing_signature_header_is_payment_required(monkeypatch):
+ adapter, gate, _op = _adapter(monkeypatch=monkeypatch)
+
+ class _Empty:
+ headers = {}
+
+ with pytest.raises(InvalidProofError) as exc:
+ await adapter.verify_and_settle(gate, _Empty())
+ assert exc.value.code == "payment_required"
+
+
+@pytest.mark.asyncio
+async def test_non_base64_signature_header(monkeypatch):
+ adapter, gate, _op = _adapter(monkeypatch=monkeypatch)
+ with pytest.raises(InvalidProofError) as exc:
+ await adapter.verify_and_settle(gate, _Req("!!!notb64!!!"))
+ assert exc.value.code == "invalid_exact_svm_payload_signature_base64"
+
+
+@pytest.mark.asyncio
+async def test_non_json_signature_payload(monkeypatch):
+ adapter, gate, _op = _adapter(monkeypatch=monkeypatch)
+ header = base64.b64encode(b"\xff\xfenot json").decode()
+ with pytest.raises(InvalidProofError) as exc:
+ await adapter.verify_and_settle(gate, _Req(header))
+ assert exc.value.code == "invalid_exact_svm_payload_signature_json"
+
+
+@pytest.mark.asyncio
+async def test_wrong_version_rejected(monkeypatch):
+ adapter, gate, _op = _adapter(monkeypatch=monkeypatch)
+ header = base64.b64encode(json.dumps({"x402Version": 99}).encode()).decode()
+ with pytest.raises(InvalidProofError) as exc:
+ await adapter.verify_and_settle(gate, _Req(header))
+ assert exc.value.code == "unsupported_x402_version"
+
+
+@pytest.mark.asyncio
+async def test_malformed_envelope_rejected(monkeypatch):
+ adapter, gate, _op = _adapter(monkeypatch=monkeypatch)
+ bad = {"x402Version": X402_VERSION, "accepted": "x", "payload": 1}
+ header = base64.b64encode(json.dumps(bad).encode()).decode()
+ with pytest.raises(InvalidProofError) as exc:
+ await adapter.verify_and_settle(gate, _Req(header))
+ assert exc.value.code == "invalid_exact_svm_payload_envelope"
+
+
+@pytest.mark.asyncio
+async def test_tier2_accepted_mismatch_rejected(monkeypatch):
+ adapter, gate, op_kp = _adapter(monkeypatch=monkeypatch)
+ header = _build_envelope(adapter, gate, op_kp)
+ decoded = json.loads(base64.b64decode(header))
+ decoded["accepted"]["payTo"] = str(Keypair().pubkey()) # tamper a pinned field
+ 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_missing_transaction_in_payload(monkeypatch):
+ adapter, gate, op_kp = _adapter(monkeypatch=monkeypatch)
+ header = _build_envelope(adapter, gate, op_kp)
+ decoded = json.loads(base64.b64decode(header))
+ decoded["payload"]["transaction"] = ""
+ 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 == "invalid_exact_svm_payload_missing_transaction"
+
+
+@pytest.mark.asyncio
+async def test_no_operator_signer_rejected(monkeypatch):
+ # MPP-only operator path: signer present but x402 still needs a cosigner.
+ # Force the effective x402 signer to None by clearing operator signer.
+ reset()
+ monkeypatch.setenv("PAY_KIT_DISABLE_PREFLIGHT", "1")
+ cfg = configure(network="solana_localnet", preflight=False, accept=(Protocol.X402,))
+ adapter = X402Adapter(cfg)
+ gate = GateCls.build(
+ name="report",
+ amount=Price.usd("0.10", Stablecoin.USDC),
+ default_pay_to=cfg.effective_recipient(),
+ accept=(Protocol.X402,),
+ )
+ # Monkeypatch the config's effective signer to None for this assertion.
+ object.__setattr__(cfg.operator, "signer", None)
+ with pytest.raises(InvalidProofError, match="requires operator.signer"):
+ await adapter.verify_and_settle(gate, _Req("anything"))
+
+
+# -- helpers -----------------------------------------------------------------
+
+
+def test_is_loopback_rpc():
+ assert _is_loopback_rpc("http://127.0.0.1:8899") is True
+ assert _is_loopback_rpc("http://localhost:8899") is True
+ assert _is_loopback_rpc("https://[::1]:8899") is True
+ assert _is_loopback_rpc("https://api.mainnet-beta.solana.com") is False
+
+
+def test_request_path_variants():
+ class _UrlReq:
+ class url:
+ path = "/from-url"
+
+ class _PathReq:
+ path = "/from-path"
+
+ assert _request_path(_PathReq()) == "/from-path"
+ assert _request_path(_UrlReq()) == "/from-url"
+ assert _request_path({"path": "/dict"}) == "/dict"
+ assert _request_path(object()) == "/"
+
+
+def test_co_sign_fee_payer_not_present_rejected():
+ payer = Keypair()
+ outsider = LocalSigner.from_keypair(Keypair())
+ from solders.system_program import TransferParams, transfer
+
+ ix = transfer(TransferParams(from_pubkey=payer.pubkey(), to_pubkey=Keypair().pubkey(), lamports=1))
+ msg = MessageV0.try_compile(payer.pubkey(), [ix], [], Hash.from_string(BH))
+ vtx = VersionedTransaction(msg, [payer])
+ tx_b64 = base64.b64encode(bytes(vtx)).decode()
+ with pytest.raises(InvalidProofError):
+ _co_sign(tx_b64, outsider)
+
+
+def test_co_sign_unparseable_bytes_rejected():
+ bogus = base64.b64encode(b"\x00\x01\x02").decode()
+ with pytest.raises(InvalidProofError) as exc:
+ _co_sign(bogus, LocalSigner.from_keypair(Keypair()))
+ assert exc.value.code == "invalid_exact_svm_payload_transaction_parse"
diff --git a/python/tests/test_pk_x402_verifier.py b/python/tests/test_pk_x402_verifier.py
new file mode 100644
index 000000000..7029a38f5
--- /dev/null
+++ b/python/tests/test_pk_x402_verifier.py
@@ -0,0 +1,569 @@
+"""x402 ``exact`` 11-rule structural verifier coverage.
+
+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, 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
+
+import base64
+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
+
+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 ExactVerifier, X402Adapter
+from pay_kit.protocols.x402.exact.verify import (
+ COMPUTE_BUDGET_PROGRAM,
+ MEMO_PROGRAM,
+ TOKEN_2022_PROGRAM,
+)
+
+BH = "4vJ9JU1bJJQpUgJ8V6hYz7xXKz4F2tN6aBrZEcD3xKhs"
+_MINT = resolve("USDC", "mainnet")
+assert _MINT is not None
+MINT: str = _MINT
+TOKEN_PROGRAM = token_program_for("USDC", "mainnet")
+AMOUNT = 100_000
+
+
+@pytest.fixture(autouse=True)
+def _clean(monkeypatch):
+ reset()
+ monkeypatch.setenv("PAY_KIT_DISABLE_PREFLIGHT", "1")
+ yield
+ reset()
+
+
+# -- transaction builders ----------------------------------------------------
+
+
+def _compute_limit_ix(disc: int = 2, length: int = 5, program: str = COMPUTE_BUDGET_PROGRAM) -> 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_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(),
+ _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])
+ 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 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 --------------------------------------------------
+
+
+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"].get("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)
diff --git a/python/tests/test_rpc_contract.py b/python/tests/test_rpc_contract.py
index c8c6a1191..d6158a889 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._paycore.errors import PaymentError
+from pay_kit._paycore.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..3f647ff35 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._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 solana_mpp._errors import PaymentError
-from solana_mpp._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 solana_mpp._rpc as rpc_mod
+ import pay_kit._paycore.rpc as rpc_mod
async def _noop_sleep(_s):
return None
@@ -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()
diff --git a/python/tests/test_rpc_send_validation.py b/python/tests/test_rpc_send_validation.py
index 9cfc76d64..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 solana_mpp._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 3c6bec1fa..88e8ea03e 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.errors import ChallengeExpiredError, ChallengeMismatchError, PaymentError, ReplayError
+from pay_kit._paycore.solana import MEMO_PROGRAM, TOKEN_2022_PROGRAM, MethodDetails, Split
+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 (
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._paycore.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._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 solana_mpp.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 solana_mpp.store import MemoryStore
+ from pay_kit._paycore.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._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
@@ -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._paycore.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._paycore.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._paycore.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._paycore.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"
@@ -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
@@ -1669,8 +1721,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 +1766,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.errors import CODE_PAYMENT_INVALID, canonical_code
+ 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(
@@ -1813,8 +1865,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._paycore.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 +1898,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._paycore.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 +1936,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._paycore.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 +1976,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 +2069,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._paycore.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 +2107,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._paycore.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 +2153,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._paycore.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 +2198,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 b5ef05d8a..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,
@@ -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")
diff --git a/python/tests/test_server_v0_transactions.py b/python/tests/test_server_v0_transactions.py
index 555b0a7f2..f730d8a95 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
@@ -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,10 +33,11 @@
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.errors import PaymentError
+from pay_kit._paycore.solana import MethodDetails
+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
TEST_BLOCKHASH = "4vJ9JU1bJJQpUgJ8V6hYz7xXKz4F2tN6aBrZEcD3xKhs"
@@ -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_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..5d5719a56 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._paycore.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._paycore.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/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" },
+]
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