Skip to content

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

Open
EfeDurmaz16 wants to merge 25 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/python-mpp-session
Open

feat(python): MPP sessions (client + server + playground)#161
EfeDurmaz16 wants to merge 25 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), 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, 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. examples/playground_api/ (runnable example)

Deferred (documented)

Server-side on-chain settlement broadcast (SubmitOpenTx / SettlementInstructions in 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 with rpc=None (offline) accordingly, and the receipt poll reports settledSignature=null until 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 real 402 Payment challenges 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:

  • Fund safety lives in session_voucher.py and session_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.
  • Decode strictness matches Go. session_routes.py rejects type-mismatched JSON fields up front with 400 invalid request body (float/numeric-string into an int64 field, JSON number into a string field), before any store access, matching Go's typed json.Decode. Regression tests cover each case.
  • Durable-record parity. session_store serializes empty delivery lists as null (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.
  • The deferred settlement-broadcast path is the one intentional gap (see above), stubbed to raise rather than fake-implemented.

@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown

Greptile Summary

This PR ports the MPP session intent to Python — wire types, client (ActiveSession + SessionConsumer), full server (store, voucher verifier, lifecycle, onchain seams, method, routes), generated payment-channels client, and a FastAPI playground — bringing Python to parity with the Go port (#160). The deferred gap (server-broadcast settlement) is clearly stubbed and documented.

  • Wire types & client (intents/session.py, client/session.py, session_consumer.py): VoucherData.from_dict now coerces JSON-number cumulative to str; CommitReceipt.from_dict rejects missing/unknown status; the prepare/record split in SessionConsumer makes failed commits safe to retry.
  • Server fund-safety surface (session_voucher.py, session_method.py, session_store.py): voucher check order (monotonicity → deposit cap → min-delta → Ed25519 → expiry) matches Go/Rust; close re-drivability is correctly scoped to channels with no settled signature; per-channel locking serializes concurrent watermark advances.
  • Strict decode (session_routes.py): typed field helpers reject JSON type mismatches before any store access, matching Go's json.Decode behaviour; _parse_session_u64 in session_method.py is missing the same isinstance(value, str) guard, causing an uncaught AttributeError on a non-string newDeposit from a topUp action.

Confidence Score: 4/5

Safe 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

Filename Overview
python/src/pay_kit/protocols/mpp/server/session_method.py HTTP-facing session handler; _parse_session_u64 is missing the isinstance(value, str) guard present in the routes module, causing an uncaught AttributeError on non-string newDeposit payloads from the topUp path.
python/src/pay_kit/protocols/mpp/server/session_voucher.py Pure voucher verifier; check order (monotonicity → deposit → min-delta → Ed25519 → expiry) faithfully mirrors the Go and Rust reference; idempotent replay is correctly gated on matching signature and re-verified.
python/src/pay_kit/protocols/mpp/server/session_store.py In-memory store with per-channel locking for update_channel; delete_channel acquires only _mu (race with concurrent update_channel noted in a prior review thread); nil-slice serialization preserves wire parity with Go.
python/src/pay_kit/protocols/mpp/server/session_routes.py Metering side-channel handlers; strict typed decode is thorough for integer/string field mismatches but the voucher field in commit is not type-checked before SignedVoucher.from_dict (non-dict value raises AttributeError as a 500 — noted in a prior thread).
python/src/pay_kit/protocols/mpp/intents/session.py Wire types for the session intent; VoucherData now coerces JSON-number cumulative to str; CommitReceipt.from_dict raises on missing/unknown status; _parse_base_units normalizes via str() before digit-check, handling int inputs from deserialization safely.
python/src/pay_kit/protocols/mpp/client/session_consumer.py SessionConsumer prepare/record split ensures a failed commit does not advance the local watermark; idempotent replay is handled for both committed and replayed receipts; transport interface is clean.
python/src/pay_kit/protocols/mpp/server/session.py Core SessionServer: process_open idempotency, verify_voucher atomic watermark advance, begin_delivery/process_commit metering flows are faithfully ported from Go with correct u64 overflow guards.
python/examples/playground_api/sessions.py FastAPI playground for session endpoints; session_routes are only wired to stream_session.core() but both sessions share the same MemoryChannelStore; receipt endpoint reads shared_store correctly.

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
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 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
Loading

Reviews (15): Last reviewed commit: "test(python/playground): add playground-..." | Re-trigger Greptile

Comment on lines +479 to +484
if "cumulativeAmount" in data:
cumulative = data["cumulativeAmount"]
elif "cumulative" in data:
cumulative = data["cumulative"]
else:
cumulative = ""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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 = ""

Comment on lines +229 to +233
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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)

Comment on lines +20 to +23
from pay_kit.protocols.mpp.client.session_consumer import (
MeteredDelivery,
SessionConsumer,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
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!

Comment on lines 26 to 33
__all__ = [
"PaymentTransport",
"ActiveSession",
"SessionConsumer",
"MeteredDelivery",
"serialize_session_credential",
"parse_session_challenge",
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Suggested change
__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!

Comment on lines +767 to +778
@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"),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
@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"),
)

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 on lines +811 to +812
def from_dict(cls, data: dict[str, Any]) -> MeteringUsage:
return cls(delivery_id=data.get("deliveryId", ""), amount=data.get("amount", ""))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

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).
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.
@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
weather / fortune / x402 routes stay live server-side but are not advertised
in the nav.
"""
return [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a huge fan of this approach. can we have endpoints declared in-line instead of this json?

Comment thread python/examples/playground_api/app.py Outdated
"feePayer": state.fee_payer_pubkey,
"recipient": state.recipient,
"network": state.network,
"rpcUrl": state.rpc_url,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the RPC url should be exposed.

pass
return JSONResponse(body)

@app.get("/api/v1/config")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment on lines +71 to +88
| 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 |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why so many endpoints?

}
)

return shutdown

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same feedback - too much code

Comment thread python/examples/playground_api/x402.py Outdated

@app.get("/x402/fact")
async def x402_fact(payment: Payment = require_fact) -> JSONResponse:
return JSONResponse({"fact": random.choice(FACTS), "source": "x402"})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same feedback - Feels like a lot of code here belongs to PayKit

Comment thread python/examples/playground_api/yahoo.py Outdated
return None
if math.isnan(result):
return None
return result

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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``.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove all these "Mirrors etc"

Comment on lines +884 to +889

delivery_id: str
session_id: str
amount: str
cumulative: str
status: CommitStatus

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Comment on lines +198 to +201
voucher_raw = body.get("voucher")
if voucher_raw is None:
return _error(400, "voucher required")
voucher = SignedVoucher.from_dict(voucher_raw)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
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.
Comment on lines +340 to +343
async def delete_channel(self, channel_id: str) -> None:
async with self._mu:
self._data.pop(channel_id, None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.
Comment thread justfile
# `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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we also have some codama commands in the main justfile, can we consolidate?

Comment thread python/examples/playground_api/app.py Outdated
# --- endpoint catalog (drives the playground web app's sidebar) -------------


def build_endpoint_list() -> list[dict[str, Any]]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we get rid of this function?

return JSONResponse(body)

@app.get("/api/v1/config")
async def config() -> JSONResponse:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this function used?

seller="7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
description="Ceramic mug for node operators",
),
"nft-sticker-pack": _Product(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are these products?

Comment thread python/README.md Outdated
| `charge/pull` | ✅ |
| `charge/push` | ✅ |
| `session` | — |
| `session` | Client-only |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
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