feat(python): MPP sessions (client + server + playground)#161
feat(python): MPP sessions (client + server + playground)#161EfeDurmaz16 wants to merge 25 commits into
Conversation
Greptile SummaryThis PR ports the MPP session intent to Python — wire types, client (
Confidence Score: 4/5Safe to merge with one fix: a topUp credential carrying newDeposit as a JSON integer escapes the ValueError handler and surfaces as an unhandled exception. The fund-safety core (voucher verifier, watermark atomicity, close re-drivability) is thorough and matches the Go/Rust reference. One input-validation gap in session_method.py — _parse_session_u64 missing the isinstance guard present in session_routes.py — means a non-string newDeposit raises AttributeError instead of ValueError, escaping the handler and reaching the framework as a 500 on the topUp path. python/src/pay_kit/protocols/mpp/server/session_method.py — the _parse_session_u64 guard fix. The rest of the server and client surface looks solid. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant C as Client
participant M as Session (method)
participant S as SessionServer (core)
participant ST as ChannelStore
participant V as VoucherVerifier
C->>M: GET /resource (no auth)
M-->>C: 402 WWW-Authenticate: SessionRequest challenge
C->>M: POST /resource (Authorization: open action)
M->>M: verify HMAC + pinned fields
M->>S: process_open(payload)
S->>ST: update_channel (insert fresh state)
ST-->>S: ChannelState
S-->>M: ChannelState
M-->>C: 200 X-Payment-Receipt
loop Per request
C->>M: POST /resource (Authorization: voucher action)
M->>S: verify_voucher(payload)
S->>ST: update_channel (verify + advance watermark)
ST->>V: verify_voucher_for_channel(args)
V-->>ST: VoucherVerifyResult (ACCEPTED)
ST-->>S: ChannelState (new cumulative)
S-->>M: cumulative
M-->>C: 200 X-Payment-Receipt
end
C->>M: POST /__402/session/deliveries (reserve)
M->>S: begin_delivery(DeliveryRequest)
S->>ST: update_channel (add PendingDelivery)
ST-->>S: MeteringDirective
S-->>M: MeteringDirective
M-->>C: 200 deliveryId+amount
C->>M: POST /__402/session/commit (voucher)
M->>S: process_commit(CommitPayload)
S->>ST: update_channel (verify voucher + commit delivery)
ST-->>S: CommitReceipt
S-->>M: CommitReceipt
M-->>C: 200 CommitReceipt
C->>M: POST /resource (Authorization: close action)
M->>ST: update_channel (set close_requested_at)
ST-->>M: ChannelState (close-pending)
M-->>C: 200 X-Payment-Receipt
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant C as Client
participant M as Session (method)
participant S as SessionServer (core)
participant ST as ChannelStore
participant V as VoucherVerifier
C->>M: GET /resource (no auth)
M-->>C: 402 WWW-Authenticate: SessionRequest challenge
C->>M: POST /resource (Authorization: open action)
M->>M: verify HMAC + pinned fields
M->>S: process_open(payload)
S->>ST: update_channel (insert fresh state)
ST-->>S: ChannelState
S-->>M: ChannelState
M-->>C: 200 X-Payment-Receipt
loop Per request
C->>M: POST /resource (Authorization: voucher action)
M->>S: verify_voucher(payload)
S->>ST: update_channel (verify + advance watermark)
ST->>V: verify_voucher_for_channel(args)
V-->>ST: VoucherVerifyResult (ACCEPTED)
ST-->>S: ChannelState (new cumulative)
S-->>M: cumulative
M-->>C: 200 X-Payment-Receipt
end
C->>M: POST /__402/session/deliveries (reserve)
M->>S: begin_delivery(DeliveryRequest)
S->>ST: update_channel (add PendingDelivery)
ST-->>S: MeteringDirective
S-->>M: MeteringDirective
M-->>C: 200 deliveryId+amount
C->>M: POST /__402/session/commit (voucher)
M->>S: process_commit(CommitPayload)
S->>ST: update_channel (verify voucher + commit delivery)
ST-->>S: CommitReceipt
S-->>M: CommitReceipt
M-->>C: 200 CommitReceipt
C->>M: POST /resource (Authorization: close action)
M->>ST: update_channel (set close_requested_at)
ST-->>M: ChannelState (close-pending)
M-->>C: 200 X-Payment-Receipt
Reviews (15): Last reviewed commit: "test(python/playground): add playground-..." | Re-trigger Greptile |
| if "cumulativeAmount" in data: | ||
| cumulative = data["cumulativeAmount"] | ||
| elif "cumulative" in data: | ||
| cumulative = data["cumulative"] | ||
| else: | ||
| cumulative = "" |
There was a problem hiding this comment.
When
VoucherData.from_dict receives a cumulativeAmount that is a JSON number rather than a string (a non-conforming but plausible server response), self.cumulative ends up as an int. Both message_bytes() (int(self.cumulative, 10)) and record_voucher (int(voucher.data.cumulative, 10)) then raise TypeError: int() can't convert non-string with explicit base instead of the expected ValueError, breaking the caller's error-handling path. Coercing to str in from_dict is the safe fix.
| if "cumulativeAmount" in data: | |
| cumulative = data["cumulativeAmount"] | |
| elif "cumulative" in data: | |
| cumulative = data["cumulative"] | |
| else: | |
| cumulative = "" | |
| if "cumulativeAmount" in data: | |
| cumulative = str(data["cumulativeAmount"]) | |
| elif "cumulative" in data: | |
| cumulative = str(data["cumulative"]) | |
| else: | |
| cumulative = "" |
| data = bytearray() | ||
| data.append(_OPEN_DISCRIMINATOR) | ||
| data += struct.pack("<Q", params.salt) | ||
| data += struct.pack("<Q", params.deposit) | ||
| data += struct.pack("<I", params.grace_period) |
There was a problem hiding this comment.
struct.pack("<I", params.grace_period) silently raises struct.error when grace_period exceeds 2**32 - 1 rather than a descriptive ValueError. Since grace_period is typed as int (unbounded in Python) and callers may compute it from seconds, a clear range guard here prevents a confusing low-level exception. The same gap exists for bps (<H, max 65 535) in the recipients loop.
| data = bytearray() | |
| data.append(_OPEN_DISCRIMINATOR) | |
| data += struct.pack("<Q", params.salt) | |
| data += struct.pack("<Q", params.deposit) | |
| data += struct.pack("<I", params.grace_period) | |
| if not (0 <= params.grace_period <= 0xFFFF_FFFF): | |
| raise ValueError(f"grace_period {params.grace_period} exceeds u32 range") | |
| data = bytearray() | |
| data.append(_OPEN_DISCRIMINATOR) | |
| data += struct.pack("<Q", params.salt) | |
| data += struct.pack("<Q", params.deposit) | |
| data += struct.pack("<I", params.grace_period) |
| from pay_kit.protocols.mpp.client.session_consumer import ( | ||
| MeteredDelivery, | ||
| SessionConsumer, | ||
| ) |
There was a problem hiding this comment.
CommitTransport is listed in session_consumer.__all__ and is the protocol users must implement to drive SessionConsumer, but it is not re-exported here. Any caller who imports only from the top-level client package will need to discover the sub-module path by reading source, which is at odds with the re-export pattern the rest of this __init__ establishes.
| from pay_kit.protocols.mpp.client.session_consumer import ( | |
| MeteredDelivery, | |
| SessionConsumer, | |
| ) | |
| from pay_kit.protocols.mpp.client.session_consumer import ( | |
| CommitTransport, | |
| MeteredDelivery, | |
| SessionConsumer, | |
| ) |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| __all__ = [ | ||
| "PaymentTransport", | ||
| "ActiveSession", | ||
| "SessionConsumer", | ||
| "MeteredDelivery", | ||
| "serialize_session_credential", | ||
| "parse_session_challenge", | ||
| ] |
There was a problem hiding this comment.
The
__all__ list omits CommitTransport after the import above is added, so from pay_kit.protocols.mpp.client import * still would not expose it to downstream consumers.
| __all__ = [ | |
| "PaymentTransport", | |
| "ActiveSession", | |
| "SessionConsumer", | |
| "MeteredDelivery", | |
| "serialize_session_credential", | |
| "parse_session_challenge", | |
| ] | |
| __all__ = [ | |
| "PaymentTransport", | |
| "ActiveSession", | |
| "CommitTransport", | |
| "SessionConsumer", | |
| "MeteredDelivery", | |
| "serialize_session_credential", | |
| "parse_session_challenge", | |
| ] |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| @classmethod | ||
| def from_dict(cls, data: dict[str, Any]) -> MeteringDirective: | ||
| return cls( | ||
| delivery_id=data.get("deliveryId", ""), | ||
| session_id=data.get("sessionId", ""), | ||
| amount=data.get("amount", ""), | ||
| currency=data.get("currency", ""), | ||
| sequence=int(data.get("sequence", 0)), | ||
| expires_at=int(data.get("expiresAt", 0)), | ||
| commit_url=data.get("commitUrl"), | ||
| proof=data.get("proof"), | ||
| ) |
There was a problem hiding this comment.
MeteringDirective.from_dict stores amount as whatever JSON hands it — typically an int when the server does not quote the field. commit_directive then calls directive.amount_base_units(), which passes self.amount directly to int(self.amount, 10). Python's int() with an explicit base rejects a plain int argument with TypeError: int() can't convert non-string with explicit base, rather than the ValueError the call-site catch-all expects. The fix is the same coercion already recommended for VoucherData.cumulative: normalize to str at deserialization time.
| @classmethod | |
| def from_dict(cls, data: dict[str, Any]) -> MeteringDirective: | |
| return cls( | |
| delivery_id=data.get("deliveryId", ""), | |
| session_id=data.get("sessionId", ""), | |
| amount=data.get("amount", ""), | |
| currency=data.get("currency", ""), | |
| sequence=int(data.get("sequence", 0)), | |
| expires_at=int(data.get("expiresAt", 0)), | |
| commit_url=data.get("commitUrl"), | |
| proof=data.get("proof"), | |
| ) | |
| @classmethod | |
| def from_dict(cls, data: dict[str, Any]) -> MeteringDirective: | |
| return cls( | |
| delivery_id=data.get("deliveryId", ""), | |
| session_id=data.get("sessionId", ""), | |
| amount=str(data.get("amount", "")), | |
| currency=data.get("currency", ""), | |
| sequence=int(data.get("sequence", 0)), | |
| expires_at=int(data.get("expiresAt", 0)), | |
| commit_url=data.get("commitUrl"), | |
| proof=data.get("proof"), | |
| ) |
…foreign vouchers On a replayed CommitReceipt the server has already settled the delivery, so its cumulative is authoritative. commit_directive now reconciles the local watermark to that cumulative (advancing when behind, e.g. a lost response, and never regressing) instead of recording the freshly prepared higher voucher, which would let a later close sign for more than was settled. record_voucher also rejects a voucher whose channel does not match the active session, and VoucherData.from_dict coerces a numeric cumulativeAmount to str. Surfaced by Greptile and Codex on solana-foundation#161. Mirrors the rust spine fix (solana-foundation#162).
3c94f18 to
f122be5
Compare
…reign vouchers On a replayed CommitReceipt the server has already settled the delivery, so its cumulative is authoritative. SessionConsumer::commit_directive now reconciles the local watermark to that cumulative (advancing when behind, e.g. a lost response, and never regressing) instead of recording the freshly prepared higher voucher, which would let a later channel close sign for more than was settled. ActiveSession::record_voucher also rejects a voucher whose channel does not match the active session. Adds reconcile_settled plus regression tests for reconcile, no-regress, and the foreign-channel guard. Surfaced by Greptile/Codex on the Go and Python session ports (solana-foundation#160, solana-foundation#161).
| def from_dict(cls, data: dict[str, Any]) -> MeteringUsage: | ||
| return cls(delivery_id=data.get("deliveryId", ""), amount=data.get("amount", "")) |
There was a problem hiding this comment.
amount coercion gap in MeteringUsage and CommitReceipt
MeteringUsage.from_dict stores amount as-is from the parsed JSON dictionary — a conforming but number-typed server response sets it to an int. amount_base_units() then calls int(self.amount, 10), which raises TypeError: int() can't convert non-string with explicit base instead of the expected ValueError. The same gap exists in CommitReceipt.from_dict for both amount (line 878) and cumulative (line 879): both amount_base_units() and cumulative_base_units() fail with TypeError when the field arrives as a JSON number. The coercion fix applied to VoucherData.cumulative (lines 1518–1519) should be replicated here.
Addresses Greptile + Codex review of solana-foundation#161: - Reconcile the local watermark to a replayed receipt's cumulative (advance when behind, e.g. a lost response; never regress) instead of recording the freshly prepared higher voucher, which could let a later close sign for more than was settled. - record_voucher rejects a voucher whose channel does not match the session; VoucherData.from_dict coerces a numeric cumulativeAmount to str. - commit_directive records only on an explicit committed receipt and rejects unknown statuses. - Base-unit accessors (deposit_amount, amount_base_units, voucher cumulative) parse strict unsigned u64 decimals, rejecting negative/fractional/over-range values like the rust/Go typed parsers. Mirrors the rust spine fix (solana-foundation#162).
f122be5 to
a59d725
Compare
Addresses Greptile + Codex review of solana-foundation#161: - Reconcile the local watermark to a replayed receipt's cumulative (advance when behind, e.g. a lost response; never regress) instead of recording the freshly prepared higher voucher, which could let a later close sign for more than was settled. - record_voucher rejects a voucher whose channel does not match the session; VoucherData.from_dict coerces a numeric cumulativeAmount to str. - commit_directive records only on an explicit committed receipt and rejects unknown statuses. - Base-unit accessors (deposit_amount, amount_base_units, voucher cumulative) parse strict unsigned u64 decimals, rejecting negative/fractional/over-range values like the rust/Go typed parsers. Mirrors the rust spine fix (solana-foundation#162).
1910fe8 to
d1ded63
Compare
…reign vouchers On a replayed CommitReceipt the server has already settled the delivery, so its cumulative is authoritative. SessionConsumer::commit_directive now reconciles the local watermark to that cumulative (advancing when behind, e.g. a lost response, and never regressing) instead of recording the freshly prepared higher voucher, which would let a later channel close sign for more than was settled. ActiveSession::record_voucher also rejects a voucher whose channel does not match the active session. Adds reconcile_settled plus regression tests for reconcile, no-regress, and the foreign-channel guard. Surfaced by Greptile/Codex on the Go and Python session ports (solana-foundation#160, solana-foundation#161).
Port the MPP client-only session intent to the Python SDK, mirroring the Rust spine and the Go port: - intents/session.py: SessionRequest, the SessionAction tagged union (open/voucher/commit/topUp/close), OpenPayload (push + pull), signed vouchers, and the metering types, in the dataclass to_dict/from_dict house style. salt serialises as a decimal string and decodes from string or number; cumulativeAmount accepts the cumulative alias. - client/session.py: ActiveSession (voucher signing, monotonic watermark, action builders) plus serialize_session_credential / parse_session_challenge. - client/session_consumer.py: SessionConsumer + CommitTransport for metered ack/commit, with prepare/record separation so a failed commit never double counts. - _paymentchannels.py: hand-rolled on-chain glue over solders (channel and event-authority PDAs, the 48-byte voucher preimage, and the open/topUp instructions). Pins the production program id over the IDL placeholder; single-byte field discriminators (open=1, topUp=3). ruff, pyright, and pytest (coverage 93.88%, gate 90) all green.
Addresses Greptile + Codex review of solana-foundation#161: - Reconcile the local watermark to a replayed receipt's cumulative (advance when behind, e.g. a lost response; never regress) instead of recording the freshly prepared higher voucher, which could let a later close sign for more than was settled. - record_voucher rejects a voucher whose channel does not match the session; VoucherData.from_dict coerces a numeric cumulativeAmount to str. - commit_directive records only on an explicit committed receipt and rejects unknown statuses. - Base-unit accessors (deposit_amount, amount_base_units, voucher cumulative) parse strict unsigned u64 decimals, rejecting negative/fractional/over-range values like the rust/Go typed parsers. Mirrors the rust spine fix (solana-foundation#162).
…d cumulative Greptile solana-foundation#162 follow-up: reconcile_settled advanced cumulative but left the nonce unchanged, so the first delivery after a lost-response replay reused the nonce the server already settled. Bump the nonce by one whenever reconcile advances (mirroring record_voucher's accounting for that delivery). The nonce is client request-counter metadata (not in the signed 48-byte preimage), so this is a consistency fix, not a fund-safety one. Adds a delivery-after-replay regression test.
…yright-clean ruff format on session.py/intents/session.py (line-length normalization, no logic change) and type the consumer test helper against the CommitTransport protocol, renaming a fake transport's param to match the protocol so a full pyright run is clean (CI runs pyright over the whole tree).
Review follow-ups on the session client: - commit_directive clamps a replayed receipt's cumulative to the voucher just prepared in the call. The server is untrusted; without the clamp it could report a replay settled above what the client signed and push the watermark up, so the next voucher over-authorizes (capped by the deposit). An honest lost-response replay settles at or below the prepared voucher, so recovery is unchanged. Adds a regression test. - record_voucher and the replayed-receipt path now parse cumulative via the strict _parse_base_units / cumulative_base_units (rejecting negative, fractional, whitespace, underscore, and over-u64) instead of bare int(); an over-u64 server value previously wedged the session on the next pack. - _salt_from_wire enforces the u64 range like the rust deserializer.
… packer The wire type hand-rolled the 48-byte voucher preimage, duplicating _paymentchannels.voucher_message_bytes. The rust spine delegates rather than duplicating, and the hand-rolled copy was exercised only by its own tests, so it could drift from the packer the signing path actually uses. Delegate to the canonical packer for a single source of truth (no import cycle: the glue does not import the intent layer). Drops the now-unused struct import. No behavior change.
Renders python/src/pay_kit/protocols/programs/paymentchannels/ from the vendored idl/payment-channels.json with the community codama-py renderer (Solana-ZH/codama-py), mirroring the Go port's codama-generated client. The renderer is pinned to the merge commit of Solana-ZH/codama-py#10, which fixed PDA seed rendering; it cannot be consumed as an npm/git dependency yet (its package ships only an uncommitted dist), so the codegen script clones the pinned commit and drives the upstream genpy CLI. Generated code is exempt from ruff/pyright/coverage. just payment-channels-generate-py regenerates; payment-channels-sync now refreshes Rust and Python together.
build_open_instruction / build_top_up_instruction now map their params onto the generated Open / TopUp builders (production program id passed explicitly), and voucher_message_bytes encodes through the generated VoucherArgs Borsh layout, matching the Rust spine's delegation to its generated client. PDA and ATA derivations stay in the glue: the channel PDA is not declared in the IDL, and the generated event-authority helper pins the IDL placeholder program id. Adds the anchorpy + borsh-construct runtime deps the generated client imports, and disables anchorpy's pytest plugin (it imports pytest-xprocess at collection time; only the Borsh helpers are used). Frozen instruction vectors are unchanged: the generated path emits byte-identical data and account metas for open and topUp.
Replayed commit receipts now record the prepared voucher unconditionally, matching rust SessionConsumer::commit_directive and the TS SessionConsumer line by line; the reconcile_settled extension API is removed. record_voucher also adopts the rust nonce rule max(nonce, voucher.nonce) instead of always advancing past stale voucher nonces.
Mirror rust serde enum decoding: SessionRequest.from_dict and OpenPayload.from_dict reject unknown mode and pullVoucherStrategy values, and CommitReceipt.from_dict rejects a missing or unknown status instead of defaulting to committed.
Port rust client/payment_channels.rs: derive_payment_channel_open resolves mint, deposit (defaults to cap), grace period (default 900s), program id, token program from the challenge currency (Token-2022 for PYUSD/USDG/CASH), splits, and a random u64 salt; build_open_payment_channel_transaction assembles the legacy open transaction with fee payer = challenge operator and only the payer slot signed; the pull/clientVoucher session openers default the action signature to PENDING_SERVER_SIGNATURE. Adds session_request_modes (modes empty or omitted means push-only), a generate_authorized_signer helper for the ephemeral session key, an ActiveSession cumulative resume option, and program-id plumbing through the instruction builders.
Port rust client/http_stream.rs: an incremental SseDecoder, metered event classification (mpp.metering/mpp.usage/done/[DONE]), the MeteredSseSession state machine enforcing that a usage event's deliveryId matches the live directive and that usage overrides only the amount, an httpx-backed HttpCommitTransport, and a transport-neutral MeteredSseStream over raw byte chunks.
The branch added the two dependencies for the generated payment-channels client without refreshing the lockfile; main also added pydoc-markdown. Refreshed with uv lock.
State the shipped client surface and the server-side, operatedVoucher, and session-transport follow-ups instead of leaving the row empty.
…on#160) Ports the server half of the mpp/session intent to the Python SDK, mirroring go/protocols/mpp/server (solana-foundation#160) and the Rust spine (rust/crates/mpp/src/server/ session.rs) as the wire truth. Pairs with the client side already in this PR. - session_store: ChannelStore + MemoryChannelStore with per-channel-locked read-modify-write, clone isolation, and Go-faithful nil-slice (null) delivery serialization for byte-parity durable records. - session_voucher: offline voucher verifier (monotonicity, deposit bound, min-delta, signature, expiry, finalized/close-pending guards) in Go check order. - session_lifecycle: idle-close watchdog. - session_onchain: open/top-up transaction verification with an optional RPC liveness seam (a None client leaves the seam unset, exactly as Go). - session_method: new_session + the open/voucher/commit/topUp/close handlers, including the re-drivable close (a closing channel with no settled signature re-drives rather than hard-rejecting, matching Go handleClose) and the pull-mode-requires-strategy method-layer guard. - session_stream: metered streaming writer. - session_routes / session: HTTP routes with strict typed decode (wrong JSON types rejected up front with 400 "invalid request body", matching Go), and the public SessionServer entry. Server-side on-chain settlement broadcast (SubmitOpenTx / SettlementInstructions) is deferred: it needs a Python transaction-broadcast layer (signer send + confirm) that does not exist yet. The offline verification core, lifecycle, routes, and store are complete and tested. 170+ tests across the new modules; full suite 1141 passing, coverage 94.6%.
…ation#160) Ports go/examples/playground-api to a FastAPI app under python/examples/playground_api/, mirroring the Go module split and route table: charges (stocks/marketplace/weather with fee splits), sessions (metered SSE stream + compute + deliveries/commit side channel + receipt poll, driven by the new mpp/session server), x402 (fact/joke), faucet, docs browser, health/config catalog, and a subscription parity stub (501). Wires the already-ported Python mpp session server and charge/x402 surfaces; reuses pay_kit rather than reimplementing. Added a 'playground' extra (fastapi + uvicorn). Run: python -m examples.playground_api.main. The dir uses an underscore (playground_api) so it is an importable package for the multi-file relative imports, unlike the single-file examples.
d1ded63 to
843b99d
Compare
| weather / fortune / x402 routes stay live server-side but are not advertised | ||
| in the nav. | ||
| """ | ||
| return [ |
There was a problem hiding this comment.
Not a huge fan of this approach. can we have endpoints declared in-line instead of this json?
| "feePayer": state.fee_payer_pubkey, | ||
| "recipient": state.recipient, | ||
| "network": state.network, | ||
| "rpcUrl": state.rpc_url, |
There was a problem hiding this comment.
I don't think the RPC url should be exposed.
| pass | ||
| return JSONResponse(body) | ||
|
|
||
| @app.get("/api/v1/config") |
There was a problem hiding this comment.
Is this endpoint actually needed by playground?
|
|
||
| # Module-level app for ``uvicorn examples.playground_api.app:app``. Boots from | ||
| # the environment; the entrypoint in ``main.py`` does the same with funding. | ||
| app = create_app() |
There was a problem hiding this comment.
I'm surprised by the amount of code required to setup a simple python server exposing 5 endpoints.
Any reusable helpers that can / should be pushed to PayKit?
| | GET | `/api/v1/docs`, `/api/v1/docs/:lang/tree`, `/api/v1/docs/:lang/file` | free | | ||
| | GET | `/api/v1/faucet/status` | free | | ||
| | POST | `/api/v1/faucet/airdrop` | free | | ||
| | GET | `/api/v1/stocks/quote/:symbol` | charge 0.01 USDC | | ||
| | GET | `/api/v1/stocks/search?q=` | charge 0.01 USDC | | ||
| | GET | `/api/v1/stocks/history/:symbol` | charge 0.05 USDC | | ||
| | GET | `/api/v1/weather/:city` | charge 0.01 USDC | | ||
| | GET | `/api/v1/marketplace/products` | free | | ||
| | GET | `/api/v1/marketplace/buy/:productId?referrer=` | charge with splits | | ||
| | GET | `/api/v1/fortune` | charge 0.01 USDC, HTML payment link | | ||
| | GET | `/api/v1/premium/feed` | 501 stub (see below) | | ||
| | GET | `/sessions/stream` | session, cap 1.00 USDC, 0.0001 USDC/chunk | | ||
| | POST | `/sessions/stream` | session voucher commits | | ||
| | POST | `/sessions/compute` | session, cap 0.50 USDC, 0.005 USDC/call | | ||
| | POST | `/__402/session/deliveries` | session side channel | | ||
| | POST | `/__402/session/commit` | session side channel | | ||
| | GET | `/sessions/receipt/:channelId` | free settle-status poll | | ||
| | GET | `/facilitator/supported` | free | |
| } | ||
| ) | ||
|
|
||
| return shutdown |
There was a problem hiding this comment.
Same feedback - too much code
|
|
||
| @app.get("/x402/fact") | ||
| async def x402_fact(payment: Payment = require_fact) -> JSONResponse: | ||
| return JSONResponse({"fact": random.choice(FACTS), "source": "x402"}) |
There was a problem hiding this comment.
Same feedback - Feels like a lot of code here belongs to PayKit
| return None | ||
| if math.isnan(result): | ||
| return None | ||
| return result |
There was a problem hiding this comment.
Is there a python lib we can use instead of all this yahoo code?
|
|
||
| The session intent opens a payment channel between a client and server, | ||
| allowing incremental payments via off-chain signed vouchers backed by the | ||
| on-chain payment-channels program. The wire format mirrors the Rust spine in |
There was a problem hiding this comment.
"The wire format mirrors"
Can we get rid of these statements? If we're citing a source of truth, it's the spec
| class ClosePayload: | ||
| """Payload for the ``close`` action. | ||
|
|
||
| Mirrors rust ``ClosePayload``; ``voucher`` is omitted when ``None``. |
There was a problem hiding this comment.
Remove all these "Mirrors etc"
|
|
||
| delivery_id: str | ||
| session_id: str | ||
| amount: str | ||
| cumulative: str | ||
| status: CommitStatus |
There was a problem hiding this comment.
Can we make sure we'll get high quality docs for all these classes, fields, etc.
Address Ludo's review of the Python playground: bring it to faithful parity with typescript/examples/playground-api instead of the drifted, heavier port. - Drop yahoo.py (426 lines, no TS/Go counterpart); stock routes serve canned demo data like the rest of the network-free example. - Remove rpcUrl from /api/v1/health and /api/v1/config (not exposed). - Inline /api/v1/config endpoint catalog via build_endpoint_list, mirroring TS buildEndpointList; trimmed to the four advertised entries. - Replace hand-rolled CORS + settlement-header/error middleware with Starlette CORSMiddleware and pay_kit.fastapi.install_exception_handler. - Trim charges/sessions/x402/faucet to the TS modules' shape and leanness, driving gates through the pay_kit surface rather than re-implementing it. Playground 2300 -> 1522 lines. subscriptions stays a documented stub (the Python SDK has no subscription server yet; TS gates it behind an active plan). Endpoints, shapes, and gating semantics now match the TS reference; app boots with charge/session/x402 routes returning 402 challenges.
| voucher_raw = body.get("voucher") | ||
| if voucher_raw is None: | ||
| return _error(400, "voucher required") | ||
| voucher = SignedVoucher.from_dict(voucher_raw) |
There was a problem hiding this comment.
Unguarded
from_dict call raises 500 on non-dict voucher
voucher_raw is checked for None but not for its JSON type. When a client sends {"deliveryId": "x", "voucher": "a-string"} or {"deliveryId": "x", "voucher": [1,2]}, SignedVoucher.from_dict(voucher_raw) calls voucher_raw.get("data", {}), which raises AttributeError on any non-dict value. This exception is not caught by the except (ValueError, json.JSONDecodeError) block below (covering only the body decode) nor by the except ValueError on the process_commit call, so it propagates to the ASGI framework as a 500. Go's json.Decode into a typed struct rejects a non-object voucher field with a 400 "invalid request body" — this path is untested in the strict-decode parity suite while deliveries has full coverage.
Add if not isinstance(voucher_raw, dict): return _error(400, "invalid request body") before the from_dict call to match Go's behaviour.
| voucher_raw = body.get("voucher") | |
| if voucher_raw is None: | |
| return _error(400, "voucher required") | |
| voucher = SignedVoucher.from_dict(voucher_raw) | |
| voucher_raw = body.get("voucher") | |
| if voucher_raw is None: | |
| return _error(400, "voucher required") | |
| if not isinstance(voucher_raw, dict): | |
| return _error(400, "invalid request body") | |
| voucher = SignedVoucher.from_dict(voucher_raw) |
Address Ludo's review on PR solana-foundation#161: - Strip every sibling-implementation citation ("Mirrors the Rust spine", "the Go port", "like mppx", "byte-identical to the Go/Rust vectors") from docstrings and comments across the session client/server/intents/glue. Where a citation carried real information (byte layouts, orderings, overflow guards, the 48-byte preimage) it is restated directly; normative references now point at the MPP specification, not sibling SDKs. Docstrings and comments only, behaviour is byte-identical (full suite still 1141 passing). - Every session wire class, field, constructor, and method now has a clear self-standing docstring. - Playground stock routes pull live data from the yfinance library instead of a hand-rolled client (the python counterpart to the TS yahoo-finance2 dep); added as a 'playground' extra, with graceful offline fallback.
| async def delete_channel(self, channel_id: str) -> None: | ||
| async with self._mu: | ||
| self._data.pop(channel_id, None) | ||
|
|
There was a problem hiding this comment.
delete_channel races with concurrent update_channel for the same channel
delete_channel removes the entry from _data while holding only _mu, not the per-channel lock. A concurrent update_channel that has already read the snapshot and released _mu (but is still inside its mutator) will then re-acquire _mu and write the channel back — silently undoing the delete. In a scenario where a settled channel is deleted while a stale in-flight voucher verification is mid-execution, the channel reappears in the store after the delete returns. update_channel should acquire the per-channel lock before removing the entry (or delete_channel should acquire the channel-level lock via _channel_lock) to serialize with concurrent updates.
…etup Ludo (solana-foundation#161 review): the playground boot wiring repeats CORS + exception handler + header plumbing every pay-kit FastAPI server needs. Push it into PayKit. install(app) bundles: - CORS exposing the payment challenge/settlement headers (PAYMENT_HEADERS) - the PayKitError -> HTTP handler + settlement-header echo middleware - the bare-dict HTTPException shape the guards rely on playground app.py now boots via install(app), dropping ~25 lines of per-server boilerplate. install_exception_handler stays for back-compat.
Ludo (solana-foundation#161 review): too much code / too many endpoints. The playground web app renders only the config catalog (stock quote, marketplace buy, the two session routes), so the un-advertised server routes were dead weight. Remove: - x402.py entirely (joke/fact + embedded facilitator; never in the catalog) - the subscription 501 stub (no Python subscription method yet) - charges.py extras: stock search/history, weather, fortune HTML demo charges.py keeps the two catalog charge routes (single-recipient quote + multi-recipient marketplace split), dropping ~265 lines. The catalog and the session routes are unchanged.
Ludo (solana-foundation#161 review): high-quality docs for the session intent classes, and the README listed more endpoints than the playground exposes. Add Attributes blocks documenting every field of SessionRequest and OpenPayload (push/pull fields labelled), and rewrite the playground README endpoint table + feature list to the routes the server actually serves after the cut.
| # `python/src/pay_kit/protocols/programs/paymentchannels/` and rewrites | ||
| # it in place — see {{codegen_dir}}/generate-payment-channels-client-py.ts. | ||
| payment-channels-generate-py: codegen-install | ||
| cd {{codegen_dir}} && pnpm run payment-channels:python |
There was a problem hiding this comment.
I think we also have some codama commands in the main justfile, can we consolidate?
| # --- endpoint catalog (drives the playground web app's sidebar) ------------- | ||
|
|
||
|
|
||
| def build_endpoint_list() -> list[dict[str, Any]]: |
There was a problem hiding this comment.
Could we get rid of this function?
| return JSONResponse(body) | ||
|
|
||
| @app.get("/api/v1/config") | ||
| async def config() -> JSONResponse: |
| seller="7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", | ||
| description="Ceramic mug for node operators", | ||
| ), | ||
| "nft-sticker-pack": _Product( |
There was a problem hiding this comment.
What are these products?
| | `charge/pull` | ✅ | | ||
| | `charge/push` | ✅ | | ||
| | `session` | — | | ||
| | `session` | Client-only | |
There was a problem hiding this comment.
Arem't we supporting server?
Cross-language audit + Ludo review follow-ups: - app.py register_charges docstring claimed weather/fortune routes that do not exist; build_endpoint_list overclaimed the catalog covered every route. Fixed both and moved the catalog to a module constant (no wrapper function). - _quote no longer fabricates a 150.0 price on an upstream failure behind a paid gate; it surfaces a 502 (Go/TS surface the error too). - dedupe the marketplace fee math into one _split_fees helper. - python/README: session is client+server now (server method + metering side channel shipped); only on-chain settle-at-close is pending, not 'Client-only'. - playground README: drop the false on-chain-settlement claim, and add a Differences section documenting the endpoints intentionally omitted vs Go/TS.
…ency Ludo: 'feels like we're not using the paykit helpers to gate api endpoints'. The session gate now raises HTTPException(402) and returns the receipt headers, so it plugs in as Depends(...) exactly like the charge routes' RequirePayment, dropping the repeated 'gated = await gate(request); if isinstance(...): return' block from all three session handlers. Route the /sessions/compute commit branch through _commit_ack too, so there is one ack shape.
Mirror Go's main_test.go: boot the FastAPI app against an unreachable RPC and assert the config catalog, the free routes, and that every paid charge/session route fires a 402 challenge before its handler. Python had no playground tests while Go ships ~670 lines; this closes that gap and guards the gate wiring.
What this adds
The MPP session intent for the Python SDK, now both halves plus the example app, bringing Python to parity with the Go port (#160). Rebased onto main (which carries #160), so the diff is the Python session surface only.
protocols/mpp/intents/session.py—SessionRequest, theSessionActiontagged union (open / voucher / commit / topUp / close), signed vouchers, metering types.protocols/mpp/client/session.py+session_consumer.py—ActiveSession(voucher signing, monotonic watermark, action builders) andSessionConsumer(metered ack/commit).protocols/mpp/server/session_*.py— the full server method ported fromgo/protocols/mpp/serveragainst the Rust spine (rust/crates/mpp/src/server/session.rs) as wire truth:session_store(channel store with per-channel-locked read-modify-write and Go-faithful nil-slice serialization),session_voucher(offline voucher verifier),session_lifecycle,session_onchain(open/top-up verification with an optional RPC liveness seam),session_method(new_session+ open/voucher/commit/topUp/close handlers, re-drivable close, pull-mode guard),session_stream(metered SSE),session_routes+session(HTTP routes with strict typed decode, publicSessionServer).protocols/programs/paymentchannels/— codama-py output (Go uses@codama/renderers-go); regenerate withjust payment-channels-generate-py.examples/playground_api/— FastAPI port ofgo/examples/playground-api: charges (stocks/marketplace/weather with fee splits), the metered session stream/compute/receipt, x402, faucet, docs, health/config catalog.Reviewing it
Suggested read order (the generated client and the playground example can be skimmed):
protocols/mpp/intents/session.py(wire types)protocols/mpp/client/session.py,session_consumer.pyprotocols/mpp/server/session_store.py,session_voucher.py,session_method.py(the fund-safety surface)protocols/mpp/server/session_routes.py(HTTP + strict decode)examples/playground_api/(runnable example)Deferred (documented)
Server-side on-chain settlement broadcast (
SubmitOpenTx/SettlementInstructionsin the Go server) is not ported: it needs a Python transaction-broadcast layer (signer send + confirm) that does not exist in the SDK yet. The offline verification core, lifecycle, routes, store, and the optional-RPC liveness seam are complete. The playground uses the client submitter withrpc=None(offline) accordingly, and the receipt poll reportssettledSignature=nulluntil that path lands. This mirrors how the Go port treats the RPC client as optional.Verification
ruff check,ruff format,pyright(0 errors),pytest --cov=pay_kit --cov-fail-under=90(1141 passing, cov 94.6%). The server modules mirror Go's test behaviors test-first. Manual check: the playground returns real402 Paymentchallenges on the session stream, charge, and x402 routes; the app boots with all 30 routes registered. The 48-byte voucher preimage and single-byte discriminators (open=1, topUp=3) match the Rust/Go frozen vectors.Reviewer note (Claude Fable 5)
Claude Fable 5 reviewed this PR. Scope grew from client-only to full parity with Go #160 (client + server + playground), so the surface worth your attention:
session_voucher.pyandsession_method.py. The voucher verifier runs the Go checks in the same order (monotonicity, deposit bound, min-delta, signature, expiry, finalized/close-pending). The close handler mirrors Go's re-drivable close: a closing channel with no recorded settlement signature re-drives rather than hard-rejecting, while a settled channel still rejects a second close. No fund-safety check was relaxed to make this work.session_routes.pyrejects type-mismatched JSON fields up front with400 invalid request body(float/numeric-string into an int64 field, JSON number into a string field), before any store access, matching Go's typedjson.Decode. Regression tests cover each case.session_storeserializes empty delivery lists asnull(Go nil-slice behavior) and decodes null/missing/[]all to empty, so durable channel records are byte-compatible across the Go and Python servers; the Rust spine's#[serde(default)]absorbs it on read.