Skip to content

feat(python): MPP sessions (client + server + playground)#161

Merged
lgalabru merged 52 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/python-mpp-session
Jun 24, 2026
Merged

feat(python): MPP sessions (client + server + playground)#161
lgalabru merged 52 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/python-mpp-session

Conversation

@EfeDurmaz16

@EfeDurmaz16 EfeDurmaz16 commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

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.

  • Wire types protocols/mpp/intents/session.pySessionRequest, the SessionAction tagged union (open / voucher / commit / topUp / close), signed vouchers, metering types.
  • Client protocols/mpp/client/session.py + session_consumer.pyActiveSession (voucher signing, monotonic watermark, action builders) and SessionConsumer (metered ack/commit).
  • Server protocols/mpp/server/session_*.py — the full server method ported from go/protocols/mpp/server against 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, public SessionServer).
  • Generated client protocols/programs/paymentchannels/ — codama-py output (Go uses @codama/renderers-go); regenerate with just payment-channels-generate-py.
  • Playground examples/playground_api/ — FastAPI port of go/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):

  1. protocols/mpp/intents/session.py (wire types)
  2. protocols/mpp/client/session.py, session_consumer.py
  3. protocols/mpp/server/session_store.py, session_voucher.py, session_method.py (the fund-safety surface)
  4. protocols/mpp/server/session_routes.py (HTTP + strict decode)
  5. protocols/mpp/server/session_onchain.py (server-broadcast open + settle-at-close + polling confirmation)
  6. 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:

  • Server-broadcast open (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 TS submitOpenTx helper. A replayed open does NOT re-broadcast: a store existence pre-check short-circuits to the idempotent process_open path before the broadcast, since re-sending an already-landed open would break idempotent-replay semantics.
  • Settle at close (settle_and_finalize_channel): a close with a signer and RPC configured builds the settle_and_finalize instruction (preceded by the Ed25519 precompile when a voucher was recorded) plus a distribute instruction, 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.
  • Polling confirmation (confirm_transaction_signature): a freshly broadcast signature commonly returns None from getSignatureStatuses for hundreds of ms to seconds. The helper now polls (mirroring the TS waitForSignatureConfirmation) until the signature reaches at least confirmed, 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.
  • Atomic settle-in-progress guard (settling): the settle path claims a settling flag 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 sets finalized), 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 with rpc=None accordingly.

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 real 402 Payment challenges 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 old verify_open_tx hard-reject). Pull-mode opens with no transaction skip the server-broadcast path and trust the channel id/deposit, mirroring the TS else open 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.

  • B1 sound: confirm_transaction_signature (session_onchain.py:354-417) now polls getSignatureStatuses on a 30s/1s deadline, exits on err (transaction-failed), treats confirmationStatus of None/confirmed/finalized as success, and cleanly distinguishes transaction-not-found (no status ever seen) from transaction-not-confirmed (saw a status, timed out). Wired into the settle broadcast (:493), so just-broadcast settles no longer reject spuriously.
  • S1 preserves idempotency: the pre-broadcast get_channel check (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.
  • S2 race-safe: _settle_channel (session_method.py:768-833) claims settling under the per-channel store lock, losers return None/the finalized signature without broadcasting, the guard is released on broadcast/confirm failure, and finalize clears it. Confirm-before-finalize + on-chain idempotency contain the restart window (settling is intentionally transient/unserialized).
  • No fund-safety regressions: no path double-credits, leaks, or bypasses the deposit; deposit is never re-credited, settle is single-broadcast, and finalize keeps the winner's signature. Approve.

@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown

Greptile Summary

This PR ports the full MPP session intent to the Python SDK, including wire types, client (ActiveSession, SessionConsumer), server (store, voucher verifier, lifecycle, on-chain open/settle paths, metered SSE, HTTP routes), a codama-generated payment-channels client, and a FastAPI playground — bringing Python to parity with the Go/TS session surface.

  • Wire types & client (intents/session.py, client/session.py, session_consumer.py): cumulative-watermark voucher signing, monotonic watermark guard, action builders, and a SessionConsumer wrapping the Kafka-style metered-delivery ack loop.
  • Server (server/session_*.py): full per-channel locked store, Ed25519 voucher verifier, on-chain open/top-up verification with polling confirmation, atomic settle-in-progress guard, re-drivable close, idle-close watchdog, and server-broadcast open (all previous fund-safety issues — missing confirmation, payer/deposit propagation, duplicate finalize, idle-close state-flip — are fixed at this HEAD).
  • Playground (examples/playground_api/): FastAPI port of the Go playground with metered stream, charge, x402, and faucet routes.

Confidence Score: 5/5

Safe 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

Filename Overview
python/src/pay_kit/protocols/mpp/server/session_method.py Core session method handler: open/voucher/commit/topUp/close dispatch with on-chain verify, atomic settle guard, re-drivable close, and idle-close watchdog. All previously flagged fund-safety issues fixed. One suppressed type-ignore in the release mutator is a minor robustness gap for custom store implementors.
python/src/pay_kit/protocols/mpp/server/session_onchain.py On-chain open verification, server-broadcast cosign, polling confirmation (30s deadline), and settle_and_finalize_channel with confirm-before-return. All previous issues resolved.
python/src/pay_kit/protocols/mpp/server/session_store.py Per-channel locked MemoryChannelStore with correct lock-then-_mu ordering in delete_channel. Go-faithful nil-slice serialization preserved.
python/src/pay_kit/protocols/mpp/server/session_routes.py Metered side-channel routes with strict typed decode. All previously flagged 400-boundary gaps fixed.
python/src/pay_kit/protocols/mpp/intents/session.py Wire types for all five session actions. CommitReceipt.from_dict raises on unknown/absent status. Base-unit parsing routes through _parse_base_units throughout.
python/src/pay_kit/protocols/mpp/client/session_consumer.py Kafka-style consumer with prepare/record split for safe commit retries. Replayed receipt path correctly clamps watermark advance.
python/examples/playground_api/sessions.py FastAPI playground session route with pull mode + clientVoucher and idle-close watchdog. Accesses session._touch as a private method — minor style issue.

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)
Loading
%%{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)
Loading

