Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
a22200a
feat(python): add MPP client session intent
EfeDurmaz16 Jun 8, 2026
2c4f758
fix(python): harden session watermark and commit-receipt handling
EfeDurmaz16 Jun 8, 2026
9e3a6c5
fix(python): advance request nonce when reconciling a replayed settle…
EfeDurmaz16 Jun 8, 2026
fcba25b
style(python): ruff-format session modules and make test transports p…
EfeDurmaz16 Jun 9, 2026
9a678ce
fix(python): clamp replayed-receipt reconcile + strict u64 parsing
EfeDurmaz16 Jun 9, 2026
3a52578
refactor(python): delegate VoucherData.message_bytes to the canonical…
EfeDurmaz16 Jun 10, 2026
569b184
feat(python): generate the payment-channels client with codama-py
EfeDurmaz16 Jun 11, 2026
a88c41d
refactor(python): delegate payment-channels glue to the generated client
EfeDurmaz16 Jun 11, 2026
e7f824f
fix(python): record replayed commits like the rust spine
EfeDurmaz16 Jun 12, 2026
81d997a
fix(python): reject unknown modes and receipt statuses at decode
EfeDurmaz16 Jun 12, 2026
070223f
feat(python): add the challenge-driven payment-channel open layer
EfeDurmaz16 Jun 12, 2026
27de6ec
feat(python): add metered SSE stream consumption
EfeDurmaz16 Jun 12, 2026
dc27ca8
chore(python): regenerate uv.lock for anchorpy and borsh-construct
EfeDurmaz16 Jun 12, 2026
89c2289
docs(python): mark session as client-only in the scheme matrix
EfeDurmaz16 Jun 12, 2026
447f59c
style(python): drop em-dashes from session docstrings
EfeDurmaz16 Jun 12, 2026
ee3e36a
feat(python): mpp/session server + routes (port of Go #160)
EfeDurmaz16 Jun 13, 2026
cf02db3
feat(python): playground-api example (FastAPI port of Go #160)
EfeDurmaz16 Jun 13, 2026
e8a8976
refactor(python): align playground-api with the TS reference (review)
EfeDurmaz16 Jun 15, 2026
d345530
docs(python): self-standing docstrings; playground uses yfinance
EfeDurmaz16 Jun 15, 2026
b533173
feat(python/fastapi): add pay_kit.fastapi.install() one-call server s…
EfeDurmaz16 Jun 16, 2026
76f8a08
refactor(python/playground): cut to the advertised endpoints
EfeDurmaz16 Jun 16, 2026
9dfeee5
docs(python): per-field docs for session intents; trim playground README
EfeDurmaz16 Jun 16, 2026
317c3ac
fix(python/playground): truthful docs + lean charges (review)
EfeDurmaz16 Jun 17, 2026
34c845a
refactor(python/playground): gate session routes via a FastAPI depend…
EfeDurmaz16 Jun 17, 2026
a37144f
test(python/playground): add playground-api smoke tests
EfeDurmaz16 Jun 17, 2026
0aacf01
refactor(python/playground): rewrite to the idiomatic pay_kit example…
EfeDurmaz16 Jun 18, 2026
3f92576
feat(python/mpp): on-chain settle-at-close + idle-close settlement
EfeDurmaz16 Jun 19, 2026
52772ac
fix(python/mpp): clamp + reconcile replayed commit watermark
EfeDurmaz16 Jun 19, 2026
42e147e
feat(python/mpp): server-broadcast open path (openTxSubmitter=server)
EfeDurmaz16 Jun 19, 2026
a5f4255
fix(python/mpp): co-sign client-built open instead of building from f…
EfeDurmaz16 Jun 19, 2026
cfb0d09
test(python/mpp): surfnet on-chain session lifecycle e2e
EfeDurmaz16 Jun 19, 2026
43c37ef
feat(python/playground): align playground-api with the TypeScript sur…
EfeDurmaz16 Jun 19, 2026
0fb6dd3
fix(python/playground): close TS-alignment gaps found by cross-check
EfeDurmaz16 Jun 19, 2026
d567ca4
fix(python/mpp): encode charge payment-receipt as the canonical receipt
EfeDurmaz16 Jun 19, 2026
d678802
feat(python/playground): settle the session on-chain + voucher route
EfeDurmaz16 Jun 19, 2026
6b01347
fix(python/mpp/server): harden session commit/topup/idle-close/delete
EfeDurmaz16 Jun 22, 2026
d61be6e
refactor(python): ship the session gate + stablecoin helpers in the SDK
EfeDurmaz16 Jun 22, 2026
0fc2572
fix(python/mpp/client): re-export CommitTransport from the client pac…
EfeDurmaz16 Jun 22, 2026
0703886
fix(python/mpp): range-guard open args, fix delete lock lifecycle, re…
EfeDurmaz16 Jun 22, 2026
39db237
fix(python/mpp/server): catch AttributeError on bad voucher, idempote…
EfeDurmaz16 Jun 22, 2026
7b34267
fix(python/mpp/server): confirm the settle tx before finalizing
EfeDurmaz16 Jun 22, 2026
241d452
fix(python/mpp/server): propagate verified open payer so settle refun…
EfeDurmaz16 Jun 22, 2026
cd57ed5
fix(python/mpp): close remaining session review gaps
EfeDurmaz16 Jun 22, 2026
91927ac
ci: disable surfpool reports without datasource
EfeDurmaz16 Jun 22, 2026
57ab5a8
ci: disable surfpool reports on fork PRs
EfeDurmaz16 Jun 22, 2026
2b3e201
fix(python/mpp): persist verified open tx facts
EfeDurmaz16 Jun 22, 2026
a0733d9
fix(python/mpp/server): gate server-broadcast open on an attached tra…
EfeDurmaz16 Jun 23, 2026
e9d3df8
fix(python/mpp/server): build open-tx expected lazily, broaden tests
EfeDurmaz16 Jun 23, 2026
ece4ed2
fix(python/mpp/server): use get_running_loop, fix CloseOnIdle type
EfeDurmaz16 Jun 23, 2026
d682dad
fix(python/mpp/server): polling confirm, settle race guard, replay-sa…
EfeDurmaz16 Jun 23, 2026
f477ee6
test(python/mpp): gate session voucher conformance + run harness via uv
EfeDurmaz16 Jun 23, 2026
c196e4e
test(python/mpp): add session harness coverage
EfeDurmaz16 Jun 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ jobs:
with:
python-version: "3.11"
cache: pip
- uses: astral-sh/setup-uv@v5
- uses: dtolnay/rust-toolchain@stable
- uses: actions/cache@v5
with:
Expand Down Expand Up @@ -106,6 +107,30 @@ jobs:
- name: Install harness
working-directory: harness
run: pnpm install --frozen-lockfile
# Sync the uv environment the conformance runner (runners/python.json:
# ``uv run python conformance_runner.py``) executes in, so the cross-SDK
# vectors — including the frozen 48-byte session voucher preimage — run
# without a per-vector cold install.
- name: Sync Python uv environment
working-directory: python
run: uv sync --extra dev
# Cross-SDK conformance vectors for Python: charge, x402-exact, and the
# session voucher-preimage byte-vector (runners/python.json declares the
# session intent). Pins the Python session wire bytes against the frozen
# cross-SDK vector rather than only the charge path.
- name: Run Python cross-SDK conformance vectors
working-directory: harness
env:
MPP_CONFORMANCE_LANGUAGES: python
run: pnpm exec vitest run test/conformance.test.ts
- name: Focused Python session harness
working-directory: harness
env:
MPP_HARNESS_INTENTS: session
MPP_HARNESS_SCENARIOS: session-basic
MPP_HARNESS_CLIENTS: python-session
MPP_HARNESS_SERVERS: python
run: pnpm exec vitest run test/e2e.test.ts
- name: Focused TS-to-Python harness
working-directory: harness
env:
Expand Down
129 changes: 99 additions & 30 deletions harness/python-server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,24 @@ def _find_repo_root(start: Path) -> Path:
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._paycore.store import MemoryStore # noqa: E402
from pay_kit.errors import InvalidProofError # noqa: E402
from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization, parse_receipt # 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 import ( # noqa: E402
SessionChallengeOptions,
SessionOptions,
new_session,
session_routes,
)
from pay_kit.protocols.mpp.server.charge import ( # noqa: E402
ChargeOptions,
Mpp,
)
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
from pay_kit.protocols.x402 import X402Adapter # noqa: E402


def require_env(name: str) -> str:
Expand Down Expand Up @@ -120,7 +128,7 @@ def _base_units_to_human(base_units: str, decimals: int) -> str:
units = int(base_units)
sign = "-" if units < 0 else ""
units = abs(units)
quotient, remainder = divmod(units, 10 ** decimals)
quotient, remainder = divmod(units, 10**decimals)
fraction = f"{remainder:0{decimals}d}".rstrip("0")
if not fraction:
return f"{sign}{quotient}"
Expand All @@ -142,32 +150,32 @@ def _coin_for_mint(mint: str) -> Stablecoin:
return Stablecoin.USDC


def _detect_x402() -> bool:
def _detect_protocol() -> str:
"""Decide which protocol this run exercises (mirror PHP detection)."""
explicit = optional_env("PAY_KIT_HARNESS_PROTOCOL", "").lower()
if explicit == "x402":
return True
if explicit in ("mpp", "charge"):
return False
if explicit in ("x402", "mpp", "charge", "session"):
return "mpp" if explicit == "charge" else explicit
x402_set = bool(os.environ.get("X402_HARNESS_RPC_URL"))
mpp_set = bool(os.environ.get("MPP_HARNESS_RPC_URL"))
if x402_set == mpp_set:
print(
"set exactly one of X402_HARNESS_RPC_URL / MPP_HARNESS_RPC_URL, "
"or set PAY_KIT_HARNESS_PROTOCOL",
"set exactly one of X402_HARNESS_RPC_URL / MPP_HARNESS_RPC_URL, or set PAY_KIT_HARNESS_PROTOCOL",
file=sys.stderr,
)
sys.exit(2)
return x402_set
return "x402" if x402_set else "mpp"


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.protocol = _detect_protocol()
self.x402 = self.protocol == "x402"
if self.protocol == "x402":
self._build_x402()
elif self.protocol == "session":
self._build_session()
else:
self._build_mpp()

Expand All @@ -179,13 +187,9 @@ def _build_x402(self) -> None:
facilitator_json = require_env("X402_HARNESS_FACILITATOR_SECRET_KEY")
amount_units = optional_env("X402_HARNESS_AMOUNT", "1000")
mint = optional_env("X402_HARNESS_MINT", "USDC")
network_raw = optional_env(
"X402_HARNESS_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"
)
network_raw = optional_env("X402_HARNESS_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1")
self.resource_path = optional_env("X402_HARNESS_RESOURCE_PATH", "/protected")
self.settlement_header = optional_env(
"X402_HARNESS_SETTLEMENT_HEADER", "x-fixture-settlement"
).lower()
self.settlement_header = optional_env("X402_HARNESS_SETTLEMENT_HEADER", "x-fixture-settlement").lower()
self.coin = _coin_for_mint(mint)

signer = Signer.json(facilitator_json)
Expand Down Expand Up @@ -215,9 +219,7 @@ def _build_mpp(self) -> None:
secret = optional_env("MPP_HARNESS_SECRET_KEY", "mpp-harness-secret-key")
network_raw = optional_env("MPP_HARNESS_NETWORK", "localnet")
self.resource_path = optional_env("MPP_HARNESS_RESOURCE_PATH", "/paid")
self.settlement_header = optional_env(
"MPP_HARNESS_SETTLEMENT_HEADER", "x-payment-settlement-signature"
).lower()
self.settlement_header = optional_env("MPP_HARNESS_SETTLEMENT_HEADER", "x-payment-settlement-signature").lower()
realm = optional_env("MPP_HARNESS_REALM", "MPP Harness")
self.splits = json.loads(optional_env("MPP_HARNESS_SPLITS", "[]"))
if not isinstance(self.splits, list):
Expand Down Expand Up @@ -258,6 +260,39 @@ def _build_mpp(self) -> None:
self.routes[replay_path] = _base_units_to_human(replay_amount, decimals)
self.replay_path = replay_path

def _build_session(self) -> None:
self.rpc_url = require_env("MPP_HARNESS_RPC_URL")
pay_to = require_env("MPP_HARNESS_PAY_TO")
amount_units = require_env("MPP_HARNESS_AMOUNT")
secret = optional_env("MPP_HARNESS_SECRET_KEY", "mpp-harness-secret-key-with-32b-pad")
network_raw = optional_env("MPP_HARNESS_NETWORK", "localnet")
self.resource_path = optional_env("MPP_HARNESS_RESOURCE_PATH", "/session")
self.settlement_header = optional_env("MPP_HARNESS_SETTLEMENT_HEADER", "x-session-settlement-signature").lower()
fee_payer_raw = require_env("MPP_HARNESS_FEE_PAYER_SECRET_KEY")
signer = Signer.json(fee_payer_raw)
self.session_method = new_session(
SessionOptions(
operator=signer.pubkey(),
recipient=pay_to,
cap=int(amount_units),
currency=optional_env("MPP_HARNESS_SESSION_CURRENCY", "USDC"),
decimals=int(optional_env("MPP_HARNESS_DECIMALS", "6")),
network=network_raw,
secret_key=secret,
realm=optional_env("MPP_HARNESS_REALM", "MPP Harness"),
modes=["pull"],
pull_voucher_strategy="clientVoucher",
open_tx_submitter="client",
signer=signer,
rpc=None,
)
)
self.session_routes = session_routes(self.session_method.core(), touch=self.session_method._touch)
self.session_challenge = SessionChallengeOptions(cap=amount_units, description="Harness session")
decimals = int(optional_env("MPP_HARNESS_DECIMALS", "6"))
self.routes = {self.resource_path: _base_units_to_human(amount_units, decimals)}
self.replay_path = ""

def charge_options(self) -> ChargeOptions:
options = ChargeOptions(
description="PayKit Python harness protected content",
Expand Down Expand Up @@ -317,6 +352,9 @@ def do_GET(self) -> None: # noqa: N802
return

adapter = self.adapter
if adapter.protocol == "session" and self.path == adapter.resource_path:
self._handle_session(adapter)
return
if self.path not in adapter.routes:
self._send_json(404, {"error": "not_found"})
return
Expand All @@ -328,6 +366,26 @@ def do_GET(self) -> None: # noqa: N802
else:
self._handle_mpp(adapter, request)

def do_POST(self) -> None: # noqa: N802
adapter = self.adapter
if adapter.protocol == "session":
if self.path == adapter.resource_path:
self._handle_session(adapter)
return
raw = self.rfile.read(int(self.headers.get("content-length", "0") or "0"))
if self.path == "/__402/session/deliveries":
response = asyncio.run(adapter.session_routes.deliveries("POST", raw or b"{}"))
self._send_json(response.status, response.body)
return
if self.path == "/__402/session/commit":
response = asyncio.run(adapter.session_routes.commit("POST", raw or b"{}"))
self._send_json(response.status, response.body)
return
if self.path == "/__402/session/close":
self._handle_session(adapter)
return
self._send_json(404, {"error": "not_found"})

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)
Expand Down Expand Up @@ -355,6 +413,19 @@ def _handle_x402(self, adapter: _Adapter, gate: Gate, request: dict[str, Any]) -
extra_headers=headers,
)

def _handle_session(self, adapter: _Adapter) -> None:
auth = self.headers.get("authorization", "")
result = asyncio.run(adapter.session_method.handle(auth or None, adapter.session_challenge))
if not result.ok:
self._send_json(result.status, result.body or {"error": "payment_required"}, extra_headers=result.headers)
return
receipt_header = result.headers.get("payment-receipt", "")
reference = parse_receipt(receipt_header).reference if receipt_header else ""
body = {"ok": True, "paid": True, "protocol": "session", "reference": reference}
if reference:
body["settledSignature"] = reference
self._send_json(200, body, extra_headers={**result.headers, adapter.settlement_header: reference})

def _handle_mpp(self, adapter: _Adapter, request: dict[str, Any]) -> None:
amount = adapter.routes[self.path]
options = adapter.charge_options()
Expand Down Expand Up @@ -384,9 +455,7 @@ 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
)
return await adapter.handler.verify_credential_with_expected(credential, expected)
finally:
await fresh_rpc.aclose()

Expand Down Expand Up @@ -475,7 +544,7 @@ def main() -> None:
"implementation": "python",
"role": "server",
"port": port,
"capabilities": ["exact" if adapter.x402 else "charge"],
"capabilities": [adapter.protocol],
}
sys.stdout.write(json.dumps(ready) + "\n")
sys.stdout.flush()
Expand Down
Loading
Loading