feat(python): MPP sessions (client + server + playground)#161
Conversation
Greptile SummaryThis PR ports the full MPP session intent to the Python SDK, including wire types, client (
Confidence Score: 5/5Safe to merge. All fund-safety paths (payer/deposit propagation, settlement confirmation, atomic settle guard, idle-close state-flip, duplicate-finalize protection) are correctly implemented at this HEAD. The fund-safety surface reviewed exhaustively: payer and deposit are propagated from verify_open_tx before process_open persists them; settle_and_finalize_channel polls for confirmation before returning; the finalize mutator is idempotent; the settling flag is claimed and released atomically. All previously flagged issues resolved. Two remaining comments are minor style/robustness nits. No files require special attention. The release mutator type-ignore in session_method.py is only reachable by a custom ChannelStore that calls delete_channel on an actively-settling channel, which is not possible with the built-in MemoryChannelStore. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant C as Client
participant S as Session (HTTP layer)
participant Store as ChannelStore
participant RPC as Solana RPC
C->>S: GET /api/v1/stream (no Auth)
S-->>C: 402 WWW-Authenticate (SessionRequest challenge)
C->>S: POST open action (Authorization: Bearer ...)
S->>S: HMAC verify challenge
alt has_transaction and server submitter
S->>S: verify_open_tx (structural)
S->>RPC: cosign + send_raw_transaction
S->>RPC: confirm_transaction_signature (poll 30s)
RPC-->>S: confirmed
else has_transaction and client submitter
S->>RPC: confirm_transaction_signature (optional)
end
S->>Store: process_open (atomic insert)
S-->>C: 200 X-Payment-Receipt
loop Metered deliveries
C->>S: POST /__402/session/deliveries
S->>Store: begin_delivery
S-->>C: 200 MeteringDirective
C->>S: POST /__402/session/commit (signed voucher)
S->>Store: process_commit (atomic watermark advance)
S-->>C: 200 CommitReceipt
end
C->>S: POST close action (final voucher)
S->>Store: update_channel (flip close_requested_at)
S->>Store: "update_channel (claim settling=True)"
S->>RPC: settle_and_finalize_channel
S->>RPC: confirm_transaction_signature
RPC-->>S: confirmed
S->>Store: "update_channel (finalized=True, settled_signature)"
S-->>C: 200 X-Payment-Receipt (settled signature)
%%{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 S as Session (HTTP layer)
participant Store as ChannelStore
participant RPC as Solana RPC
C->>S: GET /api/v1/stream (no Auth)
S-->>C: 402 WWW-Authenticate (SessionRequest challenge)
C->>S: POST open action (Authorization: Bearer ...)
S->>S: HMAC verify challenge
alt has_transaction and server submitter
S->>S: verify_open_tx (structural)
S->>RPC: cosign + send_raw_transaction
S->>RPC: confirm_transaction_signature (poll 30s)
RPC-->>S: confirmed
else has_transaction and client submitter
S->>RPC: confirm_transaction_signature (optional)
end
S->>Store: process_open (atomic insert)
S-->>C: 200 X-Payment-Receipt
loop Metered deliveries
C->>S: POST /__402/session/deliveries
S->>Store: begin_delivery
S-->>C: 200 MeteringDirective
C->>S: POST /__402/session/commit (signed voucher)
S->>Store: process_commit (atomic watermark advance)
S-->>C: 200 CommitReceipt
end
C->>S: POST close action (final voucher)
S->>Store: update_channel (flip close_requested_at)
S->>Store: "update_channel (claim settling=True)"
S->>RPC: settle_and_finalize_channel
S->>RPC: confirm_transaction_signature
RPC-->>S: confirmed
S->>Store: "update_channel (finalized=True, settled_signature)"
S-->>C: 200 X-Payment-Receipt (settled signature)
Reviews (35): Last reviewed commit: "test(python/mpp): add session harness co..." | Re-trigger Greptile |
…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).
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).
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).
d1ded63 to
843b99d
Compare
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.
… shape
Ludo: the playground-api was too much code, not using the paykit gating
helpers, full of hallucinations. Rewrite it to the flask/fastapi example
idiom (python/examples/{flask,fastapi}/app.py): pay_kit.configure() boot, a
Catalog(Pricing) of Gate.build gates, RequirePayment + payment() proof,
install(app). 1250 lines / 9 files -> 206 / 3.
- app.py (73 LOC): /health + charge-gated /report, exactly the fastapi example.
- sessions.py (108 LOC): one metered /compute route. pay_kit ships NO session
gate (charge has RequirePayment; sessions are framework-agnostic primitives
new_session + session_routes), so the ~25-line _session_gate is the one
irreducible hand-rolled piece, clearly commented; config from pay_kit.config().
- deleted: charges.py, docs.py, faucet.py, constants.py, utils.py, main.py
(AppState boot, hand-rolled catalog, SPA/docs/faucet, marketplace products,
yfinance, all the ceremony + invented data).
- test rewritten to the new routes.
Follow-up: a terse session route needs a new SDK helper (RequireSession +
one-call session-routes mount); not achievable in the example today.
Closes the Go/TS parity gap where Python recorded vouchers but never settled a channel on-chain (settledSignature stayed null, idle watchdog was a no-op). - _paymentchannels: ed25519 precompile builder (offsets 16/48/112, 0xffff markers) + settle_and_finalize and distribute builders over the codama client (channel/payer/payee/treasury ATAs, recipient split ATAs, treasury owner 0xBE 0xEF). Golden-byte tests vs the Rust/Go layouts. - session_onchain: settle_and_finalize_channel builds [ed25519?, settle, distribute], signs with the merchant signer, broadcasts, returns the signature. RpcClient gains get_latest_blockhash/send_raw_transaction. - session_method: SessionOptions.signer (operator/merchant LocalSigner); _handle_close settles when signer+rpc are present and records settledSignature + finalized; the idle-close watchdog now runs the same settle path instead of a no-op. Gated: no signer/rpc leaves close a state-flip (re-drivable). Mirrors Go server/session_method.go closeAndSettleChannel + settlement.go and TS server/session/on-chain.ts. settle/distribute instruction set verified by discriminator (1/4/7).
Match the Rust/Go (solana-foundation#162) consumer: on a replayed commit receipt the client reconciles its watermark to the server-settled cumulative, clamped to the voucher just prepared (settled.min(prepared)) since the server is untrusted, and never regresses (nonce advances by one when it does). Previously Python recorded the prepared voucher for replayed receipts too, double-counting a duplicate delivery and trusting the server's reported cumulative. ActiveSession.reconcile_settled mirrors the Rust/Go method. Updated the two replay tests to the clamp behavior and added never-regress + inflated-server clamp cases.
The session method rejected openTxSubmitter=server and any open carrying a transaction. Wire both, closing the last Go/TS server parity gap: - build_sign_broadcast_open: the server builds the open instruction from the payload facts (splits from config), signs with the operator signer, sends, and confirms. Mirrors Go SubmitOpenTx. - _handle_open: openTxSubmitter=server builds+broadcasts (requires signer+rpc), then persists; a client-broadcast open carrying a transaction is now verified via verify_open_tx (structural + on-chain liveness with rpc) instead of rejected; the no-tx client path is unchanged. Tests: server open broadcasts a single open instruction (disc 1) + persists; server open without signer/rpc is rejected.
…acts The openTxSubmitter=server path now co-signs the payer-partial-signed open transaction in the operator fee-payer slot and broadcasts it (matching Go SubmitOpenTx), reusing the charge path's _co_sign_with_fee_payer. The prior build-from-facts model reconstructed the open server-side and produced an on-chain custom program error 0x7d1; the canonical model leaves the operator fee-payer signature for the server to complete.
Gated end-to-end test against the hosted Solana Payment Sandbox: funds operator and payer via surfnet cheatcodes, runs a server-broadcast open (co-sign + broadcast), an in-band voucher, and an on-chain settle-at-close, asserting the open and settle transactions confirm on-chain. Skips when MPP_RUN_SURFNET_E2E is unset so CI stays green. Mirrors Go session_e2e_test.go.
…face
Restructure the FastAPI playground to mirror typescript/examples/playground-api:
- fortune + quote (fixed, MPP or x402), joke (MPP charge with a platform split)
- stream (MPP session, SSE) + /sessions/receipt/{id} settle-status poll
- docs.py: unpaid SDK reference routes (port of docs.ts, path-traversal guarded)
- sandbox.py: localnet surfnet faucet + opt-in funding (smoke test stays offline)
- discovery.py: /openapi.json with x-payment-info offers, byte-aligned with the
TS buildOpenApiDocument document shape
- /api/v1/health
upto (usage) and subscription gates are intentionally omitted: the Python SDK
ships neither gate kind yet (follow-up, not hand-rolled here).
- session cap 0.50 -> 1.00 USDC (challenge + discovery offer), matching TS
- session challenge description -> 'Metered token stream' (was 'Metered compute',
also inconsistent with its own discovery offer)
- discovery offer.description is now a price hint ('0.01 USDC', 'up to 1 USDC')
to match the TS offer shape; human prose stays on the route summary
- split recipient (PLATFORM) funded USDC-only at startup (no SOL), mirroring TS
- pace SSE deliveries ~80ms apart, matching the TS stream
Remaining gaps are SDK-level (out of example scope): resource-URL voucher route
(SessionRoutes ships deliveries/commit only), per-fee on-chain memo, and the
x-payment-settlement-signature header.
The MPP charge adapter set the `payment-receipt` header to the raw
`receipt.reference` string, but clients (and the session path) expect the
canonical base64url-encoded receipt envelope. A client decoding it
(`atob -> JSON.parse -> { reference }`) got a parse failure and silently
skipped the Broadcast / Settled-on-chain steps, so an MPP charge looked
unsettled even though it broadcast on-chain. Encode it with format_receipt,
matching the session gate; keep the raw signature on
x-payment-settlement-signature.
Wire the session for real on-chain settlement, mirroring the TS playground: - pass the operator signer + RpcClient + open_tx_submitter='server' so the channel opens and settle-and-finalizes on-chain - arm close_delay (idle-close watchdog) and the session_routes touch hook so the channel settles after the last delivery; the receipt poll then returns a real settle signature - add POST /api/v1/stream (voucher commit at the resource URL); the client re-POSTs each signed voucher there, so its commit no longer hits 405
Address greptile review findings on the session server: - session_routes commit: guard a non-dict voucher (AttributeError) and wrap SignedVoucher.from_dict (ValueError on non-numeric expiresAt/nonce) so both return 400 'invalid request body', matching Go's strict decode instead of escaping as a 500. - session_method _parse_session_u64: guard isinstance(str) like the routes-layer parser, so a JSON-number newDeposit raises a caught ValueError -> PaymentError instead of an uncaught AttributeError. - session_method idle-close: always run the close state-flip; only the on-chain settle stays gated on signer/rpc, so the idle timeout takes effect on signer-less deployments (the playground). - session_store delete_channel: take the per-channel lock (lock -> _mu order, matching update_channel) so a delete cannot race an in-flight mutator that would write the channel back after the pop. Regression tests added for each path.
Ludo asked to move reusable session/server logic out of the playground example and into pay_kit, so the example shrinks and the SDK gains the helpers (parity with the TS SDK's session handler). - Session.handle(authorization, options) -> SessionGateResult: a framework-agnostic 402 session gate (verify credential or answer 402), re-exported from pay_kit.protocols.mpp.server. - RequireSession(session, options): the FastAPI dependency counterpart of RequirePayment, in pay_kit.fastapi. - stablecoin_decimals(currency) in pay_kit._paycore.solana, and SYSTEM_PROGRAM re-exported from _paycore.mints. - playground: the hand-rolled _session_gate collapses to one Depends(RequireSession(...)); decimals=6 literal -> stablecoin_decimals; sandbox uses the shared program constants. discovery.py stays example-local: neither SDK ships an OpenAPI/discovery builder, so moving it would diverge from TS parity; its docstring (which falsely claimed a TS openapi.ts) is corrected.
…kage CommitTransport is the protocol users implement to drive SessionConsumer; it lived in session_consumer.__all__ but was not re-exported from the client package, so `from pay_kit.protocols.mpp.client import *` (and a plain import) could not reach it. Add it to the import and __all__, matching the re-export pattern for the other session types. Addresses greptile review on client/__init__.py.
…fresh docs Follow-ups from an independent review pass: - build_open_instruction validates salt/deposit (u64), grace_period (u32), and recipient bps (u16) up front, so an out-of-range value raises a clear ValueError instead of a low-level struct/Borsh error (greptile #3376491022). - delete_channel no longer pops the per-channel lock: popping it while a task is still queued on it would let a later op create a fresh lock and run unserialized. Locks persist for the store lifetime, as they already do for update_channel. - README session matrix + caveat and the session_method test docstring no longer claim server-broadcast open and on-chain settle-at-close are unported; both ship (gated on signer/RPC). Only pull/operatedVoucher remains deferred. Adds a range-guard regression test.
…nt finalize Second greptile review pass: - session_routes commit: a voucher that is a dict but whose 'data' is a string/list slips past the isinstance(dict) guard, and VoucherData.from_dict then calls .get on it -> AttributeError, which (ValueError, TypeError) did not catch, so it escaped as a 500. Add AttributeError to the catch so it returns 400 like Go's strict decode. - _settle_channel finalize mutator: guard 'if current.finalized: return current' so a concurrent re-drive (client close racing the idle-close watchdog) cannot overwrite a valid settled_signature with a second, possibly-rejected on-chain finalize signature. Regression test for the non-dict voucher.data case.
settle_and_finalize_channel broadcast the settlement transaction and returned the signature without confirming it, so a dropped tx (blockhash expiry, congestion, duplicate-settle race) left the channel permanently finalized with an unconfirmed signature, defeating the re-drivable-close guard. Confirm via confirm_transaction_signature before returning, mirroring cosign_and_broadcast_open's open path. Regression test: a failed settle status now raises and leaves the channel un-finalized with no settled_signature.
…ds the opener verify_open_tx extracted the payer (open slot 0) but VerifyOpenTxResult dropped it and the method-layer call-site discarded the result. With the single-arg ActiveSession.open_action() helper payload.payer is None, so process_open stored state.operator=None, and settle-at-close fell back to payer_address = state.operator or config.recipient — building the distribute instruction with the merchant's address and refunding the unspent channel balance to the recipient's ATA instead of the opener's. Surface payer on VerifyOpenTxResult and propagate it onto payload.payer when the payload omitted it, so process_open records the real opener. Regression test asserts result.payer matches the fixture opener.
…nsaction Enter the openTxSubmitter=server co-sign/broadcast path only when the open payload carries a transaction (push opens and clientVoucher pull opens whose deposit lives in an on-chain payment channel). A pull open with no transaction falls through to the trust-the-channel-id path instead of being hard-rejected by verify_open_tx's transaction requirement, so a pull-mode server configured with openTxSubmitter=server no longer rejects every pull open. Mirrors the TS open dispatch (if (payload.transaction) ... else if (push) ... else ...).
Only the transaction-carrying open paths (server-broadcast and client-broadcast verify) construct VerifyOpenTxExpected and parse config.program_id; the trust-the-channel-id paths no longer touch either, removing a failure surface and wasted work on pull/no-tx and push/no-rpc opens. Add regression tests for the client-broadcast pull+tx verify path, the push+no-tx+channelId trust path, and assert no on-chain confirmation is attempted on the pull trust path.
…fe open broadcast
The Python SDK ships mpp/session in this PR, but the cross-SDK harness only exercised the charge path for Python: - harness/runners/python.json omitted the session intent, so the frozen 48-byte voucher-preimage vector (vectors/session-voucher.json) was skipped for Python; the conformance runner had no voucherPreimage encoder. - the python harness server adapter was launched with system python3, which lacks the SDK deps; route it through uv (matching the conformance runner). - the harness-python CI job never ran the conformance suite, so session wire bytes were never gated for Python in CI. This adds the voucherPreimage canonical-bytes encoder (delegating to the production VoucherData.message_bytes packer, mirroring the Go runner), declares the session intent in the runner manifest, runs the adapter under uv, and gates the Python cross-SDK conformance vectors (charge + x402-exact + session) in CI.
03cd6ca to
f477ee6
Compare
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, submit-open server-broadcast, and settle-at-close),session_method(new_session+ open/voucher/commit/topUp/close handlers, re-drivable close, pull-mode guard, atomic settle-in-progress 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)protocols/mpp/server/session_onchain.py(server-broadcast open + settle-at-close + polling confirmation)examples/playground_api/(runnable example)On-chain broadcast and settlement
The Python SDK now ships the server-side on-chain paths the Go/TS servers have:
openTxSubmitter=server): the server completes the fee-payer signature on a client-built open transaction, broadcasts it, and waits for confirmation before persisting channel state. Mirrors the TSsubmitOpenTxhelper. A replayed open does NOT re-broadcast: a store existence pre-check short-circuits to the idempotentprocess_openpath before the broadcast, since re-sending an already-landed open would break idempotent-replay semantics.settle_and_finalize_channel): a close with a signer and RPC configured builds thesettle_and_finalizeinstruction (preceded by the Ed25519 precompile when a voucher was recorded) plus adistributeinstruction, signs, broadcasts, and confirms, then records the settlement signature and finalizes. The close is re-drivable when no settlement signature was recorded so a transient settlement failure cannot strand the channel.confirm_transaction_signature): a freshly broadcast signature commonly returnsNonefromgetSignatureStatusesfor hundreds of ms to seconds. The helper now polls (mirroring the TSwaitForSignatureConfirmation) until the signature reaches at leastconfirmed, fails on-chain, or times out (default 30s). This replaces the old single-shot check, which raised spuriously on just-broadcast transactions. The already-landed open/top-up verify path (verify_open_tx) keeps the single-shot liveness check, since the signature there was supplied by the client and is expected to be known.settling): the settle path claims asettlingflag under the per-channel store lock before broadcasting, so a concurrent close retry or idle-watchdog fire cannot both pass the finalize check and broadcast duplicate settle transactions. The guard is released by the finalize mutator (which setsfinalized), or by a failed broadcast path so a retry can claim again.The RPC client remains optional: with no RPC the open/top-up signatures and deposit amounts are trusted as provided (offline core), and close is a pure state-flip with
settledSignature=null. Mirrors how the Go port treats the RPC client as optional; the playground uses the client submitter withrpc=Noneaccordingly.Verification
ruff check,ruff format,pyright(0 errors),pytest --cov=pay_kit --cov-fail-under=90(cov 93.6%). The server modules mirror Go's test behaviors test-first. Manual check: the playground responds with real402 Paymentchallenges on the session stream, charge, and x402 routes; the app boots with all routes registered. The server-broadcast open path reaches the RPC (fails on blockhash-not-found without a live validator, not the oldverify_open_txhard-reject). Pull-mode opens with no transaction skip the server-broadcast path and trust the channel id/deposit, mirroring the TSelseopen branch. The 48-byte voucher preimage and single-byte discriminators (open=1, topUp=3) match the Rust/Go frozen vectors.Reviewer note (sakana-reviewer, fugu-ultra)
Independent verification against branch tip
81dde1d3. All three fixes (polling confirm, replay-safe open broadcast, atomic settle guard) hold against the current tree.confirm_transaction_signature(session_onchain.py:354-417) now pollsgetSignatureStatuseson a 30s/1s deadline, exits onerr(transaction-failed), treatsconfirmationStatusofNone/confirmed/finalizedas success, and cleanly distinguishestransaction-not-found(no status ever seen) fromtransaction-not-confirmed(saw a status, timed out). Wired into the settle broadcast (:493), so just-broadcast settles no longer reject spuriously.get_channelcheck (session_method.py:551-580) only short-circuits the re-broadcast;process_open(session.py:341-353) remains the atomic check-and-insert source of truth, returning existing state unchanged with the voucher watermark intact. Sequential replay is broadcast-safe; truly concurrent first-opens stay bounded by the atomic mutator._settle_channel(session_method.py:768-833) claimssettlingunder the per-channel store lock, losers returnNone/the finalized signature without broadcasting, the guard is released on broadcast/confirm failure, andfinalizeclears it. Confirm-before-finalize + on-chain idempotency contain the restart window (settlingis intentionally transient/unserialized).