Reviews (35): Last reviewed commit: "test(python/mpp): add session harness co..." | Re-trigger Greptile

Comment thread python/src/pay_kit/protocols/mpp/intents/session.py
Comment thread python/src/pay_kit/protocols/mpp/_paymentchannels.py Outdated
Comment thread python/src/pay_kit/protocols/mpp/client/__init__.py
Comment thread python/src/pay_kit/protocols/mpp/client/__init__.py
Comment thread python/src/pay_kit/protocols/mpp/intents/session.py
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 8, 2026
…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).
@EfeDurmaz16 EfeDurmaz16 force-pushed the feat/python-mpp-session branch from 3c94f18 to f122be5 Compare June 8, 2026 22:58
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 8, 2026
…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).
Comment thread python/src/pay_kit/protocols/mpp/intents/session.py
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 8, 2026
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).
@EfeDurmaz16 EfeDurmaz16 force-pushed the feat/python-mpp-session branch from f122be5 to a59d725 Compare June 8, 2026 23:09
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 12, 2026
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).
@EfeDurmaz16 EfeDurmaz16 force-pushed the feat/python-mpp-session branch from 1910fe8 to d1ded63 Compare June 12, 2026 14:39
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 12, 2026
…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).
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 13, 2026
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).
@EfeDurmaz16 EfeDurmaz16 force-pushed the feat/python-mpp-session branch from d1ded63 to 843b99d Compare June 13, 2026 12:32
@EfeDurmaz16 EfeDurmaz16 changed the title feat(python): MPP client-only sessions (session intent + payment-channels glue) feat(python): MPP sessions (client + server + playground) Jun 13, 2026
Comment thread python/examples/playground_api/app.py Outdated
Comment thread python/examples/playground_api/app.py Outdated
Comment thread python/examples/playground_api/app.py Outdated
Comment thread python/examples/playground_api/app.py Outdated
Comment thread python/examples/playground_api/README.md Outdated
Comment thread python/examples/playground_api/sessions.py Outdated
Comment thread python/examples/playground_api/x402.py Outdated
Comment thread python/examples/playground_api/yahoo.py Outdated
Comment thread python/src/pay_kit/protocols/mpp/intents/session.py Outdated
Comment thread python/src/pay_kit/protocols/mpp/intents/session.py Outdated
Comment thread python/src/pay_kit/protocols/mpp/intents/session.py
Comment thread python/src/pay_kit/protocols/mpp/server/session_routes.py Outdated
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 15, 2026
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.
Comment thread python/src/pay_kit/protocols/mpp/server/session_store.py
EfeDurmaz16 and others added 26 commits June 24, 2026 00:09
… 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.
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.
@EfeDurmaz16 EfeDurmaz16 force-pushed the feat/python-mpp-session branch from 03cd6ca to f477ee6 Compare June 23, 2026 21:42
@lgalabru lgalabru merged commit 4ca2772 into solana-foundation:main Jun 24, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants