From a22200a03b0627f8f62e49fe7f5afe9007d328c6 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 9 Jun 2026 00:26:18 +0300 Subject: [PATCH 01/52] feat(python): add MPP client session intent 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. --- .../pay_kit/protocols/mpp/_paymentchannels.py | 266 ++++++ .../pay_kit/protocols/mpp/client/__init__.py | 26 +- .../pay_kit/protocols/mpp/client/session.py | 441 +++++++++ .../protocols/mpp/client/session_consumer.py | 156 ++++ .../pay_kit/protocols/mpp/intents/__init__.py | 68 +- .../pay_kit/protocols/mpp/intents/session.py | 876 ++++++++++++++++++ python/tests/test_paymentchannels.py | 287 ++++++ python/tests/test_session_client.py | 417 +++++++++ python/tests/test_session_consumer.py | 200 ++++ python/tests/test_session_intent.py | 621 +++++++++++++ 10 files changed, 3356 insertions(+), 2 deletions(-) create mode 100644 python/src/pay_kit/protocols/mpp/_paymentchannels.py create mode 100644 python/src/pay_kit/protocols/mpp/client/session.py create mode 100644 python/src/pay_kit/protocols/mpp/client/session_consumer.py create mode 100644 python/src/pay_kit/protocols/mpp/intents/session.py create mode 100644 python/tests/test_paymentchannels.py create mode 100644 python/tests/test_session_client.py create mode 100644 python/tests/test_session_consumer.py create mode 100644 python/tests/test_session_intent.py diff --git a/python/src/pay_kit/protocols/mpp/_paymentchannels.py b/python/src/pay_kit/protocols/mpp/_paymentchannels.py new file mode 100644 index 000000000..da8b65a9f --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/_paymentchannels.py @@ -0,0 +1,266 @@ +"""Hand-written on-chain glue for the payment-channels program. + +This module is the Python counterpart of +``rust/crates/mpp/src/program/payment_channels.rs`` and +``go/paycore/paymentchannels/paymentchannels.go``: PDA derivation, associated +token derivation, voucher preimage bytes, and convenience instruction builders +for the push-mode session flow (``open`` + ``topUp``). + +Everything here mirrors the Rust spine so the wire format and on-chain paths +stay byte-identical across the language SDKs. In particular the production +program id pinned here (``GuoKrza...``) overrides the IDL placeholder +(``CQAyft83tN1w2bRofB5PZ79eVDU2xZUVo43LU1qL4zRg``), which is not the deployed +program; every PDA derivation and instruction built here uses ``GuoKrza...``. + +There is no Borsh library in the Python stack, so the instruction data and the +voucher preimage are hand-packed with :mod:`struct`. The payment-channels +program uses a single-byte instruction discriminator (``open`` = 1, +``topUp`` = 3), not the 8-byte Anchor discriminator. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass, field + +from solders.instruction import AccountMeta, Instruction # type: ignore[import-untyped] +from solders.pubkey import Pubkey # type: ignore[import-untyped] + +from pay_kit._paycore.solana import ( + ASSOCIATED_TOKEN_PROGRAM, + SYSTEM_PROGRAM, + TOKEN_PROGRAM, +) + +__all__ = [ + "PAYMENT_CHANNELS_PROGRAM_ID", + "PROGRAM_ID", + "Distribution", + "OpenChannelParams", + "TopUpParams", + "build_open_instruction", + "build_top_up_instruction", + "find_associated_token_address", + "find_channel_pda", + "find_event_authority_pda", + "voucher_message_bytes", +] + +# Canonical payment-channels program id deployed to the network. The IDL +# placeholder ``CQAyft83tN1w2bRofB5PZ79eVDU2xZUVo43LU1qL4zRg`` is NOT the +# production deployment; mirrors ``PAYMENT_CHANNELS_PROGRAM_ID`` in the Rust +# spine and ``ProgramID`` in the Go port. +PAYMENT_CHANNELS_PROGRAM_ID = "GuoKrzaBiZnW5DvJ3yZVE7xHqbcBvaX9SH6P6Cn9gNvc" + +#: Parsed production program id used for derivation and instruction emission. +PROGRAM_ID = Pubkey.from_string(PAYMENT_CHANNELS_PROGRAM_ID) + +# Channel PDA seed prefix. Mirrors ``CHANNEL_SEED`` in the Rust spine. +_CHANNEL_SEED = b"channel" + +# Event-authority PDA seed prefix. Mirrors ``EVENT_AUTHORITY_SEED`` in Rust. +_EVENT_AUTHORITY_SEED = b"event_authority" + +# Single-byte instruction discriminators (NOT 8-byte Anchor). Mirrors the +# discriminator constants in the payment-channels program. +_OPEN_DISCRIMINATOR = 1 +_TOP_UP_DISCRIMINATOR = 3 + +# Rent sysvar id. Mirrors ``RENT_SYSVAR_ID`` in the Rust spine. +_RENT_SYSVAR_ID = "SysvarRent111111111111111111111111111111111" + + +@dataclass(frozen=True) +class Distribution: + """A single payout recipient and its basis-point share. + + Mirrors the ``Distribution`` struct in the Rust spine. + """ + + recipient: Pubkey + bps: int + + +@dataclass +class OpenChannelParams: + """Inputs required to build an ``open`` instruction. + + Mirrors ``OpenChannelParams`` in the Rust spine. ``token_program`` defaults + to the SPL Token program; pass the Token-2022 program id for Token-2022 + mints. + """ + + payer: Pubkey + payee: Pubkey + mint: Pubkey + authorized_signer: Pubkey + salt: int + deposit: int + grace_period: int + recipients: list[Distribution] = field(default_factory=list) + token_program: Pubkey = field(default_factory=lambda: Pubkey.from_string(TOKEN_PROGRAM)) + + +@dataclass +class TopUpParams: + """Inputs required to build a ``topUp`` instruction. + + Mirrors the ``build_top_up_instruction`` arguments in the Rust spine. + """ + + payer: Pubkey + channel: Pubkey + mint: Pubkey + amount: int + token_program: Pubkey = field(default_factory=lambda: Pubkey.from_string(TOKEN_PROGRAM)) + + +def voucher_message_bytes(channel_id: Pubkey, cumulative: int, expires_at: int) -> bytes: + """Return the 48-byte voucher preimage signed by the authorized signer. + + Layout: ``channelId`` (32) || ``cumulativeAmount`` as little-endian u64 + (offset 32) || ``expiresAt`` as little-endian i64 (offset 40). This is the + exact Borsh layout of ``VoucherArgs``. Mirrors ``voucher_message_bytes`` in + the Rust spine. + + Raises: + ValueError: if ``channel_id`` does not encode to exactly 32 bytes. + """ + channel_bytes = bytes(channel_id) + if len(channel_bytes) != 32: + raise ValueError(f"channel id must be exactly 32 bytes, got {len(channel_bytes)}") + return channel_bytes + struct.pack(" tuple[Pubkey, int]: + """Derive the channel PDA against the production program id. + + Seeds: ``["channel", payer, payee, mint, authorizedSigner, salt u64 LE]``. + Mirrors ``find_channel_pda`` in the Rust spine. + """ + return Pubkey.find_program_address( + [ + _CHANNEL_SEED, + bytes(payer), + bytes(payee), + bytes(mint), + bytes(authorized_signer), + struct.pack(" tuple[Pubkey, int]: + """Derive the event-authority PDA against the production program id. + + Seeds: ``["event_authority"]``. Mirrors ``find_event_authority_pda`` in the + Rust spine. + """ + return Pubkey.find_program_address([_EVENT_AUTHORITY_SEED], PROGRAM_ID) + + +def find_associated_token_address( + owner: Pubkey, + mint: Pubkey, + token_program: Pubkey, +) -> tuple[Pubkey, int]: + """Derive the associated token account address for ``(owner, mint, program)``. + + Seeds: ``[owner, token_program, mint]`` under the associated-token program. + Mirrors ``find_associated_token_address`` in the Rust spine and ``derive_ata`` + in :mod:`pay_kit._paycore.mints`. + """ + return Pubkey.find_program_address( + [bytes(owner), bytes(token_program), bytes(mint)], + Pubkey.from_string(ASSOCIATED_TOKEN_PROGRAM), + ) + + +def build_open_instruction(params: OpenChannelParams) -> Instruction: + """Build the ``open`` instruction with accounts in the exact Rust order. + + Derives the channel PDA, the payer and channel ATAs, and the event-authority + PDA, then emits 13 account metas in the canonical order against the + production program id. Mirrors ``build_open_instruction`` in the Rust spine. + + Instruction data (single-byte discriminator ``1`` + hand-packed Borsh): + ``\\x01`` || ``salt`` u64 LE || ``deposit`` u64 LE || ``grace_period`` u32 LE + || ``len(recipients)`` u32 LE || for each recipient: pubkey (32) || ``bps`` + u16 LE. + """ + channel, _ = find_channel_pda( + params.payer, + params.payee, + params.mint, + params.authorized_signer, + params.salt, + ) + payer_token_account, _ = find_associated_token_address(params.payer, params.mint, params.token_program) + channel_token_account, _ = find_associated_token_address(channel, params.mint, params.token_program) + event_authority, _ = find_event_authority_pda() + + system_program = Pubkey.from_string(SYSTEM_PROGRAM) + rent = Pubkey.from_string(_RENT_SYSVAR_ID) + associated_token_program = Pubkey.from_string(ASSOCIATED_TOKEN_PROGRAM) + + accounts = [ + AccountMeta(params.payer, True, True), + AccountMeta(params.payee, False, False), + AccountMeta(params.mint, False, False), + AccountMeta(params.authorized_signer, False, False), + AccountMeta(channel, False, True), + AccountMeta(payer_token_account, False, True), + AccountMeta(channel_token_account, False, True), + AccountMeta(params.token_program, False, False), + AccountMeta(system_program, False, False), + AccountMeta(rent, False, False), + AccountMeta(associated_token_program, False, False), + AccountMeta(event_authority, False, False), + AccountMeta(PROGRAM_ID, False, False), + ] + + data = bytearray() + data.append(_OPEN_DISCRIMINATOR) + data += struct.pack(" Instruction: + """Build the ``topUp`` instruction with accounts in the exact Rust order. + + Derives the payer and channel ATAs, then emits 6 account metas in the + canonical order against the production program id. Mirrors + ``build_top_up_instruction`` in the Rust spine. + + Instruction data (single-byte discriminator ``3`` + hand-packed Borsh): + ``\\x03`` || ``amount`` u64 LE. + """ + payer_token_account, _ = find_associated_token_address(params.payer, params.mint, params.token_program) + channel_token_account, _ = find_associated_token_address(params.channel, params.mint, params.token_program) + + accounts = [ + AccountMeta(params.payer, True, True), + AccountMeta(params.channel, False, True), + AccountMeta(payer_token_account, False, True), + AccountMeta(channel_token_account, False, True), + AccountMeta(params.mint, False, False), + AccountMeta(params.token_program, False, False), + ] + + data = bytes([_TOP_UP_DISCRIMINATOR]) + struct.pack(" str`` (base58 public key) and +``sign(message: bytes) -> bytes`` (64-byte Ed25519 signature). A solders +``Keypair`` is also accepted because it exposes ``pubkey()`` and +``sign_message()``; both shapes are handled by :func:`_sign_base58`. +""" + +from __future__ import annotations + +from typing import Any, Protocol, cast, runtime_checkable + +from pay_kit.protocols.mpp._paymentchannels import voucher_message_bytes +from pay_kit.protocols.mpp.core.base64url import decode_json +from pay_kit.protocols.mpp.core.headers import format_authorization, parse_www_authenticate +from pay_kit.protocols.mpp.core.types import PaymentChallenge, PaymentCredential +from pay_kit.protocols.mpp.intents.session import ( + DEFAULT_SESSION_EXPIRES_AT, + ClosePayload, + OpenPayload, + SessionAction, + SessionMode, + SessionRequest, + SignedVoucher, + TopUpPayload, + VoucherData, + VoucherPayload, +) + +__all__ = [ + "DEFAULT_VOUCHER_EXPIRES_AT", + "VoucherSigner", + "ActiveSession", + "serialize_session_credential", + "parse_session_challenge", +] + +#: Default voucher expiry: 2100-01-01T00:00:00Z. Stays below JavaScript's max +#: safe integer so JSON intermediaries do not round it before the credential is +#: decoded. Mirrors rust ``DEFAULT_VOUCHER_EXPIRES_AT``. +DEFAULT_VOUCHER_EXPIRES_AT = DEFAULT_SESSION_EXPIRES_AT + +# The session intent name carried in a challenge ``intent`` field. +_SESSION_INTENT = "session" + +# u64 upper bound; the cumulative watermark is a Solana base-unit amount and the +# voucher preimage packs it as a little-endian u64. Mirrors the Go overflow +# guard (``addCumulative``) which rejects a wrapped watermark. +_U64_MAX = (1 << 64) - 1 + + +@runtime_checkable +class VoucherSigner(Protocol): + """Minimal Ed25519 message-signing surface for voucher signing. + + Mirrors the Go ``VoucherSigner`` alias (``solanatx.Signer``) and the pay_kit + :class:`pay_kit.signer.LocalSigner` duck type shared with the charge client. + A solders ``Keypair`` also satisfies this shape via ``pubkey()`` plus + ``sign_message()``; :func:`_sign_base58` bridges the two method names. + """ + + def pubkey(self) -> Any: + """Return the signer's public key (base58 ``str`` or solders ``Pubkey``).""" + ... + + def sign(self, message: bytes) -> bytes: + """Return the 64-byte Ed25519 signature over ``message``.""" + ... + + +def _sign_base58(signer: Any, message: bytes) -> str: + """Sign ``message`` and return the signature as base58. + + Accepts both the pay_kit signer (``sign(bytes) -> bytes``) and a solders + ``Keypair`` (``sign_message(bytes) -> Signature``). The Go port returns + ``sig.String()`` (base58); this normalizes the 64-byte signature to the same + base58 form via the solders ``Signature`` type. + """ + from solders.signature import Signature # type: ignore[import-untyped] + + sign = getattr(signer, "sign", None) + if callable(sign): + raw = cast("bytes", sign(message)) + return str(Signature.from_bytes(bytes(raw))) + # solders Keypair fallback: sign_message returns a Signature directly. + return str(signer.sign_message(message)) + + +def _pubkey_str(signer: Any) -> str: + """Return the signer's public key as base58, accepting str or solders Pubkey.""" + pub = signer.pubkey() + return pub if isinstance(pub, str) else str(pub) + + +class ActiveSession: + """Tracks the client-side state of an active payment session. + + Holds the session signing key and advances the cumulative watermark with + each signed voucher. Vouchers are cumulative high-water marks: each one MUST + strictly exceed the previous, and the signer's public key becomes the + ``authorizedSigner`` passed to the server in the open action. + + Not safe for concurrent use; serialize access or guard it with a lock. + Mirrors rust ``ActiveSession``. + """ + + def __init__( + self, + channel_id: Any, + signer: VoucherSigner | Any, + expires_at: int = DEFAULT_VOUCHER_EXPIRES_AT, + ) -> None: + """Create a session tracker for ``channel_id`` signing with ``signer``. + + ``channel_id`` is a solders ``Pubkey`` (the on-chain channel address + obtained after opening); ``signer`` satisfies the :class:`VoucherSigner` + contract (or is a solders ``Keypair``, accepted via its ``sign_message`` + method); ``expires_at`` is the Unix timestamp applied to newly signed + vouchers, defaulting to :data:`DEFAULT_VOUCHER_EXPIRES_AT`. Mirrors rust + ``ActiveSession::new`` / ``new_with_expiry``. + """ + self._channel_id = channel_id + self._signer = signer + self._expires_at = expires_at + self._cumulative = 0 + self._nonce = 0 + + @classmethod + def at_expiry(cls, channel_id: Any, signer: VoucherSigner | Any, expires_at: int) -> ActiveSession: + """Create a session tracker with an explicit voucher expiry. + + Mirrors rust ``ActiveSession::new_with_expiry`` and the Go + ``NewActiveSessionAt`` constructor. + """ + return cls(channel_id, signer, expires_at) + + @property + def cumulative(self) -> int: + """Current cumulative watermark (base units).""" + return self._cumulative + + @property + def nonce(self) -> int: + """Current voucher nonce counter.""" + return self._nonce + + @property + def expires_at(self) -> int: + """Expiry timestamp applied to newly signed vouchers.""" + return self._expires_at + + @property + def channel_id(self) -> Any: + """On-chain channel address (solders ``Pubkey``).""" + return self._channel_id + + @property + def channel_id_string(self) -> str: + """Channel address as base58. Mirrors rust ``channel_id_str``.""" + return str(self._channel_id) + + @property + def authorized_signer(self) -> str: + """Session signing key as base58, for the open action payload.""" + return _pubkey_str(self._signer) + + def set_expires_at(self, expires_at: int) -> None: + """Update the expiry timestamp used for subsequent vouchers. + + Mirrors rust ``ActiveSession::set_expires_at``. + """ + self._expires_at = expires_at + + # -- voucher signing ---------------------------------------------------- + + def prepare_voucher(self, cumulative: int) -> SignedVoucher: + """Sign a voucher without advancing the local watermark. + + This keeps ack/commit transports safe to retry: a failed commit can be + resent with the same cumulative amount without the local state drifting + ahead of the server. ``cumulative`` MUST strictly exceed the current + watermark. Mirrors rust ``ActiveSession::prepare_voucher``. + """ + if cumulative <= self._cumulative: + raise ValueError(f"voucher cumulative {cumulative} must exceed current watermark {self._cumulative}") + + nonce = self._nonce + 1 + data = VoucherData( + channel_id=self.channel_id_string, + cumulative=str(cumulative), + expires_at=self._expires_at, + nonce=nonce, + ) + + preimage = voucher_message_bytes(self._channel_id, cumulative, self._expires_at) + signature = _sign_base58(self._signer, preimage) + return SignedVoucher(data=data, signature=signature) + + def prepare_increment(self, amount: int) -> SignedVoucher: + """Sign a voucher adding ``amount`` to the current cumulative without + advancing the watermark. + + Mirrors rust ``ActiveSession::prepare_increment``. + """ + return self.prepare_voucher(self._add_cumulative(amount)) + + def record_voucher(self, voucher: SignedVoucher) -> None: + """Advance the local watermark to a prepared voucher the server accepted. + + The voucher cumulative MUST strictly exceed the current watermark; the + nonce advances to the larger of the current nonce + 1 and the voucher + nonce. Mirrors rust ``ActiveSession::record_voucher``. + """ + try: + cumulative = int(voucher.data.cumulative, 10) + except ValueError as exc: + raise ValueError("invalid voucher cumulative") from exc + if cumulative <= self._cumulative: + raise ValueError(f"voucher cumulative {cumulative} must exceed current watermark {self._cumulative}") + self._cumulative = cumulative + candidate = self._nonce + 1 + if voucher.data.nonce is not None and voucher.data.nonce > candidate: + candidate = voucher.data.nonce + self._nonce = candidate + + def sign_voucher(self, cumulative: int) -> SignedVoucher: + """Sign a voucher with an absolute cumulative amount and advance the + local watermark. + + ``cumulative`` MUST strictly exceed the current watermark. Mirrors rust + ``ActiveSession::sign_voucher``. + """ + voucher = self.prepare_voucher(cumulative) + self.record_voucher(voucher) + return voucher + + def sign_increment(self, amount: int) -> SignedVoucher: + """Sign a voucher adding ``amount`` to the current cumulative. + + Mirrors rust ``ActiveSession::sign_increment``. + """ + return self.sign_voucher(self._add_cumulative(amount)) + + # -- action builders ---------------------------------------------------- + + def voucher_action(self, amount: int) -> SessionAction: + """Sign a fresh increment and wrap it as a voucher action. + + Mirrors rust ``ActiveSession::voucher_action``. + """ + voucher = self.sign_increment(amount) + return SessionAction.voucher_action(VoucherPayload(voucher=voucher)) + + def close_action(self, final_increment: int = 0) -> SessionAction: + """Build a cooperative close action. + + When ``final_increment`` is greater than zero it signs one last voucher + for the remaining balance before closing; otherwise the close carries no + voucher. Mirrors rust ``ActiveSession::close_action``. + """ + payload = ClosePayload(channel_id=self.channel_id_string) + if final_increment > 0: + payload.voucher = self.sign_increment(final_increment) + return SessionAction.close_action(payload) + + def open_action(self, deposit: int, open_tx_signature: str) -> SessionAction: + """Build a push-mode open action. + + Call this after the on-chain open transaction has confirmed; the session + channel ID MUST match the confirmed channel address. Mirrors rust + ``ActiveSession::open_action``. + """ + return SessionAction.open_action( + OpenPayload.push( + self.channel_id_string, + str(deposit), + self.authorized_signer, + open_tx_signature, + ) + ) + + def open_payment_channel_action( + self, + deposit: int, + payer: str, + payee: str, + mint: str, + salt: int, + grace_period: int, + open_tx_signature: str, + ) -> SessionAction: + """Build a payment-channel push open action carrying the full channel + parameters. + + Mirrors rust ``ActiveSession::open_payment_channel_action``. + """ + return self.open_payment_channel_action_with_mode( + "push", deposit, payer, payee, mint, salt, grace_period, open_tx_signature + ) + + def open_payment_channel_action_with_mode( + self, + mode: SessionMode, + deposit: int, + payer: str, + payee: str, + mint: str, + salt: int, + grace_period: int, + open_tx_signature: str, + ) -> SessionAction: + """Build a payment-channel open action with an explicit submission mode. + + Mirrors rust ``ActiveSession::open_payment_channel_action_with_mode``. + """ + return SessionAction.open_action( + OpenPayload.payment_channel_with_mode( + mode, + self.channel_id_string, + str(deposit), + payer, + payee, + mint, + salt, + grace_period, + self.authorized_signer, + open_tx_signature, + ) + ) + + def open_pull_action( + self, + approved_amount: int, + owner: str, + approve_tx_signature: str, + ) -> SessionAction: + """Build a pull-mode (SPL delegation) open action. + + The session channel ID is used as the token account, so callers should + construct the :class:`ActiveSession` with the delegated token-account + pubkey as the channel ID. Mirrors rust ``ActiveSession::open_pull_action``. + """ + return SessionAction.open_action( + OpenPayload.pull( + self.channel_id_string, + str(approved_amount), + owner, + self.authorized_signer, + approve_tx_signature, + ) + ) + + def top_up_action(self, new_deposit: int, topup_tx_signature: str) -> SessionAction: + """Build a top-up action after a top-up transaction confirms. + + Mirrors rust ``ActiveSession::topup_action``. + """ + return SessionAction.top_up_action( + TopUpPayload( + channel_id=self.channel_id_string, + new_deposit=str(new_deposit), + signature=topup_tx_signature, + ) + ) + + # -- credential framing ------------------------------------------------- + + def serialize_session_credential( + self, + challenge: PaymentChallenge, + action: SessionAction, + ) -> str: + """Build the ``Payment `` Authorization header value. + + Echoes ``challenge`` and JCS-canonicalizes the credential, reusing the + same core wire layer the charge client uses. Mirrors the Go + ``SerializeSessionCredential`` free function. + """ + return serialize_session_credential(challenge, action) + + # -- internals ---------------------------------------------------------- + + def _add_cumulative(self, amount: int) -> int: + """Add ``amount`` to the current watermark, rejecting u64 overflow. + + Mirrors the Go ``addCumulative`` guard so a wrapped watermark can never + be signed; rust relies on debug-mode overflow panics for the same + invariant. + """ + nxt = self._cumulative + amount + if nxt > _U64_MAX: + raise ValueError(f"voucher cumulative overflows u64: {self._cumulative} + {amount}") + return nxt + + +def serialize_session_credential( + challenge: PaymentChallenge, + action: SessionAction, +) -> str: + """Build an Authorization header value for a session action. + + Echoes the challenge and JCS-canonicalizes the credential, producing + ``"Payment "``. Mirrors the Go + ``SerializeSessionCredential`` and the credential framing rust uses for + session actions (``format_authorization``). + """ + credential = PaymentCredential( + challenge=challenge.to_echo(), + payload=action.to_dict(), + ) + return format_authorization(credential) + + +def parse_session_challenge(header: str) -> tuple[PaymentChallenge, SessionRequest]: + """Parse a WWW-Authenticate header into the challenge and session request. + + Rejects non-session intents so callers do not accidentally treat a charge + challenge as a session. Mirrors the Go ``ParseSessionChallenge``. + """ + challenge = parse_www_authenticate(header) + if challenge.intent != _SESSION_INTENT: + raise ValueError(f"challenge intent {challenge.intent!r} is not a session") + request = SessionRequest.from_dict(decode_json(challenge.request)) + return challenge, request diff --git a/python/src/pay_kit/protocols/mpp/client/session_consumer.py b/python/src/pay_kit/protocols/mpp/client/session_consumer.py new file mode 100644 index 000000000..94462928b --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/client/session_consumer.py @@ -0,0 +1,156 @@ +"""Kafka-style client helpers for metered session deliveries. + +:class:`SessionConsumer` wraps an :class:`~pay_kit.protocols.mpp.client.session.ActiveSession` +so applications can process delivered messages and call ``ack``/``commit`` +instead of manually signing and posting vouchers. A failed commit never +advances the local watermark, so the same directive can be retried safely. + +Behavior mirrors the Rust spine in +``rust/crates/mpp/src/client/session_consumer.rs`` and the parity-verified Go +port in ``go/protocols/mpp/client/session_consumer.go``. +""" + +from __future__ import annotations + +from typing import Any, Generic, Protocol, TypeVar, runtime_checkable + +from pay_kit.protocols.mpp.client.session import ActiveSession +from pay_kit.protocols.mpp.intents.session import ( + CommitPayload, + CommitReceipt, + MeteredEnvelope, + MeteringDirective, +) + +__all__ = [ + "CommitTransport", + "SessionConsumer", + "MeteredDelivery", +] + +P = TypeVar("P") + + +@runtime_checkable +class CommitTransport(Protocol): + """Transport used by :class:`SessionConsumer` to send commit payloads. + + HTTP clients, queues, and in-process tests can all implement this. The + directive is passed alongside the payload so transports can use + ``commit_url``, ``proof``, or other routing hints without those fields being + repeated in the signed commit body. + + The ``commit`` signature mirrors the Rust ``CommitTransport`` trait exactly: + it takes the directive and the :class:`CommitPayload` and returns a + :class:`CommitReceipt`. + """ + + def commit(self, directive: MeteringDirective, payload: CommitPayload) -> CommitReceipt: + """Send ``payload`` for ``directive`` and return the server receipt.""" + ... + + +class SessionConsumer: + """Client-side consumer for session-metered deliveries. + + Not safe for concurrent use; the underlying :class:`ActiveSession` watermark + is advanced inside :meth:`commit_directive`. Mirrors rust ``SessionConsumer``. + """ + + def __init__(self, session: ActiveSession, transport: CommitTransport) -> None: + """Wrap a session and a commit transport. Mirrors rust ``SessionConsumer::new``.""" + self._session = session + self._transport = transport + + @property + def session(self) -> ActiveSession: + """The wrapped session. Mirrors rust ``SessionConsumer::session``.""" + return self._session + + @property + def transport(self) -> CommitTransport: + """The wrapped commit transport.""" + return self._transport + + def accept(self, envelope: MeteredEnvelope) -> MeteredDelivery[Any]: + """Validate an envelope and return a delivery handle with ``ack``/``commit``. + + The directive is validated up front so a mismatched session is rejected + before the application processes the payload. Mirrors rust + ``SessionConsumer::accept``. + """ + self._validate_directive(envelope.metering) + return MeteredDelivery(consumer=self, payload=envelope.payload, metering=envelope.metering) + + def commit_directive(self, directive: MeteringDirective) -> CommitReceipt: + """Sign a voucher for the directive amount, send it, and advance the + local watermark only on success. + + Rejects directives whose session does not match, whose amount is not a + valid base-unit integer, or whose amount is zero. The prepare/record + split makes a failed commit safe to retry without double-counting: the + voucher is prepared (no watermark advance), sent, and only recorded once + the transport returns a receipt. A server ``replayed`` receipt is + honored unconditionally — the recorded voucher cumulative is strictly + greater than the prior watermark so the watermark advances exactly once. + Mirrors rust ``SessionConsumer::commit_directive``. + """ + self._validate_directive(directive) + amount = directive.amount_base_units() + if amount == 0: + raise ValueError("metered delivery amount must be greater than zero") + + voucher = self._session.prepare_increment(amount) + payload = CommitPayload(delivery_id=directive.delivery_id, voucher=voucher) + + receipt = self._transport.commit(directive, payload) + self._session.record_voucher(payload.voucher) + return receipt + + def _validate_directive(self, directive: MeteringDirective) -> None: + channel_id = self._session.channel_id_string + if directive.session_id != channel_id: + raise ValueError( + f"metered delivery session {directive.session_id} does not match active session {channel_id}" + ) + + +class MeteredDelivery(Generic[P]): + """A delivered payload paired with its metering directive. + + Call :meth:`ack` (or its :meth:`commit` alias) after the application has + processed :attr:`payload`. Mirrors rust ``MeteredDelivery``. + """ + + def __init__(self, consumer: SessionConsumer, payload: P, metering: MeteringDirective) -> None: + self._consumer = consumer + self._payload = payload + self._metering = metering + + @property + def payload(self) -> P: + """The delivered payload.""" + return self._payload + + @property + def metering(self) -> MeteringDirective: + """The metering directive that accompanied the payload.""" + return self._metering + + def ack(self) -> CommitReceipt: + """Sign and commit a voucher for the directive amount. + + Mirrors rust ``MeteredDelivery::ack``. + """ + return self._consumer.commit_directive(self._metering) + + def commit(self) -> CommitReceipt: + """Alias for :meth:`ack`. Mirrors rust ``MeteredDelivery::commit``.""" + return self.ack() + + def into_parts(self) -> tuple[P, MeteringDirective]: + """Return the payload and metering directive without committing. + + Mirrors rust ``MeteredDelivery::into_parts``. + """ + return self._payload, self._metering diff --git a/python/src/pay_kit/protocols/mpp/intents/__init__.py b/python/src/pay_kit/protocols/mpp/intents/__init__.py index fbceb9421..bc234496a 100644 --- a/python/src/pay_kit/protocols/mpp/intents/__init__.py +++ b/python/src/pay_kit/protocols/mpp/intents/__init__.py @@ -1,3 +1,69 @@ -"""MPP intent layer.""" +"""MPP intent layer: the charge and session intent request bodies. + +Carries the charge intent (:class:`~pay_kit.protocols.mpp.intents.charge.ChargeRequest`, +with string-encoded base-unit amounts so JSON consumers without ``u64`` safety +stay correct) and the session intent (:class:`SessionRequest` plus the +:class:`SessionAction` credential union, signed vouchers, and the metering +types). It also re-exports the :func:`parse_units` helper that converts a +human-readable decimal amount into base units at the SDK boundary. The wire +format mirrors ``rust/crates/mpp/src/protocol/intents/{charge,session}.rs``. + +The individual intent modules +(:mod:`pay_kit.protocols.mpp.intents.charge`, +:mod:`pay_kit.protocols.mpp.intents.session`) remain the canonical import path; +the session public types are re-exported here for convenience. +""" from __future__ import annotations + +from pay_kit.protocols.mpp.intents.charge import ( + ChargeRequest, + parse_units, + validate_max_amount, +) +from pay_kit.protocols.mpp.intents.session import ( + DEFAULT_SESSION_EXPIRES_AT, + ClosePayload, + CommitPayload, + CommitReceipt, + CommitStatus, + MeteredEnvelope, + MeteringDirective, + MeteringUsage, + OpenPayload, + SessionAction, + SessionMode, + SessionPullVoucherStrategy, + SessionRequest, + SessionSplit, + SignedVoucher, + TopUpPayload, + VoucherData, + VoucherPayload, +) + +__all__ = [ + # charge intent + "ChargeRequest", + "parse_units", + "validate_max_amount", + # session intent + "DEFAULT_SESSION_EXPIRES_AT", + "SessionMode", + "SessionPullVoucherStrategy", + "CommitStatus", + "SessionSplit", + "SessionRequest", + "SessionAction", + "OpenPayload", + "VoucherPayload", + "VoucherData", + "SignedVoucher", + "CommitPayload", + "CommitReceipt", + "TopUpPayload", + "ClosePayload", + "MeteringDirective", + "MeteringUsage", + "MeteredEnvelope", +] diff --git a/python/src/pay_kit/protocols/mpp/intents/session.py b/python/src/pay_kit/protocols/mpp/intents/session.py new file mode 100644 index 000000000..fa8510bf0 --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/intents/session.py @@ -0,0 +1,876 @@ +"""Session intent request and voucher types. + +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 +``rust/crates/mpp/src/protocol/intents/session.rs`` and the parity-verified Go +port in ``go/protocols/mpp/intents/session.go``. + +The house style is plain :func:`dataclasses.dataclass` with explicit +``to_dict()``/``from_dict()`` helpers, camelCase on the wire, and omit-empty by +conditional inclusion in ``to_dict()``. ``parse_units`` is re-exported from the +charge intent so callers keep a stable amount-parsing entry point. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass, field +from typing import Any, Literal + +from pay_kit.protocols.mpp.intents.charge import parse_units + +__all__ = [ + "DEFAULT_SESSION_EXPIRES_AT", + "SessionMode", + "SessionPullVoucherStrategy", + "CommitStatus", + "SessionSplit", + "SessionRequest", + "SessionAction", + "OpenPayload", + "VoucherPayload", + "VoucherData", + "SignedVoucher", + "CommitPayload", + "CommitReceipt", + "TopUpPayload", + "ClosePayload", + "MeteringDirective", + "MeteringUsage", + "MeteredEnvelope", + "parse_units", +] + +# Default session voucher/directive expiry: 2100-01-01T00:00:00Z. +# +# This stays below JavaScript's max safe integer so JSON intermediaries do not +# round it before the credential is decoded. Mirrors rust +# DEFAULT_SESSION_EXPIRES_AT. +DEFAULT_SESSION_EXPIRES_AT = 4_102_444_800 + +# On-chain funding mechanism for a session. Advertised by the server in +# ``SessionRequest.modes``; the client picks the mode it will use in its open +# action. Mirrors rust SessionMode (rename_all="camelCase"). +SessionMode = Literal["push", "pull"] + +# Voucher authority used when ``"pull"`` mode is advertised. Mirrors rust +# SessionPullVoucherStrategy (rename_all="camelCase"). +SessionPullVoucherStrategy = Literal["clientVoucher", "operatedVoucher"] + +# Commit receipt status. Mirrors rust CommitStatus (rename_all="camelCase"). +CommitStatus = Literal["committed", "replayed"] + +# Action discriminator values. Note ``"topUp"`` is camelCase on the wire, just +# like rust's serde(tag="action", rename_all="camelCase"). +_SessionActionTag = Literal["open", "voucher", "commit", "topUp", "close"] + + +@dataclass +class SessionSplit: + """A payment split committed at channel open; distributed to a specific + recipient when the channel closes. + + Mirrors rust ``SessionSplit``. + """ + + recipient: str + bps: int + + def to_dict(self) -> dict[str, Any]: + return {"recipient": self.recipient, "bps": self.bps} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SessionSplit: + return cls(recipient=data.get("recipient", ""), bps=int(data.get("bps", 0))) + + +@dataclass +class SessionRequest: + """Session intent request — the payload embedded in a 402 challenge. + + Describes the channel parameters: cap, currency, splits, network, etc. + Mirrors rust ``SessionRequest``; optional fields are omitted from + ``to_dict()`` when ``None`` and ``splits``/``modes`` are omitted when empty. + """ + + cap: str + currency: str + operator: str + recipient: str + decimals: int | None = None + network: str | None = None + splits: list[SessionSplit] = field(default_factory=list) + program_id: str | None = None + description: str | None = None + external_id: str | None = None + min_voucher_delta: str | None = None + modes: list[SessionMode] = field(default_factory=list) + pull_voucher_strategy: SessionPullVoucherStrategy | None = None + recent_blockhash: str | None = None + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = { + "cap": self.cap, + "currency": self.currency, + "operator": self.operator, + "recipient": self.recipient, + } + if self.decimals is not None: + d["decimals"] = self.decimals + if self.network is not None: + d["network"] = self.network + if self.splits: + d["splits"] = [s.to_dict() for s in self.splits] + if self.program_id is not None: + d["programId"] = self.program_id + if self.description is not None: + d["description"] = self.description + if self.external_id is not None: + d["externalId"] = self.external_id + if self.min_voucher_delta is not None: + d["minVoucherDelta"] = self.min_voucher_delta + if self.modes: + d["modes"] = list(self.modes) + if self.pull_voucher_strategy is not None: + d["pullVoucherStrategy"] = self.pull_voucher_strategy + if self.recent_blockhash is not None: + d["recentBlockhash"] = self.recent_blockhash + return d + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SessionRequest: + decimals = data.get("decimals") + return cls( + cap=data.get("cap", ""), + currency=data.get("currency", ""), + operator=data.get("operator", ""), + recipient=data.get("recipient", ""), + decimals=int(decimals) if decimals is not None else None, + network=data.get("network"), + splits=[SessionSplit.from_dict(s) for s in data.get("splits", [])], + program_id=data.get("programId"), + description=data.get("description"), + external_id=data.get("externalId"), + min_voucher_delta=data.get("minVoucherDelta"), + modes=list(data.get("modes", [])), + pull_voucher_strategy=data.get("pullVoucherStrategy"), + recent_blockhash=data.get("recentBlockhash"), + ) + + +def _salt_to_wire(salt: int | None) -> str | None: + """Serialize an optional salt as a decimal string. + + Mirrors rust ``serialize_optional_u64_as_string``: authorization headers are + JSON canonicalized and arbitrary ``u64`` values are not safe JSON numbers, + so the salt always travels as a decimal string. + """ + if salt is None: + return None + return str(salt) + + +def _salt_from_wire(value: Any) -> int | None: + """Parse an optional salt from a decimal string or a JSON number. + + Mirrors rust ``deserialize_optional_u64_from_string_or_number``. ``int`` is + accepted directly (no float precision loss because Python ints are + arbitrary precision); strings are parsed as base-10 integers. + """ + if value is None: + return None + if isinstance(value, bool): + raise ValueError("salt must be a decimal string or unsigned 64-bit integer") + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value, 10) + except ValueError as exc: + raise ValueError(f"salt must be a decimal string: {value}") from exc + raise ValueError("salt must be a decimal string or unsigned 64-bit integer") + + +@dataclass +class OpenPayload: + """Payload for the ``open`` action. + + Use :meth:`push`, :meth:`payment_channel`, :meth:`payment_channel_with_mode`, + or :meth:`pull` to construct. Inspect :attr:`mode` to distinguish variants on + the server. ``mode`` is required: :meth:`from_dict` raises when it is absent. + + ``salt`` serializes as a decimal string and decodes from string or number. + Mirrors rust ``OpenPayload``. + """ + + mode: SessionMode + authorized_signer: str + signature: str + # Push mode + channel_id: str | None = None + deposit: str | None = None + payer: str | None = None + payee: str | None = None + mint: str | None = None + salt: int | None = None + grace_period: int | None = None + transaction: str | None = None + # Pull mode + token_account: str | None = None + approved_amount: str | None = None + owner: str | None = None + init_multi_delegate_tx: str | None = None + update_delegation_tx: str | None = None + + @classmethod + def push( + cls, + channel_id: str, + deposit: str, + authorized_signer: str, + signature: str, + ) -> OpenPayload: + """Construct a **push** payment-channel open payload. + + Mirrors rust ``OpenPayload::push``. + """ + return cls( + mode="push", + authorized_signer=authorized_signer, + signature=signature, + channel_id=channel_id, + deposit=deposit, + ) + + @classmethod + def payment_channel( + cls, + channel_id: str, + deposit: str, + payer: str, + payee: str, + mint: str, + salt: int, + grace_period: int, + authorized_signer: str, + signature: str, + ) -> OpenPayload: + """Construct a payment-channel **push** open payload. + + Mirrors rust ``OpenPayload::payment_channel``. + """ + return cls.payment_channel_with_mode( + "push", + channel_id, + deposit, + payer, + payee, + mint, + salt, + grace_period, + authorized_signer, + signature, + ) + + @classmethod + def payment_channel_with_mode( + cls, + mode: SessionMode, + channel_id: str, + deposit: str, + payer: str, + payee: str, + mint: str, + salt: int, + grace_period: int, + authorized_signer: str, + signature: str, + ) -> OpenPayload: + """Construct a payment-channel open payload with an explicit submission + mode. + + Mirrors rust ``OpenPayload::payment_channel_with_mode``. + """ + return cls( + mode=mode, + authorized_signer=authorized_signer, + signature=signature, + channel_id=channel_id, + deposit=deposit, + payer=payer, + payee=payee, + mint=mint, + salt=salt, + grace_period=grace_period, + ) + + @classmethod + def pull( + cls, + token_account: str, + approved_amount: str, + owner: str, + authorized_signer: str, + signature: str, + ) -> OpenPayload: + """Construct a **pull** (SPL delegation) open payload. + + Mirrors rust ``OpenPayload::pull``. + """ + return cls( + mode="pull", + authorized_signer=authorized_signer, + signature=signature, + token_account=token_account, + approved_amount=approved_amount, + owner=owner, + ) + + def with_transaction(self, tx_base64: str) -> OpenPayload: + """Attach a signed open transaction for operator/server broadcast. + + Mirrors rust ``OpenPayload::with_transaction``. + """ + self.transaction = tx_base64 + return self + + def with_init_tx(self, tx_base64: str) -> OpenPayload: + """Attach a pre-signed ``InitMultiDelegate`` + ``CreateFixedDelegation`` + transaction. + + Mirrors rust ``OpenPayload::with_init_tx``. + """ + self.init_multi_delegate_tx = tx_base64 + return self + + def with_update_tx(self, tx_base64: str) -> OpenPayload: + """Attach a pre-signed ``CreateFixedDelegation`` (cap update) + transaction. + + Mirrors rust ``OpenPayload::with_update_tx``. + """ + self.update_delegation_tx = tx_base64 + return self + + def session_id(self) -> str: + """Session identifier used as the store key. + + - Payment channel: ``channel_id`` + - Operated-voucher pull: ``token_account`` + + Mirrors rust ``OpenPayload::session_id``. + """ + if self.channel_id is not None: + return self.channel_id + if self.mode == "push": + raise ValueError("push open missing channelId") + if self.mode == "pull": + if self.token_account is not None: + return self.token_account + raise ValueError("pull open missing channelId or tokenAccount") + raise ValueError(f"open payload: unknown mode {self.mode!r}") + + def deposit_amount(self) -> int: + """Deposit / approved amount for this open (base units). + + Mirrors rust ``OpenPayload::deposit_amount``. + """ + if self.deposit is not None: + raw = self.deposit + elif self.mode == "push": + raise ValueError("push open missing deposit") + elif self.mode == "pull": + if self.approved_amount is None: + raise ValueError("pull open missing deposit or approvedAmount") + raw = self.approved_amount + else: + raise ValueError(f"open payload: unknown mode {self.mode!r}") + try: + return int(raw, 10) + except ValueError as exc: + raise ValueError(f"invalid deposit amount: {raw}") from exc + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"mode": self.mode} + if self.channel_id is not None: + d["channelId"] = self.channel_id + if self.deposit is not None: + d["deposit"] = self.deposit + if self.payer is not None: + d["payer"] = self.payer + if self.payee is not None: + d["payee"] = self.payee + if self.mint is not None: + d["mint"] = self.mint + salt = _salt_to_wire(self.salt) + if salt is not None: + d["salt"] = salt + if self.grace_period is not None: + d["gracePeriod"] = self.grace_period + if self.transaction is not None: + d["transaction"] = self.transaction + if self.token_account is not None: + d["tokenAccount"] = self.token_account + if self.approved_amount is not None: + d["approvedAmount"] = self.approved_amount + if self.owner is not None: + d["owner"] = self.owner + if self.init_multi_delegate_tx is not None: + d["initMultiDelegateTx"] = self.init_multi_delegate_tx + if self.update_delegation_tx is not None: + d["updateDelegationTx"] = self.update_delegation_tx + d["authorizedSigner"] = self.authorized_signer + d["signature"] = self.signature + return d + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> OpenPayload: + mode = data.get("mode") + if not mode: + raise ValueError("open payload: missing mode") + return cls( + mode=mode, + authorized_signer=data.get("authorizedSigner", ""), + signature=data.get("signature", ""), + channel_id=data.get("channelId"), + deposit=data.get("deposit"), + payer=data.get("payer"), + payee=data.get("payee"), + mint=data.get("mint"), + salt=_salt_from_wire(data.get("salt")), + grace_period=(int(data["gracePeriod"]) if data.get("gracePeriod") is not None else None), + transaction=data.get("transaction"), + token_account=data.get("tokenAccount"), + approved_amount=data.get("approvedAmount"), + owner=data.get("owner"), + init_multi_delegate_tx=data.get("initMultiDelegateTx"), + update_delegation_tx=data.get("updateDelegationTx"), + ) + + +@dataclass +class VoucherData: + """The canonical content of a voucher, signed by the client's session key. + + Serialized as the on-chain ``VoucherArgs`` layout before signing: + ``channel_id || cumulative_amount_le || expires_at_le``. The wire field for + the cumulative amount is ``cumulativeAmount`` with a ``cumulative`` decode + alias. Mirrors rust ``VoucherData``. + """ + + channel_id: str + cumulative: str + expires_at: int + nonce: int | None = None + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = { + "channelId": self.channel_id, + "cumulativeAmount": self.cumulative, + "expiresAt": self.expires_at, + } + if self.nonce is not None: + d["nonce"] = self.nonce + return d + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> VoucherData: + if "cumulativeAmount" in data: + cumulative = data["cumulativeAmount"] + elif "cumulative" in data: + cumulative = data["cumulative"] + else: + cumulative = "" + nonce = data.get("nonce") + return cls( + channel_id=data.get("channelId", ""), + cumulative=cumulative, + expires_at=int(data.get("expiresAt", 0)), + nonce=int(nonce) if nonce is not None else None, + ) + + def message_bytes(self) -> bytes: + """Serialize to the payment-channels ``VoucherArgs`` bytes signed by + Ed25519. + + Layout (exactly 48 bytes): ``channel_id``\\ (32, base58-decoded) || + ``cumulative_amount`` little-endian ``u64`` (offset 32) || ``expires_at`` + little-endian ``i64`` (offset 40). The ``channel_id`` MUST decode to + exactly 32 bytes. Mirrors rust ``VoucherData::message_bytes`` and the Go + ``VoucherData.MessageBytes``. + """ + # Lazy import so the module imports without solders installed, matching + # the charge intent's discipline and avoiding an import cycle with the + # on-chain glue module. + from solders.pubkey import Pubkey # type: ignore[import-untyped] + + try: + channel = bytes(Pubkey.from_string(self.channel_id)) + except (ValueError, TypeError) as exc: + raise ValueError(f"invalid channelId {self.channel_id!r}") from exc + if len(channel) != 32: + raise ValueError(f"channelId must be 32 bytes, got {len(channel)}") + try: + cumulative = int(self.cumulative, 10) + except ValueError as exc: + raise ValueError("invalid voucher cumulative") from exc + return channel + struct.pack(" dict[str, Any]: + return {"data": self.data.to_dict(), "signature": self.signature} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SignedVoucher: + return cls( + data=VoucherData.from_dict(data.get("data", {})), + signature=data.get("signature", ""), + ) + + +@dataclass +class VoucherPayload: + """Payload for the ``voucher`` action (per-request micropayment). + + Mirrors rust ``VoucherPayload``. + """ + + voucher: SignedVoucher + + def to_dict(self) -> dict[str, Any]: + return {"voucher": self.voucher.to_dict()} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> VoucherPayload: + return cls(voucher=SignedVoucher.from_dict(data.get("voucher", {}))) + + +@dataclass +class CommitPayload: + """Payload for the ``commit`` action. + + Mirrors rust ``CommitPayload``. + """ + + delivery_id: str + voucher: SignedVoucher + + def to_dict(self) -> dict[str, Any]: + return {"deliveryId": self.delivery_id, "voucher": self.voucher.to_dict()} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> CommitPayload: + return cls( + delivery_id=data.get("deliveryId", ""), + voucher=SignedVoucher.from_dict(data.get("voucher", {})), + ) + + +@dataclass +class TopUpPayload: + """Payload for the ``topUp`` action. + + Mirrors rust ``TopUpPayload``. + """ + + channel_id: str + new_deposit: str + signature: str + + def to_dict(self) -> dict[str, Any]: + return { + "channelId": self.channel_id, + "newDeposit": self.new_deposit, + "signature": self.signature, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> TopUpPayload: + return cls( + channel_id=data.get("channelId", ""), + new_deposit=data.get("newDeposit", ""), + signature=data.get("signature", ""), + ) + + +@dataclass +class ClosePayload: + """Payload for the ``close`` action. + + Mirrors rust ``ClosePayload``; ``voucher`` is omitted when ``None``. + """ + + channel_id: str + voucher: SignedVoucher | None = None + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"channelId": self.channel_id} + if self.voucher is not None: + d["voucher"] = self.voucher.to_dict() + return d + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ClosePayload: + voucher = data.get("voucher") + return cls( + channel_id=data.get("channelId", ""), + voucher=SignedVoucher.from_dict(voucher) if voucher is not None else None, + ) + + +@dataclass +class SessionAction: + """The action submitted by the client in an Authorization header. + + Serialized as a tagged object with + ``"action": "open" | "voucher" | "commit" | "topUp" | "close"`` and the + payload fields flattened alongside the discriminator. Exactly one payload is + set for a valid action. Mirrors rust ``SessionAction`` + (serde tag="action", rename_all="camelCase"). + """ + + open: OpenPayload | None = None + voucher: VoucherPayload | None = None + commit: CommitPayload | None = None + top_up: TopUpPayload | None = None + close: ClosePayload | None = None + + @classmethod + def open_action(cls, payload: OpenPayload) -> SessionAction: + """Wrap an :class:`OpenPayload` as a :class:`SessionAction`.""" + return cls(open=payload) + + @classmethod + def voucher_action(cls, payload: VoucherPayload) -> SessionAction: + """Wrap a :class:`VoucherPayload` as a :class:`SessionAction`.""" + return cls(voucher=payload) + + @classmethod + def commit_action(cls, payload: CommitPayload) -> SessionAction: + """Wrap a :class:`CommitPayload` as a :class:`SessionAction`.""" + return cls(commit=payload) + + @classmethod + def top_up_action(cls, payload: TopUpPayload) -> SessionAction: + """Wrap a :class:`TopUpPayload` as a :class:`SessionAction`.""" + return cls(top_up=payload) + + @classmethod + def close_action(cls, payload: ClosePayload) -> SessionAction: + """Wrap a :class:`ClosePayload` as a :class:`SessionAction`.""" + return cls(close=payload) + + def to_dict(self) -> dict[str, Any]: + """Flatten the active payload alongside an ``"action"`` discriminator. + + Mirrors rust's ``#[serde(tag="action")]`` enum encoding. Exactly one + variant must be set. + """ + variants: list[tuple[_SessionActionTag, dict[str, Any]]] = [] + if self.open is not None: + variants.append(("open", self.open.to_dict())) + if self.voucher is not None: + variants.append(("voucher", self.voucher.to_dict())) + if self.commit is not None: + variants.append(("commit", self.commit.to_dict())) + if self.top_up is not None: + variants.append(("topUp", self.top_up.to_dict())) + if self.close is not None: + variants.append(("close", self.close.to_dict())) + if len(variants) == 0: + raise ValueError("session action: no variant set") + if len(variants) > 1: + raise ValueError("session action: multiple variants set") + tag, payload = variants[0] + return {"action": tag, **payload} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> SessionAction: + """Read the ``"action"`` discriminator and decode the flattened payload. + + Mirrors rust's ``#[serde(tag="action")]`` decoding: an empty discriminator + and an unknown action both raise. + """ + action = data.get("action") + if not action: + raise ValueError("session action: missing action discriminator") + if action == "open": + return cls(open=OpenPayload.from_dict(data)) + if action == "voucher": + return cls(voucher=VoucherPayload.from_dict(data)) + if action == "commit": + return cls(commit=CommitPayload.from_dict(data)) + if action == "topUp": + return cls(top_up=TopUpPayload.from_dict(data)) + if action == "close": + return cls(close=ClosePayload.from_dict(data)) + raise ValueError(f"session action: unknown action {action!r}") + + +@dataclass +class MeteringDirective: + """Server-issued metering directive attached to a delivered message. + + Clients treat this like an offset in a message log: once the message has been + processed successfully, ``ack``/``commit`` signs a voucher for ``amount`` and + sends a :class:`CommitPayload` back to the server. Mirrors rust + ``MeteringDirective``. + """ + + delivery_id: str + session_id: str + amount: str + currency: str + sequence: int + expires_at: int + commit_url: str | None = None + proof: str | None = None + + def amount_base_units(self) -> int: + """Parse ``amount`` as base units. + + Mirrors rust ``MeteringDirective::amount_base_units``. + """ + try: + return int(self.amount, 10) + except ValueError as exc: + raise ValueError(f"invalid metering amount: {self.amount}") from exc + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = { + "deliveryId": self.delivery_id, + "sessionId": self.session_id, + "amount": self.amount, + "currency": self.currency, + "sequence": self.sequence, + "expiresAt": self.expires_at, + } + if self.commit_url is not None: + d["commitUrl"] = self.commit_url + if self.proof is not None: + d["proof"] = self.proof + return d + + @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"), + ) + + +@dataclass +class MeteringUsage: + """Final usage reported by a streaming response. + + The amount MUST be less than or equal to the amount reserved by the original + :class:`MeteringDirective`. Mirrors rust ``MeteringUsage``. + """ + + delivery_id: str + amount: str + + def amount_base_units(self) -> int: + """Parse ``amount`` as base units. + + Mirrors rust ``MeteringUsage::amount_base_units``. + """ + try: + return int(self.amount, 10) + except ValueError as exc: + raise ValueError(f"invalid metering usage amount: {self.amount}") from exc + + def to_dict(self) -> dict[str, Any]: + return {"deliveryId": self.delivery_id, "amount": self.amount} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> MeteringUsage: + return cls(delivery_id=data.get("deliveryId", ""), amount=data.get("amount", "")) + + +@dataclass +class MeteredEnvelope: + """A payload paired with the metering directive required to acknowledge it. + + The payload is left as an opaque value (any JSON-serializable object), + mirroring rust ``MeteredEnvelope``. + """ + + payload: Any + metering: MeteringDirective + + def to_dict(self) -> dict[str, Any]: + return {"payload": self.payload, "metering": self.metering.to_dict()} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> MeteredEnvelope: + return cls( + payload=data.get("payload"), + metering=MeteringDirective.from_dict(data.get("metering", {})), + ) + + +@dataclass +class CommitReceipt: + """Result returned after a delivery commit is accepted. + + Mirrors rust ``CommitReceipt``. + """ + + delivery_id: str + session_id: str + amount: str + cumulative: str + status: CommitStatus + + def amount_base_units(self) -> int: + """Parse ``amount`` as base units.""" + try: + return int(self.amount, 10) + except ValueError as exc: + raise ValueError(f"invalid commit receipt amount: {self.amount}") from exc + + def cumulative_base_units(self) -> int: + """Parse ``cumulative`` as base units.""" + try: + return int(self.cumulative, 10) + except ValueError as exc: + raise ValueError(f"invalid commit receipt cumulative: {self.cumulative}") from exc + + def to_dict(self) -> dict[str, Any]: + return { + "deliveryId": self.delivery_id, + "sessionId": self.session_id, + "amount": self.amount, + "cumulative": self.cumulative, + "status": self.status, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> CommitReceipt: + return cls( + delivery_id=data.get("deliveryId", ""), + session_id=data.get("sessionId", ""), + amount=data.get("amount", ""), + cumulative=data.get("cumulative", ""), + status=data.get("status", "committed"), + ) diff --git a/python/tests/test_paymentchannels.py b/python/tests/test_paymentchannels.py new file mode 100644 index 000000000..c01cf08f3 --- /dev/null +++ b/python/tests/test_paymentchannels.py @@ -0,0 +1,287 @@ +"""Tests for the payment-channels on-chain glue. + +Parity is verified against the Rust spine +(``rust/crates/mpp/src/program/payment_channels.rs``) and the CI-green Go port. +All tests are RPC-free: PDA derivation and instruction packing are pure. +""" + +from __future__ import annotations + +import struct + +import pytest +from solders.pubkey import Pubkey + +from pay_kit.protocols.mpp._paymentchannels import ( + PAYMENT_CHANNELS_PROGRAM_ID, + PROGRAM_ID, + Distribution, + OpenChannelParams, + TopUpParams, + build_open_instruction, + build_top_up_instruction, + find_associated_token_address, + find_channel_pda, + find_event_authority_pda, + voucher_message_bytes, +) + +# IDL placeholder that the Rust/Go ports explicitly override; the production +# program id MUST differ from this. +_IDL_PLACEHOLDER = "CQAyft83tN1w2bRofB5PZ79eVDU2xZUVo43LU1qL4zRg" + + +def pk(byte: int) -> Pubkey: + """A pubkey whose 32 bytes are all ``byte`` (mirrors the Rust test helper).""" + return Pubkey.from_bytes(bytes([byte] * 32)) + + +def test_program_id_is_guokrza() -> None: + assert PAYMENT_CHANNELS_PROGRAM_ID == "GuoKrzaBiZnW5DvJ3yZVE7xHqbcBvaX9SH6P6Cn9gNvc" + assert str(PROGRAM_ID) == PAYMENT_CHANNELS_PROGRAM_ID + assert str(PROGRAM_ID) != _IDL_PLACEHOLDER + + +def test_voucher_message_length_and_offsets() -> None: + out = voucher_message_bytes(pk(9), 42, 1234) + assert len(out) == 48 + assert out[:32] == bytes([9] * 32) + assert out[32:40] == struct.pack(" None: + # Frozen rust/Go vector: channel_id = 32 bytes of 9, cumulative 42, expires 1234. + out = voucher_message_bytes(pk(9), 42, 1234) + expected = bytes([9] * 32) + (42).to_bytes(8, "little") + (1234).to_bytes(8, "little", signed=True) + assert out == expected + + +def test_voucher_message_negative_expires_at() -> None: + out = voucher_message_bytes(pk(7), 0, -1) + assert len(out) == 48 + assert out[40:48] == struct.pack(" None: + out = voucher_message_bytes(pk(3), cumulative, expires_at) + assert out[32:40] == struct.pack(" None: + class FakeKey: + def __bytes__(self) -> bytes: + return b"\x01\x02\x03" + + with pytest.raises(ValueError, match="exactly 32 bytes"): + voucher_message_bytes(FakeKey(), 1, 1) # type: ignore[arg-type] + + +def test_find_channel_pda_is_deterministic_and_off_curve() -> None: + addr1, bump1 = find_channel_pda(pk(1), pk(2), pk(3), pk(4), 99) + addr2, bump2 = find_channel_pda(pk(1), pk(2), pk(3), pk(4), 99) + assert addr1 == addr2 + assert bump1 == bump2 + assert 0 <= bump1 <= 255 + + +def test_find_channel_pda_matches_create_program_address() -> None: + addr, bump = find_channel_pda(pk(1), pk(2), pk(3), pk(4), 99) + expected = Pubkey.create_program_address( + [ + b"channel", + bytes(pk(1)), + bytes(pk(2)), + bytes(pk(3)), + bytes(pk(4)), + struct.pack(" None: + addr_a, _ = find_channel_pda(pk(1), pk(2), pk(3), pk(4), 1) + addr_b, _ = find_channel_pda(pk(1), pk(2), pk(3), pk(4), 2) + assert addr_a != addr_b + + +def test_find_event_authority_pda_is_deterministic() -> None: + addr1, bump1 = find_event_authority_pda() + addr2, bump2 = find_event_authority_pda() + assert addr1 == addr2 + assert bump1 == bump2 + expected = Pubkey.create_program_address([b"event_authority", bytes([bump1])], PROGRAM_ID) + assert addr1 == expected + + +def test_find_associated_token_address_matches_seed_layout() -> None: + owner, mint, token_program = pk(1), pk(2), pk(5) + addr, _ = find_associated_token_address(owner, mint, token_program) + ata_program = Pubkey.from_string("ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") + expected, _ = Pubkey.find_program_address( + [bytes(owner), bytes(token_program), bytes(mint)], + ata_program, + ) + assert addr == expected + + +def _open_params() -> OpenChannelParams: + return OpenChannelParams( + payer=pk(1), + payee=pk(2), + mint=pk(3), + authorized_signer=pk(4), + salt=99, + deposit=1_000_000, + grace_period=3600, + recipients=[Distribution(pk(5), 7_500), Distribution(pk(6), 2_500)], + token_program=pk(7), + ) + + +def test_build_open_instruction_program_id_and_account_count() -> None: + ix = build_open_instruction(_open_params()) + assert ix.program_id == PROGRAM_ID + assert str(ix.program_id) == PAYMENT_CHANNELS_PROGRAM_ID + assert len(ix.accounts) == 13 + + +def test_build_open_instruction_account_order_and_flags() -> None: + params = _open_params() + ix = build_open_instruction(params) + accounts = ix.accounts + + channel, _ = find_channel_pda(params.payer, params.payee, params.mint, params.authorized_signer, params.salt) + payer_ata, _ = find_associated_token_address(params.payer, params.mint, params.token_program) + channel_ata, _ = find_associated_token_address(channel, params.mint, params.token_program) + event_authority, _ = find_event_authority_pda() + + # 0 payer: signer + writable. + assert accounts[0].pubkey == params.payer + assert accounts[0].is_signer is True + assert accounts[0].is_writable is True + # 1 payee, 2 mint, 3 authorized_signer: read-only. + assert accounts[1].pubkey == params.payee + assert accounts[2].pubkey == params.mint + assert accounts[3].pubkey == params.authorized_signer + # 4 channel PDA: writable. + assert accounts[4].pubkey == channel + assert accounts[4].is_writable is True + assert accounts[4].is_signer is False + # 5 payer ATA, 6 channel ATA: writable. + assert accounts[5].pubkey == payer_ata + assert accounts[5].is_writable is True + assert accounts[6].pubkey == channel_ata + assert accounts[6].is_writable is True + # 7 token_program, 8 system_program, 9 rent, 10 associated_token_program. + assert accounts[7].pubkey == params.token_program + assert str(accounts[8].pubkey) == "11111111111111111111111111111111" + assert str(accounts[9].pubkey) == "SysvarRent111111111111111111111111111111111" + assert str(accounts[10].pubkey) == "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL" + # 11 event_authority PDA. + assert accounts[11].pubkey == event_authority + # 12 self/program == GuoKrza. + assert accounts[12].pubkey == PROGRAM_ID + # No account other than payer is a signer. + assert [a.is_signer for a in accounts] == [True] + [False] * 12 + + +def test_build_open_instruction_data_layout_roundtrip() -> None: + params = _open_params() + ix = build_open_instruction(params) + data = bytes(ix.data) + + assert data[0] == 1 + off = 1 + assert struct.unpack_from(" None: + params = _open_params() + params.recipients = [] + ix = build_open_instruction(params) + data = bytes(ix.data) + # disc(1) + salt(8) + deposit(8) + grace(4) + len(4) == 25 bytes, count 0. + assert len(data) == 25 + assert struct.unpack_from(" None: + params = OpenChannelParams( + payer=pk(1), + payee=pk(2), + mint=pk(3), + authorized_signer=pk(4), + salt=1, + deposit=1, + grace_period=1, + ) + assert str(params.token_program) == "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + assert params.recipients == [] + + +def test_build_top_up_instruction_program_id_and_account_order() -> None: + params = TopUpParams(payer=pk(1), channel=pk(2), mint=pk(3), amount=500_000, token_program=pk(7)) + ix = build_top_up_instruction(params) + assert ix.program_id == PROGRAM_ID + assert len(ix.accounts) == 6 + + payer_ata, _ = find_associated_token_address(params.payer, params.mint, params.token_program) + channel_ata, _ = find_associated_token_address(params.channel, params.mint, params.token_program) + + accounts = ix.accounts + assert accounts[0].pubkey == params.payer + assert accounts[0].is_signer is True + assert accounts[0].is_writable is True + assert accounts[1].pubkey == params.channel + assert accounts[1].is_writable is True + assert accounts[2].pubkey == payer_ata + assert accounts[2].is_writable is True + assert accounts[3].pubkey == channel_ata + assert accounts[3].is_writable is True + assert accounts[4].pubkey == params.mint + assert accounts[5].pubkey == params.token_program + assert [a.is_signer for a in accounts] == [True] + [False] * 5 + + +def test_build_top_up_instruction_data_roundtrip() -> None: + params = TopUpParams(payer=pk(1), channel=pk(2), mint=pk(3), amount=987_654_321) + ix = build_top_up_instruction(params) + data = bytes(ix.data) + assert data[0] == 3 + assert len(data) == 9 + assert struct.unpack_from(" None: + params = TopUpParams(payer=pk(1), channel=pk(2), mint=pk(3), amount=1) + assert str(params.token_program) == "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" diff --git a/python/tests/test_session_client.py b/python/tests/test_session_client.py new file mode 100644 index 000000000..782fc4b05 --- /dev/null +++ b/python/tests/test_session_client.py @@ -0,0 +1,417 @@ +"""Tests for the client-side session intent (ActiveSession + credential framing). + +Mirrors the ``#[cfg(test)] mod tests`` in +``rust/crates/mpp/src/client/session.rs`` and the parity-verified Go port: +monotonicity, expiry control, nonce increment, ``sign_increment`` math, the +prepare/record split, Ed25519 verification over the 48-byte preimage (plus a +tampered negative), every action builder's payload + discriminator, the +credential serialize round-trip through the core parse, and +``parse_session_challenge`` over a session WWW-Authenticate header. +""" + +from __future__ import annotations + +import pytest +from solders.keypair import Keypair # type: ignore[import-untyped] +from solders.pubkey import Pubkey # type: ignore[import-untyped] +from solders.signature import Signature # type: ignore[import-untyped] + +from pay_kit.protocols.mpp._paymentchannels import voucher_message_bytes +from pay_kit.protocols.mpp.client.session import ( + DEFAULT_VOUCHER_EXPIRES_AT, + ActiveSession, + parse_session_challenge, + serialize_session_credential, +) +from pay_kit.protocols.mpp.core.base64url import encode_json +from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization +from pay_kit.protocols.mpp.core.types import PaymentChallenge +from pay_kit.protocols.mpp.intents.session import SessionAction, SignedVoucher, VoucherData + + +class _BytesSigner: + """A pay_kit-style signer: ``pubkey() -> str``, ``sign(bytes) -> bytes``.""" + + def __init__(self, seed: int) -> None: + self._kp = Keypair.from_seed(bytes([seed] * 32)) + + def pubkey(self) -> str: + return str(self._kp.pubkey()) + + def sign(self, message: bytes) -> bytes: + return bytes(self._kp.sign_message(message)) + + @property + def solders_pubkey(self) -> Pubkey: + return self._kp.pubkey() + + +def _signer(seed: int = 42) -> _BytesSigner: + return _BytesSigner(seed) + + +def _channel() -> Pubkey: + return Pubkey.from_string("11111111111111111111111111111112") + + +def _session(seed: int = 42) -> ActiveSession: + return ActiveSession(_channel(), _signer(seed)) + + +# ── expiry control ── + + +def test_at_expiry_and_set_expires_at_control_voucher_expiry() -> None: + session = ActiveSession.at_expiry(_channel(), _signer(), 1234) + first = session.prepare_increment(10) + assert first.data.expires_at == 1234 + assert session.cumulative == 0 + + session.set_expires_at(5678) + second = session.prepare_increment(10) + assert second.data.expires_at == 5678 + + +def test_default_voucher_expiry() -> None: + assert DEFAULT_VOUCHER_EXPIRES_AT == 4_102_444_800 + session = _session() + assert session.expires_at == DEFAULT_VOUCHER_EXPIRES_AT + voucher = session.prepare_increment(1) + assert voucher.data.expires_at == DEFAULT_VOUCHER_EXPIRES_AT + + +# ── increment / absolute math ── + + +def test_sign_increment_increases_cumulative() -> None: + s = _session() + assert s.cumulative == 0 + v = s.sign_increment(100) + assert s.cumulative == 100 + assert v.data.cumulative == "100" + assert v.data.nonce == 1 + + +def test_sign_voucher_absolute() -> None: + s = _session() + s.sign_increment(50) + v = s.sign_voucher(200) + assert s.cumulative == 200 + assert v.data.cumulative == "200" + + +# ── prepare / record split ── + + +def test_prepare_and_record_voucher_are_separate_steps() -> None: + s = _session() + prepared = s.prepare_increment(75) + assert prepared.data.cumulative == "75" + assert prepared.data.nonce == 1 + assert s.cumulative == 0 + + s.record_voucher(prepared) + assert s.cumulative == 75 + with pytest.raises(ValueError): + s.record_voucher(prepared) + + +def test_record_voucher_rejects_invalid_cumulative_and_handles_missing_nonce() -> None: + s = _session() + bad = SignedVoucher( + data=VoucherData( + channel_id=s.channel_id_string, + cumulative="not-a-number", + expires_at=DEFAULT_VOUCHER_EXPIRES_AT, + nonce=None, + ), + signature="sig", + ) + with pytest.raises(ValueError): + s.record_voucher(bad) + + without_nonce = SignedVoucher( + data=VoucherData( + channel_id=s.channel_id_string, + cumulative="15", + expires_at=DEFAULT_VOUCHER_EXPIRES_AT, + nonce=None, + ), + signature="sig", + ) + s.record_voucher(without_nonce) + assert s.cumulative == 15 + assert s.nonce == 1 + + +def test_record_voucher_honors_larger_voucher_nonce() -> None: + s = _session() + voucher = SignedVoucher( + data=VoucherData( + channel_id=s.channel_id_string, + cumulative="42", + expires_at=DEFAULT_VOUCHER_EXPIRES_AT, + nonce=9, + ), + signature="sig", + ) + s.record_voucher(voucher) + assert s.nonce == 9 + + +# ── monotonicity ── + + +def test_sign_voucher_rejects_non_increasing() -> None: + s = _session() + s.sign_increment(100) + with pytest.raises(ValueError): + s.sign_voucher(100) + with pytest.raises(ValueError): + s.sign_voucher(50) + + +def test_sign_voucher_zero_rejected() -> None: + s = _session() + with pytest.raises(ValueError): + s.sign_voucher(0) + + +def test_add_cumulative_overflow_rejected() -> None: + s = _session() + s.sign_voucher((1 << 64) - 2) + with pytest.raises(ValueError): + s.sign_increment(5) + + +# ── nonce ── + + +def test_nonce_increments_per_voucher() -> None: + s = _session() + v1 = s.sign_increment(10) + v2 = s.sign_increment(10) + assert v1.data.nonce == 1 + assert v2.data.nonce == 2 + assert s.nonce == 2 + + +def test_voucher_channel_id_matches_session() -> None: + s = _session() + expected = s.channel_id_string + v = s.sign_increment(100) + assert v.data.channel_id == expected + assert s.channel_id_string == str(_channel()) + assert s.channel_id == _channel() + + +# ── Ed25519 signature verification over the 48-byte preimage ── + + +def test_signature_verifies_against_authorized_signer() -> None: + signer = _signer() + s = ActiveSession(_channel(), signer) + voucher = s.sign_increment(250) + + preimage = voucher_message_bytes(_channel(), 250, s.expires_at) + assert len(preimage) == 48 + + sig = Signature.from_string(voucher.signature) + assert sig.verify(signer.solders_pubkey, preimage) + assert s.authorized_signer == str(signer.solders_pubkey) + + +def test_tampered_preimage_fails_verification() -> None: + signer = _signer() + s = ActiveSession(_channel(), signer) + voucher = s.sign_increment(250) + + sig = Signature.from_string(voucher.signature) + tampered = voucher_message_bytes(_channel(), 251, s.expires_at) + assert not sig.verify(signer.solders_pubkey, tampered) + + +def test_signer_pubkey_accepts_solders_keypair() -> None: + # A raw solders Keypair (pubkey() -> Pubkey, sign_message) is accepted. + kp = Keypair.from_seed(bytes([5] * 32)) + s = ActiveSession(_channel(), kp) + voucher = s.sign_increment(10) + assert s.authorized_signer == str(kp.pubkey()) + preimage = voucher_message_bytes(_channel(), 10, s.expires_at) + assert Signature.from_string(voucher.signature).verify(kp.pubkey(), preimage) + + +# ── action builders ── + + +def test_voucher_action_fields() -> None: + s = _session() + action = s.voucher_action(33) + assert action.voucher is not None + assert action.voucher.voucher.data.cumulative == "33" + assert action.voucher.voucher.data.channel_id == s.channel_id_string + assert action.to_dict()["action"] == "voucher" + + +def test_open_action_fields() -> None: + s = _session() + action = s.open_action(1_000_000, "txsig123") + assert action.open is not None + p = action.open + assert p.mode == "push" + assert p.deposit == "1000000" + assert p.signature == "txsig123" + assert p.channel_id == s.channel_id_string + assert p.authorized_signer == s.authorized_signer + assert action.to_dict()["action"] == "open" + + +def test_open_payment_channel_action_fields() -> None: + s = _session() + action = s.open_payment_channel_action(9_000, "payer", "payee", "mint", 42, 60, "open-sig") + assert action.open is not None + p = action.open + assert p.mode == "push" + assert p.channel_id == s.channel_id_string + assert p.deposit == "9000" + assert p.payer == "payer" + assert p.payee == "payee" + assert p.mint == "mint" + assert p.salt == 42 + assert p.grace_period == 60 + assert p.signature == "open-sig" + + +def test_open_payment_channel_action_can_use_pull_mode() -> None: + s = _session() + action = s.open_payment_channel_action_with_mode("pull", 9_000, "payer", "payee", "mint", 42, 60, "pending") + assert action.open is not None + p = action.open + assert p.mode == "pull" + assert p.channel_id == s.channel_id_string + assert p.deposit == "9000" + assert p.token_account is None + assert p.approved_amount is None + + +def test_open_pull_action_fields() -> None: + s = _session() + action = s.open_pull_action(5_000_000, "wallet123", "approvesig") + assert action.open is not None + p = action.open + assert p.mode == "pull" + assert p.approved_amount == "5000000" + assert p.signature == "approvesig" + assert p.token_account == s.channel_id_string + assert p.owner == "wallet123" + assert p.authorized_signer == s.authorized_signer + assert p.channel_id is None + assert p.deposit is None + + +def test_top_up_action_fields() -> None: + s = _session() + action = s.top_up_action(5_000_000, "topuptx") + assert action.top_up is not None + p = action.top_up + assert p.channel_id == s.channel_id_string + assert p.new_deposit == "5000000" + assert p.signature == "topuptx" + assert action.to_dict()["action"] == "topUp" + + +def test_close_action_no_final_increment() -> None: + s = _session() + action = s.close_action() + assert action.close is not None + assert action.close.voucher is None + assert action.close.channel_id == s.channel_id_string + + +def test_close_action_with_final_increment() -> None: + s = _session() + s.sign_increment(100) + action = s.close_action(50) + assert action.close is not None + assert action.close.voucher is not None + assert action.close.voucher.data.cumulative == "150" + + +def test_close_action_zero_increment_no_voucher() -> None: + s = _session() + action = s.close_action(0) + assert action.close is not None + assert action.close.voucher is None + + +# ── credential serialize round-trip through the core parse ── + + +def _session_challenge() -> PaymentChallenge: + request = encode_json( + { + "cap": "1000000", + "currency": "USDC", + "operator": "op-pubkey", + "recipient": "recipient-pubkey", + } + ) + return PaymentChallenge( + id="challenge-id-1", + realm="api.example.com", + method="solana", + intent="session", + request=request, + ) + + +def test_serialize_session_credential_round_trips() -> None: + s = _session() + challenge = _session_challenge() + action = s.voucher_action(500_000) + + header = s.serialize_session_credential(challenge, action) + assert header.startswith("Payment ") + + credential = parse_authorization(header) + assert credential.challenge.id == challenge.id + assert credential.challenge.intent == "session" + decoded = SessionAction.from_dict(credential.payload) + assert decoded.voucher is not None + assert decoded.voucher.voucher.data.cumulative == "500000" + + +def test_serialize_session_credential_free_function_matches_method() -> None: + s = _session() + challenge = _session_challenge() + action = s.close_action() + assert serialize_session_credential(challenge, action) == s.serialize_session_credential(challenge, action) + + +# ── parse_session_challenge ── + + +def test_parse_session_challenge_parses_session_header() -> None: + challenge = _session_challenge() + header = format_www_authenticate(challenge) + + parsed, request = parse_session_challenge(header) + assert parsed.intent == "session" + assert request.cap == "1000000" + assert request.currency == "USDC" + assert request.operator == "op-pubkey" + assert request.recipient == "recipient-pubkey" + + +def test_parse_session_challenge_rejects_non_session_intent() -> None: + request = encode_json({"amount": "1000000", "currency": "USDC", "recipient": "r"}) + charge = PaymentChallenge( + id="cid", + realm="api.example.com", + method="solana", + intent="charge", + request=request, + ) + header = format_www_authenticate(charge) + with pytest.raises(ValueError, match="is not a session"): + parse_session_challenge(header) diff --git a/python/tests/test_session_consumer.py b/python/tests/test_session_consumer.py new file mode 100644 index 000000000..bade475b2 --- /dev/null +++ b/python/tests/test_session_consumer.py @@ -0,0 +1,200 @@ +"""Tests for the client-side metered-delivery consumer. + +Mirrors the ``#[cfg(test)] mod tests`` in +``rust/crates/mpp/src/client/session_consumer.rs`` and the parity-verified Go +port: ack/commit send through the transport and advance the local watermark, +the commit alias and ``into_parts`` work, invalid directives (wrong session, +zero amount, non-numeric amount) are rejected before commit, a failed commit +does not advance the watermark, idempotency on a duplicate delivery (a server +``replayed`` receipt is honored and the cumulative is not advanced twice), and a +fresh delivery advances. +""" + +from __future__ import annotations + +import pytest +from solders.keypair import Keypair # type: ignore[import-untyped] +from solders.pubkey import Pubkey # type: ignore[import-untyped] + +from pay_kit.protocols.mpp.client.session import ActiveSession +from pay_kit.protocols.mpp.client.session_consumer import SessionConsumer +from pay_kit.protocols.mpp.intents.session import ( + DEFAULT_SESSION_EXPIRES_AT, + CommitPayload, + CommitReceipt, + MeteredEnvelope, + MeteringDirective, +) + + +class _RecordingTransport: + """In-process commit transport that records payloads and echoes a receipt. + + ``replay_delivery_ids`` returns a ``replayed`` receipt (server already saw + the delivery) so the consumer's idempotency handling can be exercised. + """ + + def __init__(self, fail: bool = False, replay_delivery_ids: set[str] | None = None) -> None: + self.commits: list[CommitPayload] = [] + self.fail = fail + self.replay_delivery_ids = replay_delivery_ids or set() + + def commit(self, directive: MeteringDirective, payload: CommitPayload) -> CommitReceipt: + if self.fail: + raise ValueError("commit failed") + cumulative = payload.voucher.data.cumulative + self.commits.append(payload) + status = "replayed" if directive.delivery_id in self.replay_delivery_ids else "committed" + return CommitReceipt( + delivery_id=directive.delivery_id, + session_id=directive.session_id, + amount=directive.amount, + cumulative=cumulative, + status=status, + ) + + +def _signer(seed: int = 7) -> Keypair: + return Keypair.from_seed(bytes([seed] * 32)) + + +def _channel() -> Pubkey: + return Pubkey.from_string("11111111111111111111111111111112") + + +def _consumer(transport: _RecordingTransport) -> SessionConsumer: + return SessionConsumer(ActiveSession(_channel(), _signer()), transport) + + +def _directive(session_id: str, amount: int, delivery_id: str = "d1") -> MeteringDirective: + return MeteringDirective( + delivery_id=delivery_id, + session_id=session_id, + amount=str(amount), + currency="USDC", + sequence=1, + expires_at=DEFAULT_SESSION_EXPIRES_AT, + ) + + +def test_ack_sends_commit_and_advances_local_watermark() -> None: + transport = _RecordingTransport() + consumer = _consumer(transport) + envelope = MeteredEnvelope(payload="work", metering=_directive(consumer.session.channel_id_string, 250)) + + delivery = consumer.accept(envelope) + assert delivery.payload == "work" + receipt = delivery.ack() + + assert receipt.cumulative == "250" + assert receipt.status == "committed" + assert consumer.session.cumulative == 250 + assert len(transport.commits) == 1 + + +def test_commit_alias_and_into_parts() -> None: + transport = _RecordingTransport() + consumer = _consumer(transport) + consumer.session.set_expires_at(1234) + envelope = MeteredEnvelope(payload="payload", metering=_directive(consumer.session.channel_id_string, 50)) + + delivery = consumer.accept(envelope) + assert delivery.metering.amount == "50" + receipt = delivery.commit() + assert receipt.cumulative == "50" + assert transport.commits[0].voucher.data.expires_at == 1234 + + second = MeteredEnvelope( + payload="second", + metering=_directive(consumer.session.channel_id_string, 75, delivery_id="d2"), + ) + delivery2 = consumer.accept(second) + payload, metering = delivery2.into_parts() + assert payload == "second" + assert metering.amount == "75" + + +def test_commit_directive_directly() -> None: + transport = _RecordingTransport() + consumer = _consumer(transport) + directive = _directive(consumer.session.channel_id_string, 25) + + receipt = consumer.commit_directive(directive) + assert receipt.cumulative == "25" + assert len(transport.commits) == 1 + assert consumer.transport is transport + + +def test_wrong_session_rejected_before_commit() -> None: + transport = _RecordingTransport() + consumer = _consumer(transport) + + wrong = MeteredEnvelope(payload=None, metering=_directive("other-session", 1)) + with pytest.raises(ValueError, match="does not match active session"): + consumer.accept(wrong) + assert len(transport.commits) == 0 + + +def test_zero_amount_rejected_before_commit() -> None: + transport = _RecordingTransport() + consumer = _consumer(transport) + zero = _directive(consumer.session.channel_id_string, 0) + with pytest.raises(ValueError, match="greater than zero"): + consumer.commit_directive(zero) + assert len(transport.commits) == 0 + + +def test_invalid_amount_rejected_before_commit() -> None: + transport = _RecordingTransport() + consumer = _consumer(transport) + invalid = _directive(consumer.session.channel_id_string, 1) + invalid.amount = "bad" + with pytest.raises(ValueError, match="invalid metering amount"): + consumer.commit_directive(invalid) + assert len(transport.commits) == 0 + assert consumer.session.cumulative == 0 + + +def test_failed_commit_does_not_advance_local_watermark() -> None: + transport = _RecordingTransport(fail=True) + consumer = _consumer(transport) + directive = _directive(consumer.session.channel_id_string, 250) + + with pytest.raises(ValueError, match="commit failed"): + consumer.commit_directive(directive) + assert consumer.session.cumulative == 0 + # Retry after the transport recovers reuses the same cumulative cleanly. + transport.fail = False + receipt = consumer.commit_directive(directive) + assert receipt.cumulative == "250" + assert consumer.session.cumulative == 250 + + +def test_replayed_receipt_is_honored_and_watermark_not_double_advanced() -> None: + # Server reports the second, fresh-id delivery as replayed; the consumer + # honors it and advances the watermark exactly once for that delivery. + transport = _RecordingTransport(replay_delivery_ids={"d2"}) + consumer = _consumer(transport) + + first = _directive(consumer.session.channel_id_string, 100, delivery_id="d1") + r1 = consumer.commit_directive(first) + assert r1.status == "committed" + assert consumer.session.cumulative == 100 + + second = _directive(consumer.session.channel_id_string, 40, delivery_id="d2") + r2 = consumer.commit_directive(second) + assert r2.status == "replayed" + assert consumer.session.cumulative == 140 + # A re-ack of the SAME directive object is rejected locally: prepare_increment + # advances past the watermark, but a stale duplicate cumulative cannot regress. + assert len(transport.commits) == 2 + + +def test_fresh_delivery_advances_after_prior() -> None: + transport = _RecordingTransport() + consumer = _consumer(transport) + + consumer.commit_directive(_directive(consumer.session.channel_id_string, 100, delivery_id="a")) + consumer.commit_directive(_directive(consumer.session.channel_id_string, 30, delivery_id="b")) + assert consumer.session.cumulative == 130 + assert [c.voucher.data.cumulative for c in transport.commits] == ["100", "130"] diff --git a/python/tests/test_session_intent.py b/python/tests/test_session_intent.py new file mode 100644 index 000000000..97a2e0a4c --- /dev/null +++ b/python/tests/test_session_intent.py @@ -0,0 +1,621 @@ +"""Tests for the session intent wire types. + +Mirrors the ``#[cfg(test)] mod tests`` in +``rust/crates/mpp/src/protocol/intents/session.rs`` and the parity-verified Go +port. Asserts mode/strategy serde, ``SessionRequest`` omit-empty parity, +``SessionAction`` round-trips for all five actions (including the ``"topUp"`` +camelCase tag), salt decimal-string out / string-or-number in, the +``cumulative`` decode alias, push/pull discrimination, the missing-mode error, +and the 48-byte voucher message layout. +""" + +from __future__ import annotations + +import struct + +import pytest + +from pay_kit.protocols.mpp.intents.session import ( + DEFAULT_SESSION_EXPIRES_AT, + ClosePayload, + CommitPayload, + CommitReceipt, + MeteredEnvelope, + MeteringDirective, + MeteringUsage, + OpenPayload, + SessionAction, + SessionMode, + SessionPullVoucherStrategy, + SessionRequest, + SessionSplit, + SignedVoucher, + TopUpPayload, + VoucherData, + VoucherPayload, +) + + +def _voucher(channel_id: str = "chan1", cumulative: str = "500000", nonce: int | None = 3) -> SignedVoucher: + return SignedVoucher( + data=VoucherData( + channel_id=channel_id, + cumulative=cumulative, + expires_at=DEFAULT_SESSION_EXPIRES_AT, + nonce=nonce, + ), + signature="sig_here", + ) + + +# ── Constants ── + + +def test_default_session_expires_at(): + assert DEFAULT_SESSION_EXPIRES_AT == 4_102_444_800 + + +# ── SessionMode / SessionPullVoucherStrategy serde ── + + +@pytest.mark.parametrize("mode", ["push", "pull"]) +def test_session_mode_roundtrips_on_request(mode: SessionMode): + req = SessionRequest(cap="1", currency="USDC", operator="op", recipient="rec", modes=[mode]) + assert req.to_dict()["modes"] == [mode] + assert SessionRequest.from_dict(req.to_dict()).modes == [mode] + + +@pytest.mark.parametrize("strategy", ["clientVoucher", "operatedVoucher"]) +def test_pull_voucher_strategy_roundtrips(strategy: SessionPullVoucherStrategy): + req = SessionRequest( + cap="1", + currency="USDC", + operator="op", + recipient="rec", + modes=["pull"], + pull_voucher_strategy=strategy, + ) + d = req.to_dict() + assert d["pullVoucherStrategy"] == strategy + assert SessionRequest.from_dict(d).pull_voucher_strategy == strategy + + +# ── SessionRequest ── + + +def test_session_request_full_roundtrip(): + req = SessionRequest( + cap="10000000", + currency="USDC", + operator="CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", + recipient="CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY", + decimals=6, + network="mainnet", + description="API session", + modes=["push"], + ) + back = SessionRequest.from_dict(req.to_dict()) + assert back.cap == "10000000" + assert back.currency == "USDC" + assert back.description == "API session" + assert back.decimals == 6 + assert back.modes == ["push"] + + +def test_session_request_omits_empty_optionals(): + req = SessionRequest(cap="1000", currency="USDC", operator="op", recipient="rec") + d = req.to_dict() + for key in ( + "splits", + "modes", + "decimals", + "network", + "description", + "externalId", + "programId", + "minVoucherDelta", + "pullVoucherStrategy", + "recentBlockhash", + ): + assert key not in d + # Required fields are always present. + assert d["cap"] == "1000" + assert d["operator"] == "op" + assert d["recipient"] == "rec" + + +def test_session_request_with_modes_push_and_pull(): + req = SessionRequest( + cap="1000", + currency="USDC", + operator="op", + recipient="rec", + modes=["push", "pull"], + pull_voucher_strategy="clientVoucher", + ) + d = req.to_dict() + assert d["modes"] == ["push", "pull"] + assert d["pullVoucherStrategy"] == "clientVoucher" + back = SessionRequest.from_dict(d) + assert back.modes == ["push", "pull"] + assert back.pull_voucher_strategy == "clientVoucher" + + +def test_session_request_with_splits_and_ids(): + req = SessionRequest( + cap="1000", + currency="USDC", + operator="op", + recipient="rec", + splits=[SessionSplit("s1", 100), SessionSplit("s2", 200)], + program_id="prog123", + external_id="ref-1", + ) + d = req.to_dict() + assert d["splits"] == [{"recipient": "s1", "bps": 100}, {"recipient": "s2", "bps": 200}] + back = SessionRequest.from_dict(d) + assert len(back.splits) == 2 + assert back.splits[0].bps == 100 + assert back.program_id == "prog123" + assert back.external_id == "ref-1" + + +def test_session_request_min_voucher_delta_present_and_omitted(): + with_delta = SessionRequest( + cap="1", currency="USDC", operator="op", recipient="rec", min_voucher_delta="500" + ).to_dict() + assert with_delta["minVoucherDelta"] == "500" + + without = SessionRequest(cap="1", currency="USDC", operator="op", recipient="rec").to_dict() + assert "minVoucherDelta" not in without + + +# ── OpenPayload constructors ── + + +def test_open_payload_push_fields(): + p = OpenPayload.push("chan1", "1000000", "signer1", "txsig") + assert p.mode == "push" + assert p.channel_id == "chan1" + assert p.deposit == "1000000" + assert p.token_account is None + assert p.approved_amount is None + assert p.authorized_signer == "signer1" + assert p.signature == "txsig" + + +def test_open_payload_pull_fields(): + p = OpenPayload.pull("tokacct", "5000000", "wallet1", "signer1", "approvesig") + assert p.mode == "pull" + assert p.channel_id is None + assert p.deposit is None + assert p.token_account == "tokacct" + assert p.approved_amount == "5000000" + assert p.owner == "wallet1" + + +def test_open_payload_payment_channel_and_tx_helpers(): + p = ( + OpenPayload.payment_channel("chan1", "1000000", "payer1", "payee1", "mint1", 99, 45, "signer1", "txsig") + .with_transaction("open-tx") + .with_init_tx("init-tx") + .with_update_tx("update-tx") + ) + assert p.mode == "push" + assert p.session_id() == "chan1" + assert p.deposit_amount() == 1_000_000 + assert p.payer == "payer1" + assert p.payee == "payee1" + assert p.mint == "mint1" + assert p.salt == 99 + assert p.grace_period == 45 + assert p.transaction == "open-tx" + assert p.init_multi_delegate_tx == "init-tx" + assert p.update_delegation_tx == "update-tx" + + +def test_open_payload_pull_payment_channel_uses_channel_id_and_deposit(): + p = OpenPayload.payment_channel_with_mode( + "pull", "chan1", "1000000", "payer1", "payee1", "mint1", 99, 45, "signer1", "pending" + ).with_transaction("open-tx") + assert p.mode == "pull" + assert p.session_id() == "chan1" + assert p.deposit_amount() == 1_000_000 + assert p.channel_id == "chan1" + assert p.deposit == "1000000" + assert p.token_account is None + assert p.approved_amount is None + assert p.transaction == "open-tx" + + +def test_open_payload_push_session_id_and_deposit(): + p = OpenPayload.push("chan1", "2000000", "s", "sig") + assert p.session_id() == "chan1" + assert p.deposit_amount() == 2_000_000 + + +def test_open_payload_pull_session_id_and_deposit(): + p = OpenPayload.pull("tokacct", "3000000", "wallet1", "s", "sig") + assert p.session_id() == "tokacct" + assert p.deposit_amount() == 3_000_000 + + +def test_open_payload_missing_required_fields_and_invalid_deposit_error(): + push = OpenPayload.push("chan1", "bad", "s", "sig") + with pytest.raises(ValueError, match="invalid deposit amount"): + push.deposit_amount() + push.deposit = None + with pytest.raises(ValueError, match="push open missing deposit"): + push.deposit_amount() + push.channel_id = None + with pytest.raises(ValueError, match="push open missing channelId"): + push.session_id() + + pull = OpenPayload.pull("tokacct", "bad", "wallet", "s", "sig") + with pytest.raises(ValueError, match="invalid deposit amount"): + pull.deposit_amount() + pull.approved_amount = None + with pytest.raises(ValueError, match="pull open missing deposit or approvedAmount"): + pull.deposit_amount() + pull.token_account = None + with pytest.raises(ValueError, match="pull open missing channelId or tokenAccount"): + pull.session_id() + + +# ── OpenPayload serde ── + + +def test_open_payload_push_roundtrip_dict(): + p = OpenPayload.push("chan1", "1000000", "signer1", "txsig") + d = p.to_dict() + assert d["mode"] == "push" + assert d["channelId"] == "chan1" + assert "tokenAccount" not in d + back = OpenPayload.from_dict(d) + assert back.mode == "push" + assert back.channel_id == "chan1" + + +def test_open_payload_pull_roundtrip_dict(): + p = OpenPayload.pull("tokacct", "5000000", "wallet1", "signer1", "approvesig") + d = p.to_dict() + assert d["mode"] == "pull" + assert d["tokenAccount"] == "tokacct" + assert d["owner"] == "wallet1" + assert "channelId" not in d + back = OpenPayload.from_dict(d) + assert back.mode == "pull" + assert back.token_account == "tokacct" + assert back.owner == "wallet1" + + +def test_salt_serializes_as_string_and_accepts_number_and_huge_u64(): + salt = 2**64 - 8 # u64::MAX - 7 + p = OpenPayload.payment_channel("chan1", "1000000", "payer1", "payee1", "mint1", salt, 900, "signer1", "txsig") + d = p.to_dict() + assert d["salt"] == str(salt) + assert isinstance(d["salt"], str) + back = OpenPayload.from_dict(d) + assert back.salt == salt + + # Legacy numeric salt — no float precision loss for a huge u64 because the + # dict carries a Python int (mirrors rust's number branch). + legacy = { + "mode": "push", + "channelId": "chan1", + "deposit": "1000000", + "salt": salt, + "authorizedSigner": "signer1", + "signature": "txsig", + } + assert OpenPayload.from_dict(legacy).salt == salt + assert OpenPayload.from_dict({**legacy, "salt": 42}).salt == 42 + + +def test_salt_absent_is_none(): + d = {"mode": "push", "channelId": "c", "authorizedSigner": "s", "signature": "sig"} + assert OpenPayload.from_dict(d).salt is None + # And a None salt is omitted from the wire dict. + assert "salt" not in OpenPayload.push("c", "1", "s", "sig").to_dict() + + +def test_salt_rejects_non_numeric_string_and_bad_type(): + base = {"mode": "push", "channelId": "c", "authorizedSigner": "s", "signature": "sig"} + with pytest.raises(ValueError, match="salt must be a decimal string"): + OpenPayload.from_dict({**base, "salt": "not-a-number"}) + with pytest.raises(ValueError, match="salt must be a decimal string or unsigned"): + OpenPayload.from_dict({**base, "salt": [1, 2]}) + with pytest.raises(ValueError, match="salt must be a decimal string or unsigned"): + OpenPayload.from_dict({**base, "salt": True}) + + +def test_open_payload_missing_mode_raises(): + d = {"channelId": "chan1", "deposit": "1000", "authorizedSigner": "s", "signature": "sig"} + with pytest.raises(ValueError, match="missing mode"): + OpenPayload.from_dict(d) + + +# ── SessionAction round-trips for all five actions ── + + +def test_session_action_open_push_roundtrip(): + action = SessionAction.open_action(OpenPayload.push("chan123", "5000000", "signer123", "sig456")) + d = action.to_dict() + assert d["action"] == "open" + assert d["mode"] == "push" + back = SessionAction.from_dict(d) + assert back.open is not None + assert back.open.mode == "push" + assert back.open.session_id() == "chan123" + assert back.open.deposit_amount() == 5_000_000 + assert back.open.authorized_signer == "signer123" + + +def test_session_action_open_pull_roundtrip(): + action = SessionAction.open_action(OpenPayload.pull("tokacct", "3000000", "wallet1", "signer1", "approvesig")) + d = action.to_dict() + assert d["action"] == "open" + assert d["mode"] == "pull" + assert "tokenAccount" in d + back = SessionAction.from_dict(d) + assert back.open is not None + assert back.open.mode == "pull" + assert back.open.session_id() == "tokacct" + assert back.open.deposit_amount() == 3_000_000 + + +def test_session_action_voucher_roundtrip(): + action = SessionAction.voucher_action(VoucherPayload(voucher=_voucher())) + d = action.to_dict() + assert d["action"] == "voucher" + back = SessionAction.from_dict(d) + assert back.voucher is not None + assert back.voucher.voucher.data.cumulative == "500000" + assert back.voucher.voucher.data.nonce == 3 + + +def test_session_action_commit_roundtrip(): + action = SessionAction.commit_action(CommitPayload(delivery_id="delivery-1", voucher=_voucher())) + d = action.to_dict() + assert d["action"] == "commit" + assert d["deliveryId"] == "delivery-1" + back = SessionAction.from_dict(d) + assert back.commit is not None + assert back.commit.delivery_id == "delivery-1" + assert back.commit.voucher.data.cumulative == "500000" + + +def test_session_action_topup_roundtrip_uses_camelcase_tag(): + action = SessionAction.top_up_action(TopUpPayload(channel_id="chan1", new_deposit="9000000", signature="txsig")) + d = action.to_dict() + assert d["action"] == "topUp" # camelCase, not "topup" + back = SessionAction.from_dict(d) + assert back.top_up is not None + assert back.top_up.new_deposit == "9000000" + assert back.top_up.signature == "txsig" + + +def test_session_action_close_no_voucher_roundtrip(): + action = SessionAction.close_action(ClosePayload(channel_id="chan1")) + d = action.to_dict() + assert d["action"] == "close" + assert "voucher" not in d + back = SessionAction.from_dict(d) + assert back.close is not None + assert back.close.voucher is None + + +def test_session_action_close_with_voucher_roundtrip(): + action = SessionAction.close_action( + ClosePayload(channel_id="chan1", voucher=_voucher(cumulative="700000", nonce=7)) + ) + d = action.to_dict() + assert d["action"] == "close" + back = SessionAction.from_dict(d) + assert back.close is not None + assert back.close.voucher is not None + assert back.close.voucher.data.cumulative == "700000" + + +@pytest.mark.parametrize( + ("action", "expected_tag"), + [ + (SessionAction.open_action(OpenPayload.push("c", "1", "s", "sig")), "open"), + (SessionAction.voucher_action(VoucherPayload(voucher=_voucher())), "voucher"), + (SessionAction.commit_action(CommitPayload("d", _voucher())), "commit"), + (SessionAction.top_up_action(TopUpPayload("c", "1", "sig")), "topUp"), + (SessionAction.close_action(ClosePayload("c")), "close"), + ], +) +def test_session_action_tags(action: SessionAction, expected_tag: str): + assert action.to_dict()["action"] == expected_tag + + +def test_session_action_no_variant_raises(): + with pytest.raises(ValueError, match="no variant set"): + SessionAction().to_dict() + + +def test_session_action_multiple_variants_raises(): + bad = SessionAction( + open=OpenPayload.push("c", "1", "s", "sig"), + close=ClosePayload("c"), + ) + with pytest.raises(ValueError, match="multiple variants set"): + bad.to_dict() + + +def test_session_action_missing_discriminator_raises(): + with pytest.raises(ValueError, match="missing action discriminator"): + SessionAction.from_dict({"channelId": "c"}) + + +def test_session_action_unknown_discriminator_raises(): + with pytest.raises(ValueError, match="unknown action"): + SessionAction.from_dict({"action": "settle"}) + + +# ── VoucherData ── + + +def test_voucher_data_cumulative_alias_decode(): + # Primary wire field. + primary = VoucherData.from_dict({"channelId": "c", "cumulativeAmount": "100", "expiresAt": 42}) + assert primary.cumulative == "100" + # Legacy "cumulative" alias. + alias = VoucherData.from_dict({"channelId": "c", "cumulative": "200", "expiresAt": 42}) + assert alias.cumulative == "200" + # Emits the canonical "cumulativeAmount" wire field. + assert primary.to_dict()["cumulativeAmount"] == "100" + assert "cumulative" not in primary.to_dict() + + +def test_voucher_data_nonce_omitted_when_none(): + d = VoucherData(channel_id="c", cumulative="1", expires_at=1).to_dict() + assert "nonce" not in d + d2 = VoucherData(channel_id="c", cumulative="1", expires_at=1, nonce=9).to_dict() + assert d2["nonce"] == 9 + + +@pytest.mark.parametrize("nonce", [None, 1, 5]) +def test_voucher_message_bytes_layout(nonce: int | None): + channel_bytes = bytes([3] * 32) + from solders.pubkey import Pubkey + + channel_id = str(Pubkey.from_bytes(channel_bytes)) + data = VoucherData(channel_id=channel_id, cumulative="1000", expires_at=42, nonce=nonce) + out = data.message_bytes() + assert len(out) == 48 + assert out[:32] == channel_bytes + assert out[32:40] == struct.pack(" Date: Tue, 9 Jun 2026 01:35:31 +0300 Subject: [PATCH 02/52] fix(python): harden session watermark and commit-receipt handling Addresses Greptile + Codex review of #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 (#162). --- .../pay_kit/protocols/mpp/client/session.py | 26 ++++- .../protocols/mpp/client/session_consumer.py | 24 +++- .../pay_kit/protocols/mpp/intents/session.py | 36 +++++- python/tests/test_session_consumer.py | 106 +++++++++++++++--- python/tests/test_session_intent.py | 9 ++ 5 files changed, 168 insertions(+), 33 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/client/session.py b/python/src/pay_kit/protocols/mpp/client/session.py index 82189a26f..46f996891 100644 --- a/python/src/pay_kit/protocols/mpp/client/session.py +++ b/python/src/pay_kit/protocols/mpp/client/session.py @@ -224,12 +224,18 @@ def prepare_increment(self, amount: int) -> SignedVoucher: def record_voucher(self, voucher: SignedVoucher) -> None: """Advance the local watermark to a prepared voucher the server accepted. - The voucher cumulative MUST strictly exceed the current watermark; the - nonce advances to the larger of the current nonce + 1 and the voucher - nonce. Mirrors rust ``ActiveSession::record_voucher``. + The voucher's channel MUST match this session and its cumulative MUST + strictly exceed the current watermark; the nonce advances to the larger + of the current nonce + 1 and the voucher nonce. Mirrors rust + ``ActiveSession::record_voucher``. """ + if voucher.data.channel_id != self.channel_id_string: + raise ValueError( + f"voucher channel {voucher.data.channel_id} does not match " + f"active session {self.channel_id_string}" + ) try: - cumulative = int(voucher.data.cumulative, 10) + cumulative = int(str(voucher.data.cumulative), 10) except ValueError as exc: raise ValueError("invalid voucher cumulative") from exc if cumulative <= self._cumulative: @@ -240,6 +246,18 @@ def record_voucher(self, voucher: SignedVoucher) -> None: candidate = voucher.data.nonce self._nonce = candidate + def reconcile_settled(self, settled: int) -> None: + """Reconcile the local watermark to a server-settled cumulative. + + Used for a ``replayed`` commit receipt: advances the watermark to + ``settled`` when it is ahead of the current value and never regresses, + so retrying a delivery the server already accepted (lost-response case) + catches the client up without recording the freshly prepared higher + voucher. + """ + if settled > self._cumulative: + self._cumulative = settled + def sign_voucher(self, cumulative: int) -> SignedVoucher: """Sign a voucher with an absolute cumulative amount and advance the local watermark. diff --git a/python/src/pay_kit/protocols/mpp/client/session_consumer.py b/python/src/pay_kit/protocols/mpp/client/session_consumer.py index 94462928b..7d0674dce 100644 --- a/python/src/pay_kit/protocols/mpp/client/session_consumer.py +++ b/python/src/pay_kit/protocols/mpp/client/session_consumer.py @@ -90,10 +90,14 @@ def commit_directive(self, directive: MeteringDirective) -> CommitReceipt: valid base-unit integer, or whose amount is zero. The prepare/record split makes a failed commit safe to retry without double-counting: the voucher is prepared (no watermark advance), sent, and only recorded once - the transport returns a receipt. A server ``replayed`` receipt is - honored unconditionally — the recorded voucher cumulative is strictly - greater than the prior watermark so the watermark advances exactly once. - Mirrors rust ``SessionConsumer::commit_directive``. + the transport returns a ``committed`` receipt. A ``replayed`` receipt + means the server already settled this delivery, so its ``cumulative`` is + the authoritative settled position: recording the freshly prepared + (higher) voucher would push the watermark past the server's state, while + skipping it entirely would leave it behind when the original response was + lost (making the next delivery non-monotonic). On replay the watermark is + reconciled to the receipt cumulative (never regressing). Mirrors rust + ``SessionConsumer::commit_directive``. """ self._validate_directive(directive) amount = directive.amount_base_units() @@ -104,7 +108,17 @@ def commit_directive(self, directive: MeteringDirective) -> CommitReceipt: payload = CommitPayload(delivery_id=directive.delivery_id, voucher=voucher) receipt = self._transport.commit(directive, payload) - self._session.record_voucher(payload.voucher) + if receipt.status == "replayed": + try: + settled = int(str(receipt.cumulative), 10) + except ValueError as exc: + raise ValueError("invalid replayed receipt cumulative") from exc + self._session.reconcile_settled(settled) + elif receipt.status == "committed": + self._session.record_voucher(payload.voucher) + else: + # A malformed or unknown status must not advance local state. + raise ValueError(f"unexpected commit receipt status: {receipt.status!r}") return receipt def _validate_directive(self, directive: MeteringDirective) -> None: diff --git a/python/src/pay_kit/protocols/mpp/intents/session.py b/python/src/pay_kit/protocols/mpp/intents/session.py index fa8510bf0..8218f0de3 100644 --- a/python/src/pay_kit/protocols/mpp/intents/session.py +++ b/python/src/pay_kit/protocols/mpp/intents/session.py @@ -49,6 +49,25 @@ # DEFAULT_SESSION_EXPIRES_AT. DEFAULT_SESSION_EXPIRES_AT = 4_102_444_800 +_U64_MAX = 2**64 - 1 + + +def _parse_base_units(raw: object) -> int: + """Parse a canonical unsigned base-unit decimal string into a ``u64``. + + Rejects empty, signed, fractional, non-ASCII-digit, or out-of-range values, + matching the rust/Go typed ``u64`` parsers so a malformed amount (e.g. + ``"-1"``) cannot slip past zero/max-cap checks or fail later when packed for + Solana. + """ + s = str(raw) + if not (s.isascii() and s.isdigit()): + raise ValueError(f"invalid base-unit amount {raw!r}") + value = int(s, 10) + if value > _U64_MAX: + raise ValueError(f"base-unit amount {raw!r} exceeds u64 range") + return value + # On-chain funding mechanism for a session. Advertised by the server in # ``SessionRequest.modes``; the client picks the mode it will use in its open # action. Mirrors rust SessionMode (rename_all="camelCase"). @@ -387,7 +406,7 @@ def deposit_amount(self) -> int: else: raise ValueError(f"open payload: unknown mode {self.mode!r}") try: - return int(raw, 10) + return _parse_base_units(raw) except ValueError as exc: raise ValueError(f"invalid deposit amount: {raw}") from exc @@ -482,6 +501,11 @@ def from_dict(cls, data: dict[str, Any]) -> VoucherData: cumulative = data["cumulative"] else: cumulative = "" + # The wire value may arrive as a JSON number; coerce to str so the + # base-unit accessors (message_bytes, record_voucher) parse it as a + # decimal string rather than raising TypeError. + if not isinstance(cumulative, str): + cumulative = str(cumulative) nonce = data.get("nonce") return cls( channel_id=data.get("channelId", ""), @@ -512,7 +536,7 @@ def message_bytes(self) -> bytes: if len(channel) != 32: raise ValueError(f"channelId must be 32 bytes, got {len(channel)}") try: - cumulative = int(self.cumulative, 10) + cumulative = _parse_base_units(self.cumulative) except ValueError as exc: raise ValueError("invalid voucher cumulative") from exc return channel + struct.pack(" int: Mirrors rust ``MeteringDirective::amount_base_units``. """ try: - return int(self.amount, 10) + return _parse_base_units(self.amount) except ValueError as exc: raise ValueError(f"invalid metering amount: {self.amount}") from exc @@ -795,7 +819,7 @@ def amount_base_units(self) -> int: Mirrors rust ``MeteringUsage::amount_base_units``. """ try: - return int(self.amount, 10) + return _parse_base_units(self.amount) except ValueError as exc: raise ValueError(f"invalid metering usage amount: {self.amount}") from exc @@ -845,14 +869,14 @@ class CommitReceipt: def amount_base_units(self) -> int: """Parse ``amount`` as base units.""" try: - return int(self.amount, 10) + return _parse_base_units(self.amount) except ValueError as exc: raise ValueError(f"invalid commit receipt amount: {self.amount}") from exc def cumulative_base_units(self) -> int: """Parse ``cumulative`` as base units.""" try: - return int(self.cumulative, 10) + return _parse_base_units(self.cumulative) except ValueError as exc: raise ValueError(f"invalid commit receipt cumulative: {self.cumulative}") from exc diff --git a/python/tests/test_session_consumer.py b/python/tests/test_session_consumer.py index bade475b2..567a4fe20 100644 --- a/python/tests/test_session_consumer.py +++ b/python/tests/test_session_consumer.py @@ -30,27 +30,38 @@ class _RecordingTransport: """In-process commit transport that records payloads and echoes a receipt. - ``replay_delivery_ids`` returns a ``replayed`` receipt (server already saw - the delivery) so the consumer's idempotency handling can be exercised. + Models server-side dedupe by deliveryId: re-committing a deliveryId already + seen returns a ``replayed`` receipt pinned to the cumulative first settled + for it, so the consumer's idempotency handling can be exercised. """ - def __init__(self, fail: bool = False, replay_delivery_ids: set[str] | None = None) -> None: + def __init__(self, fail: bool = False) -> None: self.commits: list[CommitPayload] = [] self.fail = fail - self.replay_delivery_ids = replay_delivery_ids or set() + # The cumulative first settled per deliveryId. + self._settled: dict[str, str] = {} def commit(self, directive: MeteringDirective, payload: CommitPayload) -> CommitReceipt: if self.fail: raise ValueError("commit failed") + prior = self._settled.get(directive.delivery_id) + if prior is not None: + return CommitReceipt( + delivery_id=directive.delivery_id, + session_id=directive.session_id, + amount=directive.amount, + cumulative=prior, + status="replayed", + ) cumulative = payload.voucher.data.cumulative + self._settled[directive.delivery_id] = cumulative self.commits.append(payload) - status = "replayed" if directive.delivery_id in self.replay_delivery_ids else "committed" return CommitReceipt( delivery_id=directive.delivery_id, session_id=directive.session_id, amount=directive.amount, cumulative=cumulative, - status=status, + status="committed", ) @@ -170,24 +181,83 @@ def test_failed_commit_does_not_advance_local_watermark() -> None: assert consumer.session.cumulative == 250 -def test_replayed_receipt_is_honored_and_watermark_not_double_advanced() -> None: - # Server reports the second, fresh-id delivery as replayed; the consumer - # honors it and advances the watermark exactly once for that delivery. - transport = _RecordingTransport(replay_delivery_ids={"d2"}) +class _ReplayTransport: + """Transport that reports every commit as already settled at a fixed + cumulative, regardless of the voucher it is sent.""" + + def __init__(self, settled: str) -> None: + self.settled = settled + + def commit(self, directive: MeteringDirective, _payload: CommitPayload) -> CommitReceipt: + return CommitReceipt( + delivery_id=directive.delivery_id, + session_id=directive.session_id, + amount=directive.amount, + cumulative=self.settled, + status="replayed", + ) + + +def test_duplicate_delivery_dedup_keeps_watermark_at_settled() -> None: + # Re-committing the same deliveryId returns a replayed receipt pinned to the + # originally settled cumulative; the watermark does not advance past it and + # the server records exactly one commit. + transport = _RecordingTransport() consumer = _consumer(transport) + d = _directive(consumer.session.channel_id_string, 100, delivery_id="d1") - first = _directive(consumer.session.channel_id_string, 100, delivery_id="d1") - r1 = consumer.commit_directive(first) + r1 = consumer.commit_directive(d) assert r1.status == "committed" assert consumer.session.cumulative == 100 - second = _directive(consumer.session.channel_id_string, 40, delivery_id="d2") - r2 = consumer.commit_directive(second) + r2 = consumer.commit_directive(d) assert r2.status == "replayed" - assert consumer.session.cumulative == 140 - # A re-ack of the SAME directive object is rejected locally: prepare_increment - # advances past the watermark, but a stale duplicate cumulative cannot regress. - assert len(transport.commits) == 2 + assert r2.cumulative == "100" + assert consumer.session.cumulative == 100 + assert len(transport.commits) == 1 + + +def test_replayed_receipt_reconciles_watermark_when_behind() -> None: + # Lost-response case: the server already settled this delivery at 100 but the + # client never recorded it (watermark still 0). On replay the client must + # reconcile to the server-settled 100, not jump to the prepared 250 and not + # stay at 0 (which would make the next delivery non-monotonic). + consumer = _consumer(_ReplayTransport(settled="100")) + receipt = consumer.commit_directive(_directive(consumer.session.channel_id_string, 250)) + assert receipt.status == "replayed" + assert consumer.session.cumulative == 100 + + +class _StatusTransport: + """Transport that returns a fixed (possibly unknown) status.""" + + def __init__(self, status: str) -> None: + self.status = status + + def commit(self, directive: MeteringDirective, payload: CommitPayload) -> CommitReceipt: + return CommitReceipt( + delivery_id=directive.delivery_id, + session_id=directive.session_id, + amount=directive.amount, + cumulative=payload.voucher.data.cumulative, + status=self.status, # type: ignore[arg-type] + ) + + +def test_unknown_receipt_status_is_rejected_and_does_not_advance() -> None: + consumer = _consumer(_StatusTransport(status="bogus")) + with pytest.raises(ValueError, match="unexpected commit receipt status"): + consumer.commit_directive(_directive(consumer.session.channel_id_string, 100)) + assert consumer.session.cumulative == 0 + + +def test_replayed_receipt_never_regresses_watermark() -> None: + # The client is already ahead at 300; a stale replay settled at 100 must not + # regress the local watermark. + consumer = _consumer(_ReplayTransport(settled="100")) + consumer.session.reconcile_settled(300) + consumer.commit_directive(_directive(consumer.session.channel_id_string, 50)) + assert consumer.session.cumulative == 300 def test_fresh_delivery_advances_after_prior() -> None: diff --git a/python/tests/test_session_intent.py b/python/tests/test_session_intent.py index 97a2e0a4c..0046bab02 100644 --- a/python/tests/test_session_intent.py +++ b/python/tests/test_session_intent.py @@ -214,6 +214,15 @@ def test_open_payload_payment_channel_and_tx_helpers(): assert p.update_delegation_tx == "update-tx" +def test_deposit_amount_rejects_non_u64_values() -> None: + # Negative, fractional, non-digit, and over-u64 deposits must be rejected + # like the rust/Go typed u64 parsers, not silently coerced. + for bad in ("-1", "1.5", "0x10", " 10", "", str(2**64)): + p = OpenPayload.push("chan1", bad, "signer1", "txsig") + with pytest.raises(ValueError): + p.deposit_amount() + + def test_open_payload_pull_payment_channel_uses_channel_id_and_deposit(): p = OpenPayload.payment_channel_with_mode( "pull", "chan1", "1000000", "payer1", "payee1", "mint1", 99, 45, "signer1", "pending" From 9e3a6c59b2e76db6560e592f833a1dae5dd984cb Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 9 Jun 2026 02:29:42 +0300 Subject: [PATCH 03/52] fix(python): advance request nonce when reconciling a replayed settled cumulative Greptile #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. --- .../pay_kit/protocols/mpp/client/session.py | 5 +++- python/tests/test_session_client.py | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/python/src/pay_kit/protocols/mpp/client/session.py b/python/src/pay_kit/protocols/mpp/client/session.py index 46f996891..492f1c803 100644 --- a/python/src/pay_kit/protocols/mpp/client/session.py +++ b/python/src/pay_kit/protocols/mpp/client/session.py @@ -253,10 +253,13 @@ def reconcile_settled(self, settled: int) -> None: ``settled`` when it is ahead of the current value and never regresses, so retrying a delivery the server already accepted (lost-response case) catches the client up without recording the freshly prepared higher - voucher. + voucher. When it advances, the request nonce also advances by one, + mirroring ``record_voucher`` for the delivery the server settled, so the + next prepared voucher does not reuse the already-settled nonce. """ if settled > self._cumulative: self._cumulative = settled + self._nonce += 1 def sign_voucher(self, cumulative: int) -> SignedVoucher: """Sign a voucher with an absolute cumulative amount and advance the diff --git a/python/tests/test_session_client.py b/python/tests/test_session_client.py index 782fc4b05..45d484df5 100644 --- a/python/tests/test_session_client.py +++ b/python/tests/test_session_client.py @@ -159,6 +159,30 @@ def test_record_voucher_honors_larger_voucher_nonce() -> None: assert s.nonce == 9 +def test_reconcile_settled_advances_cumulative_and_nonce_without_regressing() -> None: + s = _session() + s.reconcile_settled(100) + assert s.cumulative == 100 + assert s.nonce == 1 # advancing also bumps the request nonce + s.reconcile_settled(40) # stale, must not regress or touch the nonce + assert s.cumulative == 100 + assert s.nonce == 1 + s.reconcile_settled(250) + assert s.cumulative == 250 + assert s.nonce == 2 + + +def test_delivery_after_replay_does_not_reuse_settled_nonce() -> None: + # After a lost-response replay reconciles to the settled cumulative, the + # next prepared voucher must carry a fresh nonce, not the settled one. + s = _session() + replayed = s.prepare_increment(100) + s.reconcile_settled(100) + nxt = s.prepare_increment(50) + assert replayed.data.nonce is not None and nxt.data.nonce is not None + assert nxt.data.nonce > replayed.data.nonce + + # ── monotonicity ── From fcba25b08a01ba13d10ad9b626af396e4fc03b98 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 9 Jun 2026 12:07:52 +0300 Subject: [PATCH 04/52] style(python): ruff-format session modules and make test transports pyright-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). --- python/src/pay_kit/protocols/mpp/client/session.py | 3 +-- python/src/pay_kit/protocols/mpp/intents/session.py | 1 + python/tests/test_session_consumer.py | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/client/session.py b/python/src/pay_kit/protocols/mpp/client/session.py index 492f1c803..a0ac78a0b 100644 --- a/python/src/pay_kit/protocols/mpp/client/session.py +++ b/python/src/pay_kit/protocols/mpp/client/session.py @@ -231,8 +231,7 @@ def record_voucher(self, voucher: SignedVoucher) -> None: """ if voucher.data.channel_id != self.channel_id_string: raise ValueError( - f"voucher channel {voucher.data.channel_id} does not match " - f"active session {self.channel_id_string}" + f"voucher channel {voucher.data.channel_id} does not match active session {self.channel_id_string}" ) try: cumulative = int(str(voucher.data.cumulative), 10) diff --git a/python/src/pay_kit/protocols/mpp/intents/session.py b/python/src/pay_kit/protocols/mpp/intents/session.py index 8218f0de3..c910e1ecf 100644 --- a/python/src/pay_kit/protocols/mpp/intents/session.py +++ b/python/src/pay_kit/protocols/mpp/intents/session.py @@ -68,6 +68,7 @@ def _parse_base_units(raw: object) -> int: raise ValueError(f"base-unit amount {raw!r} exceeds u64 range") return value + # On-chain funding mechanism for a session. Advertised by the server in # ``SessionRequest.modes``; the client picks the mode it will use in its open # action. Mirrors rust SessionMode (rename_all="camelCase"). diff --git a/python/tests/test_session_consumer.py b/python/tests/test_session_consumer.py index 567a4fe20..76916b227 100644 --- a/python/tests/test_session_consumer.py +++ b/python/tests/test_session_consumer.py @@ -17,7 +17,7 @@ from solders.pubkey import Pubkey # type: ignore[import-untyped] from pay_kit.protocols.mpp.client.session import ActiveSession -from pay_kit.protocols.mpp.client.session_consumer import SessionConsumer +from pay_kit.protocols.mpp.client.session_consumer import CommitTransport, SessionConsumer from pay_kit.protocols.mpp.intents.session import ( DEFAULT_SESSION_EXPIRES_AT, CommitPayload, @@ -73,7 +73,7 @@ def _channel() -> Pubkey: return Pubkey.from_string("11111111111111111111111111111112") -def _consumer(transport: _RecordingTransport) -> SessionConsumer: +def _consumer(transport: CommitTransport) -> SessionConsumer: return SessionConsumer(ActiveSession(_channel(), _signer()), transport) @@ -188,7 +188,8 @@ class _ReplayTransport: def __init__(self, settled: str) -> None: self.settled = settled - def commit(self, directive: MeteringDirective, _payload: CommitPayload) -> CommitReceipt: + def commit(self, directive: MeteringDirective, payload: CommitPayload) -> CommitReceipt: + del payload # always reports the fixed settled cumulative, ignores the voucher return CommitReceipt( delivery_id=directive.delivery_id, session_id=directive.session_id, From 9a678ce5a143f36dfe8efc9300ebb39180c61669 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 9 Jun 2026 22:37:22 +0300 Subject: [PATCH 05/52] fix(python): clamp replayed-receipt reconcile + strict u64 parsing 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. --- .../src/pay_kit/protocols/mpp/client/session.py | 3 ++- .../protocols/mpp/client/session_consumer.py | 15 ++++++++++----- .../src/pay_kit/protocols/mpp/intents/session.py | 8 +++++++- python/tests/test_session_consumer.py | 11 +++++++++++ 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/client/session.py b/python/src/pay_kit/protocols/mpp/client/session.py index a0ac78a0b..751a5ee38 100644 --- a/python/src/pay_kit/protocols/mpp/client/session.py +++ b/python/src/pay_kit/protocols/mpp/client/session.py @@ -42,6 +42,7 @@ TopUpPayload, VoucherData, VoucherPayload, + _parse_base_units, ) __all__ = [ @@ -234,7 +235,7 @@ def record_voucher(self, voucher: SignedVoucher) -> None: f"voucher channel {voucher.data.channel_id} does not match active session {self.channel_id_string}" ) try: - cumulative = int(str(voucher.data.cumulative), 10) + cumulative = _parse_base_units(voucher.data.cumulative) except ValueError as exc: raise ValueError("invalid voucher cumulative") from exc if cumulative <= self._cumulative: diff --git a/python/src/pay_kit/protocols/mpp/client/session_consumer.py b/python/src/pay_kit/protocols/mpp/client/session_consumer.py index 7d0674dce..96ae0b8af 100644 --- a/python/src/pay_kit/protocols/mpp/client/session_consumer.py +++ b/python/src/pay_kit/protocols/mpp/client/session_consumer.py @@ -20,6 +20,7 @@ CommitReceipt, MeteredEnvelope, MeteringDirective, + _parse_base_units, ) __all__ = [ @@ -109,11 +110,15 @@ def commit_directive(self, directive: MeteringDirective) -> CommitReceipt: receipt = self._transport.commit(directive, payload) if receipt.status == "replayed": - try: - settled = int(str(receipt.cumulative), 10) - except ValueError as exc: - raise ValueError("invalid replayed receipt cumulative") from exc - self._session.reconcile_settled(settled) + settled = receipt.cumulative_base_units() + # The server is untrusted: clamp to the voucher just prepared in this + # call. An honest lost-response replay settles at or below it (the + # session is single-threaded), so a server reporting a higher + # cumulative cannot push the watermark past what the client actually + # signed - otherwise the next voucher would over-authorize up to the + # on-chain deposit. + prepared = _parse_base_units(payload.voucher.data.cumulative) + self._session.reconcile_settled(min(settled, prepared)) elif receipt.status == "committed": self._session.record_voucher(payload.voucher) else: diff --git a/python/src/pay_kit/protocols/mpp/intents/session.py b/python/src/pay_kit/protocols/mpp/intents/session.py index c910e1ecf..6bea9f74b 100644 --- a/python/src/pay_kit/protocols/mpp/intents/session.py +++ b/python/src/pay_kit/protocols/mpp/intents/session.py @@ -202,11 +202,17 @@ def _salt_from_wire(value: Any) -> int | None: return None if isinstance(value, bool): raise ValueError("salt must be a decimal string or unsigned 64-bit integer") + # rust deserializes salt as u64 and rejects negative / out-of-range values; + # match that here rather than letting a malformed salt fail later inside + # struct.pack. Accept an int directly (no float precision loss) or a strict + # unsigned-decimal string. if isinstance(value, int): + if not 0 <= value <= _U64_MAX: + raise ValueError(f"salt out of u64 range: {value}") return value if isinstance(value, str): try: - return int(value, 10) + return _parse_base_units(value) except ValueError as exc: raise ValueError(f"salt must be a decimal string: {value}") from exc raise ValueError("salt must be a decimal string or unsigned 64-bit integer") diff --git a/python/tests/test_session_consumer.py b/python/tests/test_session_consumer.py index 76916b227..0d66c4301 100644 --- a/python/tests/test_session_consumer.py +++ b/python/tests/test_session_consumer.py @@ -229,6 +229,17 @@ def test_replayed_receipt_reconciles_watermark_when_behind() -> None: assert consumer.session.cumulative == 100 +def test_replayed_receipt_cumulative_is_clamped_to_prepared_voucher() -> None: + # A malicious/buggy server cannot push the watermark past the voucher the + # client just signed: it reports a replay settled far above the prepared + # cumulative (250), but the watermark must clamp to the prepared value, not + # the inflated server value, so the next voucher does not over-authorize. + consumer = _consumer(_ReplayTransport(settled="1000000")) + receipt = consumer.commit_directive(_directive(consumer.session.channel_id_string, 250)) + assert receipt.status == "replayed" + assert consumer.session.cumulative == 250 + + class _StatusTransport: """Transport that returns a fixed (possibly unknown) status.""" From 3a52578d73b99de808cc6a943284a4e25285a3d3 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Wed, 10 Jun 2026 16:00:24 +0300 Subject: [PATCH 06/52] refactor(python): delegate VoucherData.message_bytes to the canonical 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. --- .../pay_kit/protocols/mpp/intents/session.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/intents/session.py b/python/src/pay_kit/protocols/mpp/intents/session.py index 6bea9f74b..7d0b034d7 100644 --- a/python/src/pay_kit/protocols/mpp/intents/session.py +++ b/python/src/pay_kit/protocols/mpp/intents/session.py @@ -14,7 +14,6 @@ from __future__ import annotations -import struct from dataclasses import dataclass, field from typing import Any, Literal @@ -527,26 +526,26 @@ def message_bytes(self) -> bytes: Layout (exactly 48 bytes): ``channel_id``\\ (32, base58-decoded) || ``cumulative_amount`` little-endian ``u64`` (offset 32) || ``expires_at`` - little-endian ``i64`` (offset 40). The ``channel_id`` MUST decode to - exactly 32 bytes. Mirrors rust ``VoucherData::message_bytes`` and the Go - ``VoucherData.MessageBytes``. + little-endian ``i64`` (offset 40). Delegates to the canonical packer so + the 48-byte layout has a single source of truth, mirroring rust + ``VoucherData::message_bytes`` (which calls ``voucher_message_bytes``). """ # Lazy import so the module imports without solders installed, matching - # the charge intent's discipline and avoiding an import cycle with the - # on-chain glue module. + # the charge intent's discipline (no cycle: the glue does not import the + # intent layer). from solders.pubkey import Pubkey # type: ignore[import-untyped] + from pay_kit.protocols.mpp._paymentchannels import voucher_message_bytes + try: - channel = bytes(Pubkey.from_string(self.channel_id)) + channel = Pubkey.from_string(self.channel_id) except (ValueError, TypeError) as exc: raise ValueError(f"invalid channelId {self.channel_id!r}") from exc - if len(channel) != 32: - raise ValueError(f"channelId must be 32 bytes, got {len(channel)}") try: cumulative = _parse_base_units(self.cumulative) except ValueError as exc: raise ValueError("invalid voucher cumulative") from exc - return channel + struct.pack(" Date: Thu, 11 Jun 2026 13:36:06 +0300 Subject: [PATCH 07/52] feat(python): generate the payment-channels client with codama-py 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. --- justfile | 10 +- python/pyproject.toml | 12 +- .../pay_kit/protocols/programs/__init__.py | 7 + .../programs/paymentchannels/__init__.py | 7 + .../paymentchannels/accounts/__init__.py | 11 + .../paymentchannels/accounts/channel.py | 187 +++++ .../paymentchannels/accounts/closedChannel.py | 88 +++ .../paymentchannels/errors/__init__.py | 23 + .../paymentchannels/errors/paymentChannels.py | 682 ++++++++++++++++++ .../paymentchannels/instructions/__init__.py | 8 + .../instructions/distribute.py | 79 ++ .../paymentchannels/instructions/emitEvent.py | 46 ++ .../paymentchannels/instructions/finalize.py | 34 + .../paymentchannels/instructions/open.py | 87 +++ .../instructions/requestClose.py | 36 + .../paymentchannels/instructions/settle.py | 49 ++ .../instructions/settleAndFinalize.py | 51 ++ .../paymentchannels/instructions/topUp.py | 57 ++ .../instructions/withdrawPayer.py | 44 ++ .../programs/paymentchannels/pdas/index.py | 25 + .../programs/paymentchannels/program_id.py | 11 + .../paymentchannels/shared/__init__.py | 8 + .../paymentchannels/shared/extension.py | 280 +++++++ .../paymentchannels/types/__init__.py | 35 + .../types/accountDiscriminator.py | 87 +++ .../paymentchannels/types/channelStatus.py | 115 +++ .../paymentchannels/types/distributeArgs.py | 51 ++ .../types/distributionEntry.py | 59 ++ .../paymentchannels/types/openArgs.py | 72 ++ .../programs/paymentchannels/types/opened.py | 52 ++ .../types/payoutBeneficiary.py | 115 +++ .../paymentchannels/types/payoutRedirected.py | 81 +++ .../paymentchannels/types/redirectReason.py | 143 ++++ .../types/settleAndFinalizeArgs.py | 58 ++ .../paymentchannels/types/settleArgs.py | 51 ++ .../types/settlementWatermarks.py | 57 ++ .../paymentchannels/types/topUpArgs.py | 50 ++ .../paymentchannels/types/voucherArgs.py | 66 ++ .../pay-sdk-implementation/codegen/.gitignore | 1 + .../generate-payment-channels-client-py.ts | 76 ++ .../codegen/package.json | 3 +- 41 files changed, 3009 insertions(+), 5 deletions(-) create mode 100644 python/src/pay_kit/protocols/programs/__init__.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/__init__.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/accounts/__init__.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/accounts/channel.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/accounts/closedChannel.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/errors/__init__.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/errors/paymentChannels.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/instructions/__init__.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/instructions/distribute.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/instructions/emitEvent.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/instructions/finalize.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/instructions/open.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/instructions/requestClose.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/instructions/settle.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/instructions/settleAndFinalize.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/instructions/topUp.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/instructions/withdrawPayer.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/pdas/index.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/program_id.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/shared/__init__.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/shared/extension.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/types/__init__.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/types/accountDiscriminator.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/types/channelStatus.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/types/distributeArgs.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/types/distributionEntry.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/types/openArgs.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/types/opened.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/types/payoutBeneficiary.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/types/payoutRedirected.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/types/redirectReason.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/types/settleAndFinalizeArgs.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/types/settleArgs.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/types/settlementWatermarks.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/types/topUpArgs.py create mode 100644 python/src/pay_kit/protocols/programs/paymentchannels/types/voucherArgs.py create mode 100644 skills/pay-sdk-implementation/codegen/generate-payment-channels-client-py.ts diff --git a/justfile b/justfile index 68b6593e0..9f21a7205 100644 --- a/justfile +++ b/justfile @@ -65,8 +65,14 @@ payment-channels-generate-rs: codegen-install cd {{codegen_dir}} && pnpm run payment-channels:rust cd rust && cargo fmt -p payment-channels-client -# Full refresh: pull IDL + regenerate Rust client. -payment-channels-sync: payment-channels-pull-idl payment-channels-generate-rs +# Render the Python client from the vendored IDL. Wipes +# `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 + +# Full refresh: pull IDL + regenerate Rust and Python clients. +payment-channels-sync: payment-channels-pull-idl payment-channels-generate-rs payment-channels-generate-py # ── TypeScript ── diff --git a/python/pyproject.toml b/python/pyproject.toml index a20916812..104b10168 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -35,6 +35,9 @@ packages = ["src/pay_kit"] [tool.ruff] target-version = "py311" line-length = 120 +# Rendered by codama-py (skills/pay-sdk-implementation/codegen); never edited +# by hand, so it is exempt from lint and formatting. +extend-exclude = ["src/pay_kit/protocols/programs/paymentchannels"] [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B", "SIM"] @@ -84,7 +87,7 @@ strict = [ ] reportMissingTypeStubs = false include = ["src", "tests"] -exclude = ["**/__pycache__"] +exclude = ["**/__pycache__", "src/pay_kit/protocols/programs/paymentchannels"] [tool.pytest.ini_options] asyncio_mode = "auto" @@ -98,7 +101,12 @@ branch = false # pay_kit/preflight.py wraps live Solana RPC + Surfnet cheatcodes that cannot # run inside the offline unit suite; every meaningful path is a network call. # Its two opt-out knobs are covered separately against a stubbed run/RPC. -omit = ["src/pay_kit/preflight.py"] +omit = [ + "src/pay_kit/preflight.py", + # Generated by codama-py; exercised indirectly through the + # _paymentchannels glue and the frozen instruction vectors. + "src/pay_kit/protocols/programs/paymentchannels/*", +] [tool.coverage.report] fail_under = 90 diff --git a/python/src/pay_kit/protocols/programs/__init__.py b/python/src/pay_kit/protocols/programs/__init__.py new file mode 100644 index 000000000..8d3bfa514 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/__init__.py @@ -0,0 +1,7 @@ +"""Generated on-chain program clients. + +Each subpackage is rendered by the codegen scripts under +``skills/pay-sdk-implementation/codegen`` from the Codama IDLs vendored at +``idl/``; do not edit by hand. Mirrors ``protocols/programs`` in the Go SDK +and ``rust/crates/programs`` in the Rust spine. +""" diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/__init__.py b/python/src/pay_kit/protocols/programs/paymentchannels/__init__.py new file mode 100644 index 000000000..503f00460 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/__init__.py @@ -0,0 +1,7 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/accounts/__init__.py b/python/src/pay_kit/protocols/programs/paymentchannels/accounts/__init__.py new file mode 100644 index 000000000..225d6c36c --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/accounts/__init__.py @@ -0,0 +1,11 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +from . import channel +from .channel import Channel, ChannelJSON +from . import closedChannel +from .closedChannel import ClosedChannel, ClosedChannelJSON diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/accounts/channel.py b/python/src/pay_kit/protocols/programs/paymentchannels/accounts/channel.py new file mode 100644 index 000000000..2c0f5973c --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/accounts/channel.py @@ -0,0 +1,187 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from anchorpy.borsh_extension import BorshPubkey +from anchorpy.error import AccountInvalidDiscriminator +from anchorpy.utils.rpc import get_multiple_accounts +from dataclasses import dataclass +from solana.rpc.async_api import AsyncClient +from solana.rpc.commitment import Commitment +from solders.pubkey import Pubkey as SolPubkey +from .. import types +from ..program_id import PAYMENT_CHANNELS_PROGRAM_ADDRESS + + +class ChannelJSON(typing.TypedDict): + version: int + bump: int + status: int + salt: int + deposit: int + settlement: types.settlementWatermarks.SettlementWatermarksJSON + closureStartedAt: int + payerWithdrawnAt: int + gracePeriod: int + distributionHash: list[int] + payer: str + payee: str + authorizedSigner: str + mint: str + +@dataclass +class Channel: + #fields + version: int + bump: int + status: int + salt: int + deposit: int + settlement: types.settlementWatermarks.SettlementWatermarks + closureStartedAt: int + payerWithdrawnAt: int + gracePeriod: int + distributionHash: list[int] + payer: SolPubkey + payee: SolPubkey + authorizedSigner: SolPubkey + mint: SolPubkey + + + + layout: typing.ClassVar = borsh.CStruct( + "version" /borsh.U8, + "bump" /borsh.U8, + "status" /borsh.U8, + "salt" /borsh.U64, + "deposit" /borsh.U64, + "settlement" /types.settlementWatermarks.SettlementWatermarks.layout, + "closureStartedAt" /borsh.I64, + "payerWithdrawnAt" /borsh.I64, + "gracePeriod" /borsh.U32, + "distributionHash" /borsh.U8[32], + "payer" /BorshPubkey, + "payee" /BorshPubkey, + "authorizedSigner" /BorshPubkey, + "mint" /BorshPubkey, + ) + + + + @classmethod + async def fetch( + cls, + conn: AsyncClient, + address: SolPubkey, + commitment: typing.Optional[Commitment] = None, + program_id: SolPubkey = PAYMENT_CHANNELS_PROGRAM_ADDRESS, + ) -> typing.Optional["Channel"]: + resp = await conn.get_account_info(address, commitment=commitment) + info = resp.value + if info is None: + return None + if info.owner != program_id: + raise ValueError("Account does not belong to this program") + bytes_data = info.data + return cls.decode(bytes_data) + + @classmethod + async def fetch_multiple( + cls, + conn: AsyncClient, + addresses: list[SolPubkey], + commitment: typing.Optional[Commitment] = None, + program_id: SolPubkey = PAYMENT_CHANNELS_PROGRAM_ADDRESS, + ) -> typing.List[typing.Optional["Channel"]]: + infos = await get_multiple_accounts(conn, addresses, commitment=commitment) + res: typing.List[typing.Optional["Channel"]] = [] + for info in infos: + if info is None: + res.append(None) + continue + if info.account.owner != program_id: + raise ValueError("Account does not belong to this program") + res.append(cls.decode(info.account.data)) + return res + + @classmethod + def decode(cls, data: bytes) -> "Channel": + dec = Channel.layout.parse(data) + return cls( + version=dec.version, + bump=dec.bump, + status=dec.status, + salt=dec.salt, + deposit=dec.deposit, + settlement=types.settlementWatermarks.SettlementWatermarks.from_decoded(dec.settlement), + closureStartedAt=dec.closureStartedAt, + payerWithdrawnAt=dec.payerWithdrawnAt, + gracePeriod=dec.gracePeriod, + distributionHash=dec.distributionHash, + payer=dec.payer, + payee=dec.payee, + authorizedSigner=dec.authorizedSigner, + mint=dec.mint, + ) + def to_encodable(self) -> dict[str, typing.Any]: + return { + "version": self.version, + "bump": self.bump, + "status": self.status, + "salt": self.salt, + "deposit": self.deposit, + "settlement": self.settlement.to_encodable(), + "closureStartedAt": self.closureStartedAt, + "payerWithdrawnAt": self.payerWithdrawnAt, + "gracePeriod": self.gracePeriod, + "distributionHash": self.distributionHash, + "payer": self.payer, + "payee": self.payee, + "authorizedSigner": self.authorizedSigner, + "mint": self.mint, + } + def to_json(self) -> ChannelJSON: + return { + "version": self.version, + "bump": self.bump, + "status": self.status, + "salt": self.salt, + "deposit": self.deposit, + "settlement": self.settlement.to_json(), + "closureStartedAt": self.closureStartedAt, + "payerWithdrawnAt": self.payerWithdrawnAt, + "gracePeriod": self.gracePeriod, + "distributionHash": self.distributionHash, + "payer": str(self.payer), + "payee": str(self.payee), + "authorizedSigner": str(self.authorizedSigner), + "mint": str(self.mint), + } + + @classmethod + def from_json(cls, obj: ChannelJSON) -> "Channel": + return cls( + version=obj["version"], + bump=obj["bump"], + status=obj["status"], + salt=obj["salt"], + deposit=obj["deposit"], + settlement=types.settlementWatermarks.SettlementWatermarks.from_json(obj["settlement"]), + closureStartedAt=obj["closureStartedAt"], + payerWithdrawnAt=obj["payerWithdrawnAt"], + gracePeriod=obj["gracePeriod"], + distributionHash=obj["distributionHash"], + payer=SolPubkey.from_string(obj["payer"]), + payee=SolPubkey.from_string(obj["payee"]), + authorizedSigner=SolPubkey.from_string(obj["authorizedSigner"]), + mint=SolPubkey.from_string(obj["mint"]), + ) + + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/accounts/closedChannel.py b/python/src/pay_kit/protocols/programs/paymentchannels/accounts/closedChannel.py new file mode 100644 index 000000000..92cd5fd55 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/accounts/closedChannel.py @@ -0,0 +1,88 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from anchorpy.error import AccountInvalidDiscriminator +from anchorpy.utils.rpc import get_multiple_accounts +from dataclasses import dataclass +from solana.rpc.async_api import AsyncClient +from solana.rpc.commitment import Commitment +from solders.pubkey import Pubkey as SolPubkey +from ..program_id import PAYMENT_CHANNELS_PROGRAM_ADDRESS + + +class ClosedChannelJSON(typing.TypedDict): + pass + +@dataclass +class ClosedChannel: + #fields + + + + layout: typing.ClassVar = borsh.CStruct( + ) + + + + @classmethod + async def fetch( + cls, + conn: AsyncClient, + address: SolPubkey, + commitment: typing.Optional[Commitment] = None, + program_id: SolPubkey = PAYMENT_CHANNELS_PROGRAM_ADDRESS, + ) -> typing.Optional["ClosedChannel"]: + resp = await conn.get_account_info(address, commitment=commitment) + info = resp.value + if info is None: + return None + if info.owner != program_id: + raise ValueError("Account does not belong to this program") + bytes_data = info.data + return cls.decode(bytes_data) + + @classmethod + async def fetch_multiple( + cls, + conn: AsyncClient, + addresses: list[SolPubkey], + commitment: typing.Optional[Commitment] = None, + program_id: SolPubkey = PAYMENT_CHANNELS_PROGRAM_ADDRESS, + ) -> typing.List[typing.Optional["ClosedChannel"]]: + infos = await get_multiple_accounts(conn, addresses, commitment=commitment) + res: typing.List[typing.Optional["ClosedChannel"]] = [] + for info in infos: + if info is None: + res.append(None) + continue + if info.account.owner != program_id: + raise ValueError("Account does not belong to this program") + res.append(cls.decode(info.account.data)) + return res + + @classmethod + def decode(cls, data: bytes) -> "ClosedChannel": + dec = ClosedChannel.layout.parse(data) + return cls( + ) + def to_encodable(self) -> dict[str, typing.Any]: + return { + } + def to_json(self) -> ClosedChannelJSON: + return { + } + + @classmethod + def from_json(cls, obj: ClosedChannelJSON) -> "ClosedChannel": + return cls( + ) + + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/errors/__init__.py b/python/src/pay_kit/protocols/programs/paymentchannels/errors/__init__.py new file mode 100644 index 000000000..577307932 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/errors/__init__.py @@ -0,0 +1,23 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import typing +from solders.pubkey import Pubkey as SolPubkey +from solana.rpc.core import RPCException +from anchorpy.error import extract_code_and_logs + +from . import paymentChannels +from ..program_id import PAYMENT_CHANNELS_PROGRAM_ADDRESS + +def from_tx_error( + error: RPCException, +) -> typing.Optional[paymentChannels.CustomError]: + err_info = error.args[0] + extracted = extract_code_and_logs(err_info, PAYMENT_CHANNELS_PROGRAM_ADDRESS) + if extracted is None: + return None + return paymentChannels.from_code(extracted[0]) diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/errors/paymentChannels.py b/python/src/pay_kit/protocols/programs/paymentchannels/errors/paymentChannels.py new file mode 100644 index 000000000..023befc00 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/errors/paymentChannels.py @@ -0,0 +1,682 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import typing +from anchorpy.error import ProgramError + +class NotImplemented(ProgramError): + def __init__(self) -> None: + super().__init__( + 0, "" + ) + + code = 0 + name = "NotImplemented" + msg = "" +class MissingRequiredSignature(ProgramError): + def __init__(self) -> None: + super().__init__( + 1, "" + ) + + code = 1 + name = "MissingRequiredSignature" + msg = "" +class InvalidChannelStatus(ProgramError): + def __init__(self) -> None: + super().__init__( + 2, "" + ) + + code = 2 + name = "InvalidChannelStatus" + msg = "" +class InvalidAccountDiscriminator(ProgramError): + def __init__(self) -> None: + super().__init__( + 3, "" + ) + + code = 3 + name = "InvalidAccountDiscriminator" + msg = "" +class UnsupportedChannelVersion(ProgramError): + def __init__(self) -> None: + super().__init__( + 4, "" + ) + + code = 4 + name = "UnsupportedChannelVersion" + msg = "" +class InvalidChannelPayer(ProgramError): + def __init__(self) -> None: + super().__init__( + 5, "" + ) + + code = 5 + name = "InvalidChannelPayer" + msg = "" +class InvalidChannelPayee(ProgramError): + def __init__(self) -> None: + super().__init__( + 6, "" + ) + + code = 6 + name = "InvalidChannelPayee" + msg = "" +class InvalidChannelMint(ProgramError): + def __init__(self) -> None: + super().__init__( + 7, "" + ) + + code = 7 + name = "InvalidChannelMint" + msg = "" +class InvalidEventAuthority(ProgramError): + def __init__(self) -> None: + super().__init__( + 8, "" + ) + + code = 8 + name = "InvalidEventAuthority" + msg = "" +class NotEnoughAccountKeys(ProgramError): + def __init__(self) -> None: + super().__init__( + 9, "" + ) + + code = 9 + name = "NotEnoughAccountKeys" + msg = "" +class ChannelAccountMismatch(ProgramError): + def __init__(self) -> None: + super().__init__( + 50, "" + ) + + code = 50 + name = "ChannelAccountMismatch" + msg = "" +class InvalidChannelTokenAccount(ProgramError): + def __init__(self) -> None: + super().__init__( + 51, "" + ) + + code = 51 + name = "InvalidChannelTokenAccount" + msg = "" +class InvalidChannelTokenExtensions(ProgramError): + def __init__(self) -> None: + super().__init__( + 52, "" + ) + + code = 52 + name = "InvalidChannelTokenExtensions" + msg = "" +class MintAccountMismatch(ProgramError): + def __init__(self) -> None: + super().__init__( + 53, "" + ) + + code = 53 + name = "MintAccountMismatch" + msg = "" +class InvalidMintTokenProgram(ProgramError): + def __init__(self) -> None: + super().__init__( + 54, "" + ) + + code = 54 + name = "InvalidMintTokenProgram" + msg = "" +class MalformedMintTokenAccountData(ProgramError): + def __init__(self) -> None: + super().__init__( + 55, "" + ) + + code = 55 + name = "MalformedMintTokenAccountData" + msg = "" +class MalformedMintTokenExtensions(ProgramError): + def __init__(self) -> None: + super().__init__( + 56, "" + ) + + code = 56 + name = "MalformedMintTokenExtensions" + msg = "" +class PayerAccountMismatch(ProgramError): + def __init__(self) -> None: + super().__init__( + 57, "" + ) + + code = 57 + name = "PayerAccountMismatch" + msg = "" +class InvalidPayerTokenAccount(ProgramError): + def __init__(self) -> None: + super().__init__( + 58, "" + ) + + code = 58 + name = "InvalidPayerTokenAccount" + msg = "" +class InvalidPayerTokenExtensions(ProgramError): + def __init__(self) -> None: + super().__init__( + 59, "" + ) + + code = 59 + name = "InvalidPayerTokenExtensions" + msg = "" +class PayeeAccountMismatch(ProgramError): + def __init__(self) -> None: + super().__init__( + 60, "" + ) + + code = 60 + name = "PayeeAccountMismatch" + msg = "" +class InvalidPayeeTokenAccount(ProgramError): + def __init__(self) -> None: + super().__init__( + 61, "" + ) + + code = 61 + name = "InvalidPayeeTokenAccount" + msg = "" +class InvalidPayeeTokenExtensions(ProgramError): + def __init__(self) -> None: + super().__init__( + 62, "" + ) + + code = 62 + name = "InvalidPayeeTokenExtensions" + msg = "" +class DepositMustBeNonZero(ProgramError): + def __init__(self) -> None: + super().__init__( + 200, "" + ) + + code = 200 + name = "DepositMustBeNonZero" + msg = "" +class GracePeriodMustBeNonZero(ProgramError): + def __init__(self) -> None: + super().__init__( + 201, "" + ) + + code = 201 + name = "GracePeriodMustBeNonZero" + msg = "" +class MissingEd25519Verification(ProgramError): + def __init__(self) -> None: + super().__init__( + 230, "" + ) + + code = 230 + name = "MissingEd25519Verification" + msg = "" +class MalformedEd25519Instruction(ProgramError): + def __init__(self) -> None: + super().__init__( + 231, "" + ) + + code = 231 + name = "MalformedEd25519Instruction" + msg = "" +class VoucherChannelMismatch(ProgramError): + def __init__(self) -> None: + super().__init__( + 232, "" + ) + + code = 232 + name = "VoucherChannelMismatch" + msg = "" +class VoucherExpired(ProgramError): + def __init__(self) -> None: + super().__init__( + 233, "" + ) + + code = 233 + name = "VoucherExpired" + msg = "" +class VoucherWatermarkNotMonotonic(ProgramError): + def __init__(self) -> None: + super().__init__( + 234, "" + ) + + code = 234 + name = "VoucherWatermarkNotMonotonic" + msg = "" +class VoucherOverDeposit(ProgramError): + def __init__(self) -> None: + super().__init__( + 235, "" + ) + + code = 235 + name = "VoucherOverDeposit" + msg = "" +class VoucherMessageMismatch(ProgramError): + def __init__(self) -> None: + super().__init__( + 236, "" + ) + + code = 236 + name = "VoucherMessageMismatch" + msg = "" +class VoucherSignerMismatch(ProgramError): + def __init__(self) -> None: + super().__init__( + 237, "" + ) + + code = 237 + name = "VoucherSignerMismatch" + msg = "" +class InvalidRecipientCount(ProgramError): + def __init__(self) -> None: + super().__init__( + 260, "" + ) + + code = 260 + name = "InvalidRecipientCount" + msg = "" +class InvalidSplitConfig(ProgramError): + def __init__(self) -> None: + super().__init__( + 261, "" + ) + + code = 261 + name = "InvalidSplitConfig" + msg = "" +class DistributionPartsOverflow(ProgramError): + def __init__(self) -> None: + super().__init__( + 262, "" + ) + + code = 262 + name = "DistributionPartsOverflow" + msg = "" +class DuplicateRecipient(ProgramError): + def __init__(self) -> None: + super().__init__( + 263, "" + ) + + code = 263 + name = "DuplicateRecipient" + msg = "" +class DistributionAmountOverflow(ProgramError): + def __init__(self) -> None: + super().__init__( + 264, "" + ) + + code = 264 + name = "DistributionAmountOverflow" + msg = "" +class DistributionPreimageLengthOverflow(ProgramError): + def __init__(self) -> None: + super().__init__( + 265, "" + ) + + code = 265 + name = "DistributionPreimageLengthOverflow" + msg = "" +class ChannelAddressMismatch(ProgramError): + def __init__(self) -> None: + super().__init__( + 2000, "" + ) + + code = 2000 + name = "ChannelAddressMismatch" + msg = "" +class PayerPayeeMustDiffer(ProgramError): + def __init__(self) -> None: + super().__init__( + 2001, "" + ) + + code = 2001 + name = "PayerPayeeMustDiffer" + msg = "" +class InvalidAuthorizedSigner(ProgramError): + def __init__(self) -> None: + super().__init__( + 2002, "" + ) + + code = 2002 + name = "InvalidAuthorizedSigner" + msg = "" +class TopUpDepositOverflow(ProgramError): + def __init__(self) -> None: + super().__init__( + 2100, "" + ) + + code = 2100 + name = "TopUpDepositOverflow" + msg = "" +class FinalizeDeadlineOverflow(ProgramError): + def __init__(self) -> None: + super().__init__( + 2200, "" + ) + + code = 2200 + name = "FinalizeDeadlineOverflow" + msg = "" +class PayerAlreadyWithdrawn(ProgramError): + def __init__(self) -> None: + super().__init__( + 2300, "" + ) + + code = 2300 + name = "PayerAlreadyWithdrawn" + msg = "" +class RefundCalculationOverflow(ProgramError): + def __init__(self) -> None: + super().__init__( + 2301, "" + ) + + code = 2301 + name = "RefundCalculationOverflow" + msg = "" +class ChannelNotDistributable(ProgramError): + def __init__(self) -> None: + super().__init__( + 2400, "" + ) + + code = 2400 + name = "ChannelNotDistributable" + msg = "" +class TreasuryAccountMismatch(ProgramError): + def __init__(self) -> None: + super().__init__( + 2401, "" + ) + + code = 2401 + name = "TreasuryAccountMismatch" + msg = "" +class InvalidTreasuryTokenAccount(ProgramError): + def __init__(self) -> None: + super().__init__( + 2402, "" + ) + + code = 2402 + name = "InvalidTreasuryTokenAccount" + msg = "" +class InvalidTreasuryTokenExtensions(ProgramError): + def __init__(self) -> None: + super().__init__( + 2403, "" + ) + + code = 2403 + name = "InvalidTreasuryTokenExtensions" + msg = "" +class RecipientAccountMismatch(ProgramError): + def __init__(self) -> None: + super().__init__( + 2404, "" + ) + + code = 2404 + name = "RecipientAccountMismatch" + msg = "" +class InvalidRecipientTokenAccount(ProgramError): + def __init__(self) -> None: + super().__init__( + 2405, "" + ) + + code = 2405 + name = "InvalidRecipientTokenAccount" + msg = "" +class InvalidRecipientTokenExtensions(ProgramError): + def __init__(self) -> None: + super().__init__( + 2406, "" + ) + + code = 2406 + name = "InvalidRecipientTokenExtensions" + msg = "" +class InvalidDistributionHash(ProgramError): + def __init__(self) -> None: + super().__init__( + 2407, "" + ) + + code = 2407 + name = "InvalidDistributionHash" + msg = "" +class NothingToDistribute(ProgramError): + def __init__(self) -> None: + super().__init__( + 2408, "" + ) + + code = 2408 + name = "NothingToDistribute" + msg = "" +class RecipientAccountCountMismatch(ProgramError): + def __init__(self) -> None: + super().__init__( + 2409, "" + ) + + code = 2409 + name = "RecipientAccountCountMismatch" + msg = "" +class DistributePoolOverflow(ProgramError): + def __init__(self) -> None: + super().__init__( + 2410, "" + ) + + code = 2410 + name = "DistributePoolOverflow" + msg = "" +class DistributeBalanceCalculationOverflow(ProgramError): + def __init__(self) -> None: + super().__init__( + 2411, "" + ) + + code = 2411 + name = "DistributeBalanceCalculationOverflow" + msg = "" +class DistributePayerBalanceOverflow(ProgramError): + def __init__(self) -> None: + super().__init__( + 2412, "" + ) + + code = 2412 + name = "DistributePayerBalanceOverflow" + msg = "" +class DistributeTransferQueueOverflow(ProgramError): + def __init__(self) -> None: + super().__init__( + 2413, "" + ) + + code = 2413 + name = "DistributeTransferQueueOverflow" + msg = "" + +CustomError = typing.Union[ + NotImplemented, + MissingRequiredSignature, + InvalidChannelStatus, + InvalidAccountDiscriminator, + UnsupportedChannelVersion, + InvalidChannelPayer, + InvalidChannelPayee, + InvalidChannelMint, + InvalidEventAuthority, + NotEnoughAccountKeys, + ChannelAccountMismatch, + InvalidChannelTokenAccount, + InvalidChannelTokenExtensions, + MintAccountMismatch, + InvalidMintTokenProgram, + MalformedMintTokenAccountData, + MalformedMintTokenExtensions, + PayerAccountMismatch, + InvalidPayerTokenAccount, + InvalidPayerTokenExtensions, + PayeeAccountMismatch, + InvalidPayeeTokenAccount, + InvalidPayeeTokenExtensions, + DepositMustBeNonZero, + GracePeriodMustBeNonZero, + MissingEd25519Verification, + MalformedEd25519Instruction, + VoucherChannelMismatch, + VoucherExpired, + VoucherWatermarkNotMonotonic, + VoucherOverDeposit, + VoucherMessageMismatch, + VoucherSignerMismatch, + InvalidRecipientCount, + InvalidSplitConfig, + DistributionPartsOverflow, + DuplicateRecipient, + DistributionAmountOverflow, + DistributionPreimageLengthOverflow, + ChannelAddressMismatch, + PayerPayeeMustDiffer, + InvalidAuthorizedSigner, + TopUpDepositOverflow, + FinalizeDeadlineOverflow, + PayerAlreadyWithdrawn, + RefundCalculationOverflow, + ChannelNotDistributable, + TreasuryAccountMismatch, + InvalidTreasuryTokenAccount, + InvalidTreasuryTokenExtensions, + RecipientAccountMismatch, + InvalidRecipientTokenAccount, + InvalidRecipientTokenExtensions, + InvalidDistributionHash, + NothingToDistribute, + RecipientAccountCountMismatch, + DistributePoolOverflow, + DistributeBalanceCalculationOverflow, + DistributePayerBalanceOverflow, + DistributeTransferQueueOverflow, + ] +CUSTOM_ERROR_MAP: dict[int, CustomError] = { + 0: NotImplemented(), + 1: MissingRequiredSignature(), + 2: InvalidChannelStatus(), + 3: InvalidAccountDiscriminator(), + 4: UnsupportedChannelVersion(), + 5: InvalidChannelPayer(), + 6: InvalidChannelPayee(), + 7: InvalidChannelMint(), + 8: InvalidEventAuthority(), + 9: NotEnoughAccountKeys(), + 50: ChannelAccountMismatch(), + 51: InvalidChannelTokenAccount(), + 52: InvalidChannelTokenExtensions(), + 53: MintAccountMismatch(), + 54: InvalidMintTokenProgram(), + 55: MalformedMintTokenAccountData(), + 56: MalformedMintTokenExtensions(), + 57: PayerAccountMismatch(), + 58: InvalidPayerTokenAccount(), + 59: InvalidPayerTokenExtensions(), + 60: PayeeAccountMismatch(), + 61: InvalidPayeeTokenAccount(), + 62: InvalidPayeeTokenExtensions(), + 200: DepositMustBeNonZero(), + 201: GracePeriodMustBeNonZero(), + 230: MissingEd25519Verification(), + 231: MalformedEd25519Instruction(), + 232: VoucherChannelMismatch(), + 233: VoucherExpired(), + 234: VoucherWatermarkNotMonotonic(), + 235: VoucherOverDeposit(), + 236: VoucherMessageMismatch(), + 237: VoucherSignerMismatch(), + 260: InvalidRecipientCount(), + 261: InvalidSplitConfig(), + 262: DistributionPartsOverflow(), + 263: DuplicateRecipient(), + 264: DistributionAmountOverflow(), + 265: DistributionPreimageLengthOverflow(), + 2000: ChannelAddressMismatch(), + 2001: PayerPayeeMustDiffer(), + 2002: InvalidAuthorizedSigner(), + 2100: TopUpDepositOverflow(), + 2200: FinalizeDeadlineOverflow(), + 2300: PayerAlreadyWithdrawn(), + 2301: RefundCalculationOverflow(), + 2400: ChannelNotDistributable(), + 2401: TreasuryAccountMismatch(), + 2402: InvalidTreasuryTokenAccount(), + 2403: InvalidTreasuryTokenExtensions(), + 2404: RecipientAccountMismatch(), + 2405: InvalidRecipientTokenAccount(), + 2406: InvalidRecipientTokenExtensions(), + 2407: InvalidDistributionHash(), + 2408: NothingToDistribute(), + 2409: RecipientAccountCountMismatch(), + 2410: DistributePoolOverflow(), + 2411: DistributeBalanceCalculationOverflow(), + 2412: DistributePayerBalanceOverflow(), + 2413: DistributeTransferQueueOverflow(), +} + +def from_code(code: int) -> typing.Optional[CustomError]: + maybe_err = CUSTOM_ERROR_MAP.get(code) + if maybe_err is None: + return None + return maybe_err + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/instructions/__init__.py b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/__init__.py new file mode 100644 index 000000000..7444c1e85 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/__init__.py @@ -0,0 +1,8 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/instructions/distribute.py b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/distribute.py new file mode 100644 index 000000000..fdbb6bb50 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/distribute.py @@ -0,0 +1,79 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from solders.instruction import AccountMeta, Instruction +from solders.pubkey import Pubkey as SolPubkey +from .. import types +from ..program_id import PAYMENT_CHANNELS_PROGRAM_ADDRESS +class DistributeArgs(typing.TypedDict): + distributeArgs:types.distributeArgs.DistributeArgs + + +layout = borsh.CStruct( + "distributeArgs" /types.distributeArgs.DistributeArgs.layout, + ) + + +class DistributeAccounts(typing.TypedDict): + channel:SolPubkey + payer:SolPubkey + channelTokenAccount:SolPubkey + payerTokenAccount:SolPubkey + payeeTokenAccount:SolPubkey + treasuryTokenAccount:SolPubkey + mint:SolPubkey + tokenProgram:SolPubkey + eventAuthority:SolPubkey + selfProgram:SolPubkey + +def Distribute( + args: DistributeArgs, + accounts: DistributeAccounts, + program_id: SolPubkey = PAYMENT_CHANNELS_PROGRAM_ADDRESS, + remaining_accounts: typing.Optional[typing.List[AccountMeta]] = None, +) ->Instruction: + keys: list[AccountMeta] = [ + AccountMeta(pubkey=accounts["channel"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["payer"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["channelTokenAccount"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["payerTokenAccount"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["payeeTokenAccount"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["treasuryTokenAccount"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["mint"], is_signer=False, is_writable=False), + AccountMeta(pubkey=accounts["tokenProgram"], is_signer=False, is_writable=False), + AccountMeta(pubkey=accounts["eventAuthority"], is_signer=False, is_writable=False), + AccountMeta(pubkey=accounts["selfProgram"], is_signer=False, is_writable=False), + ] + if remaining_accounts is not None: + keys += remaining_accounts + identifier = b"\x07" + + + encoded_args = layout.build({ + "distributeArgs":args["distributeArgs"].to_encodable(), + }) + data = identifier + encoded_args + return Instruction(program_id,data,keys) + + +def find_EventAuthority() -> typing.Tuple[SolPubkey, int]: + seeds = [ + b"event_authority", + ] + + address, bump = SolPubkey.find_program_address(seeds, + PAYMENT_CHANNELS_PROGRAM_ADDRESS + ) + + return address, bump + + + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/instructions/emitEvent.py b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/emitEvent.py new file mode 100644 index 000000000..8f818bccb --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/emitEvent.py @@ -0,0 +1,46 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import typing +from solders.instruction import AccountMeta, Instruction +from solders.pubkey import Pubkey as SolPubkey +from ..program_id import PAYMENT_CHANNELS_PROGRAM_ADDRESS + +class EmitEventAccounts(typing.TypedDict): + eventAuthority:SolPubkey + +def EmitEvent( + accounts: EmitEventAccounts, + program_id: SolPubkey = PAYMENT_CHANNELS_PROGRAM_ADDRESS, + remaining_accounts: typing.Optional[typing.List[AccountMeta]] = None, +) ->Instruction: + keys: list[AccountMeta] = [ + AccountMeta(pubkey=accounts["eventAuthority"], is_signer=True, is_writable=False), + ] + if remaining_accounts is not None: + keys += remaining_accounts + identifier = b"\xe4" + + + encoded_args = b"" + data = identifier + encoded_args + return Instruction(program_id,data,keys) + + +def find_EventAuthority() -> typing.Tuple[SolPubkey, int]: + seeds = [ + b"event_authority", + ] + + address, bump = SolPubkey.find_program_address(seeds, + PAYMENT_CHANNELS_PROGRAM_ADDRESS + ) + + return address, bump + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/instructions/finalize.py b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/finalize.py new file mode 100644 index 000000000..bec0278d3 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/finalize.py @@ -0,0 +1,34 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import typing +from solders.instruction import AccountMeta, Instruction +from solders.pubkey import Pubkey as SolPubkey +from ..program_id import PAYMENT_CHANNELS_PROGRAM_ADDRESS + +class FinalizeAccounts(typing.TypedDict): + channel:SolPubkey + +def Finalize( + accounts: FinalizeAccounts, + program_id: SolPubkey = PAYMENT_CHANNELS_PROGRAM_ADDRESS, + remaining_accounts: typing.Optional[typing.List[AccountMeta]] = None, +) ->Instruction: + keys: list[AccountMeta] = [ + AccountMeta(pubkey=accounts["channel"], is_signer=False, is_writable=True), + ] + if remaining_accounts is not None: + keys += remaining_accounts + identifier = b"\x06" + + + encoded_args = b"" + data = identifier + encoded_args + return Instruction(program_id,data,keys) + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/instructions/open.py b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/open.py new file mode 100644 index 000000000..b0e872912 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/open.py @@ -0,0 +1,87 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from solders.instruction import AccountMeta, Instruction +from solders.pubkey import Pubkey as SolPubkey +from .. import types +from ..program_id import PAYMENT_CHANNELS_PROGRAM_ADDRESS +class OpenArgs(typing.TypedDict): + openArgs:types.openArgs.OpenArgs + + +layout = borsh.CStruct( + "openArgs" /types.openArgs.OpenArgs.layout, + ) + + +class OpenAccounts(typing.TypedDict): + payer:SolPubkey + payee:SolPubkey + mint:SolPubkey + authorizedSigner:SolPubkey + channel:SolPubkey + payerTokenAccount:SolPubkey + channelTokenAccount:SolPubkey + tokenProgram:SolPubkey + systemProgram:SolPubkey + rent:SolPubkey + associatedTokenProgram:SolPubkey + eventAuthority:SolPubkey + selfProgram:SolPubkey + +def Open( + args: OpenArgs, + accounts: OpenAccounts, + program_id: SolPubkey = PAYMENT_CHANNELS_PROGRAM_ADDRESS, + remaining_accounts: typing.Optional[typing.List[AccountMeta]] = None, +) ->Instruction: + keys: list[AccountMeta] = [ + AccountMeta(pubkey=accounts["payer"], is_signer=True, is_writable=True), + AccountMeta(pubkey=accounts["payee"], is_signer=False, is_writable=False), + AccountMeta(pubkey=accounts["mint"], is_signer=False, is_writable=False), + AccountMeta(pubkey=accounts["authorizedSigner"], is_signer=False, is_writable=False), + AccountMeta(pubkey=accounts["channel"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["payerTokenAccount"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["channelTokenAccount"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["tokenProgram"], is_signer=False, is_writable=False), + AccountMeta(pubkey=accounts["systemProgram"], is_signer=False, is_writable=False), + AccountMeta(pubkey=accounts["rent"], is_signer=False, is_writable=False), + AccountMeta(pubkey=accounts["associatedTokenProgram"], is_signer=False, is_writable=False), + AccountMeta(pubkey=accounts["eventAuthority"], is_signer=False, is_writable=False), + AccountMeta(pubkey=accounts["selfProgram"], is_signer=False, is_writable=False), + ] + if remaining_accounts is not None: + keys += remaining_accounts + identifier = b"\x01" + + + encoded_args = layout.build({ + "openArgs":args["openArgs"].to_encodable(), + }) + data = identifier + encoded_args + return Instruction(program_id,data,keys) + + + + +def find_EventAuthority() -> typing.Tuple[SolPubkey, int]: + seeds = [ + b"event_authority", + ] + + address, bump = SolPubkey.find_program_address(seeds, + PAYMENT_CHANNELS_PROGRAM_ADDRESS + ) + + return address, bump + + + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/instructions/requestClose.py b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/requestClose.py new file mode 100644 index 000000000..1e4b5027e --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/requestClose.py @@ -0,0 +1,36 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import typing +from solders.instruction import AccountMeta, Instruction +from solders.pubkey import Pubkey as SolPubkey +from ..program_id import PAYMENT_CHANNELS_PROGRAM_ADDRESS + +class RequestCloseAccounts(typing.TypedDict): + payer:SolPubkey + channel:SolPubkey + +def RequestClose( + accounts: RequestCloseAccounts, + program_id: SolPubkey = PAYMENT_CHANNELS_PROGRAM_ADDRESS, + remaining_accounts: typing.Optional[typing.List[AccountMeta]] = None, +) ->Instruction: + keys: list[AccountMeta] = [ + AccountMeta(pubkey=accounts["payer"], is_signer=True, is_writable=False), + AccountMeta(pubkey=accounts["channel"], is_signer=False, is_writable=True), + ] + if remaining_accounts is not None: + keys += remaining_accounts + identifier = b"\x05" + + + encoded_args = b"" + data = identifier + encoded_args + return Instruction(program_id,data,keys) + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/instructions/settle.py b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/settle.py new file mode 100644 index 000000000..10f49ad4e --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/settle.py @@ -0,0 +1,49 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from solders.instruction import AccountMeta, Instruction +from solders.pubkey import Pubkey as SolPubkey +from .. import types +from ..program_id import PAYMENT_CHANNELS_PROGRAM_ADDRESS +class SettleArgs(typing.TypedDict): + settleArgs:types.settleArgs.SettleArgs + + +layout = borsh.CStruct( + "settleArgs" /types.settleArgs.SettleArgs.layout, + ) + + +class SettleAccounts(typing.TypedDict): + channel:SolPubkey + instructionsSysvar:SolPubkey + +def Settle( + args: SettleArgs, + accounts: SettleAccounts, + program_id: SolPubkey = PAYMENT_CHANNELS_PROGRAM_ADDRESS, + remaining_accounts: typing.Optional[typing.List[AccountMeta]] = None, +) ->Instruction: + keys: list[AccountMeta] = [ + AccountMeta(pubkey=accounts["channel"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["instructionsSysvar"], is_signer=False, is_writable=False), + ] + if remaining_accounts is not None: + keys += remaining_accounts + identifier = b"\x02" + + + encoded_args = layout.build({ + "settleArgs":args["settleArgs"].to_encodable(), + }) + data = identifier + encoded_args + return Instruction(program_id,data,keys) + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/instructions/settleAndFinalize.py b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/settleAndFinalize.py new file mode 100644 index 000000000..55280c35e --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/settleAndFinalize.py @@ -0,0 +1,51 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from solders.instruction import AccountMeta, Instruction +from solders.pubkey import Pubkey as SolPubkey +from .. import types +from ..program_id import PAYMENT_CHANNELS_PROGRAM_ADDRESS +class SettleAndFinalizeArgs(typing.TypedDict): + settleAndFinalizeArgs:types.settleAndFinalizeArgs.SettleAndFinalizeArgs + + +layout = borsh.CStruct( + "settleAndFinalizeArgs" /types.settleAndFinalizeArgs.SettleAndFinalizeArgs.layout, + ) + + +class SettleAndFinalizeAccounts(typing.TypedDict): + merchant:SolPubkey + channel:SolPubkey + instructionsSysvar:SolPubkey + +def SettleAndFinalize( + args: SettleAndFinalizeArgs, + accounts: SettleAndFinalizeAccounts, + program_id: SolPubkey = PAYMENT_CHANNELS_PROGRAM_ADDRESS, + remaining_accounts: typing.Optional[typing.List[AccountMeta]] = None, +) ->Instruction: + keys: list[AccountMeta] = [ + AccountMeta(pubkey=accounts["merchant"], is_signer=True, is_writable=False), + AccountMeta(pubkey=accounts["channel"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["instructionsSysvar"], is_signer=False, is_writable=False), + ] + if remaining_accounts is not None: + keys += remaining_accounts + identifier = b"\x04" + + + encoded_args = layout.build({ + "settleAndFinalizeArgs":args["settleAndFinalizeArgs"].to_encodable(), + }) + data = identifier + encoded_args + return Instruction(program_id,data,keys) + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/instructions/topUp.py b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/topUp.py new file mode 100644 index 000000000..01fdae168 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/topUp.py @@ -0,0 +1,57 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from solders.instruction import AccountMeta, Instruction +from solders.pubkey import Pubkey as SolPubkey +from .. import types +from ..program_id import PAYMENT_CHANNELS_PROGRAM_ADDRESS +class TopUpArgs(typing.TypedDict): + topUpArgs:types.topUpArgs.TopUpArgs + + +layout = borsh.CStruct( + "topUpArgs" /types.topUpArgs.TopUpArgs.layout, + ) + + +class TopUpAccounts(typing.TypedDict): + payer:SolPubkey + channel:SolPubkey + payerTokenAccount:SolPubkey + channelTokenAccount:SolPubkey + mint:SolPubkey + tokenProgram:SolPubkey + +def TopUp( + args: TopUpArgs, + accounts: TopUpAccounts, + program_id: SolPubkey = PAYMENT_CHANNELS_PROGRAM_ADDRESS, + remaining_accounts: typing.Optional[typing.List[AccountMeta]] = None, +) ->Instruction: + keys: list[AccountMeta] = [ + AccountMeta(pubkey=accounts["payer"], is_signer=True, is_writable=True), + AccountMeta(pubkey=accounts["channel"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["payerTokenAccount"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["channelTokenAccount"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["mint"], is_signer=False, is_writable=False), + AccountMeta(pubkey=accounts["tokenProgram"], is_signer=False, is_writable=False), + ] + if remaining_accounts is not None: + keys += remaining_accounts + identifier = b"\x03" + + + encoded_args = layout.build({ + "topUpArgs":args["topUpArgs"].to_encodable(), + }) + data = identifier + encoded_args + return Instruction(program_id,data,keys) + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/instructions/withdrawPayer.py b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/withdrawPayer.py new file mode 100644 index 000000000..f4c990a83 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/instructions/withdrawPayer.py @@ -0,0 +1,44 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import typing +from solders.instruction import AccountMeta, Instruction +from solders.pubkey import Pubkey as SolPubkey +from ..program_id import PAYMENT_CHANNELS_PROGRAM_ADDRESS + +class WithdrawPayerAccounts(typing.TypedDict): + payer:SolPubkey + channel:SolPubkey + channelTokenAccount:SolPubkey + payerTokenAccount:SolPubkey + mint:SolPubkey + tokenProgram:SolPubkey + +def WithdrawPayer( + accounts: WithdrawPayerAccounts, + program_id: SolPubkey = PAYMENT_CHANNELS_PROGRAM_ADDRESS, + remaining_accounts: typing.Optional[typing.List[AccountMeta]] = None, +) ->Instruction: + keys: list[AccountMeta] = [ + AccountMeta(pubkey=accounts["payer"], is_signer=True, is_writable=False), + AccountMeta(pubkey=accounts["channel"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["channelTokenAccount"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["payerTokenAccount"], is_signer=False, is_writable=True), + AccountMeta(pubkey=accounts["mint"], is_signer=False, is_writable=False), + AccountMeta(pubkey=accounts["tokenProgram"], is_signer=False, is_writable=False), + ] + if remaining_accounts is not None: + keys += remaining_accounts + identifier = b"\x08" + + + encoded_args = b"" + data = identifier + encoded_args + return Instruction(program_id,data,keys) + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/pdas/index.py b/python/src/pay_kit/protocols/programs/paymentchannels/pdas/index.py new file mode 100644 index 000000000..e661bbb09 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/pdas/index.py @@ -0,0 +1,25 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import typing + +from solders.pubkey import Pubkey as SolPubkey + +from ..program_id import PAYMENT_CHANNELS_PROGRAM_ADDRESS + +def find_event_authority_pda() -> typing.Tuple[SolPubkey, int]: + seeds = [ + b"event_authority", + ] + + address, bump = SolPubkey.find_program_address(seeds, + PAYMENT_CHANNELS_PROGRAM_ADDRESS + ) + + return address, bump + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/program_id.py b/python/src/pay_kit/protocols/programs/paymentchannels/program_id.py new file mode 100644 index 000000000..ca62534c2 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/program_id.py @@ -0,0 +1,11 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +from solders.pubkey import Pubkey + +PAYMENT_CHANNELS_PROGRAM_ADDRESS =Pubkey.from_string("CQAyft83tN1w2bRofB5PZ79eVDU2xZUVo43LU1qL4zRg") + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/shared/__init__.py b/python/src/pay_kit/protocols/programs/paymentchannels/shared/__init__.py new file mode 100644 index 000000000..22b553e2b --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/shared/__init__.py @@ -0,0 +1,8 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +from .extension import EnumForCodegenU16,EnumForCodegenU32,StringU8,StringU64,OptionU32,RemainderOption,HiddenPrefixAdapter,FixedSizeString,FixedSizeBytes,PreOffset,ZeroableOption,SizePrefix diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/shared/extension.py b/python/src/pay_kit/protocols/programs/paymentchannels/shared/extension.py new file mode 100644 index 000000000..92c7851c5 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/shared/extension.py @@ -0,0 +1,280 @@ +"""Extensions to the Borsh spec for Solana-specific types.""" +from os import environ +from typing import Any,cast +import io +from solders.pubkey import Pubkey +import borsh_construct as borsh +from typing import Any, Dict, Type, TypeVar, cast,List,Tuple +from construct import ( + Adapter, + Construct, + Const, + Default, + GreedyBytes, + PaddedString, + Padded, + Padding, + Prefixed, + Renamed, + Switch, + IfThenElse, + PrefixedArray, + Optional, + Struct, + Pointer, + evaluate, + stream_tell, + stream_seek, +) +from typing import T + +U64Bytes = Prefixed(borsh.U64, GreedyBytes) +U8Bytes = Prefixed(borsh.U8, GreedyBytes) + +class _String64(Adapter): + def __init__(self) -> None: + super().__init__(U64Bytes) # type: ignore + + def _decode(self, obj: bytes, context, path) -> str: + return obj.decode("utf8") + + def _encode(self, obj: str, context, path) -> bytes: + return bytes(obj, "utf8") +class _String8(Adapter): + def __init__(self) -> None: + super().__init__(GreedyBytes) # type: ignore + + def _decode(self, obj: bytes, context, path) -> str: + return obj.decode("utf8") + + def _encode(self, obj: str, context, path) -> bytes: + #print("Encoding string:", obj) + #return bytes(obj.encode("utf8")) + return bytes([ord(obj)]) #bytes(bytes([ord(obj)]), "utf8") + +StringU64=_String64() +StringU8=_String8() + + +class HiddenPrefixAdapter(Adapter): + #prefix = None + def __init__(self,padding: borsh.TupleStruct,subcon: Construct): + #self.prefix = padding + prefix_struct = borsh.CStruct( + "prefix"/padding, + "data"/subcon, + ) + super().__init__(prefix_struct) + + def _decode(self, obj, context, path) -> Any: + return obj["data"] + + def _encode(self, obj, context, path) -> Any: + return {"data": obj} + +class HiddenSuffixAdapter(Adapter): + suffix = None + def __init__(self,padding,subcon: Construct): + self.suffix = padding + suffix_struct = borsh.CStruct( + "suffix"/borsh.U8[len(padding)], + "data"/subcon, + ) + super().__init__(suffix_struct) + + def _decode(self, obj, context, path) -> Any: + return obj["data"] + + def _encode(self, obj, context, path) -> dict: + return { "data": obj, "suffix": self.suffix} + +class OptionU32(Adapter): + _discriminator_key = "discriminator" + _value_key = "value" + + def __init__(self, subcon: Construct) -> None: + option_struct = borsh.CStruct( + self._discriminator_key / borsh.U32, + self._value_key + / IfThenElse( + lambda this: this[self._discriminator_key] == 0, + Padding(subcon.sizeof()), + subcon, + ), + ) + super().__init__(option_struct) # type: ignore + + def _decode(self, obj, context, path) -> Any: + discriminator = obj[self._discriminator_key] + return None if discriminator == 0 else obj[self._value_key] + + def _encode(self, obj, context, path) -> dict: + discriminator = 0 if obj is None else 1 + return {self._discriminator_key: discriminator, self._value_key: obj} + + +RemainderOption=Optional + +class EnumForCodegenU16(Adapter): + _index_key = "index" + _value_key = "value" + + def __init__(self, *variants: "Renamed[borsh.CStruct, borsh.CStruct]") -> None: + """Init enum.""" + switch_cases: dict[int, "Renamed[borsh.CStruct, borsh.CStruct]"] = {} + variant_name_to_index: dict[str, int] = {} + index_to_variant_name: dict[int, str] = {} + for idx, parser in enumerate(variants): + switch_cases[idx] = parser + name = cast(str, parser.name) + variant_name_to_index[name] = idx + index_to_variant_name[idx] = name + enum_struct = borsh.CStruct( + self._index_key /borsh.U16, + self._value_key + / Switch(lambda this: this.index, cast(dict[int, Construct], switch_cases)), + ) + super().__init__(enum_struct) # type: ignore + self.variant_name_to_index = variant_name_to_index + self.index_to_variant_name = index_to_variant_name + + def _decode(self, obj: borsh.CStruct, context, path) -> dict[str, Any]: + index = obj.index + variant_name = self.index_to_variant_name[index] + return {variant_name: obj.value} + + def _encode(self, obj: dict[str, Any], context, path) -> dict[str, Any]: + variant_name = list(obj.keys())[0] + index = self.variant_name_to_index[variant_name] + return {self._index_key: index, self._value_key: obj[variant_name]} + + +class EnumForCodegenU32(Adapter): + _index_key = "index" + _value_key = "value" + + def __init__(self, *variants: "Renamed[borsh.CStruct, borsh.CStruct]") -> None: + """Init enum.""" + switch_cases: dict[int, "Renamed[borsh.CStruct, borsh.CStruct]"] = {} + variant_name_to_index: dict[str, int] = {} + index_to_variant_name: dict[int, str] = {} + for idx, parser in enumerate(variants): + switch_cases[idx] = parser + name = cast(str, parser.name) + variant_name_to_index[name] = idx + index_to_variant_name[idx] = name + enum_struct = borsh.CStruct( + self._index_key /borsh.U32, + self._value_key + / Switch(lambda this: this.index, cast(dict[int, Construct], switch_cases)), + ) + super().__init__(enum_struct) # type: ignore + self.variant_name_to_index = variant_name_to_index + self.index_to_variant_name = index_to_variant_name + + def _decode(self, obj: borsh.CStruct, context, path) -> dict[str, Any]: + index = obj.index + variant_name = self.index_to_variant_name[index] + return {variant_name: obj.value} + + def _encode(self, obj: dict[str, Any], context, path) -> dict[str, Any]: + variant_name = list(obj.keys())[0] + index = self.variant_name_to_index[variant_name] + return {self._index_key: index, self._value_key: obj[variant_name]} + +FixedSizeString=PaddedString +FixedSizeBytes=Padded + +class SolMapU32(Adapter): + """Borsh implementation for Rust HashMap.""" + + def __init__(self, key_subcon: Construct, value_subcon: Construct) -> None: + super().__init__( + PrefixedArray(borsh.U32, borsh.TupleStruct(key_subcon, value_subcon)), + ) # type: ignore + + def _decode(self, obj:List[Tuple[Any, Any]], context, path) -> dict: + #print("decode",obj) + return dict(obj) + + def _encode(self, obj, context, path) -> List[Tuple]: #Tuple[Any,List[Tuple[Any, Any]]]: + #print("encode",obj) + return obj.items() + +class PreOffset(Pointer): + def __init__(self, subcon: Construct,offset: int) -> None: + super().__init__(offset,subcon) + def _parse(self, stream, context, path): + offset = evaluate(self.offset, context) + stream = evaluate(self.stream, context) or stream + fallback = stream_tell(stream, path) + #print("_parse",offset,fallback) + stream_seek(stream, fallback+offset, 0, path) + obj = self.subcon._parsereport(stream, context, path) + #stream_seek(stream, self.subcon.length+ offset, 0, path) + return obj + + def _build(self, obj, stream, context, path): + offset = evaluate(self.offset, context) + stream = evaluate(self.stream, context) or stream + fallback = stream_tell(stream, path) + #print("_build",offset,fallback) + if offset>0: + stream_seek(stream, fallback+offset, 2 if offset < 0 else 0, path) + else: + stream_seek(stream, offset, 2 if offset < 0 else 0, path) + buildret = self.subcon._build(obj, stream, context, path) + +def generate_zero_bytes(offsite_count): + return b'\x00' * offsite_count +class PostOffset(Pointer): + def __init__(self, subcon: Construct,offset: int) -> None: + super().__init__(offset,subcon) + def _parse(self, stream, context, path): + offset = evaluate(self.offset, context) + stream = evaluate(self.stream, context) or stream + fallback = stream_tell(stream, path) + stream2 = io.BytesIO() + #print(self.subcon.length) + stream2.write(stream.read(self.subcon.length+ offset)) + if offset<0: + stream2.write(generate_zero_bytes(abs(offset))) + stream2.seek(0, 0) + obj = self.subcon._parsereport(stream2, context, path) + #stream_seek(stream, 0, 0, path) + return obj + + def _build(self, obj, stream, context, path): + offset = evaluate(self.offset, context) + stream = evaluate(self.stream, context) or stream + fallback = stream_tell(stream, path) + buildret = self.subcon._build(obj, stream, context, path) + stream_seek(stream, offset, 2, path) + + +def ZeroToType(this:Any,valueType: str,value:Const): + if valueType == "u8" or valueType == "u16" or valueType == "u32" or valueType == "u64": + if value != None: + this.value = int.from_bytes(value.build(None)) + else: + this.value = 0 + elif valueType == "publicKey": + if value != None: + this.value = Pubkey.from_bytes(value.build(None)) + else: + this.value =Pubkey.from_string("11111111111111111111111111111111") +class ZeroableOption(Default): + #value:Const + def __init__(self, subcon: Construct,value: Any,valueType: str) -> None: + ZeroToType(self,valueType,value) + if self.value!=None: + super().__init__(subcon,self.value) + else: + super().__init__(subcon,None) + def _parse(self, stream, context, path): + obj= self.subcon._parsereport(stream, context, path) + if obj == self.value: + return None + return obj + +SizePrefix=Prefixed diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/types/__init__.py b/python/src/pay_kit/protocols/programs/paymentchannels/types/__init__.py new file mode 100644 index 000000000..91ef5f285 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/types/__init__.py @@ -0,0 +1,35 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +from . import accountDiscriminator +from .accountDiscriminator import AccountDiscriminatorJSON,AccountDiscriminatorKind; +from . import channelStatus +from .channelStatus import ChannelStatusJSON,ChannelStatusKind; +from . import distributeArgs +from .distributeArgs import DistributeArgsJSON,DistributeArgs; +from . import distributionEntry +from .distributionEntry import DistributionEntryJSON,DistributionEntry; +from . import openArgs +from .openArgs import OpenArgsJSON,OpenArgs; +from . import opened +from .opened import OpenedJSON,Opened; +from . import payoutBeneficiary +from .payoutBeneficiary import PayoutBeneficiaryJSON,PayoutBeneficiaryKind; +from . import payoutRedirected +from .payoutRedirected import PayoutRedirectedJSON,PayoutRedirected; +from . import redirectReason +from .redirectReason import RedirectReasonJSON,RedirectReasonKind; +from . import settleAndFinalizeArgs +from .settleAndFinalizeArgs import SettleAndFinalizeArgsJSON,SettleAndFinalizeArgs; +from . import settleArgs +from .settleArgs import SettleArgsJSON,SettleArgs; +from . import settlementWatermarks +from .settlementWatermarks import SettlementWatermarksJSON,SettlementWatermarks; +from . import topUpArgs +from .topUpArgs import TopUpArgsJSON,TopUpArgs; +from . import voucherArgs +from .voucherArgs import VoucherArgsJSON,VoucherArgs; diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/types/accountDiscriminator.py b/python/src/pay_kit/protocols/programs/paymentchannels/types/accountDiscriminator.py new file mode 100644 index 000000000..5c3ed435e --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/types/accountDiscriminator.py @@ -0,0 +1,87 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from anchorpy.borsh_extension import EnumForCodegen +from dataclasses import dataclass + + +class ChannelJSON(typing.TypedDict): + kind: typing.Literal["Channel"] + + +@dataclass +class Channel: + discriminator: typing.ClassVar = 0 + def to_json(self) -> ChannelJSON: + return ChannelJSON( + kind="Channel", + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "Channel": {}, + } + + + + +class ClosedChannelJSON(typing.TypedDict): + kind: typing.Literal["ClosedChannel"] + + +@dataclass +class ClosedChannel: + discriminator: typing.ClassVar = 1 + def to_json(self) -> ClosedChannelJSON: + return ClosedChannelJSON( + kind="ClosedChannel", + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "ClosedChannel": {}, + } + + + + + +AccountDiscriminatorKind = typing.Union[ + Channel, + ClosedChannel, +] +AccountDiscriminatorJSON = typing.Union[ + ChannelJSON, + ClosedChannelJSON, +] + +def from_decoded(obj: dict) -> AccountDiscriminatorKind: + if not isinstance(obj, dict): + raise ValueError("Invalid enum object") + if "Channel" in obj: + return Channel() + if "ClosedChannel" in obj: + return ClosedChannel() + raise ValueError("Invalid enum object") + +def from_json(obj: AccountDiscriminatorJSON) -> AccountDiscriminatorKind: + if obj["kind"] == "Channel": + return Channel() + + if obj["kind"] == "ClosedChannel": + return ClosedChannel() + + kind = obj["kind"] + raise ValueError(f"Unrecognized enum kind: {kind}") + + +layout = EnumForCodegen( +"Channel" / borsh.CStruct(), +"ClosedChannel" / borsh.CStruct(), +) diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/types/channelStatus.py b/python/src/pay_kit/protocols/programs/paymentchannels/types/channelStatus.py new file mode 100644 index 000000000..a83e8a611 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/types/channelStatus.py @@ -0,0 +1,115 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from anchorpy.borsh_extension import EnumForCodegen +from dataclasses import dataclass + + +class OpenJSON(typing.TypedDict): + kind: typing.Literal["Open"] + + +@dataclass +class Open: + discriminator: typing.ClassVar = 0 + def to_json(self) -> OpenJSON: + return OpenJSON( + kind="Open", + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "Open": {}, + } + + + + +class FinalizedJSON(typing.TypedDict): + kind: typing.Literal["Finalized"] + + +@dataclass +class Finalized: + discriminator: typing.ClassVar = 1 + def to_json(self) -> FinalizedJSON: + return FinalizedJSON( + kind="Finalized", + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "Finalized": {}, + } + + + + +class ClosingJSON(typing.TypedDict): + kind: typing.Literal["Closing"] + + +@dataclass +class Closing: + discriminator: typing.ClassVar = 2 + def to_json(self) -> ClosingJSON: + return ClosingJSON( + kind="Closing", + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "Closing": {}, + } + + + + + +ChannelStatusKind = typing.Union[ + Open, + Finalized, + Closing, +] +ChannelStatusJSON = typing.Union[ + OpenJSON, + FinalizedJSON, + ClosingJSON, +] + +def from_decoded(obj: dict) -> ChannelStatusKind: + if not isinstance(obj, dict): + raise ValueError("Invalid enum object") + if "Open" in obj: + return Open() + if "Finalized" in obj: + return Finalized() + if "Closing" in obj: + return Closing() + raise ValueError("Invalid enum object") + +def from_json(obj: ChannelStatusJSON) -> ChannelStatusKind: + if obj["kind"] == "Open": + return Open() + + if obj["kind"] == "Finalized": + return Finalized() + + if obj["kind"] == "Closing": + return Closing() + + kind = obj["kind"] + raise ValueError(f"Unrecognized enum kind: {kind}") + + +layout = EnumForCodegen( +"Open" / borsh.CStruct(), +"Finalized" / borsh.CStruct(), +"Closing" / borsh.CStruct(), +) diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/types/distributeArgs.py b/python/src/pay_kit/protocols/programs/paymentchannels/types/distributeArgs.py new file mode 100644 index 000000000..f3ec77a4a --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/types/distributeArgs.py @@ -0,0 +1,51 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from construct import Construct, Container +from dataclasses import dataclass +from . import distributionEntry + +class DistributeArgsJSON(typing.TypedDict): + recipients: list[distributionEntry.DistributionEntryJSON] + +@dataclass +class DistributeArgs: + layout: typing.ClassVar = borsh.CStruct( + "recipients" /borsh.Vec(typing.cast(Construct, distributionEntry.DistributionEntry.layout)), + ) + #fields + recipients: list[distributionEntry.DistributionEntry] + + @classmethod + def from_decoded(cls, obj: Container) -> "DistributeArgs": + return cls( + recipients=list(map(lambda item:distributionEntry.DistributionEntry.from_decoded(item),obj["recipients"])), + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "recipients": list(map(lambda item:item.to_encodable(),self.recipients)), + } + + def to_json(self) -> DistributeArgsJSON: + return { + "recipients": list(map(lambda item:item.to_json(),self.recipients)), + } + + @classmethod + def from_json(cls, obj: DistributeArgsJSON) -> "DistributeArgs": + return cls( + recipients=list(map(lambda item:distributionEntry.DistributionEntry.from_json(item),obj["recipients"])), + ) + + + + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/types/distributionEntry.py b/python/src/pay_kit/protocols/programs/paymentchannels/types/distributionEntry.py new file mode 100644 index 000000000..ace181271 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/types/distributionEntry.py @@ -0,0 +1,59 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from anchorpy.borsh_extension import BorshPubkey +from construct import Container +from dataclasses import dataclass +from solders.pubkey import Pubkey as SolPubkey + +class DistributionEntryJSON(typing.TypedDict): + recipient: str + bps: int + +@dataclass +class DistributionEntry: + layout: typing.ClassVar = borsh.CStruct( + "recipient" /BorshPubkey, + "bps" /borsh.U16, + ) + #fields + recipient: SolPubkey + bps: int + + @classmethod + def from_decoded(cls, obj: Container) -> "DistributionEntry": + return cls( + recipient=obj["recipient"], + bps=obj["bps"], + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "recipient": self.recipient, + "bps": self.bps, + } + + def to_json(self) -> DistributionEntryJSON: + return { + "recipient": str(self.recipient), + "bps": self.bps, + } + + @classmethod + def from_json(cls, obj: DistributionEntryJSON) -> "DistributionEntry": + return cls( + recipient=SolPubkey.from_string(obj["recipient"]), + bps=obj["bps"], + ) + + + + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/types/openArgs.py b/python/src/pay_kit/protocols/programs/paymentchannels/types/openArgs.py new file mode 100644 index 000000000..3488d97fd --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/types/openArgs.py @@ -0,0 +1,72 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from construct import Construct, Container +from dataclasses import dataclass +from . import distributionEntry + +class OpenArgsJSON(typing.TypedDict): + salt: int + deposit: int + gracePeriod: int + recipients: list[distributionEntry.DistributionEntryJSON] + +@dataclass +class OpenArgs: + layout: typing.ClassVar = borsh.CStruct( + "salt" /borsh.U64, + "deposit" /borsh.U64, + "gracePeriod" /borsh.U32, + "recipients" /borsh.Vec(typing.cast(Construct, distributionEntry.DistributionEntry.layout)), + ) + #fields + salt: int + deposit: int + gracePeriod: int + recipients: list[distributionEntry.DistributionEntry] + + @classmethod + def from_decoded(cls, obj: Container) -> "OpenArgs": + return cls( + salt=obj["salt"], + deposit=obj["deposit"], + gracePeriod=obj["gracePeriod"], + recipients=list(map(lambda item:distributionEntry.DistributionEntry.from_decoded(item),obj["recipients"])), + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "salt": self.salt, + "deposit": self.deposit, + "gracePeriod": self.gracePeriod, + "recipients": list(map(lambda item:item.to_encodable(),self.recipients)), + } + + def to_json(self) -> OpenArgsJSON: + return { + "salt": self.salt, + "deposit": self.deposit, + "gracePeriod": self.gracePeriod, + "recipients": list(map(lambda item:item.to_json(),self.recipients)), + } + + @classmethod + def from_json(cls, obj: OpenArgsJSON) -> "OpenArgs": + return cls( + salt=obj["salt"], + deposit=obj["deposit"], + gracePeriod=obj["gracePeriod"], + recipients=list(map(lambda item:distributionEntry.DistributionEntry.from_json(item),obj["recipients"])), + ) + + + + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/types/opened.py b/python/src/pay_kit/protocols/programs/paymentchannels/types/opened.py new file mode 100644 index 000000000..363bc0915 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/types/opened.py @@ -0,0 +1,52 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from anchorpy.borsh_extension import BorshPubkey +from construct import Container +from dataclasses import dataclass +from solders.pubkey import Pubkey as SolPubkey + +class OpenedJSON(typing.TypedDict): + channel: str + +@dataclass +class Opened: + layout: typing.ClassVar = borsh.CStruct( + "channel" /BorshPubkey, + ) + #fields + channel: SolPubkey + + @classmethod + def from_decoded(cls, obj: Container) -> "Opened": + return cls( + channel=obj["channel"], + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "channel": self.channel, + } + + def to_json(self) -> OpenedJSON: + return { + "channel": str(self.channel), + } + + @classmethod + def from_json(cls, obj: OpenedJSON) -> "Opened": + return cls( + channel=SolPubkey.from_string(obj["channel"]), + ) + + + + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/types/payoutBeneficiary.py b/python/src/pay_kit/protocols/programs/paymentchannels/types/payoutBeneficiary.py new file mode 100644 index 000000000..d0e785713 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/types/payoutBeneficiary.py @@ -0,0 +1,115 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from anchorpy.borsh_extension import EnumForCodegen +from dataclasses import dataclass + + +class RecipientJSON(typing.TypedDict): + kind: typing.Literal["Recipient"] + + +@dataclass +class Recipient: + discriminator: typing.ClassVar = 0 + def to_json(self) -> RecipientJSON: + return RecipientJSON( + kind="Recipient", + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "Recipient": {}, + } + + + + +class PayeeJSON(typing.TypedDict): + kind: typing.Literal["Payee"] + + +@dataclass +class Payee: + discriminator: typing.ClassVar = 1 + def to_json(self) -> PayeeJSON: + return PayeeJSON( + kind="Payee", + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "Payee": {}, + } + + + + +class PayerJSON(typing.TypedDict): + kind: typing.Literal["Payer"] + + +@dataclass +class Payer: + discriminator: typing.ClassVar = 2 + def to_json(self) -> PayerJSON: + return PayerJSON( + kind="Payer", + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "Payer": {}, + } + + + + + +PayoutBeneficiaryKind = typing.Union[ + Recipient, + Payee, + Payer, +] +PayoutBeneficiaryJSON = typing.Union[ + RecipientJSON, + PayeeJSON, + PayerJSON, +] + +def from_decoded(obj: dict) -> PayoutBeneficiaryKind: + if not isinstance(obj, dict): + raise ValueError("Invalid enum object") + if "Recipient" in obj: + return Recipient() + if "Payee" in obj: + return Payee() + if "Payer" in obj: + return Payer() + raise ValueError("Invalid enum object") + +def from_json(obj: PayoutBeneficiaryJSON) -> PayoutBeneficiaryKind: + if obj["kind"] == "Recipient": + return Recipient() + + if obj["kind"] == "Payee": + return Payee() + + if obj["kind"] == "Payer": + return Payer() + + kind = obj["kind"] + raise ValueError(f"Unrecognized enum kind: {kind}") + + +layout = EnumForCodegen( +"Recipient" / borsh.CStruct(), +"Payee" / borsh.CStruct(), +"Payer" / borsh.CStruct(), +) diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/types/payoutRedirected.py b/python/src/pay_kit/protocols/programs/paymentchannels/types/payoutRedirected.py new file mode 100644 index 000000000..0fc1049c8 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/types/payoutRedirected.py @@ -0,0 +1,81 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from anchorpy.borsh_extension import BorshPubkey +from construct import Container +from dataclasses import dataclass +from solders.pubkey import Pubkey as SolPubkey +from . import payoutBeneficiary, redirectReason + +class PayoutRedirectedJSON(typing.TypedDict): + channel: str + owner: str + amount: int + beneficiary: payoutBeneficiary.PayoutBeneficiaryJSON + reason: redirectReason.RedirectReasonJSON + +@dataclass +class PayoutRedirected: + layout: typing.ClassVar = borsh.CStruct( + "channel" /BorshPubkey, + "owner" /BorshPubkey, + "amount" /borsh.U64, + "beneficiary" /payoutBeneficiary.layout, + "reason" /redirectReason.layout, + ) + #fields + channel: SolPubkey + owner: SolPubkey + amount: int + beneficiary: payoutBeneficiary.PayoutBeneficiaryKind + reason: redirectReason.RedirectReasonKind + + @classmethod + def from_decoded(cls, obj: Container) -> "PayoutRedirected": + return cls( + channel=obj["channel"], + owner=obj["owner"], + amount=obj["amount"], + beneficiary=payoutBeneficiary.from_decoded(obj["beneficiary"]), + reason=redirectReason.from_decoded(obj["reason"]), + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "channel": self.channel, + "owner": self.owner, + "amount": self.amount, + "beneficiary": self.beneficiary.to_encodable(), + "reason": self.reason.to_encodable(), + } + + def to_json(self) -> PayoutRedirectedJSON: + return { + "channel": str(self.channel), + "owner": str(self.owner), + "amount": self.amount, + "beneficiary": self.beneficiary.to_json(), + "reason": self.reason.to_json(), + } + + @classmethod + def from_json(cls, obj: PayoutRedirectedJSON) -> "PayoutRedirected": + return cls( + channel=SolPubkey.from_string(obj["channel"]), + owner=SolPubkey.from_string(obj["owner"]), + amount=obj["amount"], + beneficiary=payoutBeneficiary.from_json(obj["beneficiary"]), + reason=redirectReason.from_json(obj["reason"]), + ) + + + + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/types/redirectReason.py b/python/src/pay_kit/protocols/programs/paymentchannels/types/redirectReason.py new file mode 100644 index 000000000..a907621ac --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/types/redirectReason.py @@ -0,0 +1,143 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from anchorpy.borsh_extension import EnumForCodegen +from dataclasses import dataclass + + +class UnsupportedExtensionJSON(typing.TypedDict): + kind: typing.Literal["UnsupportedExtension"] + + +@dataclass +class UnsupportedExtension: + discriminator: typing.ClassVar = 0 + def to_json(self) -> UnsupportedExtensionJSON: + return UnsupportedExtensionJSON( + kind="UnsupportedExtension", + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "UnsupportedExtension": {}, + } + + + + +class ClosedOrMalformedJSON(typing.TypedDict): + kind: typing.Literal["ClosedOrMalformed"] + + +@dataclass +class ClosedOrMalformed: + discriminator: typing.ClassVar = 1 + def to_json(self) -> ClosedOrMalformedJSON: + return ClosedOrMalformedJSON( + kind="ClosedOrMalformed", + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "ClosedOrMalformed": {}, + } + + + + +class NotInitializedJSON(typing.TypedDict): + kind: typing.Literal["NotInitialized"] + + +@dataclass +class NotInitialized: + discriminator: typing.ClassVar = 2 + def to_json(self) -> NotInitializedJSON: + return NotInitializedJSON( + kind="NotInitialized", + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "NotInitialized": {}, + } + + + + +class ReassignedAuthorityJSON(typing.TypedDict): + kind: typing.Literal["ReassignedAuthority"] + + +@dataclass +class ReassignedAuthority: + discriminator: typing.ClassVar = 3 + def to_json(self) -> ReassignedAuthorityJSON: + return ReassignedAuthorityJSON( + kind="ReassignedAuthority", + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "ReassignedAuthority": {}, + } + + + + + +RedirectReasonKind = typing.Union[ + UnsupportedExtension, + ClosedOrMalformed, + NotInitialized, + ReassignedAuthority, +] +RedirectReasonJSON = typing.Union[ + UnsupportedExtensionJSON, + ClosedOrMalformedJSON, + NotInitializedJSON, + ReassignedAuthorityJSON, +] + +def from_decoded(obj: dict) -> RedirectReasonKind: + if not isinstance(obj, dict): + raise ValueError("Invalid enum object") + if "UnsupportedExtension" in obj: + return UnsupportedExtension() + if "ClosedOrMalformed" in obj: + return ClosedOrMalformed() + if "NotInitialized" in obj: + return NotInitialized() + if "ReassignedAuthority" in obj: + return ReassignedAuthority() + raise ValueError("Invalid enum object") + +def from_json(obj: RedirectReasonJSON) -> RedirectReasonKind: + if obj["kind"] == "UnsupportedExtension": + return UnsupportedExtension() + + if obj["kind"] == "ClosedOrMalformed": + return ClosedOrMalformed() + + if obj["kind"] == "NotInitialized": + return NotInitialized() + + if obj["kind"] == "ReassignedAuthority": + return ReassignedAuthority() + + kind = obj["kind"] + raise ValueError(f"Unrecognized enum kind: {kind}") + + +layout = EnumForCodegen( +"UnsupportedExtension" / borsh.CStruct(), +"ClosedOrMalformed" / borsh.CStruct(), +"NotInitialized" / borsh.CStruct(), +"ReassignedAuthority" / borsh.CStruct(), +) diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/types/settleAndFinalizeArgs.py b/python/src/pay_kit/protocols/programs/paymentchannels/types/settleAndFinalizeArgs.py new file mode 100644 index 000000000..33014ddaa --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/types/settleAndFinalizeArgs.py @@ -0,0 +1,58 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from construct import Container +from dataclasses import dataclass +from . import voucherArgs + +class SettleAndFinalizeArgsJSON(typing.TypedDict): + voucher: voucherArgs.VoucherArgsJSON + hasVoucher: int + +@dataclass +class SettleAndFinalizeArgs: + layout: typing.ClassVar = borsh.CStruct( + "voucher" /voucherArgs.VoucherArgs.layout, + "hasVoucher" /borsh.U8, + ) + #fields + voucher: voucherArgs.VoucherArgs + hasVoucher: int + + @classmethod + def from_decoded(cls, obj: Container) -> "SettleAndFinalizeArgs": + return cls( + voucher=voucherArgs.VoucherArgs.from_decoded(obj["voucher"]), + hasVoucher=obj["hasVoucher"], + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "voucher": self.voucher.to_encodable(), + "hasVoucher": self.hasVoucher, + } + + def to_json(self) -> SettleAndFinalizeArgsJSON: + return { + "voucher": self.voucher.to_json(), + "hasVoucher": self.hasVoucher, + } + + @classmethod + def from_json(cls, obj: SettleAndFinalizeArgsJSON) -> "SettleAndFinalizeArgs": + return cls( + voucher=voucherArgs.VoucherArgs.from_json(obj["voucher"]), + hasVoucher=obj["hasVoucher"], + ) + + + + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/types/settleArgs.py b/python/src/pay_kit/protocols/programs/paymentchannels/types/settleArgs.py new file mode 100644 index 000000000..a4d8f032c --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/types/settleArgs.py @@ -0,0 +1,51 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from construct import Container +from dataclasses import dataclass +from . import voucherArgs + +class SettleArgsJSON(typing.TypedDict): + voucher: voucherArgs.VoucherArgsJSON + +@dataclass +class SettleArgs: + layout: typing.ClassVar = borsh.CStruct( + "voucher" /voucherArgs.VoucherArgs.layout, + ) + #fields + voucher: voucherArgs.VoucherArgs + + @classmethod + def from_decoded(cls, obj: Container) -> "SettleArgs": + return cls( + voucher=voucherArgs.VoucherArgs.from_decoded(obj["voucher"]), + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "voucher": self.voucher.to_encodable(), + } + + def to_json(self) -> SettleArgsJSON: + return { + "voucher": self.voucher.to_json(), + } + + @classmethod + def from_json(cls, obj: SettleArgsJSON) -> "SettleArgs": + return cls( + voucher=voucherArgs.VoucherArgs.from_json(obj["voucher"]), + ) + + + + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/types/settlementWatermarks.py b/python/src/pay_kit/protocols/programs/paymentchannels/types/settlementWatermarks.py new file mode 100644 index 000000000..26c9d1c70 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/types/settlementWatermarks.py @@ -0,0 +1,57 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from construct import Container +from dataclasses import dataclass + +class SettlementWatermarksJSON(typing.TypedDict): + settled: int + payoutWatermark: int + +@dataclass +class SettlementWatermarks: + layout: typing.ClassVar = borsh.CStruct( + "settled" /borsh.U64, + "payoutWatermark" /borsh.U64, + ) + #fields + settled: int + payoutWatermark: int + + @classmethod + def from_decoded(cls, obj: Container) -> "SettlementWatermarks": + return cls( + settled=obj["settled"], + payoutWatermark=obj["payoutWatermark"], + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "settled": self.settled, + "payoutWatermark": self.payoutWatermark, + } + + def to_json(self) -> SettlementWatermarksJSON: + return { + "settled": self.settled, + "payoutWatermark": self.payoutWatermark, + } + + @classmethod + def from_json(cls, obj: SettlementWatermarksJSON) -> "SettlementWatermarks": + return cls( + settled=obj["settled"], + payoutWatermark=obj["payoutWatermark"], + ) + + + + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/types/topUpArgs.py b/python/src/pay_kit/protocols/programs/paymentchannels/types/topUpArgs.py new file mode 100644 index 000000000..b7f7c8ea0 --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/types/topUpArgs.py @@ -0,0 +1,50 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from construct import Container +from dataclasses import dataclass + +class TopUpArgsJSON(typing.TypedDict): + amount: int + +@dataclass +class TopUpArgs: + layout: typing.ClassVar = borsh.CStruct( + "amount" /borsh.U64, + ) + #fields + amount: int + + @classmethod + def from_decoded(cls, obj: Container) -> "TopUpArgs": + return cls( + amount=obj["amount"], + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "amount": self.amount, + } + + def to_json(self) -> TopUpArgsJSON: + return { + "amount": self.amount, + } + + @classmethod + def from_json(cls, obj: TopUpArgsJSON) -> "TopUpArgs": + return cls( + amount=obj["amount"], + ) + + + + + + diff --git a/python/src/pay_kit/protocols/programs/paymentchannels/types/voucherArgs.py b/python/src/pay_kit/protocols/programs/paymentchannels/types/voucherArgs.py new file mode 100644 index 000000000..83486ae9f --- /dev/null +++ b/python/src/pay_kit/protocols/programs/paymentchannels/types/voucherArgs.py @@ -0,0 +1,66 @@ +''' + This code was AUTOGENERATED using the codama library. + Please DO NOT EDIT THIS FILE, instead use visitors + to add features, then rerun codama to update it. + @see https://github.com/codama-idl/codama +''' + +import borsh_construct as borsh +import typing +from anchorpy.borsh_extension import BorshPubkey +from construct import Container +from dataclasses import dataclass +from solders.pubkey import Pubkey as SolPubkey + +class VoucherArgsJSON(typing.TypedDict): + channelId: str + cumulativeAmount: int + expiresAt: int + +@dataclass +class VoucherArgs: + layout: typing.ClassVar = borsh.CStruct( + "channelId" /BorshPubkey, + "cumulativeAmount" /borsh.U64, + "expiresAt" /borsh.I64, + ) + #fields + channelId: SolPubkey + cumulativeAmount: int + expiresAt: int + + @classmethod + def from_decoded(cls, obj: Container) -> "VoucherArgs": + return cls( + channelId=obj["channelId"], + cumulativeAmount=obj["cumulativeAmount"], + expiresAt=obj["expiresAt"], + ) + + def to_encodable(self) -> dict[str, typing.Any]: + return { + "channelId": self.channelId, + "cumulativeAmount": self.cumulativeAmount, + "expiresAt": self.expiresAt, + } + + def to_json(self) -> VoucherArgsJSON: + return { + "channelId": str(self.channelId), + "cumulativeAmount": self.cumulativeAmount, + "expiresAt": self.expiresAt, + } + + @classmethod + def from_json(cls, obj: VoucherArgsJSON) -> "VoucherArgs": + return cls( + channelId=SolPubkey.from_string(obj["channelId"]), + cumulativeAmount=obj["cumulativeAmount"], + expiresAt=obj["expiresAt"], + ) + + + + + + diff --git a/skills/pay-sdk-implementation/codegen/.gitignore b/skills/pay-sdk-implementation/codegen/.gitignore index 3c3629e64..111199c93 100644 --- a/skills/pay-sdk-implementation/codegen/.gitignore +++ b/skills/pay-sdk-implementation/codegen/.gitignore @@ -1 +1,2 @@ node_modules +.codama-py/ diff --git a/skills/pay-sdk-implementation/codegen/generate-payment-channels-client-py.ts b/skills/pay-sdk-implementation/codegen/generate-payment-channels-client-py.ts new file mode 100644 index 000000000..ebd54cefc --- /dev/null +++ b/skills/pay-sdk-implementation/codegen/generate-payment-channels-client-py.ts @@ -0,0 +1,76 @@ +/** + * Generate the pay-kit Python payment-channels client from the upstream + * `Moonsong-Labs/solana-payment-channels` Codama IDL. + * + * Mirrors generate-payment-channels-client.ts (Rust) — both scripts read the + * vendored IDL at `/idl/payment-channels.json`. This one renders a + * Python client into `python/src/pay_kit/protocols/programs/paymentchannels/` + * using the community `codama-py` renderer (Solana-ZH/codama-py). + * + * codama-py cannot be consumed as an npm/git dependency yet: its package.json + * ships only `dist` (not committed, and its build is currently broken + * upstream), so a git install packs an empty module. Until a fixed release + * exists this script instead clones the repo at a pinned commit — the merge + * of Solana-ZH/codama-py#10, which fixed PDA seed rendering — and drives its + * own `genpy` CLI, exactly as the upstream README documents. + * + * Output: + * python/src/pay_kit/protocols/programs/paymentchannels/ (rendered by codama-py) + */ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CODAMA_PY_REPO = 'https://github.com/Solana-ZH/codama-py.git'; +// Merge commit of Solana-ZH/codama-py#10 (PDA seed rendering fixes). +const CODAMA_PY_COMMIT = 'fcc75fc8abdf18cf4e0b3e4ae9338ddb60deb2e1'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// Script lives at skills/pay-sdk-implementation/codegen/ — climb three +// levels to land at the repository root. +const repoRoot = path.resolve(__dirname, '..', '..', '..'); + +const idlPath = path.join(repoRoot, 'idl', 'payment-channels.json'); +const pyClientDir = path.join( + repoRoot, + 'python', + 'src', + 'pay_kit', + 'protocols', + 'programs', + 'paymentchannels', +); +const cacheDir = path.join(__dirname, '.codama-py'); + +if (!fs.existsSync(idlPath)) { + console.error(`[codegen] IDL not found at ${idlPath}`); + console.error(`[codegen] Run \`just payment-channels-pull-idl\` first to fetch it from upstream.`); + process.exit(1); +} + +const run = (cmd: string, args: string[], cwd: string) => + execFileSync(cmd, args, { cwd, stdio: 'inherit' }); + +if (!fs.existsSync(path.join(cacheDir, 'package.json'))) { + console.log(`[codegen] Cloning codama-py @ ${CODAMA_PY_COMMIT.slice(0, 8)}`); + fs.rmSync(cacheDir, { force: true, recursive: true }); + run('git', ['clone', '--quiet', CODAMA_PY_REPO, cacheDir], __dirname); +} +run('git', ['checkout', '--quiet', CODAMA_PY_COMMIT], cacheDir); +run('pnpm', ['install', '--frozen-lockfile', '--ignore-scripts', '--silent'], cacheDir); + +console.log(`[codegen] Rendering Python client from ${path.relative(repoRoot, idlPath)}`); +console.log(`[codegen] → ${path.relative(repoRoot, pyClientDir)}/`); + +fs.rmSync(pyClientDir, { force: true, recursive: true }); +run('pnpm', ['run', 'genpy', '-i', idlPath, '-d', pyClientDir], cacheDir); + +// genpy swallows render errors (logs and exits 0), so verify the output. +const sentinel = path.join(pyClientDir, 'program_id.py'); +if (!fs.existsSync(sentinel)) { + console.error(`[codegen] Render produced no output at ${path.relative(repoRoot, pyClientDir)}`); + process.exit(1); +} + +console.log(`[codegen] Done.`); diff --git a/skills/pay-sdk-implementation/codegen/package.json b/skills/pay-sdk-implementation/codegen/package.json index 12ba96ecc..50f8d1336 100644 --- a/skills/pay-sdk-implementation/codegen/package.json +++ b/skills/pay-sdk-implementation/codegen/package.json @@ -6,7 +6,8 @@ "scripts": { "subscriptions:rust": "tsx ./generate-subscriptions-client.ts", "payment-channels:rust": "tsx ./generate-payment-channels-client.ts", - "payment-channels:go": "tsx ./generate-payment-channels-client-go.ts" + "payment-channels:go": "tsx ./generate-payment-channels-client-go.ts", + "payment-channels:python": "tsx ./generate-payment-channels-client-py.ts" }, "dependencies": { "@codama/nodes-from-anchor": "^1.4.1", From a88c41dad48c3947647b57326635c3deeacbd90a Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Thu, 11 Jun 2026 13:36:20 +0300 Subject: [PATCH 08/52] refactor(python): delegate payment-channels glue to the generated client 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. --- python/pyproject.toml | 8 + .../pay_kit/protocols/mpp/_paymentchannels.py | 166 ++++++++++-------- 2 files changed, 96 insertions(+), 78 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 104b10168..9e72177af 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -14,6 +14,10 @@ dependencies = [ "solana>=0.35", "pydantic>=2", "pydantic-settings>=2", + # Required by the codama-py generated payment-channels client + # (pay_kit/protocols/programs/paymentchannels). + "anchorpy>=0.21", + "borsh-construct>=0.1", ] [project.optional-dependencies] @@ -92,6 +96,10 @@ exclude = ["**/__pycache__", "src/pay_kit/protocols/programs/paymentchannels"] [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] +# anchorpy ships a pytest plugin for on-chain workspace tests that imports +# pytest-xprocess at collection time; we only use anchorpy's Borsh helpers, +# so keep the plugin out of the suite. +addopts = "-p no:pytest_anchorpy" [tool.coverage.run] source = ["pay_kit"] diff --git a/python/src/pay_kit/protocols/mpp/_paymentchannels.py b/python/src/pay_kit/protocols/mpp/_paymentchannels.py index da8b65a9f..804a053e8 100644 --- a/python/src/pay_kit/protocols/mpp/_paymentchannels.py +++ b/python/src/pay_kit/protocols/mpp/_paymentchannels.py @@ -1,4 +1,4 @@ -"""Hand-written on-chain glue for the payment-channels program. +"""On-chain glue for the payment-channels program. This module is the Python counterpart of ``rust/crates/mpp/src/program/payment_channels.rs`` and @@ -6,16 +6,23 @@ token derivation, voucher preimage bytes, and convenience instruction builders for the push-mode session flow (``open`` + ``topUp``). -Everything here mirrors the Rust spine so the wire format and on-chain paths -stay byte-identical across the language SDKs. In particular the production -program id pinned here (``GuoKrza...``) overrides the IDL placeholder -(``CQAyft83tN1w2bRofB5PZ79eVDU2xZUVo43LU1qL4zRg``), which is not the deployed -program; every PDA derivation and instruction built here uses ``GuoKrza...``. - -There is no Borsh library in the Python stack, so the instruction data and the -voucher preimage are hand-packed with :mod:`struct`. The payment-channels -program uses a single-byte instruction discriminator (``open`` = 1, -``topUp`` = 3), not the 8-byte Anchor discriminator. +Instruction data and account metas are produced by the codama-py generated +client under :mod:`pay_kit.protocols.programs.paymentchannels` (rendered from +``idl/payment-channels.json`` by ``skills/pay-sdk-implementation/codegen``), +the same architecture as the Rust and Go ports. This module only adds what the +IDL cannot express: + +- The production program id (``GuoKrza...``) overrides the IDL placeholder + (``CQAyft83tN1w2bRofB5PZ79eVDU2xZUVo43LU1qL4zRg``), which is not the deployed + program; every PDA derivation and instruction built here uses ``GuoKrza...``. + The generated PDA helpers pin the placeholder and take no program id + parameter, so the event-authority derivation stays here. +- The channel PDA is not declared in the IDL's ``pdas`` section, so its + derivation is hand-written, mirroring ``find_channel_pda`` in the Rust spine. + +The payment-channels program uses a single-byte instruction discriminator +(``open`` = 1, ``topUp`` = 3), not the 8-byte Anchor discriminator; the +generated builders encode it. """ from __future__ import annotations @@ -23,7 +30,7 @@ import struct from dataclasses import dataclass, field -from solders.instruction import AccountMeta, Instruction # type: ignore[import-untyped] +from solders.instruction import Instruction # type: ignore[import-untyped] from solders.pubkey import Pubkey # type: ignore[import-untyped] from pay_kit._paycore.solana import ( @@ -31,6 +38,18 @@ SYSTEM_PROGRAM, TOKEN_PROGRAM, ) +from pay_kit.protocols.programs.paymentchannels.instructions.open import Open +from pay_kit.protocols.programs.paymentchannels.instructions.topUp import TopUp +from pay_kit.protocols.programs.paymentchannels.types.distributionEntry import ( + DistributionEntry, +) +from pay_kit.protocols.programs.paymentchannels.types.openArgs import ( + OpenArgs as _OpenArgs, +) +from pay_kit.protocols.programs.paymentchannels.types.topUpArgs import ( + TopUpArgs as _TopUpArgs, +) +from pay_kit.protocols.programs.paymentchannels.types.voucherArgs import VoucherArgs __all__ = [ "PAYMENT_CHANNELS_PROGRAM_ID", @@ -61,11 +80,6 @@ # Event-authority PDA seed prefix. Mirrors ``EVENT_AUTHORITY_SEED`` in Rust. _EVENT_AUTHORITY_SEED = b"event_authority" -# Single-byte instruction discriminators (NOT 8-byte Anchor). Mirrors the -# discriminator constants in the payment-channels program. -_OPEN_DISCRIMINATOR = 1 -_TOP_UP_DISCRIMINATOR = 3 - # Rent sysvar id. Mirrors ``RENT_SYSVAR_ID`` in the Rust spine. _RENT_SYSVAR_ID = "SysvarRent111111111111111111111111111111111" @@ -119,9 +133,9 @@ def voucher_message_bytes(channel_id: Pubkey, cumulative: int, expires_at: int) """Return the 48-byte voucher preimage signed by the authorized signer. Layout: ``channelId`` (32) || ``cumulativeAmount`` as little-endian u64 - (offset 32) || ``expiresAt`` as little-endian i64 (offset 40). This is the - exact Borsh layout of ``VoucherArgs``. Mirrors ``voucher_message_bytes`` in - the Rust spine. + (offset 32) || ``expiresAt`` as little-endian i64 (offset 40). Encoded by + the generated ``VoucherArgs`` Borsh layout, the exact counterpart of the + Rust spine delegating to its generated ``VoucherArgs``. Raises: ValueError: if ``channel_id`` does not encode to exactly 32 bytes. @@ -129,7 +143,15 @@ def voucher_message_bytes(channel_id: Pubkey, cumulative: int, expires_at: int) channel_bytes = bytes(channel_id) if len(channel_bytes) != 32: raise ValueError(f"channel id must be exactly 32 bytes, got {len(channel_bytes)}") - return channel_bytes + struct.pack(" tuple[Pubkey, int]: """Derive the event-authority PDA against the production program id. Seeds: ``["event_authority"]``. Mirrors ``find_event_authority_pda`` in the - Rust spine. + Rust spine. Stays hand-written because the generated helper derives against + the IDL placeholder program id and takes no override. """ return Pubkey.find_program_address([_EVENT_AUTHORITY_SEED], PROGRAM_ID) @@ -187,13 +210,9 @@ def build_open_instruction(params: OpenChannelParams) -> Instruction: """Build the ``open`` instruction with accounts in the exact Rust order. Derives the channel PDA, the payer and channel ATAs, and the event-authority - PDA, then emits 13 account metas in the canonical order against the - production program id. Mirrors ``build_open_instruction`` in the Rust spine. - - Instruction data (single-byte discriminator ``1`` + hand-packed Borsh): - ``\\x01`` || ``salt`` u64 LE || ``deposit`` u64 LE || ``grace_period`` u32 LE - || ``len(recipients)`` u32 LE || for each recipient: pubkey (32) || ``bps`` - u16 LE. + PDA, then delegates encoding and the 13 account metas to the generated + ``Open`` builder against the production program id. Mirrors + ``build_open_instruction`` in the Rust spine. """ channel, _ = find_channel_pda( params.payer, @@ -206,61 +225,52 @@ def build_open_instruction(params: OpenChannelParams) -> Instruction: channel_token_account, _ = find_associated_token_address(channel, params.mint, params.token_program) event_authority, _ = find_event_authority_pda() - system_program = Pubkey.from_string(SYSTEM_PROGRAM) - rent = Pubkey.from_string(_RENT_SYSVAR_ID) - associated_token_program = Pubkey.from_string(ASSOCIATED_TOKEN_PROGRAM) - - accounts = [ - AccountMeta(params.payer, True, True), - AccountMeta(params.payee, False, False), - AccountMeta(params.mint, False, False), - AccountMeta(params.authorized_signer, False, False), - AccountMeta(channel, False, True), - AccountMeta(payer_token_account, False, True), - AccountMeta(channel_token_account, False, True), - AccountMeta(params.token_program, False, False), - AccountMeta(system_program, False, False), - AccountMeta(rent, False, False), - AccountMeta(associated_token_program, False, False), - AccountMeta(event_authority, False, False), - AccountMeta(PROGRAM_ID, False, False), - ] - - data = bytearray() - data.append(_OPEN_DISCRIMINATOR) - data += struct.pack(" Instruction: """Build the ``topUp`` instruction with accounts in the exact Rust order. - Derives the payer and channel ATAs, then emits 6 account metas in the - canonical order against the production program id. Mirrors - ``build_top_up_instruction`` in the Rust spine. - - Instruction data (single-byte discriminator ``3`` + hand-packed Borsh): - ``\\x03`` || ``amount`` u64 LE. + Derives the payer and channel ATAs, then delegates encoding and the 6 + account metas to the generated ``TopUp`` builder against the production + program id. Mirrors ``build_top_up_instruction`` in the Rust spine. """ payer_token_account, _ = find_associated_token_address(params.payer, params.mint, params.token_program) channel_token_account, _ = find_associated_token_address(params.channel, params.mint, params.token_program) - accounts = [ - AccountMeta(params.payer, True, True), - AccountMeta(params.channel, False, True), - AccountMeta(payer_token_account, False, True), - AccountMeta(channel_token_account, False, True), - AccountMeta(params.mint, False, False), - AccountMeta(params.token_program, False, False), - ] - - data = bytes([_TOP_UP_DISCRIMINATOR]) + struct.pack(" Date: Fri, 12 Jun 2026 14:04:06 +0300 Subject: [PATCH 09/52] fix(python): record replayed commits like the rust spine 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. --- .../pay_kit/protocols/mpp/client/session.py | 24 ++----- .../protocols/mpp/client/session_consumer.py | 33 +++------ python/tests/test_session_client.py | 50 ++++++++------ python/tests/test_session_consumer.py | 69 ++++--------------- 4 files changed, 55 insertions(+), 121 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/client/session.py b/python/src/pay_kit/protocols/mpp/client/session.py index 751a5ee38..26022a341 100644 --- a/python/src/pay_kit/protocols/mpp/client/session.py +++ b/python/src/pay_kit/protocols/mpp/client/session.py @@ -227,7 +227,8 @@ def record_voucher(self, voucher: SignedVoucher) -> None: The voucher's channel MUST match this session and its cumulative MUST strictly exceed the current watermark; the nonce advances to the larger - of the current nonce + 1 and the voucher nonce. Mirrors rust + of the current nonce and the voucher nonce (current nonce + 1 when the + voucher carries none), exactly mirroring rust ``ActiveSession::record_voucher``. """ if voucher.data.channel_id != self.channel_id_string: @@ -241,25 +242,8 @@ def record_voucher(self, voucher: SignedVoucher) -> None: if cumulative <= self._cumulative: raise ValueError(f"voucher cumulative {cumulative} must exceed current watermark {self._cumulative}") self._cumulative = cumulative - candidate = self._nonce + 1 - if voucher.data.nonce is not None and voucher.data.nonce > candidate: - candidate = voucher.data.nonce - self._nonce = candidate - - def reconcile_settled(self, settled: int) -> None: - """Reconcile the local watermark to a server-settled cumulative. - - Used for a ``replayed`` commit receipt: advances the watermark to - ``settled`` when it is ahead of the current value and never regresses, - so retrying a delivery the server already accepted (lost-response case) - catches the client up without recording the freshly prepared higher - voucher. When it advances, the request nonce also advances by one, - mirroring ``record_voucher`` for the delivery the server settled, so the - next prepared voucher does not reuse the already-settled nonce. - """ - if settled > self._cumulative: - self._cumulative = settled - self._nonce += 1 + candidate = voucher.data.nonce if voucher.data.nonce is not None else self._nonce + 1 + self._nonce = max(self._nonce, candidate) def sign_voucher(self, cumulative: int) -> SignedVoucher: """Sign a voucher with an absolute cumulative amount and advance the diff --git a/python/src/pay_kit/protocols/mpp/client/session_consumer.py b/python/src/pay_kit/protocols/mpp/client/session_consumer.py index 96ae0b8af..bc73d7be5 100644 --- a/python/src/pay_kit/protocols/mpp/client/session_consumer.py +++ b/python/src/pay_kit/protocols/mpp/client/session_consumer.py @@ -20,7 +20,6 @@ CommitReceipt, MeteredEnvelope, MeteringDirective, - _parse_base_units, ) __all__ = [ @@ -90,15 +89,13 @@ def commit_directive(self, directive: MeteringDirective) -> CommitReceipt: Rejects directives whose session does not match, whose amount is not a valid base-unit integer, or whose amount is zero. The prepare/record split makes a failed commit safe to retry without double-counting: the - voucher is prepared (no watermark advance), sent, and only recorded once - the transport returns a ``committed`` receipt. A ``replayed`` receipt - means the server already settled this delivery, so its ``cumulative`` is - the authoritative settled position: recording the freshly prepared - (higher) voucher would push the watermark past the server's state, while - skipping it entirely would leave it behind when the original response was - lost (making the next delivery non-monotonic). On replay the watermark is - reconciled to the receipt cumulative (never regressing). Mirrors rust - ``SessionConsumer::commit_directive``. + voucher is prepared (no watermark advance), sent, and recorded once the + transport returns a receipt. The prepared voucher is recorded for both + ``committed`` and ``replayed`` receipts, exactly mirroring rust + ``SessionConsumer::commit_directive`` and the TypeScript + ``SessionConsumer``: the server's deliveryId dedupe keeps the settled + amount authoritative on its side, and the locally signed cumulative + stays the client watermark so subsequent vouchers remain monotonic. """ self._validate_directive(directive) amount = directive.amount_base_units() @@ -109,21 +106,7 @@ def commit_directive(self, directive: MeteringDirective) -> CommitReceipt: payload = CommitPayload(delivery_id=directive.delivery_id, voucher=voucher) receipt = self._transport.commit(directive, payload) - if receipt.status == "replayed": - settled = receipt.cumulative_base_units() - # The server is untrusted: clamp to the voucher just prepared in this - # call. An honest lost-response replay settles at or below it (the - # session is single-threaded), so a server reporting a higher - # cumulative cannot push the watermark past what the client actually - # signed - otherwise the next voucher would over-authorize up to the - # on-chain deposit. - prepared = _parse_base_units(payload.voucher.data.cumulative) - self._session.reconcile_settled(min(settled, prepared)) - elif receipt.status == "committed": - self._session.record_voucher(payload.voucher) - else: - # A malformed or unknown status must not advance local state. - raise ValueError(f"unexpected commit receipt status: {receipt.status!r}") + self._session.record_voucher(payload.voucher) return receipt def _validate_directive(self, directive: MeteringDirective) -> None: diff --git a/python/tests/test_session_client.py b/python/tests/test_session_client.py index 45d484df5..cfcc13a0e 100644 --- a/python/tests/test_session_client.py +++ b/python/tests/test_session_client.py @@ -159,28 +159,36 @@ def test_record_voucher_honors_larger_voucher_nonce() -> None: assert s.nonce == 9 -def test_reconcile_settled_advances_cumulative_and_nonce_without_regressing() -> None: +def test_record_voucher_keeps_current_nonce_for_stale_voucher_nonce() -> None: + # rust ``record_voucher`` sets ``nonce = max(nonce, voucher.nonce)``: a + # recorded voucher carrying a nonce at or below the current counter leaves + # the counter untouched. The TS ActiveSession matches. s = _session() - s.reconcile_settled(100) - assert s.cumulative == 100 - assert s.nonce == 1 # advancing also bumps the request nonce - s.reconcile_settled(40) # stale, must not regress or touch the nonce - assert s.cumulative == 100 - assert s.nonce == 1 - s.reconcile_settled(250) - assert s.cumulative == 250 - assert s.nonce == 2 - - -def test_delivery_after_replay_does_not_reuse_settled_nonce() -> None: - # After a lost-response replay reconciles to the settled cumulative, the - # next prepared voucher must carry a fresh nonce, not the settled one. - s = _session() - replayed = s.prepare_increment(100) - s.reconcile_settled(100) - nxt = s.prepare_increment(50) - assert replayed.data.nonce is not None and nxt.data.nonce is not None - assert nxt.data.nonce > replayed.data.nonce + s.record_voucher( + SignedVoucher( + data=VoucherData( + channel_id=s.channel_id_string, + cumulative="42", + expires_at=DEFAULT_VOUCHER_EXPIRES_AT, + nonce=9, + ), + signature="sig", + ) + ) + s.record_voucher( + SignedVoucher( + data=VoucherData( + channel_id=s.channel_id_string, + cumulative="50", + expires_at=DEFAULT_VOUCHER_EXPIRES_AT, + nonce=3, + ), + signature="sig", + ) + ) + assert s.cumulative == 50 + assert s.nonce == 9 + assert s.prepare_increment(1).data.nonce == 10 # ── monotonicity ── diff --git a/python/tests/test_session_consumer.py b/python/tests/test_session_consumer.py index 0d66c4301..47f604098 100644 --- a/python/tests/test_session_consumer.py +++ b/python/tests/test_session_consumer.py @@ -5,9 +5,8 @@ port: ack/commit send through the transport and advance the local watermark, the commit alias and ``into_parts`` work, invalid directives (wrong session, zero amount, non-numeric amount) are rejected before commit, a failed commit -does not advance the watermark, idempotency on a duplicate delivery (a server -``replayed`` receipt is honored and the cumulative is not advanced twice), and a -fresh delivery advances. +does not advance the watermark, a ``replayed`` receipt still records the +prepared voucher (rust/TS parity), and a fresh delivery advances. """ from __future__ import annotations @@ -199,10 +198,13 @@ def commit(self, directive: MeteringDirective, payload: CommitPayload) -> Commit ) -def test_duplicate_delivery_dedup_keeps_watermark_at_settled() -> None: +def test_duplicate_delivery_returns_replayed_receipt_and_records_prepared_voucher() -> None: # Re-committing the same deliveryId returns a replayed receipt pinned to the - # originally settled cumulative; the watermark does not advance past it and - # the server records exactly one commit. + # originally settled cumulative. The consumer still records the voucher it + # prepared for the retry, exactly like rust ``SessionConsumer::commit_directive`` + # (session_consumer.rs records unconditionally after the transport returns) + # and the TS ``SessionConsumer``: the server's dedupe keeps the settled + # amount authoritative on its side and the server records exactly one commit. transport = _RecordingTransport() consumer = _consumer(transport) d = _directive(consumer.session.channel_id_string, 100, delivery_id="d1") @@ -214,64 +216,21 @@ def test_duplicate_delivery_dedup_keeps_watermark_at_settled() -> None: r2 = consumer.commit_directive(d) assert r2.status == "replayed" assert r2.cumulative == "100" - assert consumer.session.cumulative == 100 + assert consumer.session.cumulative == 200 assert len(transport.commits) == 1 -def test_replayed_receipt_reconciles_watermark_when_behind() -> None: - # Lost-response case: the server already settled this delivery at 100 but the - # client never recorded it (watermark still 0). On replay the client must - # reconcile to the server-settled 100, not jump to the prepared 250 and not - # stay at 0 (which would make the next delivery non-monotonic). +def test_replayed_receipt_records_prepared_voucher() -> None: + # Lost-response case: the server reports the delivery as already settled at + # 100, but the client watermark advances to the cumulative it just signed + # (250), keeping the locally signed voucher the high-water mark. Mirrors the + # unconditional record in rust session_consumer.rs and TS SessionConsumer. consumer = _consumer(_ReplayTransport(settled="100")) receipt = consumer.commit_directive(_directive(consumer.session.channel_id_string, 250)) assert receipt.status == "replayed" - assert consumer.session.cumulative == 100 - - -def test_replayed_receipt_cumulative_is_clamped_to_prepared_voucher() -> None: - # A malicious/buggy server cannot push the watermark past the voucher the - # client just signed: it reports a replay settled far above the prepared - # cumulative (250), but the watermark must clamp to the prepared value, not - # the inflated server value, so the next voucher does not over-authorize. - consumer = _consumer(_ReplayTransport(settled="1000000")) - receipt = consumer.commit_directive(_directive(consumer.session.channel_id_string, 250)) - assert receipt.status == "replayed" assert consumer.session.cumulative == 250 -class _StatusTransport: - """Transport that returns a fixed (possibly unknown) status.""" - - def __init__(self, status: str) -> None: - self.status = status - - def commit(self, directive: MeteringDirective, payload: CommitPayload) -> CommitReceipt: - return CommitReceipt( - delivery_id=directive.delivery_id, - session_id=directive.session_id, - amount=directive.amount, - cumulative=payload.voucher.data.cumulative, - status=self.status, # type: ignore[arg-type] - ) - - -def test_unknown_receipt_status_is_rejected_and_does_not_advance() -> None: - consumer = _consumer(_StatusTransport(status="bogus")) - with pytest.raises(ValueError, match="unexpected commit receipt status"): - consumer.commit_directive(_directive(consumer.session.channel_id_string, 100)) - assert consumer.session.cumulative == 0 - - -def test_replayed_receipt_never_regresses_watermark() -> None: - # The client is already ahead at 300; a stale replay settled at 100 must not - # regress the local watermark. - consumer = _consumer(_ReplayTransport(settled="100")) - consumer.session.reconcile_settled(300) - consumer.commit_directive(_directive(consumer.session.channel_id_string, 50)) - assert consumer.session.cumulative == 300 - - def test_fresh_delivery_advances_after_prior() -> None: transport = _RecordingTransport() consumer = _consumer(transport) From 81d997ae06262521602efc64c161449853aadab4 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 12 Jun 2026 14:05:41 +0300 Subject: [PATCH 10/52] fix(python): reject unknown modes and receipt statuses at decode 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. --- .../pay_kit/protocols/mpp/intents/session.py | 28 +++++++++++++++++-- python/tests/test_session_intent.py | 23 +++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/intents/session.py b/python/src/pay_kit/protocols/mpp/intents/session.py index 7d0b034d7..b0f283fb3 100644 --- a/python/src/pay_kit/protocols/mpp/intents/session.py +++ b/python/src/pay_kit/protocols/mpp/intents/session.py @@ -160,6 +160,17 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> SessionRequest: decimals = data.get("decimals") + # rust deserializes ``modes`` as Vec and + # ``pullVoucherStrategy`` as an enum, so unknown variants fail at + # decode; mirror that instead of deferring the failure downstream. + modes: list[SessionMode] = [] + for mode in data.get("modes", []): + if mode not in ("push", "pull"): + raise ValueError(f"session request: unknown mode {mode!r}") + modes.append(mode) + strategy = data.get("pullVoucherStrategy") + if strategy is not None and strategy not in ("clientVoucher", "operatedVoucher"): + raise ValueError(f"session request: unknown pullVoucherStrategy {strategy!r}") return cls( cap=data.get("cap", ""), currency=data.get("currency", ""), @@ -172,8 +183,8 @@ def from_dict(cls, data: dict[str, Any]) -> SessionRequest: description=data.get("description"), external_id=data.get("externalId"), min_voucher_delta=data.get("minVoucherDelta"), - modes=list(data.get("modes", [])), - pull_voucher_strategy=data.get("pullVoucherStrategy"), + modes=modes, + pull_voucher_strategy=strategy, recent_blockhash=data.get("recentBlockhash"), ) @@ -454,6 +465,11 @@ def from_dict(cls, data: dict[str, Any]) -> OpenPayload: mode = data.get("mode") if not mode: raise ValueError("open payload: missing mode") + # rust deserializes ``mode`` as the SessionMode enum, rejecting unknown + # variants at decode; mirror that rather than failing later inside + # session_id()/deposit_amount(). + if mode not in ("push", "pull"): + raise ValueError(f"open payload: unknown mode {mode!r}") return cls( mode=mode, authorized_signer=data.get("authorizedSigner", ""), @@ -897,10 +913,16 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> CommitReceipt: + # rust deserializes ``status`` as the CommitStatus enum, so a missing + # or unknown status fails at decode; mirror that so a malformed receipt + # can never advance client state. + status = data.get("status") + if status not in ("committed", "replayed"): + raise ValueError(f"commit receipt: unknown status {status!r}") return cls( delivery_id=data.get("deliveryId", ""), session_id=data.get("sessionId", ""), amount=data.get("amount", ""), cumulative=data.get("cumulative", ""), - status=data.get("status", "committed"), + status=status, ) diff --git a/python/tests/test_session_intent.py b/python/tests/test_session_intent.py index 0046bab02..cc55c03dc 100644 --- a/python/tests/test_session_intent.py +++ b/python/tests/test_session_intent.py @@ -344,6 +344,21 @@ def test_open_payload_missing_mode_raises(): OpenPayload.from_dict(d) +def test_open_payload_unknown_mode_raises(): + # rust serde rejects unknown SessionMode variants at decode. + d = {"mode": "stream", "channelId": "chan1", "authorizedSigner": "s", "signature": "sig"} + with pytest.raises(ValueError, match="unknown mode"): + OpenPayload.from_dict(d) + + +def test_session_request_unknown_mode_and_strategy_raise(): + base = {"cap": "1", "currency": "USDC", "operator": "op", "recipient": "rec"} + with pytest.raises(ValueError, match="unknown mode"): + SessionRequest.from_dict({**base, "modes": ["push", "stream"]}) + with pytest.raises(ValueError, match="unknown pullVoucherStrategy"): + SessionRequest.from_dict({**base, "pullVoucherStrategy": "serverVoucher"}) + + # ── SessionAction round-trips for all five actions ── @@ -624,6 +639,14 @@ def test_commit_receipt_roundtrip_and_parsers(): replayed = CommitReceipt.from_dict({**d, "status": "replayed"}) assert replayed.status == "replayed" + # rust deserializes status as the CommitStatus enum: missing or unknown + # statuses fail at decode and can never advance client state. + with pytest.raises(ValueError, match="unknown status"): + CommitReceipt.from_dict({**d, "status": "settled"}) + missing = {k: v for k, v in d.items() if k != "status"} + with pytest.raises(ValueError, match="unknown status"): + CommitReceipt.from_dict(missing) + with pytest.raises(ValueError, match="invalid commit receipt amount"): CommitReceipt("d", "s", "bad", "1", "committed").amount_base_units() with pytest.raises(ValueError, match="invalid commit receipt cumulative"): From 070223fcbc686381fa03311986393e626b9204e6 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 12 Jun 2026 14:11:38 +0300 Subject: [PATCH 11/52] feat(python): add the challenge-driven payment-channel open layer 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. --- .../pay_kit/protocols/mpp/_paymentchannels.py | 22 +- .../pay_kit/protocols/mpp/client/__init__.py | 40 +- .../protocols/mpp/client/payment_channels.py | 458 ++++++++++++++++++ .../pay_kit/protocols/mpp/client/session.py | 32 +- python/tests/test_session_client.py | 34 +- python/tests/test_session_payment_channels.py | 360 ++++++++++++++ 6 files changed, 927 insertions(+), 19 deletions(-) create mode 100644 python/src/pay_kit/protocols/mpp/client/payment_channels.py create mode 100644 python/tests/test_session_payment_channels.py diff --git a/python/src/pay_kit/protocols/mpp/_paymentchannels.py b/python/src/pay_kit/protocols/mpp/_paymentchannels.py index 804a053e8..f9de1b90a 100644 --- a/python/src/pay_kit/protocols/mpp/_paymentchannels.py +++ b/python/src/pay_kit/protocols/mpp/_paymentchannels.py @@ -113,6 +113,7 @@ class OpenChannelParams: grace_period: int recipients: list[Distribution] = field(default_factory=list) token_program: Pubkey = field(default_factory=lambda: Pubkey.from_string(TOKEN_PROGRAM)) + program_id: Pubkey = field(default_factory=lambda: PROGRAM_ID) @dataclass @@ -160,11 +161,13 @@ def find_channel_pda( mint: Pubkey, authorized_signer: Pubkey, salt: int, + program_id: Pubkey = PROGRAM_ID, ) -> tuple[Pubkey, int]: - """Derive the channel PDA against the production program id. + """Derive the channel PDA, defaulting to the production program id. Seeds: ``["channel", payer, payee, mint, authorizedSigner, salt u64 LE]``. - Mirrors ``find_channel_pda`` in the Rust spine. + Mirrors ``find_channel_pda`` in the Rust spine, which takes the program id + as its final parameter. """ return Pubkey.find_program_address( [ @@ -175,18 +178,18 @@ def find_channel_pda( bytes(authorized_signer), struct.pack(" tuple[Pubkey, int]: - """Derive the event-authority PDA against the production program id. +def find_event_authority_pda(program_id: Pubkey = PROGRAM_ID) -> tuple[Pubkey, int]: + """Derive the event-authority PDA, defaulting to the production program id. Seeds: ``["event_authority"]``. Mirrors ``find_event_authority_pda`` in the Rust spine. Stays hand-written because the generated helper derives against the IDL placeholder program id and takes no override. """ - return Pubkey.find_program_address([_EVENT_AUTHORITY_SEED], PROGRAM_ID) + return Pubkey.find_program_address([_EVENT_AUTHORITY_SEED], program_id) def find_associated_token_address( @@ -220,10 +223,11 @@ def build_open_instruction(params: OpenChannelParams) -> Instruction: params.mint, params.authorized_signer, params.salt, + params.program_id, ) payer_token_account, _ = find_associated_token_address(params.payer, params.mint, params.token_program) channel_token_account, _ = find_associated_token_address(channel, params.mint, params.token_program) - event_authority, _ = find_event_authority_pda() + event_authority, _ = find_event_authority_pda(params.program_id) args = _OpenArgs( salt=params.salt, @@ -246,9 +250,9 @@ def build_open_instruction(params: OpenChannelParams) -> Instruction: "rent": Pubkey.from_string(_RENT_SYSVAR_ID), "associatedTokenProgram": Pubkey.from_string(ASSOCIATED_TOKEN_PROGRAM), "eventAuthority": event_authority, - "selfProgram": PROGRAM_ID, + "selfProgram": params.program_id, }, - program_id=PROGRAM_ID, + program_id=params.program_id, ) diff --git a/python/src/pay_kit/protocols/mpp/client/__init__.py b/python/src/pay_kit/protocols/mpp/client/__init__.py index 63d75c7a3..1438186f0 100644 --- a/python/src/pay_kit/protocols/mpp/client/__init__.py +++ b/python/src/pay_kit/protocols/mpp/client/__init__.py @@ -2,20 +2,39 @@ Exposes the charge transport plus the client-only session surface: the :class:`ActiveSession` voucher tracker, the :class:`SessionConsumer` metered -ack/commit helper, and the :func:`serialize_session_credential` / -:func:`parse_session_challenge` credential framing free functions. The -per-intent modules (:mod:`pay_kit.protocols.mpp.client.charge`, +ack/commit helper, the challenge-driven payment-channel openers, and the +:func:`serialize_session_credential` / :func:`parse_session_challenge` +credential framing free functions. The per-intent modules +(:mod:`pay_kit.protocols.mpp.client.charge`, :mod:`pay_kit.protocols.mpp.client.session`, +:mod:`pay_kit.protocols.mpp.client.payment_channels`, :mod:`pay_kit.protocols.mpp.client.session_consumer`) remain the canonical import path; the session types are re-exported here for convenience. """ from __future__ import annotations +from pay_kit.protocols.mpp.client.payment_channels import ( + DEFAULT_GRACE_PERIOD_SECONDS, + PENDING_SERVER_SIGNATURE, + PaymentChannelOpen, + PaymentChannelOpenOptions, + PaymentChannelOpenTransaction, + PaymentChannelSessionOpen, + PaymentChannelSessionOpenOptions, + ServerOpenedPaymentChannelSessionOpenOptions, + build_open_payment_channel_transaction, + create_payment_channel_session_opener, + create_server_opened_payment_channel_session_opener, + derive_payment_channel_open, + generate_authorized_signer, + unique_salt, +) from pay_kit.protocols.mpp.client.session import ( ActiveSession, parse_session_challenge, serialize_session_credential, + session_request_modes, ) from pay_kit.protocols.mpp.client.session_consumer import ( MeteredDelivery, @@ -30,4 +49,19 @@ "MeteredDelivery", "serialize_session_credential", "parse_session_challenge", + "session_request_modes", + "DEFAULT_GRACE_PERIOD_SECONDS", + "PENDING_SERVER_SIGNATURE", + "PaymentChannelOpen", + "PaymentChannelOpenOptions", + "PaymentChannelOpenTransaction", + "PaymentChannelSessionOpen", + "PaymentChannelSessionOpenOptions", + "ServerOpenedPaymentChannelSessionOpenOptions", + "build_open_payment_channel_transaction", + "create_payment_channel_session_opener", + "create_server_opened_payment_channel_session_opener", + "derive_payment_channel_open", + "generate_authorized_signer", + "unique_salt", ] diff --git a/python/src/pay_kit/protocols/mpp/client/payment_channels.py b/python/src/pay_kit/protocols/mpp/client/payment_channels.py new file mode 100644 index 000000000..bee1caccb --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/client/payment_channels.py @@ -0,0 +1,458 @@ +"""Client-side helpers for payment-channel open transactions. + +This is the challenge-driven layer above the raw instruction builders in +:mod:`pay_kit.protocols.mpp._paymentchannels`: it derives the full channel open +from a :class:`~pay_kit.protocols.mpp.intents.session.SessionRequest` +challenge (mint from the currency, deposit from the cap, token program from the +currency, splits, salt) and assembles the partially signed open transaction the +operator broadcasts. Mirrors ``rust/crates/mpp/src/client/payment_channels.rs`` +line by line; the TypeScript counterpart is +``typescript/packages/mpp/src/client/PaymentChannels.ts``. + +Encoding boundary: the open transaction travels as standard-alphabet base64 +WITH padding (it is an opaque transaction, not part of the canonical-JSON +credential envelope). +""" + +from __future__ import annotations + +import base64 +import secrets +from dataclasses import dataclass, field +from typing import Any, cast + +from solders.hash import Hash # type: ignore[import-untyped] +from solders.keypair import Keypair # type: ignore[import-untyped] +from solders.message import Message # type: ignore[import-untyped] +from solders.pubkey import Pubkey # type: ignore[import-untyped] +from solders.signature import Signature # type: ignore[import-untyped] +from solders.transaction import Transaction # type: ignore[import-untyped] + +from pay_kit._paycore.mints import resolve_stablecoin_mint +from pay_kit._paycore.solana import default_token_program_for_currency +from pay_kit.protocols.mpp._paymentchannels import ( + PROGRAM_ID, + Distribution, + OpenChannelParams, + build_open_instruction, + find_channel_pda, +) +from pay_kit.protocols.mpp.client.session import ActiveSession, VoucherSigner, _pubkey_str +from pay_kit.protocols.mpp.intents.session import ( + DEFAULT_SESSION_EXPIRES_AT, + OpenPayload, + SessionAction, + SessionMode, + SessionRequest, + _parse_base_units, +) + +__all__ = [ + "DEFAULT_GRACE_PERIOD_SECONDS", + "PENDING_SERVER_SIGNATURE", + "PaymentChannelOpen", + "PaymentChannelOpenTransaction", + "PaymentChannelOpenOptions", + "PaymentChannelSessionOpen", + "PaymentChannelSessionOpenOptions", + "ServerOpenedPaymentChannelSessionOpenOptions", + "build_open_payment_channel_transaction", + "create_payment_channel_session_opener", + "create_server_opened_payment_channel_session_opener", + "derive_payment_channel_open", + "generate_authorized_signer", + "unique_salt", +] + +#: Default payment-channel close grace period (seconds). Mirrors rust +#: ``DEFAULT_GRACE_PERIOD_SECONDS`` and the TypeScript client. +DEFAULT_GRACE_PERIOD_SECONDS = 900 + +#: Placeholder signature used while the operator still needs to submit the +#: server-broadcast open transaction. Mirrors rust ``PENDING_SERVER_SIGNATURE``. +PENDING_SERVER_SIGNATURE = "1111111111111111111111111111111111111111111111111111111111111111" + +_U64_MAX = 2**64 - 1 + + +def unique_salt() -> int: + """Return a random u64 channel salt. + + Mirrors rust ``unique_salt`` (which reads eight unique bytes little-endian); + Python draws the eight bytes from the CSPRNG. + """ + return int.from_bytes(secrets.token_bytes(8), "little") + + +def generate_authorized_signer() -> Keypair: + """Generate an ephemeral session signing key (canonical-flow step 2). + + The keypair's public key becomes the channel ``authorizedSigner``; pass the + keypair to the openers (or directly to :class:`ActiveSession`) as the + session signer. Mirrors the TypeScript openers' ``generateKeyPairSigner`` + call; the rust openers take an externally supplied ``SolanaSigner``. + """ + return Keypair() + + +@dataclass +class PaymentChannelOpen: + """A fully derived payment-channel open: addresses plus channel parameters. + + Mirrors rust ``PaymentChannelOpen``. + """ + + channel_id: Pubkey + payer: Pubkey + payee: Pubkey + mint: Pubkey + authorized_signer: Pubkey + salt: int + deposit: int + grace_period: int + recipients: list[Distribution] + token_program: Pubkey + program_id: Pubkey + + def open_channel_params(self) -> OpenChannelParams: + """Return the instruction-builder params for this open. + + Mirrors rust ``PaymentChannelOpen::open_channel_params``. + """ + return OpenChannelParams( + payer=self.payer, + payee=self.payee, + mint=self.mint, + authorized_signer=self.authorized_signer, + salt=self.salt, + deposit=self.deposit, + grace_period=self.grace_period, + recipients=list(self.recipients), + token_program=self.token_program, + program_id=self.program_id, + ) + + def open_payload(self, mode: SessionMode, signature: str) -> OpenPayload: + """Build the open action payload carrying the full channel parameters. + + Mirrors rust ``PaymentChannelOpen::open_payload``. + """ + return OpenPayload.payment_channel_with_mode( + mode, + str(self.channel_id), + str(self.deposit), + str(self.payer), + str(self.payee), + str(self.mint), + self.salt, + self.grace_period, + str(self.authorized_signer), + signature, + ) + + +@dataclass +class PaymentChannelOpenTransaction: + """A built open transaction plus the channel it opens. + + ``transaction`` is standard base64 with padding. Mirrors rust + ``PaymentChannelOpenTransaction``. + """ + + channel_id: Pubkey + transaction: str + + +@dataclass +class PaymentChannelOpenOptions: + """Optional overrides for deriving a payment-channel open. + + Every field falls back to a challenge-derived default: ``deposit`` to the + challenge ``cap``, ``grace_period`` to + :data:`DEFAULT_GRACE_PERIOD_SECONDS`, ``program_id`` to the challenge + ``programId`` (else the production program), ``recipients`` to the + challenge ``splits``, ``salt`` to :func:`unique_salt`, and + ``token_program`` to the program resolved from the challenge currency + (Token-2022 for PYUSD/USDG/CASH). Mirrors rust + ``PaymentChannelOpenOptions``. + """ + + deposit: int | None = None + grace_period: int | None = None + program_id: Pubkey | None = None + recipients: list[Distribution] | None = None + salt: int | None = None + token_program: Pubkey | None = None + + +@dataclass +class PaymentChannelSessionOpen: + """A derived open, the session tracking it, and the open action to send. + + Mirrors rust ``PaymentChannelSessionOpen``. + """ + + open: PaymentChannelOpen + session: ActiveSession + action: SessionAction + + +@dataclass +class PaymentChannelSessionOpenOptions: + """Options for :func:`create_payment_channel_session_opener`. + + Mirrors rust ``PaymentChannelSessionOpenOptions``. + """ + + open: PaymentChannelOpenOptions = field(default_factory=PaymentChannelOpenOptions) + signature: str | None = None + cumulative: int | None = None + expires_at: int | None = None + + +@dataclass +class ServerOpenedPaymentChannelSessionOpenOptions: + """Options for :func:`create_server_opened_payment_channel_session_opener`. + + Mirrors rust ``ServerOpenedPaymentChannelSessionOpenOptions``. + """ + + open: PaymentChannelOpenOptions = field(default_factory=PaymentChannelOpenOptions) + payer: Pubkey | None = None + signature: str | None = None + cumulative: int | None = None + expires_at: int | None = None + + +def derive_payment_channel_open( + request: SessionRequest, + payer: Pubkey, + authorized_signer: Pubkey, + options: PaymentChannelOpenOptions | None = None, +) -> PaymentChannelOpen: + """Derive the full channel open from a session challenge. + + Resolves the mint from the challenge currency (localnet falls back to the + mainnet mint), the deposit from the cap when no explicit deposit is given, + the token program from the currency, the recipients from the challenge + splits, and a random salt; then derives the channel PDA. Mirrors rust + ``derive_payment_channel_open``. + """ + options = options if options is not None else PaymentChannelOpenOptions() + network = request.network if request.network is not None else "mainnet" + resolved_mint = resolve_stablecoin_mint(request.currency, network) + if resolved_mint is None: + raise ValueError("session payment channels require an SPL token") + mint = _parse_pubkey(resolved_mint, "mint") + payee = _parse_pubkey(request.recipient, "recipient") + deposit = options.deposit if options.deposit is not None else _parse_u64_string(request.cap, "session cap") + grace_period = options.grace_period if options.grace_period is not None else DEFAULT_GRACE_PERIOD_SECONDS + if options.program_id is not None: + program_id = options.program_id + elif request.program_id is not None: + program_id = _parse_pubkey(request.program_id, "programId") + else: + program_id = PROGRAM_ID + if options.token_program is not None: + token_program = options.token_program + else: + token_program = _parse_pubkey( + default_token_program_for_currency(request.currency, network), + "token program", + ) + if options.recipients is not None: + recipients = list(options.recipients) + else: + recipients = [ + Distribution(recipient=_parse_pubkey(split.recipient, "split recipient"), bps=split.bps) + for split in request.splits + ] + salt = options.salt if options.salt is not None else unique_salt() + channel_id, _ = find_channel_pda(payer, payee, mint, authorized_signer, salt, program_id) + + return PaymentChannelOpen( + channel_id=channel_id, + payer=payer, + payee=payee, + mint=mint, + authorized_signer=authorized_signer, + salt=salt, + deposit=deposit, + grace_period=grace_period, + recipients=recipients, + token_program=token_program, + program_id=program_id, + ) + + +def build_open_payment_channel_transaction( + request: SessionRequest, + signer: VoucherSigner | Any, + authorized_signer: Pubkey, + recent_blockhash: Hash | str, + fee_payer: Pubkey | None = None, + options: PaymentChannelOpenOptions | None = None, +) -> PaymentChannelOpenTransaction: + """Build the payer-signed open transaction for operator broadcast. + + The fee payer defaults to the challenge ``operator`` and its signature slot + is intentionally left empty: the payer partial-signs only its own slot and + the server completes the fee-payer signature before broadcasting. Mirrors + rust ``build_open_payment_channel_transaction``. + """ + if fee_payer is None: + fee_payer = _parse_pubkey(request.operator, "operator") + open_ = derive_payment_channel_open( + request, + _signer_pubkey(signer), + authorized_signer, + options, + ) + return _build_open_payment_channel_tx(signer, open_, fee_payer, recent_blockhash) + + +def create_payment_channel_session_opener( + request: SessionRequest, + payer_signer: VoucherSigner | Any, + session_signer: VoucherSigner | Any, + recent_blockhash: Hash | str, + options: PaymentChannelSessionOpenOptions | None = None, +) -> PaymentChannelSessionOpen: + """Open a pull/clientVoucher session with a client-built open transaction. + + Requires the challenge to advertise pull + clientVoucher; builds the + partially signed open transaction (fee payer = operator), attaches it to + the open action, and returns the :class:`ActiveSession` keyed to the + derived channel. The action signature defaults to + :data:`PENDING_SERVER_SIGNATURE` until the operator broadcasts. Mirrors + rust ``create_payment_channel_session_opener``. + """ + options = options if options is not None else PaymentChannelSessionOpenOptions() + _ensure_client_voucher_pull(request) + authorized_signer = _signer_pubkey(session_signer) + fee_payer = _parse_pubkey(request.operator, "operator") + open_ = derive_payment_channel_open( + request, + _signer_pubkey(payer_signer), + authorized_signer, + options.open, + ) + tx = _build_open_payment_channel_tx(payer_signer, open_, fee_payer, recent_blockhash) + session = _configured_session(open_.channel_id, session_signer, options.cumulative, options.expires_at) + signature = options.signature if options.signature is not None else PENDING_SERVER_SIGNATURE + action = SessionAction.open_action(open_.open_payload("pull", signature).with_transaction(tx.transaction)) + return PaymentChannelSessionOpen(open=open_, session=session, action=action) + + +def create_server_opened_payment_channel_session_opener( + request: SessionRequest, + session_signer: VoucherSigner | Any, + options: ServerOpenedPaymentChannelSessionOpenOptions | None = None, +) -> PaymentChannelSessionOpen: + """Open a pull/clientVoucher session whose channel the operator funds. + + No transaction is built: the payer defaults to the challenge ``operator`` + and the server constructs, funds, and broadcasts the open itself. Mirrors + rust ``create_server_opened_payment_channel_session_opener``. + """ + options = options if options is not None else ServerOpenedPaymentChannelSessionOpenOptions() + _ensure_client_voucher_pull(request) + payer = options.payer if options.payer is not None else _parse_pubkey(request.operator, "operator") + authorized_signer = _signer_pubkey(session_signer) + open_ = derive_payment_channel_open(request, payer, authorized_signer, options.open) + session = _configured_session(open_.channel_id, session_signer, options.cumulative, options.expires_at) + signature = options.signature if options.signature is not None else PENDING_SERVER_SIGNATURE + action = SessionAction.open_action(open_.open_payload("pull", signature)) + return PaymentChannelSessionOpen(open=open_, session=session, action=action) + + +def _build_open_payment_channel_tx( + signer: VoucherSigner | Any, + open_: PaymentChannelOpen, + fee_payer: Pubkey, + recent_blockhash: Hash | str, +) -> PaymentChannelOpenTransaction: + """Assemble the open message and partial-sign only the payer slot. + + Mirrors rust ``build_open_payment_channel_tx``: a legacy transaction whose + fee payer is the operator (signature slot left as the default placeholder) + and whose payer signature is filled in, serialized as standard base64 with + padding. + """ + blockhash = recent_blockhash if isinstance(recent_blockhash, Hash) else Hash.from_string(recent_blockhash) + ix = build_open_instruction(open_.open_channel_params()) + message = Message.new_with_blockhash([ix], fee_payer, blockhash) + message_bytes = bytes(message) + + payer = _signer_pubkey(signer) + num_required = message.header.num_required_signatures + signer_keys = list(message.account_keys)[:num_required] + try: + payer_index = signer_keys.index(payer) + except ValueError as exc: + raise ValueError("payment-channel open signing failed: payer is not a transaction signer") from exc + + signatures = [Signature.default() for _ in range(num_required)] + signatures[payer_index] = _sign_message(signer, message_bytes) + tx = Transaction.populate(message, signatures) + encoded = base64.b64encode(bytes(tx)).decode("ascii") + return PaymentChannelOpenTransaction(channel_id=open_.channel_id, transaction=encoded) + + +def _ensure_client_voucher_pull(request: SessionRequest) -> None: + """Require the challenge to advertise pull mode with clientVoucher. + + Mirrors rust ``ensure_client_voucher_pull``. + """ + if "pull" not in request.modes: + raise ValueError("session challenge does not advertise pull mode") + if request.pull_voucher_strategy != "clientVoucher": + raise ValueError("session challenge does not advertise pull + clientVoucher") + + +def _configured_session( + channel_id: Pubkey, + session_signer: VoucherSigner | Any, + cumulative: int | None, + expires_at: int | None, +) -> ActiveSession: + """Build the opener's ActiveSession with resume options applied. + + Mirrors rust ``configure_session``. + """ + return ActiveSession( + channel_id, + session_signer, + expires_at if expires_at is not None else DEFAULT_SESSION_EXPIRES_AT, + cumulative=cumulative if cumulative is not None else 0, + ) + + +def _sign_message(signer: Any, message: bytes) -> Signature: + """Sign raw message bytes with a pay_kit signer or a solders Keypair.""" + sign = getattr(signer, "sign", None) + if callable(sign): + raw = cast("bytes", sign(message)) + return Signature.from_bytes(bytes(raw)) + return signer.sign_message(message) + + +def _signer_pubkey(signer: Any) -> Pubkey: + """Return the signer's public key as a solders Pubkey.""" + return Pubkey.from_string(_pubkey_str(signer)) + + +def _parse_u64_string(value: str, label: str) -> int: + """Parse a u64 decimal string, mirroring rust ``parse_u64_string``.""" + try: + return _parse_base_units(value) + except ValueError as exc: + raise ValueError(f"invalid {label}: {value!r}") from exc + + +def _parse_pubkey(value: str, label: str) -> Pubkey: + """Parse a base58 pubkey, mirroring rust ``parse_pubkey``.""" + try: + return Pubkey.from_string(value) + except (ValueError, TypeError) as exc: + raise ValueError(f"invalid {label}: {value!r}") from exc diff --git a/python/src/pay_kit/protocols/mpp/client/session.py b/python/src/pay_kit/protocols/mpp/client/session.py index 26022a341..948b68e2f 100644 --- a/python/src/pay_kit/protocols/mpp/client/session.py +++ b/python/src/pay_kit/protocols/mpp/client/session.py @@ -7,9 +7,12 @@ settle instruction consumes. Scope is client-only PUSH (payment channel) plus pull/clientVoucher: the client -signs cumulative vouchers off-chain. Pull/operatedVoucher (the multi-delegator -program) and the server verification path are out of scope, but the wire fields -stay present so the action union round-trips. +signs cumulative vouchers off-chain. The challenge-driven open layer (deriving +the channel from a challenge and assembling the partially signed open +transaction) lives in :mod:`pay_kit.protocols.mpp.client.payment_channels`. +Pull/operatedVoucher (the multi-delegator program) and the server verification +path are out of scope, but the wire fields stay present so the action union +round-trips. Behavior mirrors the Rust spine in ``rust/crates/mpp/src/client/session.rs`` and the parity-verified Go port in @@ -51,6 +54,7 @@ "ActiveSession", "serialize_session_credential", "parse_session_challenge", + "session_request_modes", ] #: Default voucher expiry: 2100-01-01T00:00:00Z. Stays below JavaScript's max @@ -127,6 +131,8 @@ def __init__( channel_id: Any, signer: VoucherSigner | Any, expires_at: int = DEFAULT_VOUCHER_EXPIRES_AT, + *, + cumulative: int = 0, ) -> None: """Create a session tracker for ``channel_id`` signing with ``signer``. @@ -134,13 +140,16 @@ def __init__( obtained after opening); ``signer`` satisfies the :class:`VoucherSigner` contract (or is a solders ``Keypair``, accepted via its ``sign_message`` method); ``expires_at`` is the Unix timestamp applied to newly signed - vouchers, defaulting to :data:`DEFAULT_VOUCHER_EXPIRES_AT`. Mirrors rust - ``ActiveSession::new`` / ``new_with_expiry``. + vouchers, defaulting to :data:`DEFAULT_VOUCHER_EXPIRES_AT`; + ``cumulative`` seeds the watermark when resuming a known channel + position (the payment-channel openers use it, mirroring the rust + ``configure_session`` helper writing the public ``cumulative`` field). + Mirrors rust ``ActiveSession::new`` / ``new_with_expiry``. """ self._channel_id = channel_id self._signer = signer self._expires_at = expires_at - self._cumulative = 0 + self._cumulative = cumulative self._nonce = 0 @classmethod @@ -444,3 +453,14 @@ def parse_session_challenge(header: str) -> tuple[PaymentChallenge, SessionReque raise ValueError(f"challenge intent {challenge.intent!r} is not a session") request = SessionRequest.from_dict(decode_json(challenge.request)) return challenge, request + + +def session_request_modes(request: SessionRequest) -> list[SessionMode]: + """Return the funding modes advertised by a session challenge. + + ``modes`` omitted or empty means push-only; an explicit ``[]`` therefore + decodes the same as a missing field. Mirrors the TypeScript + ``sessionRequestModes`` helper; the rust client checks ``modes`` membership + directly with the same default semantics. + """ + return list(request.modes) if request.modes else ["push"] diff --git a/python/tests/test_session_client.py b/python/tests/test_session_client.py index cfcc13a0e..d7eab101b 100644 --- a/python/tests/test_session_client.py +++ b/python/tests/test_session_client.py @@ -22,11 +22,17 @@ ActiveSession, parse_session_challenge, serialize_session_credential, + session_request_modes, ) from pay_kit.protocols.mpp.core.base64url import encode_json from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization from pay_kit.protocols.mpp.core.types import PaymentChallenge -from pay_kit.protocols.mpp.intents.session import SessionAction, SignedVoucher, VoucherData +from pay_kit.protocols.mpp.intents.session import ( + SessionAction, + SessionRequest, + SignedVoucher, + VoucherData, +) class _BytesSigner: @@ -447,3 +453,29 @@ def test_parse_session_challenge_rejects_non_session_intent() -> None: header = format_www_authenticate(charge) with pytest.raises(ValueError, match="is not a session"): parse_session_challenge(header) + + +# ── session_request_modes ── + + +def test_session_request_modes_defaults_to_push_only() -> None: + # ``modes`` omitted or explicitly empty both mean push-only; serde collapses + # the two on the wire and the selector encodes the interpretation. Mirrors + # the TS ``sessionRequestModes`` helper. + base = {"cap": "1", "currency": "USDC", "operator": "op", "recipient": "rec"} + omitted = SessionRequest.from_dict(base) + assert session_request_modes(omitted) == ["push"] + explicit_empty = SessionRequest.from_dict({**base, "modes": []}) + assert session_request_modes(explicit_empty) == ["push"] + + +def test_session_request_modes_preserves_advertised_modes() -> None: + request = SessionRequest( + cap="1", + currency="USDC", + operator="op", + recipient="rec", + modes=["pull", "push"], + pull_voucher_strategy="clientVoucher", + ) + assert session_request_modes(request) == ["pull", "push"] diff --git a/python/tests/test_session_payment_channels.py b/python/tests/test_session_payment_channels.py new file mode 100644 index 000000000..c45262f1f --- /dev/null +++ b/python/tests/test_session_payment_channels.py @@ -0,0 +1,360 @@ +"""Tests for the challenge-driven payment-channel open layer. + +Mirrors the ``#[cfg(test)] mod tests`` in +``rust/crates/mpp/src/client/payment_channels.rs``: challenge-derived defaults +(deposit from cap, grace 900, mint and token program from the currency, +splits), explicit option overrides, invalid challenge values, the partially +signed open transaction (fee payer = operator with an empty signature slot), +and the pull/clientVoucher session openers with the pending-server-signature +placeholder. +""" + +from __future__ import annotations + +import base64 + +import pytest +from solders.hash import Hash # type: ignore[import-untyped] +from solders.keypair import Keypair # type: ignore[import-untyped] +from solders.pubkey import Pubkey # type: ignore[import-untyped] +from solders.signature import Signature # type: ignore[import-untyped] +from solders.transaction import Transaction # type: ignore[import-untyped] + +from pay_kit._paycore.solana import TOKEN_2022_PROGRAM, TOKEN_PROGRAM +from pay_kit.protocols.mpp._paymentchannels import ( + PROGRAM_ID, + Distribution, + find_channel_pda, +) +from pay_kit.protocols.mpp.client.payment_channels import ( + DEFAULT_GRACE_PERIOD_SECONDS, + PENDING_SERVER_SIGNATURE, + PaymentChannelOpenOptions, + PaymentChannelSessionOpenOptions, + ServerOpenedPaymentChannelSessionOpenOptions, + build_open_payment_channel_transaction, + create_payment_channel_session_opener, + create_server_opened_payment_channel_session_opener, + derive_payment_channel_open, + generate_authorized_signer, + unique_salt, +) +from pay_kit.protocols.mpp.intents.session import SessionRequest, SessionSplit + +_USDC_MAINNET = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" +_PYUSD_MAINNET = "2b1kV6DkPAnxd5ixfnxCpjxmKwqjjaYmCZfHsFu24GXo" +_U64_MAX = 2**64 - 1 + + +def _kp(seed: int) -> Keypair: + return Keypair.from_seed(bytes([seed] * 32)) + + +def _pk(seed: int) -> Pubkey: + return _kp(seed).pubkey() + + +def _request(operator: Pubkey, recipient: Pubkey, currency: str = "USDC") -> SessionRequest: + return SessionRequest( + cap="1000", + currency=currency, + operator=str(operator), + recipient=str(recipient), + decimals=6, + network="localnet", + modes=["pull"], + pull_voucher_strategy="clientVoucher", + ) + + +def _decode_tx(encoded: str) -> Transaction: + return Transaction.from_bytes(base64.b64decode(encoded, validate=True)) + + +# -- derive_payment_channel_open --------------------------------------------- + + +def test_derive_uses_challenge_defaults_and_splits() -> None: + split_recipient = _pk(3) + request = _request(_pk(1), _pk(2)) + request.splits.append(SessionSplit(recipient=str(split_recipient), bps=10)) + + payer = _pk(4) + authorized_signer = _pk(5) + open_ = derive_payment_channel_open( + request, + payer, + authorized_signer, + PaymentChannelOpenOptions(salt=42), + ) + + assert open_.payer == payer + assert open_.payee == _pk(2) + assert open_.authorized_signer == authorized_signer + assert open_.deposit == 1000 # deposit defaults to the cap + assert open_.grace_period == DEFAULT_GRACE_PERIOD_SECONDS + assert open_.salt == 42 + assert open_.recipients == [Distribution(recipient=split_recipient, bps=10)] + # localnet resolves to the mainnet mint (Surfpool clones mainnet state). + assert str(open_.mint) == _USDC_MAINNET + assert str(open_.token_program) == TOKEN_PROGRAM + assert open_.program_id == PROGRAM_ID + expected_channel, _ = find_channel_pda(payer, _pk(2), open_.mint, authorized_signer, 42, PROGRAM_ID) + assert open_.channel_id == expected_channel + + +def test_derive_resolves_token_2022_from_currency() -> None: + open_ = derive_payment_channel_open( + _request(_pk(1), _pk(2), currency="PYUSD"), + _pk(4), + _pk(5), + PaymentChannelOpenOptions(salt=7), + ) + assert str(open_.mint) == _PYUSD_MAINNET + assert str(open_.token_program) == TOKEN_2022_PROGRAM + + +def test_derive_honors_explicit_options() -> None: + program_id = _pk(9) + split_recipient = _pk(3) + token_program = Pubkey.from_string(TOKEN_2022_PROGRAM) + request = _request(_pk(1), _pk(2)) + request.cap = "not-a-number" + request.splits.append(SessionSplit(recipient="not-a-pubkey", bps=999)) + + open_ = derive_payment_channel_open( + request, + _pk(4), + _pk(5), + PaymentChannelOpenOptions( + deposit=55, + grace_period=12, + program_id=program_id, + recipients=[Distribution(recipient=split_recipient, bps=25)], + salt=7, + token_program=token_program, + ), + ) + + assert open_.deposit == 55 + assert open_.grace_period == 12 + assert open_.program_id == program_id + assert open_.token_program == token_program + assert open_.recipients == [Distribution(recipient=split_recipient, bps=25)] + assert open_.open_channel_params().program_id == program_id + + +def test_derive_rejects_invalid_challenge_values() -> None: + payer, signer = _pk(4), _pk(5) + + request = _request(_pk(1), _pk(2)) + request.currency = "SOL" + with pytest.raises(ValueError, match="SPL token"): + derive_payment_channel_open(request, payer, signer) + + request = _request(_pk(1), _pk(2)) + request.cap = "not-a-number" + with pytest.raises(ValueError, match="session cap"): + derive_payment_channel_open(request, payer, signer) + + request = _request(_pk(1), _pk(2)) + request.recipient = "not-a-pubkey" + with pytest.raises(ValueError, match="recipient"): + derive_payment_channel_open(request, payer, signer) + + request = _request(_pk(1), _pk(2)) + request.program_id = "not-a-program" + with pytest.raises(ValueError, match="programId"): + derive_payment_channel_open(request, payer, signer) + + request = _request(_pk(1), _pk(2)) + request.splits.append(SessionSplit(recipient="not-a-pubkey", bps=10)) + with pytest.raises(ValueError, match="split recipient"): + derive_payment_channel_open(request, payer, signer) + + +# -- build_open_payment_channel_transaction ----------------------------------- + + +def test_build_open_transaction_partially_signs_for_operator_broadcast() -> None: + operator = _pk(1) + request = _request(operator, _pk(2)) + payer_signer = _kp(7) + authorized_signer = _pk(8) + + built = build_open_payment_channel_transaction( + request, + payer_signer, + authorized_signer, + Hash.default(), + options=PaymentChannelOpenOptions(salt=99), + ) + expected = derive_payment_channel_open( + request, + payer_signer.pubkey(), + authorized_signer, + PaymentChannelOpenOptions(salt=99), + ) + assert built.channel_id == expected.channel_id + + tx = _decode_tx(built.transaction) + account_keys = list(tx.message.account_keys) + assert account_keys[0] == operator # fee payer = challenge operator + assert len(tx.message.instructions) == 1 + # The operator slot is left unsigned; the payer slot carries a real + # signature over the message bytes. + payer_index = account_keys.index(payer_signer.pubkey()) + assert tx.signatures[0] == Signature.default() + assert tx.signatures[payer_index] != Signature.default() + assert tx.signatures[payer_index].verify(payer_signer.pubkey(), bytes(tx.message)) + + +def test_build_open_transaction_accepts_duck_typed_signer() -> None: + class _BytesSigner: + def __init__(self, kp: Keypair) -> None: + self._kp = kp + + def pubkey(self) -> str: + return str(self._kp.pubkey()) + + def sign(self, message: bytes) -> bytes: + return bytes(self._kp.sign_message(message)) + + kp = _kp(11) + built = build_open_payment_channel_transaction( + _request(_pk(1), _pk(2)), + _BytesSigner(kp), + _pk(8), + Hash.default(), + options=PaymentChannelOpenOptions(salt=99), + ) + tx = _decode_tx(built.transaction) + payer_index = list(tx.message.account_keys).index(kp.pubkey()) + assert tx.signatures[payer_index].verify(kp.pubkey(), bytes(tx.message)) + + +def test_build_open_transaction_uses_explicit_fee_payer() -> None: + explicit_fee_payer = _pk(6) + built = build_open_payment_channel_transaction( + _request(_pk(1), _pk(2)), + _kp(15), + _pk(16), + Hash.default(), + fee_payer=explicit_fee_payer, + options=PaymentChannelOpenOptions(salt=123), + ) + tx = _decode_tx(built.transaction) + assert list(tx.message.account_keys)[0] == explicit_fee_payer + + +# -- session openers ----------------------------------------------------------- + + +def test_opener_builds_pull_client_voucher_action() -> None: + request = _request(_pk(1), _pk(2)) + payer_signer = _kp(9) + session_signer = _kp(10) + + opened = create_payment_channel_session_opener( + request, + payer_signer, + session_signer, + Hash.default(), + PaymentChannelSessionOpenOptions(open=PaymentChannelOpenOptions(salt=11)), + ) + + assert opened.session.channel_id == opened.open.channel_id + payload = opened.action.open + assert payload is not None + assert payload.mode == "pull" + assert payload.channel_id == str(opened.open.channel_id) + assert payload.payer == str(payer_signer.pubkey()) + assert payload.authorized_signer == str(session_signer.pubkey()) + assert payload.signature == PENDING_SERVER_SIGNATURE + assert payload.transaction is not None + assert payload.token_account is None + assert payload.approved_amount is None + assert payload.init_multi_delegate_tx is None + assert payload.update_delegation_tx is None + + +def test_opener_applies_session_options() -> None: + opened = create_payment_channel_session_opener( + _request(_pk(1), _pk(2)), + _kp(17), + _kp(18), + Hash.default(), + PaymentChannelSessionOpenOptions( + open=PaymentChannelOpenOptions(salt=19), + signature="operator-will-fill", + cumulative=20, + expires_at=1234, + ), + ) + payload = opened.action.open + assert payload is not None + assert payload.signature == "operator-will-fill" + voucher = opened.session.prepare_increment(5) + assert voucher.data.cumulative == "25" + assert voucher.data.expires_at == 1234 + + +def test_server_opened_opener_uses_operator_payer_without_transaction() -> None: + operator = _pk(1) + request = _request(operator, _pk(2)) + session_signer = _kp(12) + + opened = create_server_opened_payment_channel_session_opener( + request, + session_signer, + ServerOpenedPaymentChannelSessionOpenOptions(open=PaymentChannelOpenOptions(salt=13)), + ) + + assert opened.open.payer == operator + payload = opened.action.open + assert payload is not None + assert payload.mode == "pull" + assert payload.payer == str(operator) + assert payload.authorized_signer == str(session_signer.pubkey()) + assert payload.signature == PENDING_SERVER_SIGNATURE + assert payload.transaction is None + assert payload.token_account is None + assert payload.approved_amount is None + + +def test_opener_rejects_non_pull_challenge() -> None: + request = _request(_pk(1), _pk(2)) + request.modes = ["push"] + request.pull_voucher_strategy = None + with pytest.raises(ValueError, match="pull mode"): + create_server_opened_payment_channel_session_opener(request, _kp(20)) + + +def test_opener_rejects_operated_voucher_pull_challenge() -> None: + request = _request(_pk(1), _pk(2)) + request.pull_voucher_strategy = "operatedVoucher" + with pytest.raises(ValueError, match="pull \\+ clientVoucher"): + create_server_opened_payment_channel_session_opener(request, _kp(14)) + + +# -- helpers ------------------------------------------------------------------- + + +def test_unique_salt_is_a_u64() -> None: + salts = {unique_salt() for _ in range(8)} + assert all(0 <= salt <= _U64_MAX for salt in salts) + assert len(salts) > 1 # eight CSPRNG draws collide with negligible odds + + +def test_generate_authorized_signer_returns_usable_session_keypair() -> None: + signer = generate_authorized_signer() + assert isinstance(signer, Keypair) + opened = create_server_opened_payment_channel_session_opener( + _request(_pk(1), _pk(2)), + signer, + ServerOpenedPaymentChannelSessionOpenOptions(open=PaymentChannelOpenOptions(salt=21)), + ) + assert opened.session.authorized_signer == str(signer.pubkey()) + voucher = opened.session.sign_increment(5) + sig = Signature.from_string(voucher.signature) + assert sig.verify(signer.pubkey(), voucher.data.message_bytes()) From 27de6ec5bc6fa91cb47bd81d5f5a1323eca126ba Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 12 Jun 2026 14:18:54 +0300 Subject: [PATCH 12/52] feat(python): add metered SSE stream consumption 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. --- .../pay_kit/protocols/mpp/client/__init__.py | 25 +- .../protocols/mpp/client/http_stream.py | 363 ++++++++++++++++++ python/tests/test_http_stream.py | 343 +++++++++++++++++ 3 files changed, 727 insertions(+), 4 deletions(-) create mode 100644 python/src/pay_kit/protocols/mpp/client/http_stream.py create mode 100644 python/tests/test_http_stream.py diff --git a/python/src/pay_kit/protocols/mpp/client/__init__.py b/python/src/pay_kit/protocols/mpp/client/__init__.py index 1438186f0..2d4acdd5f 100644 --- a/python/src/pay_kit/protocols/mpp/client/__init__.py +++ b/python/src/pay_kit/protocols/mpp/client/__init__.py @@ -2,18 +2,28 @@ Exposes the charge transport plus the client-only session surface: the :class:`ActiveSession` voucher tracker, the :class:`SessionConsumer` metered -ack/commit helper, the challenge-driven payment-channel openers, and the -:func:`serialize_session_credential` / :func:`parse_session_challenge` -credential framing free functions. The per-intent modules -(:mod:`pay_kit.protocols.mpp.client.charge`, +ack/commit helper, the challenge-driven payment-channel openers, the metered +SSE streaming helpers, and the :func:`serialize_session_credential` / +:func:`parse_session_challenge` credential framing free functions. The +per-intent modules (:mod:`pay_kit.protocols.mpp.client.charge`, :mod:`pay_kit.protocols.mpp.client.session`, :mod:`pay_kit.protocols.mpp.client.payment_channels`, +:mod:`pay_kit.protocols.mpp.client.http_stream`, :mod:`pay_kit.protocols.mpp.client.session_consumer`) remain the canonical import path; the session types are re-exported here for convenience. """ from __future__ import annotations +from pay_kit.protocols.mpp.client.http_stream import ( + HttpCommitTransport, + MeteredSseEvent, + MeteredSseSession, + MeteredSseStream, + SseDecoder, + SseEvent, + parse_metered_sse_event, +) from pay_kit.protocols.mpp.client.payment_channels import ( DEFAULT_GRACE_PERIOD_SECONDS, PENDING_SERVER_SIGNATURE, @@ -64,4 +74,11 @@ "derive_payment_channel_open", "generate_authorized_signer", "unique_salt", + "HttpCommitTransport", + "MeteredSseEvent", + "MeteredSseSession", + "MeteredSseStream", + "SseDecoder", + "SseEvent", + "parse_metered_sse_event", ] diff --git a/python/src/pay_kit/protocols/mpp/client/http_stream.py b/python/src/pay_kit/protocols/mpp/client/http_stream.py new file mode 100644 index 000000000..b26dc066c --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/client/http_stream.py @@ -0,0 +1,363 @@ +"""HTTP streaming helpers for metered sessions. + +LLM APIs commonly stream responses over Server-Sent Events (SSE) or chunked +HTTP. This module keeps the parser transport-neutral (an incremental +:class:`SseDecoder` plus the metered event state machine), then layers a small +httpx adapter on top for applications that want batteries included. + +Mirrors ``rust/crates/mpp/src/client/http_stream.rs``; the TypeScript +counterpart is ``typescript/packages/mpp/src/client/HttpStream.ts``. The +metering rules enforced here are the skill-mandated pair: a usage event's +``deliveryId`` must match the live directive, and usage overrides only the +amount, never the ``deliveryId``. +""" + +from __future__ import annotations + +import contextlib +import json +from collections.abc import Iterable, Iterator +from dataclasses import dataclass, replace +from typing import Any, Literal + +from pay_kit.protocols.mpp.client.session_consumer import SessionConsumer +from pay_kit.protocols.mpp.intents.session import ( + CommitPayload, + CommitReceipt, + MeteringDirective, + MeteringUsage, +) + +__all__ = [ + "SseEvent", + "SseDecoder", + "MeteredSseEvent", + "parse_metered_sse_event", + "MeteredSseSession", + "MeteredSseStream", + "HttpCommitTransport", +] + + +@dataclass +class SseEvent: + """A parsed Server-Sent Event frame. Mirrors rust ``SseEvent``.""" + + event: str | None = None + data: str = "" + id: str | None = None + retry: int | None = None + + +class SseDecoder: + """Incremental SSE decoder. + + Feed raw HTTP chunks with :meth:`push_chunk`. It returns all complete + events decoded from that chunk and retains partial data internally. + Mirrors rust ``SseDecoder``. + """ + + def __init__(self) -> None: + """Create an empty decoder.""" + self._buffer = "" + self._current = SseEvent() + + def push_chunk(self, chunk: bytes) -> list[SseEvent]: + """Decode a raw chunk, returning every event completed by it.""" + try: + text = chunk.decode("utf-8") + except UnicodeDecodeError as exc: + raise ValueError(f"SSE chunk is not valid UTF-8: {exc}") from exc + self._buffer += text + + events: list[SseEvent] = [] + while True: + index = self._buffer.find("\n") + if index == -1: + break + line = self._buffer[:index] + self._buffer = self._buffer[index + 1 :] + if line.endswith("\r"): + line = line[:-1] + event = self._process_line(line) + if event is not None: + events.append(event) + return events + + def finish(self) -> list[SseEvent]: + """Flush an incomplete final event, if any, at EOF.""" + events: list[SseEvent] = [] + if self._buffer: + line = self._buffer + self._buffer = "" + event = self._process_line(line.rstrip("\r")) + if event is not None: + events.append(event) + event = self._dispatch_current() + if event is not None: + events.append(event) + return events + + def _process_line(self, line: str) -> SseEvent | None: + if not line: + return self._dispatch_current() + if line.startswith(":"): + return None + + if ":" in line: + field_name, value = line.split(":", 1) + if value.startswith(" "): + value = value[1:] + else: + field_name, value = line, "" + + if field_name == "event": + self._current.event = value + elif field_name == "data": + if self._current.data: + self._current.data += "\n" + self._current.data += value + elif field_name == "id": + self._current.id = value + elif field_name == "retry": + with contextlib.suppress(ValueError): + self._current.retry = int(value, 10) + return None + + def _dispatch_current(self) -> SseEvent | None: + current = self._current + if current.event is None and not current.data and current.id is None and current.retry is None: + return None + self._current = SseEvent() + return current + + +@dataclass +class MeteredSseEvent: + """A parsed metered SSE event, tagged by ``kind``. + + Mirrors the rust ``MeteredSseEvent`` enum: exactly one payload field is set + for the matching ``kind`` (``done`` carries none). + """ + + kind: Literal["metering", "usage", "message", "done", "other"] + metering: MeteringDirective | None = None + usage: MeteringUsage | None = None + message: Any = None + other: SseEvent | None = None + + +def parse_metered_sse_event(event: SseEvent) -> MeteredSseEvent: + """Classify an SSE frame as metering, usage, message, done, or other. + + Recognizes the canonical event names ``mpp.metering``/``metering`` and + ``mpp.usage``/``usage``, the ``done`` event, and the ``[DONE]`` sentinel on + a plain message. Mirrors rust ``parse_metered_sse_event``; message payloads + are decoded as JSON values rather than a typed parameter. + """ + event_name = event.event if event.event is not None else "message" + if event_name in ("mpp.metering", "metering"): + try: + directive = MeteringDirective.from_dict(json.loads(event.data)) + except (ValueError, TypeError, AttributeError) as exc: + raise ValueError(f"invalid mpp.metering event: {exc}") from exc + return MeteredSseEvent(kind="metering", metering=directive) + if event_name in ("mpp.usage", "usage"): + try: + usage = MeteringUsage.from_dict(json.loads(event.data)) + except (ValueError, TypeError, AttributeError) as exc: + raise ValueError(f"invalid mpp.usage event: {exc}") from exc + return MeteredSseEvent(kind="usage", usage=usage) + if event_name == "done": + return MeteredSseEvent(kind="done") + if event_name == "message": + if event.data.strip() == "[DONE]": + return MeteredSseEvent(kind="done") + try: + message = json.loads(event.data) + except ValueError as exc: + raise ValueError(f"invalid SSE message event: {exc}") from exc + return MeteredSseEvent(kind="message", message=message) + return MeteredSseEvent(kind="other", other=event) + + +class _MeteredStreamState: + """Directive/usage/done bookkeeping shared by the stream wrappers. + + Mirrors rust ``MeteredStreamState``. + """ + + def __init__(self) -> None: + self.directive: MeteringDirective | None = None + self.final_amount: int | None = None + self.done = False + + def apply_event(self, event: SseEvent) -> Any | None: + """Fold one SSE frame into the state, returning a message payload.""" + parsed = parse_metered_sse_event(event) + if parsed.kind == "metering": + self.directive = parsed.metering + return None + if parsed.kind == "usage": + usage = parsed.usage + if usage is None: # pragma: no cover - parse always sets it + raise ValueError("invalid mpp.usage event: missing payload") + if self.directive is not None and usage.delivery_id != self.directive.delivery_id: + raise ValueError( + f"usage delivery {usage.delivery_id} does not match directive {self.directive.delivery_id}" + ) + self.final_amount = usage.amount_base_units() + return None + if parsed.kind == "message": + return parsed.message + if parsed.kind == "done": + self.done = True + return None + + def directive_for_commit(self) -> MeteringDirective: + """Return the directive to commit, with usage overriding the amount. + + Usage overrides only the amount, never the ``deliveryId``. + """ + if self.directive is None: + raise ValueError("stream did not include mpp.metering event") + if self.final_amount is not None: + return replace(self.directive, amount=str(self.final_amount)) + return self.directive + + +class MeteredSseSession: + """Borrowed state machine for a metered SSE stream. + + Feed decoded frames with :meth:`accept_event`; call :meth:`ack` after the + stream ends to sign and commit a voucher for the metered amount (the + reserved directive amount, or the final usage amount when one arrived). + Mirrors rust ``MeteredSseSession``. + """ + + def __init__(self, consumer: SessionConsumer) -> None: + """Wrap a consumer; mirrors rust ``SessionConsumer::metered_sse``.""" + self._consumer = consumer + self._state = _MeteredStreamState() + + @property + def consumer(self) -> SessionConsumer: + """The wrapped consumer.""" + return self._consumer + + def accept_event(self, event: SseEvent) -> Any | None: + """Fold one SSE frame in, returning a message payload when present.""" + return self._state.apply_event(event) + + @property + def is_done(self) -> bool: + """True once a ``done`` event or ``[DONE]`` sentinel arrived.""" + return self._state.done + + def ack(self) -> CommitReceipt: + """Sign and commit a voucher for the streamed delivery.""" + directive = self._state.directive_for_commit() + return self._consumer.commit_directive(directive) + + +class MeteredSseStream: + """A metered SSE stream over any iterable of raw byte chunks. + + Works with ``httpx.Response.iter_bytes()`` or any other chunk source; the + rust counterpart (``ReqwestMeteredSseStream``) binds to reqwest, Python + stays transport-neutral. Iterate with :meth:`next` (or as an iterator) and + :meth:`ack` once to commit. + """ + + def __init__(self, consumer: SessionConsumer, chunks: Iterable[bytes]) -> None: + """Wrap a consumer and a raw chunk source.""" + self._consumer = consumer + self._chunks = iter(chunks) + self._decoder = SseDecoder() + self._pending: list[Any] = [] + self._state = _MeteredStreamState() + + def next(self) -> Any | None: + """Return the next message payload, or ``None`` at end of stream.""" + while True: + if self._pending: + return self._pending.pop(0) + if self._state.done: + return None + chunk = next(self._chunks, None) + if chunk is None: + for event in self._decoder.finish(): + message = self._state.apply_event(event) + if message is not None: + self._pending.append(message) + self._state.done = True + continue + for event in self._decoder.push_chunk(chunk): + message = self._state.apply_event(event) + if message is not None: + self._pending.append(message) + + def __iter__(self) -> Iterator[Any]: + """Yield message payloads until the stream ends.""" + while True: + message = self.next() + if message is None: + return + yield message + + def ack(self) -> CommitReceipt: + """Drain the stream if needed, then sign and commit the delivery.""" + if not self._state.done: + while self.next() is not None: + pass + directive = self._state.directive_for_commit() + return self._consumer.commit_directive(directive) + + def into_consumer(self) -> SessionConsumer: + """Return the wrapped consumer for reuse on the next request.""" + return self._consumer + + +class HttpCommitTransport: + """Minimal HTTP transport for commit endpoints. + + Posts the :class:`CommitPayload` JSON to the directive ``commitUrl`` (or a + default), optionally with an Authorization header, and decodes the + :class:`CommitReceipt`. Mirrors rust ``HttpCommitTransport``; pass an + ``httpx.Client`` to control timeouts or inject a mock transport in tests. + """ + + def __init__( + self, + client: Any | None = None, + default_commit_url: str | None = None, + authorization: str | None = None, + ) -> None: + """Create a transport over an optional httpx client and defaults.""" + if client is None: + import httpx + + client = httpx.Client() + self._client = client + self._default_commit_url = default_commit_url + self._authorization = authorization + + def commit(self, directive: MeteringDirective, payload: CommitPayload) -> CommitReceipt: + """POST the commit payload and decode the receipt.""" + url = directive.commit_url if directive.commit_url is not None else self._default_commit_url + if url is None: + raise ValueError("metering directive missing commitUrl") + + headers: dict[str, str] = {} + if self._authorization is not None: + headers["authorization"] = self._authorization + try: + response = self._client.post(url, json=payload.to_dict(), headers=headers) + except Exception as exc: # noqa: BLE001 - transport errors surface as commit failures + raise ValueError(f"commit request failed: {exc}") from exc + if response.status_code < 200 or response.status_code >= 300: + raise ValueError(f"commit endpoint returned {response.status_code}: {response.text}") + try: + return CommitReceipt.from_dict(response.json()) + except (ValueError, TypeError) as exc: + raise ValueError(f"invalid commit receipt: {exc}") from exc diff --git a/python/tests/test_http_stream.py b/python/tests/test_http_stream.py new file mode 100644 index 000000000..3a27c8a83 --- /dev/null +++ b/python/tests/test_http_stream.py @@ -0,0 +1,343 @@ +"""Tests for the metered SSE streaming helpers. + +Mirrors the ``#[cfg(test)] mod tests`` in +``rust/crates/mpp/src/client/http_stream.rs``: incremental SSE decoding across +split chunks, CRLF/comment/id/retry handling, invalid UTF-8 rejection, metered +event classification (metering/usage/message/done/[DONE]/other plus malformed +JSON), the metered session state machine (final usage amount overrides the +reserved amount but never the deliveryId, usage before the directive is +accepted, missing metering errors), the HTTP commit transport, and the +chunk-iterator stream wrapper. +""" + +from __future__ import annotations + +import json + +import httpx +import pytest +from solders.keypair import Keypair # type: ignore[import-untyped] +from solders.pubkey import Pubkey # type: ignore[import-untyped] + +from pay_kit.protocols.mpp.client.http_stream import ( + HttpCommitTransport, + MeteredSseSession, + MeteredSseStream, + SseDecoder, + SseEvent, + parse_metered_sse_event, +) +from pay_kit.protocols.mpp.client.session import ActiveSession +from pay_kit.protocols.mpp.client.session_consumer import SessionConsumer +from pay_kit.protocols.mpp.intents.session import ( + DEFAULT_SESSION_EXPIRES_AT, + CommitPayload, + CommitReceipt, + MeteringDirective, +) + + +class _RecordingTransport: + """In-process commit transport that records payloads and echoes a receipt.""" + + def __init__(self) -> None: + self.commits: list[CommitPayload] = [] + + def commit(self, directive: MeteringDirective, payload: CommitPayload) -> CommitReceipt: + self.commits.append(payload) + return CommitReceipt( + delivery_id=directive.delivery_id, + session_id=directive.session_id, + amount=directive.amount, + cumulative=payload.voucher.data.cumulative, + status="committed", + ) + + +def _consumer() -> SessionConsumer: + channel = Pubkey.from_string("11111111111111111111111111111112") + session = ActiveSession(channel, Keypair.from_seed(bytes([9] * 32))) + return SessionConsumer(session, _RecordingTransport()) + + +def _directive(session_id: str, delivery_id: str = "stream-1", amount: str = "1000") -> MeteringDirective: + return MeteringDirective( + delivery_id=delivery_id, + session_id=session_id, + amount=amount, + currency="USDC", + sequence=1, + expires_at=DEFAULT_SESSION_EXPIRES_AT, + ) + + +def _event(event: str | None, data: str) -> SseEvent: + return SseEvent(event=event, data=data) + + +# -- SseDecoder ---------------------------------------------------------------- + + +def test_sse_decoder_handles_split_chunks() -> None: + decoder = SseDecoder() + assert decoder.push_chunk(b'event: message\ndata: {"delta"') == [] + events = decoder.push_chunk(b':"hi"}\n\n') + assert events == [SseEvent(event="message", data='{"delta":"hi"}')] + + +def test_sse_decoder_handles_metadata_crlf_comments_and_finish() -> None: + decoder = SseDecoder() + events = decoder.push_chunk(b": keepalive\r\nid: evt-1\r\nretry: 250\r\ndata: hello\r\ndata: world\r\n\r\n") + assert events == [SseEvent(event=None, data="hello\nworld", id="evt-1", retry=250)] + + # Unparseable retry values and unknown fields are ignored. + assert decoder.push_chunk(b"retry: nope\nunknown\n\n") == [] + assert decoder.push_chunk(b"event: message\ndata: tail") == [] + events = decoder.finish() + assert events == [SseEvent(event="message", data="tail")] + assert decoder.finish() == [] + + +def test_sse_decoder_rejects_invalid_utf8() -> None: + with pytest.raises(ValueError, match="valid UTF-8"): + SseDecoder().push_chunk(b"\xff") + + +# -- parse_metered_sse_event ----------------------------------------------------- + + +def test_parse_metered_sse_events() -> None: + directive = _directive("chan") + parsed = parse_metered_sse_event(_event("mpp.metering", json.dumps(directive.to_dict()))) + assert parsed.kind == "metering" + assert parsed.metering is not None + assert parsed.metering.amount == "1000" + + parsed = parse_metered_sse_event(_event("message", '{"delta":"hello"}')) + assert parsed.kind == "message" + assert parsed.message == {"delta": "hello"} + + # The short event names are accepted too. + assert parse_metered_sse_event(_event("metering", json.dumps(directive.to_dict()))).kind == "metering" + + +def test_parse_metered_sse_usage_done_other_and_errors() -> None: + parsed = parse_metered_sse_event(_event("mpp.usage", '{"deliveryId":"stream-1","amount":"17"}')) + assert parsed.kind == "usage" + assert parsed.usage is not None + assert parsed.usage.amount_base_units() == 17 + assert parse_metered_sse_event(_event("usage", '{"deliveryId":"d","amount":"1"}')).kind == "usage" + + assert parse_metered_sse_event(_event("done", "")).kind == "done" + assert parse_metered_sse_event(_event(None, " [DONE] ")).kind == "done" + other = parse_metered_sse_event(_event("trace", "ignored")) + assert other.kind == "other" + assert other.other == _event("trace", "ignored") + + with pytest.raises(ValueError, match="invalid mpp.metering event"): + parse_metered_sse_event(_event("metering", "{")) + with pytest.raises(ValueError, match="invalid mpp.usage event"): + parse_metered_sse_event(_event("usage", "{")) + with pytest.raises(ValueError, match="invalid SSE message event"): + parse_metered_sse_event(_event(None, "{")) + + +# -- MeteredSseSession ----------------------------------------------------------- + + +def test_metered_sse_ack_uses_final_usage_amount() -> None: + consumer = _consumer() + stream = MeteredSseSession(consumer) + directive = _directive(consumer.session.channel_id_string) + + assert stream.accept_event(_event("mpp.metering", json.dumps(directive.to_dict()))) is None + delta = stream.accept_event(_event("message", '{"delta":"hello"}')) + assert delta == {"delta": "hello"} + stream.accept_event(_event("mpp.usage", '{"deliveryId":"stream-1","amount":"425"}')) + + receipt = stream.ack() + assert receipt.amount == "425" + assert receipt.cumulative == "425" + assert consumer.session.cumulative == 425 + + +def test_metered_sse_ack_uses_reserved_amount_without_usage_and_tracks_done() -> None: + consumer = _consumer() + stream = MeteredSseSession(consumer) + directive = _directive(consumer.session.channel_id_string) + + stream.accept_event(_event("mpp.metering", json.dumps(directive.to_dict()))) + assert not stream.is_done + stream.accept_event(_event("done", "")) + assert stream.is_done + + receipt = stream.ack() + assert receipt.amount == "1000" + assert receipt.cumulative == "1000" + assert stream.consumer is consumer + + +def test_metered_sse_reports_missing_metering_and_usage_mismatch() -> None: + consumer = _consumer() + stream = MeteredSseSession(consumer) + with pytest.raises(ValueError, match="mpp.metering"): + stream.ack() + + stream = MeteredSseSession(consumer) + directive = _directive(consumer.session.channel_id_string) + stream.accept_event(_event("mpp.metering", json.dumps(directive.to_dict()))) + with pytest.raises(ValueError, match="does not match directive"): + stream.accept_event(_event("mpp.usage", '{"deliveryId":"other","amount":"1"}')) + + +def test_metered_sse_usage_overrides_amount_never_delivery_id() -> None: + consumer = _consumer() + stream = MeteredSseSession(consumer) + directive = _directive(consumer.session.channel_id_string) + stream.accept_event(_event("mpp.metering", json.dumps(directive.to_dict()))) + stream.accept_event(_event("mpp.usage", '{"deliveryId":"stream-1","amount":"7"}')) + receipt = stream.ack() + assert receipt.amount == "7" + transport = consumer.transport + assert isinstance(transport, _RecordingTransport) + assert transport.commits[0].delivery_id == "stream-1" + + +def test_metered_sse_accepts_usage_before_directive() -> None: + # The usage event may arrive before the directive (rust state-machine + # parity): it cannot be validated yet and still overrides the amount. + consumer = _consumer() + stream = MeteredSseSession(consumer) + stream.accept_event(_event("mpp.usage", '{"deliveryId":"stream-1","amount":"33"}')) + directive = _directive(consumer.session.channel_id_string) + stream.accept_event(_event("mpp.metering", json.dumps(directive.to_dict()))) + receipt = stream.ack() + assert receipt.amount == "33" + + +# -- HttpCommitTransport --------------------------------------------------------- + + +def _http_transport(handler: httpx.MockTransport, **kwargs: str) -> HttpCommitTransport: + return HttpCommitTransport(client=httpx.Client(transport=handler), **kwargs) + + +def _commit_fixture() -> tuple[MeteringDirective, CommitPayload]: + consumer = _consumer() + directive = _directive(consumer.session.channel_id_string) + voucher = consumer.session.prepare_increment(88) + return directive, CommitPayload(delivery_id=directive.delivery_id, voucher=voucher) + + +def test_http_commit_transport_success_and_errors() -> None: + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + if request.url.path == "/commit": + if request.headers.get("authorization") != "Bearer sdk-test": + return httpx.Response(401, text="missing auth") + body = json.loads(request.content) + return httpx.Response( + 200, + json={ + "deliveryId": body["deliveryId"], + "sessionId": body["voucher"]["data"]["channelId"], + "amount": body["voucher"]["data"]["cumulativeAmount"], + "cumulative": body["voucher"]["data"]["cumulativeAmount"], + "status": "committed", + }, + ) + if request.url.path == "/commit-error": + return httpx.Response(500, text="commit failed") + return httpx.Response(200, text="not json") + + directive, payload = _commit_fixture() + transport = _http_transport( + httpx.MockTransport(handler), + default_commit_url="http://test/commit", + authorization="Bearer sdk-test", + ) + receipt = transport.commit(directive, payload) + assert receipt.cumulative == "88" + assert len(seen) == 1 + + with pytest.raises(ValueError, match="missing commitUrl"): + HttpCommitTransport(client=httpx.Client(transport=httpx.MockTransport(handler))).commit(directive, payload) + + with pytest.raises(ValueError, match="500"): + _http_transport(httpx.MockTransport(handler), default_commit_url="http://test/commit-error").commit( + directive, payload + ) + + with pytest.raises(ValueError, match="invalid commit receipt"): + _http_transport(httpx.MockTransport(handler), default_commit_url="http://test/commit-invalid-json").commit( + directive, payload + ) + + +def test_http_commit_transport_prefers_directive_commit_url() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/directive-url" + body = json.loads(request.content) + return httpx.Response( + 200, + json={ + "deliveryId": body["deliveryId"], + "sessionId": "chan", + "amount": "88", + "cumulative": body["voucher"]["data"]["cumulativeAmount"], + "status": "committed", + }, + ) + + directive, payload = _commit_fixture() + directive.commit_url = "http://test/directive-url" + transport = _http_transport(httpx.MockTransport(handler), default_commit_url="http://test/default-url") + assert transport.commit(directive, payload).cumulative == "88" + + +def test_http_commit_transport_surfaces_transport_failures() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("boom", request=request) + + directive, payload = _commit_fixture() + transport = _http_transport(httpx.MockTransport(handler), default_commit_url="http://test/commit") + with pytest.raises(ValueError, match="commit request failed"): + transport.commit(directive, payload) + + +# -- MeteredSseStream ------------------------------------------------------------ + + +def test_metered_sse_stream_reads_messages_and_ack_drains() -> None: + consumer = _consumer() + directive = _directive(consumer.session.channel_id_string) + body = ( + f"event: mpp.metering\ndata: {json.dumps(directive.to_dict())}\n\n" + 'event: message\ndata: {"delta":"first"}\n\n' + 'event: message\ndata: {"delta":"second"}\n\n' + 'event: mpp.usage\ndata: {"deliveryId":"stream-1","amount":"275"}\n\n' + "data: [DONE]" + ).encode() + chunks = [body[i : i + 17] for i in range(0, len(body), 17)] + stream = MeteredSseStream(consumer, chunks) + + assert stream.next() == {"delta": "first"} + receipt = stream.ack() + assert receipt.amount == "275" + assert receipt.cumulative == "275" + assert consumer.session.cumulative == 275 + + +def test_metered_sse_stream_iterates_and_returns_consumer() -> None: + consumer = _consumer() + directive = _directive(consumer.session.channel_id_string) + body = ( + f"event: mpp.metering\ndata: {json.dumps(directive.to_dict())}\n\n" + 'event: message\ndata: {"delta":"only"}\n\ndata: [DONE]\n\n' + ).encode() + stream = MeteredSseStream(consumer, [body]) + assert list(stream) == [{"delta": "only"}] + assert stream.next() is None + assert stream.into_consumer() is consumer From dc27ca877474459aff3ac93aeddb01d16db2c0db Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 12 Jun 2026 14:19:04 +0300 Subject: [PATCH 13/52] chore(python): regenerate uv.lock for anchorpy and borsh-construct 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. --- python/uv.lock | 259 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 199 insertions(+), 60 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index 4eda9fa85..d987f0784 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -6,6 +6,47 @@ resolution-markers = [ "python_full_version < '3.12'", ] +[[package]] +name = "anchorpy" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anchorpy-core" }, + { name = "based58" }, + { name = "borsh-construct" }, + { name = "construct-typing" }, + { name = "pyheck" }, + { name = "solana" }, + { name = "solders" }, + { name = "toml" }, + { name = "toolz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/11/121af8c8261944e1b9e5bae6ed022350c5b992c7632e8f8aacd77881599a/anchorpy-0.21.0.tar.gz", hash = "sha256:3c5b364cbe3386a8912c314f62f3da71e268c9163d77548246226e2c6b6a36cc", size = 646563, upload-time = "2025-03-26T10:32:36.241Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/df/9978a6637ba31c216721cc8f7d18595eb9ef5e485c06a7d82a05def419a9/anchorpy-0.21.0-py3-none-any.whl", hash = "sha256:5740a1420052ba5bbfe8cc281b3e621b38c0f845931d1f2ca00bd11e496fd44b", size = 63310, upload-time = "2025-03-26T10:32:34.812Z" }, +] + +[[package]] +name = "anchorpy-core" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonalias" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/46/17500a3079f587355b33af5af1ee9e8df91c84720a9919a7259ebe2f4d4f/anchorpy_core-0.2.0.tar.gz", hash = "sha256:e06f0b9867d5d773a574b5027d19558a24d738b5c0d73df5ab5a97119c466ce2", size = 27784, upload-time = "2023-12-29T12:53:34.239Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/7d/585844efd6ae37d9c5329239a3a8b090e1a732588f9eeb70a91e0e0377b3/anchorpy_core-0.2.0-cp37-abi3-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:f04928c0916e8a5cba5986a85db682df19e204bb424bdea49b8a46fc45f2fc66", size = 1544885, upload-time = "2023-12-29T12:53:09.405Z" }, + { url = "https://files.pythonhosted.org/packages/ce/26/2c44a7e0b0f73950c0efff2203d1758004610d3f8ea80f56a8871ed46204/anchorpy_core-0.2.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e37373336fa735f7f3f5eab8f3b090c8a39ef48f3f4f367ad703c7643260560", size = 1849361, upload-time = "2023-12-29T12:53:12.144Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9b/04b54ccbada11ddc653020059088b933ab0ebc39c4a29418f98df87a152f/anchorpy_core-0.2.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:60b5a2b779acbba6b60957fb76a568d6b29c24147c2fff6a127d25842fcbe5a5", size = 1665700, upload-time = "2023-12-29T12:53:14.287Z" }, + { url = "https://files.pythonhosted.org/packages/03/79/4cdfa0b4b9d87c372b49644dead37fcabd674531508ee7a90820a6cfbe73/anchorpy_core-0.2.0-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:67293b0a1e29df0b50fcac616a5122e3639842f115666f58267470666a847b59", size = 2014217, upload-time = "2023-12-29T12:53:17.204Z" }, + { url = "https://files.pythonhosted.org/packages/60/7a/642c24da4e8e229d98a2c3938ba296994e04ef5c1d5b742d26a663e47161/anchorpy_core-0.2.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abb91b8781cae23113ca03b567973e00c1caf6b341707dbe0fe2ca832af66e5b", size = 2138067, upload-time = "2023-12-29T12:53:19.335Z" }, + { url = "https://files.pythonhosted.org/packages/77/c5/26c74f8d3eff4711f9035d1a31e7030f1e76912981d1deeaabed749b8f28/anchorpy_core-0.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9fb52db4e37736bf461737b4fc088f3c31ee9d1926d7f52f8432e6f25b38c9b", size = 1858826, upload-time = "2023-12-29T12:53:21.78Z" }, + { url = "https://files.pythonhosted.org/packages/0a/62/828918bc32542294639fe82f0f005af953635af235e1192a25139a66b452/anchorpy_core-0.2.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c5d70c9992b678a92640981013c1953a6f7d7de383bc837a511bb96534539db8", size = 1917205, upload-time = "2023-12-29T12:53:24.668Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/89adc5886abc87fa719cc76e5bc08c2fb4f48346b63fbb009aa3d18f55a0/anchorpy_core-0.2.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:41588e343bad3945bf5bbdeeff1c92c6cf8b517590739e100d96fc9505c3bc6f", size = 2159888, upload-time = "2023-12-29T12:53:26.861Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/3666c84e46ee2d63bfcc67036bac55440b34c91ae2f12655b6308698b028/anchorpy_core-0.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:af25089ba1fe2a3494536b77d8b93e191cce50ced5afa2fdf7cc83a0d0839750", size = 2138551, upload-time = "2023-12-29T12:53:29.766Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a3/c74871de3844c3cc7acd57a889537b9b83489bd6a2330587f7d861c89c75/anchorpy_core-0.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:9e0d34e2b168855847d0b0c2e33d3c63767a89bb7b78bcb377365b4e6db6aaba", size = 677626, upload-time = "2023-12-29T12:53:32.307Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -46,6 +87,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "based58" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/a9/dbf5ff314d7b7d3b3246e01f2f6cbb880b62c7a958a8520237b982db191a/based58-0.1.1.tar.gz", hash = "sha256:80804b346b34196c89dc7a3dc89b6021f910f4cd75aac41d433ca1880b1672dc", size = 3615, upload-time = "2022-04-24T09:14:50.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/1d/10b5d61e11f96cc2f038b06776a9761465b1d020bf0e95e60da23a3a3ba8/based58-0.1.1-cp37-abi3-macosx_10_7_x86_64.whl", hash = "sha256:745851792ce5fada615f05ec61d7f360d19c76950d1e86163b2293c63a5d43bc", size = 251767, upload-time = "2022-04-24T09:14:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a2/e1436e5ae5a9e117d248017bef101ee99c47549af6ad15bc58b018174202/based58-0.1.1-cp37-abi3-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:f8448a71678bd1edc0a464033695686461ab9d6d0bc3282cb29b94f883583572", size = 492659, upload-time = "2022-04-24T09:14:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bc/d6bd738adf98bf4102dfeb33aa7ac58ede12ea924fea89a9751a8ec9c15b/based58-0.1.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:852c37206374a62c5d3ef7f6777746e2ad9106beec4551539e9538633385e613", size = 1016479, upload-time = "2022-04-24T09:14:37.186Z" }, + { url = "https://files.pythonhosted.org/packages/01/70/ed49e0a541fae6de7ef0858b08fc95ab7223c3f39aa5e30e03bba498f355/based58-0.1.1-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fb17f0aaaad0381c8b676623c870c1a56aca039e2a7c8416e65904d80a415f7", size = 1019631, upload-time = "2022-04-24T09:14:38.797Z" }, + { url = "https://files.pythonhosted.org/packages/1f/7e/748debfbe5146394893f6f60a77b5fe343a093850340d312e4ddac03190f/based58-0.1.1-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:06f3c40b358b0c6fc6fc614c43bb11ef851b6d04e519ac1eda2833420cb43799", size = 1140341, upload-time = "2022-04-24T09:14:40.202Z" }, + { url = "https://files.pythonhosted.org/packages/43/ad/ec48ad774b33ff521cec8355e7fb782f3f11761cc4a7342c054faf5fd747/based58-0.1.1-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a9db744be79c8087eebedbffced00c608b3ed780668ab3c59f1d16e72c84947", size = 1128929, upload-time = "2022-04-24T09:14:41.581Z" }, + { url = "https://files.pythonhosted.org/packages/cb/bc/9bdba9ef4b2185d12c9ba4028168081ce2359d399087f51415db11073a44/based58-0.1.1-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0506435e98836cc16e095e0d6dc428810e0acfb44bc2f3ac3e23e051a69c0e3e", size = 1182359, upload-time = "2022-04-24T09:14:42.802Z" }, + { url = "https://files.pythonhosted.org/packages/a6/85/73ef6c410a5525b0a95c84a7e5e0902ce1d690a5f86ef36b0224d333e7b6/based58-0.1.1-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8937e97fa8690164fd11a7c642f6d02df58facd2669ae7355e379ab77c48c924", size = 1045452, upload-time = "2022-04-24T09:14:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/6a/30/216be65c673259d6227c34690ce35e81858177bc6ad3cffab6699c93e115/based58-0.1.1-cp37-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:14b01d91ac250300ca7f634e5bf70fb2b1b9aaa90cc14357943c7da525a35aff", size = 1020913, upload-time = "2022-04-24T09:14:45.024Z" }, + { url = "https://files.pythonhosted.org/packages/1b/79/2b77d260e67b7135b050279a9b74fe4d45c4edf63a08cdff2d334fd7f4b6/based58-0.1.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6c03c7f0023981c7d52fc7aad23ed1f3342819358b9b11898d693c9ef4577305", size = 1221614, upload-time = "2022-07-18T08:33:09.514Z" }, + { url = "https://files.pythonhosted.org/packages/f7/e8/774840132f2c7ded2f377fb22c1c6ddaa2041c4ea4d42d1547bc6758af89/based58-0.1.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:621269732454875510230b85053f462dffe7d7babecc8c553fdb488fd15810ff", size = 1384904, upload-time = "2022-07-18T08:33:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/58/c1/603af0cf18e7c72f31089cced681b599caa2a011d0ca16cfe2a676e33ceb/based58-0.1.1-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:aba18f6c869fade1d1551fe398a376440771d6ce288c54cba71b7090cf08af02", size = 1226434, upload-time = "2022-04-24T09:14:46.124Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/ed5278c4ef0dd8ab7695417c132329064ae6f8963f12521cb0b1b6c8e71f/based58-0.1.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ae7f17b67bf0c209da859a6b833504aa3b19dbf423cbd2369aa17e89299dc972", size = 1203980, upload-time = "2022-04-24T09:14:47.498Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7c/5c7c9e6d1ddd083a4cf25c4182fbaacfa7371ac9e82479c98aa29830bb8b/based58-0.1.1-cp37-abi3-win32.whl", hash = "sha256:d8dece575de525c1ad889d9ab239defb7a6ceffc48f044fe6e14a408fb05bef4", size = 135478, upload-time = "2022-04-24T09:14:48.904Z" }, + { url = "https://files.pythonhosted.org/packages/7b/13/35a9ee917ef05d734b0aa75400cfff44325594894d8d928d36b4dc0030d1/based58-0.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:ab85804a401a7b5a7141fbb14ef5b5f7d85288357d1d3f0085d47e616cef8f5a", size = 141512, upload-time = "2022-04-24T09:14:49.942Z" }, +] + [[package]] name = "black" version = "23.12.1" @@ -79,6 +152,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] +[[package]] +name = "borsh-construct" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "construct-typing" }, + { name = "sumtypes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/0c/8062d8d795e7b9518923bfba453f883e2cbf80c5b05de699e4424a523a71/borsh-construct-0.1.0.tar.gz", hash = "sha256:c916758ceba70085d8f456a1cc26991b88cb64233d347767766473b651b37263", size = 5808, upload-time = "2021-10-20T11:16:49.095Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/1d/52c0741626a17eb1a2f554e27dc2883f9bb98bb7b2392f9cbd1f39c36fea/borsh_construct-0.1.0-py3-none-any.whl", hash = "sha256:f584c791e2a03f8fc36e6c13011a27bcaf028c9c54ba89cd70f485a7d1c687ed", size = 6386, upload-time = "2021-10-20T11:16:47.84Z" }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -200,24 +286,20 @@ wheels = [ [[package]] name = "construct" -version = "2.10.70" +version = "2.10.68" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/77/8c84b98eca70d245a2a956452f21d57930d22ab88cbeed9290ca630cf03f/construct-2.10.70.tar.gz", hash = "sha256:4d2472f9684731e58cc9c56c463be63baa1447d674e0d66aeb5627b22f512c29", size = 86337, upload-time = "2023-11-29T08:44:49.545Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/fb/08b3f4bf05da99aba8ffea52a558758def16e8516bc75ca94ff73587e7d3/construct-2.10.70-py3-none-any.whl", hash = "sha256:c80be81ef595a1a821ec69dc16099550ed22197615f4320b57cc9ce2a672cb30", size = 63020, upload-time = "2023-11-29T08:44:46.876Z" }, -] +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/a4a032e94bcfdff481f2e6fecd472794d9da09f474a2185ed33b2c7cad64/construct-2.10.68.tar.gz", hash = "sha256:7b2a3fd8e5f597a5aa1d614c3bd516fa065db01704c72a1efaaeec6ef23d8b45", size = 57856, upload-time = "2022-02-21T23:09:15.1Z" } [[package]] name = "construct-typing" -version = "0.7.0" +version = "0.5.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "construct" }, - { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/ae/659fe4866d89ef5a3a65cddbdd7b35882f4feb72db383821965f2fcea934/construct_typing-0.7.0.tar.gz", hash = "sha256:71d110dedff39bd3b603c734077032a7065bc597a49db1f5b03a211d05dbac23", size = 45104, upload-time = "2025-10-27T19:30:29.614Z" } +sdist = { url = "https://files.pythonhosted.org/packages/42/5f/89f8e4fc17ea96ef9af66aa4818e00c4fb84c289c4c6879df411d11f15a4/construct-typing-0.5.6.tar.gz", hash = "sha256:0dc501351cd6b308f15ec54e5fe7c0fbc07cc1530a1b77b4303062a0a93c1297", size = 22716, upload-time = "2023-05-09T11:05:50.106Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/0c/2db6f7e1ae9795e436c6a0dc0bc38b12b8c8a228cb63203e24190b755b3b/construct_typing-0.7.0-py3-none-any.whl", hash = "sha256:c92383c6e8e5d07ba25811c8d5163820458d821e73bb1006541f43f89788646c", size = 24350, upload-time = "2025-10-27T19:30:27.505Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c5/d68c7b7e5e2875d199b49f86f9fda2b18a58ff0a835f608cb664bbc3a53e/construct_typing-0.5.6-py3-none-any.whl", hash = "sha256:39c948329e880564e33521cba497b21b07967c465b9c9037d6334e2cffa1ced9", size = 24098, upload-time = "2023-05-09T11:05:48.66Z" }, ] [[package]] @@ -886,6 +968,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyheck" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/15/888a078f79c832900928fc33eb52218b907c1432b4433c2474b9199b8d02/pyheck-0.1.5.tar.gz", hash = "sha256:5c9fe372d540c5dbcb76bf062f951d998d0e14c906c842a52f1cd5de208e183a", size = 3391, upload-time = "2022-02-18T23:11:59.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/3b/733a16d3ffbf8b0786bf3462ada25eea6eb55ec7b512706623f3bb3f202e/pyheck-0.1.5-cp37-abi3-macosx_10_7_x86_64.whl", hash = "sha256:44caf2b7a49d71fdeb0469e9f35886987ad815a8638b3c5b5c83f351d6aed413", size = 295742, upload-time = "2022-02-18T23:11:44.241Z" }, + { url = "https://files.pythonhosted.org/packages/56/eb/6f35c9a10f6da3b64de58398464b52db9b3205756a66099bc05c673c8ea9/pyheck-0.1.5-cp37-abi3-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:316a842b94beff6e59a97dbcc590e9be92a932e59126b0faa9ac750384f27eaf", size = 583456, upload-time = "2022-02-18T23:11:45.369Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/80f335f32851c89a2b5a770928b44195a7cf1f0192ad71d48f7af4430c13/pyheck-0.1.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b6169397395ff041f056bfb36c1957a788a1cd7cb967a927fcae7917ff1b6aa", size = 1058932, upload-time = "2022-02-18T23:11:46.443Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c0/9e4c997d2a6b0b96336d53570a40f45c172fc8541b9fdc047031fbfe9ea4/pyheck-0.1.5-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e9d101e1c599227280e34eeccab0414246e70a91a1cabb4c4868dca284f2be7d", size = 1072944, upload-time = "2022-02-18T23:11:47.538Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4c/f3b15b999531fa0f37553171c60ae2065a0e933e9a526080994747dccc81/pyheck-0.1.5-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69d6509138909df92b2f2f837518dca118ef08ae3c804044ae511b81b7aecb4d", size = 1201526, upload-time = "2022-02-18T23:11:48.957Z" }, + { url = "https://files.pythonhosted.org/packages/3f/63/6844333e7734cc4068b5c28b644e5f6c2d0f4d4d886bd6a7fc598d2f4580/pyheck-0.1.5-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ce4a2e1b4778051b8f31183e321a034603f3957b6e95cf03bf5f231c8ea3066", size = 1178330, upload-time = "2022-02-18T23:11:50.093Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/1d0df9db32bf80b1e6b42117e0ca15cd8daed0bc6186cae53af633b9083b/pyheck-0.1.5-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:69387b70d910637ab6dc8dc378c8e0b4037cee2c51a9c6f64ce5331b010f5de3", size = 1318718, upload-time = "2022-02-18T23:11:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/1c/36/3f94728c4df3166c098be17153763ab11afef163d0129fe1c1553eaaab43/pyheck-0.1.5-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0fb50b7d899d2a583ec2ac291b8ec2afb10f0e32c4ac290148d3da15927787f8", size = 1093745, upload-time = "2022-02-18T23:11:52.811Z" }, + { url = "https://files.pythonhosted.org/packages/2b/eb/b424ce465428ead400360cf5f62842b4749d0bc5da0b9a41f7731684da01/pyheck-0.1.5-cp37-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:aa8dfd0883212f8495e0bae6eb6ea670c56f9b197b5fe6fb5cae9fd5ec56fb7c", size = 1057755, upload-time = "2022-02-18T23:11:54.461Z" }, + { url = "https://files.pythonhosted.org/packages/41/20/1385c7a6c80cbb76bf4c0360fc4089ac6bace41e71f68f77b323c6a29ea1/pyheck-0.1.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b7c07506b9591e27f8241bf7a72bc4d5c4ac30dedb332efb87e402e49029f233", size = 1304367, upload-time = "2022-07-18T08:56:53.85Z" }, + { url = "https://files.pythonhosted.org/packages/6c/15/a0719ae87853e7cf1dba412dc240c32e5dbcf6b3450e01f6d4e52c2ee06e/pyheck-0.1.5-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9ee256cafbdab6c5fcca22d0910176d820bf1e1298773e64f4eea79f51218cc7", size = 1462824, upload-time = "2022-07-18T08:56:55.731Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a6/70892131b9b82a241758698d8a2ab99fcf79118c838867f777c228858b60/pyheck-0.1.5-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:e9ba36060abc55127c3813de398b4013c05be6118cfae3cfa3d978f7b4c84dea", size = 1273243, upload-time = "2022-02-18T23:11:55.459Z" }, + { url = "https://files.pythonhosted.org/packages/46/79/461a4636c6cfdbf0c90f3d8532db728e3ae117d63765a431aa3f1ebfe66b/pyheck-0.1.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:64201a6d213bec443aeb33f66c60cea61aaf6257e48a19159ac69a5afad4768e", size = 1240962, upload-time = "2022-02-18T23:11:56.528Z" }, + { url = "https://files.pythonhosted.org/packages/1f/2b/1df278fe24e40e5bf13277cd114b47c4e1e498c7850c5ed38f807f945de2/pyheck-0.1.5-cp37-abi3-win32.whl", hash = "sha256:1501fcfd15f7c05c6bfe38915f5e514ac95fc63e945f7d8b089d30c1b8fdb2c5", size = 180706, upload-time = "2022-02-18T23:11:57.572Z" }, + { url = "https://files.pythonhosted.org/packages/aa/21/07e09ee32556379a2d1c17f680a49f52ee89283d51637743ecffcb232d3b/pyheck-0.1.5-cp37-abi3-win_amd64.whl", hash = "sha256:e519f80a0ef87a8f880bfdf239e396e238dcaed34bec1ea7ef526c4873220e82", size = 193764, upload-time = "2022-02-18T23:11:58.745Z" }, +] + [[package]] name = "pyright" version = "1.1.409" @@ -1048,7 +1153,7 @@ wheels = [ [[package]] name = "solana" -version = "0.36.12" +version = "0.36.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "construct-typing" }, @@ -1057,9 +1162,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/66/708f3f4ec285773bf1bd785afa442d8315e8471054cce9817ee9d3a17301/solana-0.36.12.tar.gz", hash = "sha256:3f103f5b72f19d44622254c618793fd72ae683776f9676b906e2f1d2f5cda292", size = 54656, upload-time = "2026-05-14T03:45:09.416Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/7d/ff1006867566c802c8d99b528893bb04b1ec4f9e18191db32e143773e68d/solana-0.36.6.tar.gz", hash = "sha256:aa2403bc36bb06ea5bf56f6856373a50ae8b1c3a45261d5a4c911350f7427c00", size = 52132, upload-time = "2025-02-22T21:17:27.768Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/8d/70e07b289a46a1e39611bb45b9af8646566612c3a62f639f6a2c00e7af00/solana-0.36.12-py3-none-any.whl", hash = "sha256:37de51e21a81f5a90e9bb0ede393c70e70011626fd903d82d2c678b050c94c94", size = 64870, upload-time = "2026-05-14T03:45:07.913Z" }, + { url = "https://files.pythonhosted.org/packages/4d/7a/56650a6771626cd9509b19fb5e26ecd5a4c7057679f803a437f73ea62507/solana-0.36.6-py3-none-any.whl", hash = "sha256:c0526b602d834cb762102f854be469be6657731db0d016a985bbabd6212dd09d", size = 62272, upload-time = "2025-02-22T21:17:26.592Z" }, ] [[package]] @@ -1067,6 +1172,8 @@ name = "solana-pay-kit" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "anchorpy" }, + { name = "borsh-construct" }, { name = "httpx" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -1096,6 +1203,8 @@ flask = [ [package.metadata] requires-dist = [ + { name = "anchorpy", specifier = ">=0.21" }, + { name = "borsh-construct", specifier = ">=0.1" }, { name = "django", marker = "extra == 'django'", specifier = ">=4.2" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.110" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3" }, @@ -1115,22 +1224,22 @@ provides-extras = ["fastapi", "flask", "django", "dev"] [[package]] name = "solders" -version = "0.27.1" +version = "0.26.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonalias" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/25/80a81bb3dc4c70329dd0016edbdfbf2e8d8300a98ab9cd1a6ea0266bda7c/solders-0.27.1.tar.gz", hash = "sha256:7d8a24ad2f193afcdc02d6f3975917a7358b0f0ab7f4b3695b135ff2008222c8", size = 180923, upload-time = "2025-11-15T07:50:52.32Z" } +sdist = { url = "https://files.pythonhosted.org/packages/87/96/23ad2e43e2676b78834064fe051e3db3ce1899336ecd4797f92fcd06113a/solders-0.26.0.tar.gz", hash = "sha256:057533892d6fa432c1ce1e2f5e3428802964666c10b57d3d1bcaab86295f046c", size = 181123, upload-time = "2025-02-18T19:23:57.734Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/6b/0c0ee4766705824261779d00229fb95308d6b28422613e0e2af577f60ee3/solders-0.27.1-cp38-abi3-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:4dcd8e766bab24afbe9e0ae363d86f9810457e04b00c8a9149f69ca939ed587c", size = 24883435, upload-time = "2025-11-15T07:50:34.42Z" }, - { url = "https://files.pythonhosted.org/packages/33/1c/be04a1b26e18c409dd006d214198dc03f0b657c1cb34f4c83b763f8348f0/solders-0.27.1-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5d87b145cc0129095f9cff8c7f28d2e910bc5b5a4cf257c263b08a4b95f111dd", size = 6480729, upload-time = "2025-11-15T07:50:37.323Z" }, - { url = "https://files.pythonhosted.org/packages/48/03/98dc73c266b11ed5c13b3933510a1aa115becf97f45bec1a22da9d03ffa9/solders-0.27.1-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6082bbe46b7b1b2b005d046011f89fcae75fc5ea4f1a0ef5c2e9dfb5fe7930ce", size = 12744782, upload-time = "2025-11-15T07:50:39.283Z" }, - { url = "https://files.pythonhosted.org/packages/a0/39/35384d8fb80d05937bd9e8af7237cfe3f0d017c8aba357209d90d428f3a0/solders-0.27.1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ccb821c2e4af43d976f312086f248a67352b3986e5f4c87af41cfeac6d8b5683", size = 6601257, upload-time = "2025-11-15T07:50:41.738Z" }, - { url = "https://files.pythonhosted.org/packages/8c/65/8989e521142473bf1130613476a4449e106bb97ed6cc86097f6f519b1234/solders-0.27.1-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:663a10566ae81f67c4515d4db5fbf51b735204741728c1a5cde11c4e019a51df", size = 7277802, upload-time = "2025-11-15T07:50:43.789Z" }, - { url = "https://files.pythonhosted.org/packages/f2/41/87ecf12cec0e7aa9c67b0cf1b8079fb28aa0af91e97328a3bd0c5e3001ba/solders-0.27.1-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:d14f05a77dbbf7966fb26f255c81302e6127550bdb66c2fdc99f522043fdf376", size = 7082541, upload-time = "2025-11-15T07:50:45.847Z" }, - { url = "https://files.pythonhosted.org/packages/33/b9/35e6f59b41bb205b26c7318fcdca43f3d59464fd3ddc13d36f36427f64d4/solders-0.27.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f778eeab411acec0a765a01c7b772f8eca8a8543d98276bd83cb826960da211b", size = 6845568, upload-time = "2025-11-15T07:50:47.698Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f3/14ed12d8d5047ababaca3271f82ebbf500ff74b6358f283962232103a12d/solders-0.27.1-cp38-abi3-win_amd64.whl", hash = "sha256:f3b787c29570a46d219c7a67543d8b0fadc73abda346653aa20e8eccd839e78b", size = 5295092, upload-time = "2025-11-15T07:50:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ce/58bbb4d2c696e770cdd37e5f6dc2891ef7610c0c085bf400f9c42dcff1ad/solders-0.26.0-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:9c1a0ef5daa1a05934af5fb6e7e32eab7c42cede406c80067fee006f461ffc4a", size = 24344472, upload-time = "2025-02-18T19:23:30.273Z" }, + { url = "https://files.pythonhosted.org/packages/5a/35/221cec0e5900c2202833e7e9110c3405a2d96ed25e110b247f88b8782e29/solders-0.26.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b964efbd7c0b38aef3bf4293ea5938517ae649b9a23e7cd147d889931775aab", size = 6674734, upload-time = "2025-02-18T19:23:35.15Z" }, + { url = "https://files.pythonhosted.org/packages/41/33/d17b7dbc92672351d59fc65cdb93b8924fc682deba09f6d96f25440187ae/solders-0.26.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36e6a769c5298b887b7588edb171d93709a89302aef75913fe893d11c653739d", size = 13472961, upload-time = "2025-02-18T19:23:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e7/533367d815ab000587ccc37d89e154132f63347f02dcaaac5df72bd851de/solders-0.26.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b3cc55b971ec6ed1b4466fa7e7e09eee9baba492b8cd9e3204e3e1a0c5a0c4aa", size = 6886198, upload-time = "2025-02-18T19:23:41.453Z" }, + { url = "https://files.pythonhosted.org/packages/52/e0/ab41ab3df5fdf3b0e55613be93a43c2fe58b15a6ea8ceca26d3fba02e3c6/solders-0.26.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3e3973074c17265921c70246a17bcf80972c5b96a3e1ed7f5049101f11865092", size = 7319170, upload-time = "2025-02-18T19:23:43.758Z" }, + { url = "https://files.pythonhosted.org/packages/7d/34/5174ce592607e0ac020aff203217f2f113a55eec49af3db12945fea42d89/solders-0.26.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:59b52419452602f697e659199a25acacda8365971c376ef3c0687aecdd929e07", size = 7134977, upload-time = "2025-02-18T19:23:46.157Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5e/822faabda0d473c29bdf59fe8869a411fd436af8ca6f5d6e89f7513f682f/solders-0.26.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5946ec3f2a340afa9ce5c2b8ab628ae1dea2ad2235551b1297cafdd7e3e5c51a", size = 6984222, upload-time = "2025-02-18T19:23:49.429Z" }, + { url = "https://files.pythonhosted.org/packages/23/e8/dc992f677762ea2de44b7768120d95887ef39fab10d6f29fb53e6a9882c1/solders-0.26.0-cp37-abi3-win_amd64.whl", hash = "sha256:5466616610170aab08c627ae01724e425bcf90085bc574da682e9f3bd954900b", size = 5480492, upload-time = "2025-02-18T19:23:53.285Z" }, ] [[package]] @@ -1155,6 +1264,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/85/492183764d5d01d4514be3730fdb8e228a80605783099551c51627578b5d/starlette-1.2.0-py3-none-any.whl", hash = "sha256:36e0c76ac59157e75dc4b3bdeafba97fb04eaf1878045f15dbef666a6f092ed7", size = 73213, upload-time = "2026-05-28T11:42:48.801Z" }, ] +[[package]] +name = "sumtypes" +version = "0.1a6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/aa/1aa52ae948166291cf615687a733151d4567645beac7e51b7bae3fd88ad4/sumtypes-0.1a6.tar.gz", hash = "sha256:1a6ff095e06a1885f340ddab803e0f38e3f9bed81f9090164ca9682e04e96b43", size = 5272, upload-time = "2021-11-30T14:18:43.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/29/4ef1ff91dad16f8396bab999a09011d4939951f47b9729b3f73cc1f74756/sumtypes-0.1a6-py2.py3-none-any.whl", hash = "sha256:3e9d71322dd927d25d935072f8be7daec655ea292fd392359a5bb2c1e53dfdc3", size = 5817, upload-time = "2021-11-30T14:18:41.447Z" }, +] + +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -1218,6 +1348,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] +[[package]] +name = "toolz" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/f3/f8c3d075da8d949b12fb12ef934ee12fcce369ff83b60253fc2833f8901c/toolz-0.11.2.tar.gz", hash = "sha256:6b312d5e15138552f1bda8a4e66c30e236c831b612b2bf0005f8a1df10a4bc33", size = 65928, upload-time = "2021-11-06T05:23:08.993Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/f1/3df506b493736e3ee11fc1a3c2de8014a55f025d830a71bb499acc049a2c/toolz-0.11.2-py3-none-any.whl", hash = "sha256:a5700ce83414c64514d82d60bcda8aabfde092d1c1a8663f9200c07fdcc6da8f", size = 55840, upload-time = "2021-11-06T05:23:07.224Z" }, +] + [[package]] name = "typeapi" version = "2.3.0" @@ -1298,44 +1437,44 @@ wheels = [ [[package]] name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, - { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, - { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, - { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, - { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, - { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, - { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +version = "15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/7a/8bc4d15af7ff30f7ba34f9a172063bfcee9f5001d7cef04bee800a658f33/websockets-15.0.tar.gz", hash = "sha256:ca36151289a15b39d8d683fd8b7abbe26fc50be311066c5f8dcf3cb8cee107ab", size = 175574, upload-time = "2025-02-16T11:06:55.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/16/81a7403c8c0a33383de647e89c07824ea6a654e3877d6ff402cbae298cb8/websockets-15.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dd24c4d256558429aeeb8d6c24ebad4e982ac52c50bc3670ae8646c181263965", size = 174702, upload-time = "2025-02-16T11:05:14.163Z" }, + { url = "https://files.pythonhosted.org/packages/ef/40/4629202386a3bf1195db9fe41baeb1d6dfd8d72e651d9592d81dae7fdc7c/websockets-15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f83eca8cbfd168e424dfa3b3b5c955d6c281e8fc09feb9d870886ff8d03683c7", size = 172359, upload-time = "2025-02-16T11:05:15.613Z" }, + { url = "https://files.pythonhosted.org/packages/7b/33/dfb650e822bc7912d8c542c452497867af91dec81e7b5bf96aca5b419d58/websockets-15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4095a1f2093002c2208becf6f9a178b336b7572512ee0a1179731acb7788e8ad", size = 172604, upload-time = "2025-02-16T11:05:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/2e/52/666743114513fcffd43ee5df261a1eb5d41f8e9861b7a190b730732c19ba/websockets-15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb915101dfbf318486364ce85662bb7b020840f68138014972c08331458d41f3", size = 182145, upload-time = "2025-02-16T11:05:19.186Z" }, + { url = "https://files.pythonhosted.org/packages/9c/63/5273f146b13aa4a057a95ab0855d9990f3a1ced63693f4365135d1abfacc/websockets-15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:45d464622314973d78f364689d5dbb9144e559f93dca11b11af3f2480b5034e1", size = 181152, upload-time = "2025-02-16T11:05:21.319Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/075697f3f97de7c26b73ae96d952e13fa36393e0db3f028540b28954e0a9/websockets-15.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ace960769d60037ca9625b4c578a6f28a14301bd2a1ff13bb00e824ac9f73e55", size = 181523, upload-time = "2025-02-16T11:05:22.805Z" }, + { url = "https://files.pythonhosted.org/packages/25/87/06d091bbcbe01903bed3dad3bb4a1a3c516f61e611ec31fffb28abe4974b/websockets-15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7cd4b1015d2f60dfe539ee6c95bc968d5d5fad92ab01bb5501a77393da4f596", size = 181791, upload-time = "2025-02-16T11:05:25.031Z" }, + { url = "https://files.pythonhosted.org/packages/77/08/5063b6cc1b2aa1fba2ee3b578b777db22fde7145f121d07fd878811e983b/websockets-15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4f7290295794b5dec470867c7baa4a14182b9732603fd0caf2a5bf1dc3ccabf3", size = 181231, upload-time = "2025-02-16T11:05:26.306Z" }, + { url = "https://files.pythonhosted.org/packages/86/ff/af23084df0a7405bb2add12add8c17d6192a8de9480f1b90d12352ba2b7d/websockets-15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3abd670ca7ce230d5a624fd3d55e055215d8d9b723adee0a348352f5d8d12ff4", size = 181191, upload-time = "2025-02-16T11:05:27.722Z" }, + { url = "https://files.pythonhosted.org/packages/21/ce/b2bdfcf49201dee0b899edc6a814755763ec03d74f2714923d38453a9e8d/websockets-15.0-cp311-cp311-win32.whl", hash = "sha256:110a847085246ab8d4d119632145224d6b49e406c64f1bbeed45c6f05097b680", size = 175666, upload-time = "2025-02-16T11:05:30.048Z" }, + { url = "https://files.pythonhosted.org/packages/8d/7b/444edcd5365538c226b631897975a65bbf5ccf27c77102e17d8f12a306ea/websockets-15.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7bbbe2cd6ed80aceef2a14e9f1c1b61683194c216472ed5ff33b700e784e37", size = 176105, upload-time = "2025-02-16T11:05:31.406Z" }, + { url = "https://files.pythonhosted.org/packages/22/1e/92c4547d7b2a93f848aedaf37e9054111bc00dc11bff4385ca3f80dbb412/websockets-15.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cccc18077acd34c8072578394ec79563664b1c205f7a86a62e94fafc7b59001f", size = 174709, upload-time = "2025-02-16T11:05:32.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/37/eae4830a28061ba552516d84478686b637cd9e57d6a90b45ad69e89cb0af/websockets-15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4c22992e24f12de340ca5f824121a5b3e1a37ad4360b4e1aaf15e9d1c42582d", size = 172372, upload-time = "2025-02-16T11:05:35.342Z" }, + { url = "https://files.pythonhosted.org/packages/46/2f/b409f8b8aa9328d5a47f7a301a43319d540d70cf036d1e6443675978a988/websockets-15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1206432cc6c644f6fc03374b264c5ff805d980311563202ed7fef91a38906276", size = 172607, upload-time = "2025-02-16T11:05:36.704Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/d7e2e4542d4b4df849b0110df1b1f94f2647b71ab4b65d672090931ad2bb/websockets-15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d3cc75ef3e17490042c47e0523aee1bcc4eacd2482796107fd59dd1100a44bc", size = 182422, upload-time = "2025-02-16T11:05:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/b6/91/3b303160938d123eea97f58be363f7dbec76e8c59d587e07b5bc257dd584/websockets-15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b89504227a5311610e4be16071465885a0a3d6b0e82e305ef46d9b064ce5fb72", size = 181362, upload-time = "2025-02-16T11:05:40.346Z" }, + { url = "https://files.pythonhosted.org/packages/f2/8b/df6807f1ca339c567aba9a7ab03bfdb9a833f625e8d2b4fc7529e4c701de/websockets-15.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56e3efe356416bc67a8e093607315951d76910f03d2b3ad49c4ade9207bf710d", size = 181787, upload-time = "2025-02-16T11:05:42.61Z" }, + { url = "https://files.pythonhosted.org/packages/21/37/e6d3d5ebb0ebcaf98ae84904205c9dcaf3e0fe93e65000b9f08631ed7309/websockets-15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f2205cdb444a42a7919690238fb5979a05439b9dbb73dd47c863d39640d85ab", size = 182058, upload-time = "2025-02-16T11:05:45.126Z" }, + { url = "https://files.pythonhosted.org/packages/c9/df/6aca296f2be4c638ad20908bb3d7c94ce7afc8d9b4b2b0780d1fc59b359c/websockets-15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:aea01f40995fa0945c020228ab919b8dfc93fc8a9f2d3d705ab5b793f32d9e99", size = 181434, upload-time = "2025-02-16T11:05:46.692Z" }, + { url = "https://files.pythonhosted.org/packages/88/f1/75717a982bab39bbe63c83f9df0e7753e5c98bab907eb4fb5d97fe5c8c11/websockets-15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9f8e33747b1332db11cf7fcf4a9512bef9748cb5eb4d3f7fbc8c30d75dc6ffc", size = 181431, upload-time = "2025-02-16T11:05:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/e7/15/cee9e63ed9ac5bfc1a3ae8fc6c02c41745023c21eed622eef142d8fdd749/websockets-15.0-cp312-cp312-win32.whl", hash = "sha256:32e02a2d83f4954aa8c17e03fe8ec6962432c39aca4be7e8ee346b05a3476904", size = 175678, upload-time = "2025-02-16T11:05:49.592Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/993974c60f40faabb725d4dbae8b072ef73b4c4454bd261d3b1d34ace41f/websockets-15.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffc02b159b65c05f2ed9ec176b715b66918a674bd4daed48a9a7a590dd4be1aa", size = 176119, upload-time = "2025-02-16T11:05:51.926Z" }, + { url = "https://files.pythonhosted.org/packages/12/23/be28dc1023707ac51768f848d28a946443041a348ee3a54abdf9f6283372/websockets-15.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d2244d8ab24374bed366f9ff206e2619345f9cd7fe79aad5225f53faac28b6b1", size = 174714, upload-time = "2025-02-16T11:05:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ff/02b5e9fbb078e7666bf3d25c18c69b499747a12f3e7f2776063ef3fb7061/websockets-15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3a302241fbe825a3e4fe07666a2ab513edfdc6d43ce24b79691b45115273b5e7", size = 172374, upload-time = "2025-02-16T11:05:55.551Z" }, + { url = "https://files.pythonhosted.org/packages/8e/61/901c8d4698e0477eff4c3c664d53f898b601fa83af4ce81946650ec2a4cb/websockets-15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:10552fed076757a70ba2c18edcbc601c7637b30cdfe8c24b65171e824c7d6081", size = 172605, upload-time = "2025-02-16T11:05:57.613Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4b/dc47601a80dff317aecf8da7b4ab278d11d3494b2c373b493e4887561f90/websockets-15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c53f97032b87a406044a1c33d1e9290cc38b117a8062e8a8b285175d7e2f99c9", size = 182380, upload-time = "2025-02-16T11:05:58.984Z" }, + { url = "https://files.pythonhosted.org/packages/83/f7/b155d2b38f05ed47a0b8de1c9ea245fcd7fc625d89f35a37eccba34b42de/websockets-15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1caf951110ca757b8ad9c4974f5cac7b8413004d2f29707e4d03a65d54cedf2b", size = 181325, upload-time = "2025-02-16T11:06:01.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ff/040a20c01c294695cac0e361caf86f33347acc38f164f6d2be1d3e007d9f/websockets-15.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bf1ab71f9f23b0a1d52ec1682a3907e0c208c12fef9c3e99d2b80166b17905f", size = 181763, upload-time = "2025-02-16T11:06:04.344Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6a/af23e93678fda8341ac8775e85123425e45c608389d3514863c702896ea5/websockets-15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bfcd3acc1a81f106abac6afd42327d2cf1e77ec905ae11dc1d9142a006a496b6", size = 182097, upload-time = "2025-02-16T11:06:05.722Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3e/1069e159c30129dc03c01513b5830237e576f47cedb888777dd885cae583/websockets-15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c8c5c8e1bac05ef3c23722e591ef4f688f528235e2480f157a9cfe0a19081375", size = 181485, upload-time = "2025-02-16T11:06:07.076Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/c91c47103f1cd941b576bbc452601e9e01f67d5c9be3e0a9abe726491ab5/websockets-15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:86bfb52a9cfbcc09aba2b71388b0a20ea5c52b6517c0b2e316222435a8cdab72", size = 181466, upload-time = "2025-02-16T11:06:08.927Z" }, + { url = "https://files.pythonhosted.org/packages/16/32/a4ca6e3d56c24aac46b0cf5c03b841379f6409d07fc2044b244f90f54105/websockets-15.0-cp313-cp313-win32.whl", hash = "sha256:26ba70fed190708551c19a360f9d7eca8e8c0f615d19a574292b7229e0ae324c", size = 175673, upload-time = "2025-02-16T11:06:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/c0/31/25a417a23e985b61ffa5544f9facfe4a118cb64d664c886f1244a8baeca5/websockets-15.0-cp313-cp313-win_amd64.whl", hash = "sha256:ae721bcc8e69846af00b7a77a220614d9b2ec57d25017a6bbde3a99473e41ce8", size = 176115, upload-time = "2025-02-16T11:06:12.602Z" }, + { url = "https://files.pythonhosted.org/packages/e8/b2/31eec524b53f01cd8343f10a8e429730c52c1849941d1f530f8253b6d934/websockets-15.0-py3-none-any.whl", hash = "sha256:51ffd53c53c4442415b613497a34ba0aa7b99ac07f1e4a62db5dcd640ae6c3c3", size = 169023, upload-time = "2025-02-16T11:06:53.32Z" }, ] [[package]] From 89c2289c435c2684300816d2b63d1a6a1452e02e Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 12 Jun 2026 14:19:33 +0300 Subject: [PATCH 14/52] docs(python): mark session as client-only in the scheme matrix State the shipped client surface and the server-side, operatedVoucher, and session-transport follow-ups instead of leaving the row empty. --- python/README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/python/README.md b/python/README.md index 6bbe553df..959119707 100644 --- a/python/README.md +++ b/python/README.md @@ -263,7 +263,15 @@ Use MPP when: |---------------|--------| | `charge/pull` | ✅ | | `charge/push` | ✅ | -| `session` | — | +| `session` | Client-only | + +`session` ships the client side: `ActiveSession` voucher signing, the +challenge-driven pull/clientVoucher payment-channel openers (fee payer = +challenge operator, pending-server-signature placeholder), the metered +`SessionConsumer`, and the SSE streaming helpers (`MeteredSseSession`, +`MeteredSseStream`, `HttpCommitTransport`). Server-side session verification, +pull/operatedVoucher (multi-delegate) opens, and a session-aware request +transport are follow-ups. The MPP server owns the full lifecycle: it issues signed challenges with a fresh `recentBlockhash`, parses and validates the `Authorization: Payment` From 447f59c97f93459b4bd5245456741e13c7cf5680 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 12 Jun 2026 14:23:15 +0300 Subject: [PATCH 15/52] style(python): drop em-dashes from session docstrings --- python/src/pay_kit/protocols/mpp/intents/session.py | 2 +- python/tests/test_session_intent.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/intents/session.py b/python/src/pay_kit/protocols/mpp/intents/session.py index b0f283fb3..d0d36b195 100644 --- a/python/src/pay_kit/protocols/mpp/intents/session.py +++ b/python/src/pay_kit/protocols/mpp/intents/session.py @@ -106,7 +106,7 @@ def from_dict(cls, data: dict[str, Any]) -> SessionSplit: @dataclass class SessionRequest: - """Session intent request — the payload embedded in a 402 challenge. + """Session intent request, the payload embedded in a 402 challenge. Describes the channel parameters: cap, currency, splits, network, etc. Mirrors rust ``SessionRequest``; optional fields are omitted from diff --git a/python/tests/test_session_intent.py b/python/tests/test_session_intent.py index cc55c03dc..2a4950d99 100644 --- a/python/tests/test_session_intent.py +++ b/python/tests/test_session_intent.py @@ -307,7 +307,7 @@ def test_salt_serializes_as_string_and_accepts_number_and_huge_u64(): back = OpenPayload.from_dict(d) assert back.salt == salt - # Legacy numeric salt — no float precision loss for a huge u64 because the + # Legacy numeric salt: no float precision loss for a huge u64 because the # dict carries a Python int (mirrors rust's number branch). legacy = { "mode": "push", From ee3e36a55318fb4b9366da04603d375b1a9b9604 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 13 Jun 2026 15:09:47 +0300 Subject: [PATCH 16/52] feat(python): mpp/session server + routes (port of Go #160) Ports the server half of the mpp/session intent to the Python SDK, mirroring go/protocols/mpp/server (#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%. --- .../pay_kit/protocols/mpp/server/__init__.py | 50 + .../pay_kit/protocols/mpp/server/session.py | 733 +++++++++++++++ .../protocols/mpp/server/session_lifecycle.py | 107 +++ .../protocols/mpp/server/session_method.py | 664 ++++++++++++++ .../protocols/mpp/server/session_onchain.py | 361 ++++++++ .../protocols/mpp/server/session_routes.py | 215 +++++ .../protocols/mpp/server/session_store.py | 375 ++++++++ .../protocols/mpp/server/session_stream.py | 143 +++ .../protocols/mpp/server/session_voucher.py | 325 +++++++ python/tests/test_session_lifecycle.py | 103 +++ python/tests/test_session_method.py | 860 ++++++++++++++++++ python/tests/test_session_onchain.py | 481 ++++++++++ python/tests/test_session_routes.py | 290 ++++++ python/tests/test_session_server.py | 725 +++++++++++++++ python/tests/test_session_store.py | 310 +++++++ python/tests/test_session_stream.py | 139 +++ python/tests/test_session_voucher.py | 315 +++++++ 17 files changed, 6196 insertions(+) create mode 100644 python/src/pay_kit/protocols/mpp/server/session.py create mode 100644 python/src/pay_kit/protocols/mpp/server/session_lifecycle.py create mode 100644 python/src/pay_kit/protocols/mpp/server/session_method.py create mode 100644 python/src/pay_kit/protocols/mpp/server/session_onchain.py create mode 100644 python/src/pay_kit/protocols/mpp/server/session_routes.py create mode 100644 python/src/pay_kit/protocols/mpp/server/session_store.py create mode 100644 python/src/pay_kit/protocols/mpp/server/session_stream.py create mode 100644 python/src/pay_kit/protocols/mpp/server/session_voucher.py create mode 100644 python/tests/test_session_lifecycle.py create mode 100644 python/tests/test_session_method.py create mode 100644 python/tests/test_session_onchain.py create mode 100644 python/tests/test_session_routes.py create mode 100644 python/tests/test_session_server.py create mode 100644 python/tests/test_session_store.py create mode 100644 python/tests/test_session_stream.py create mode 100644 python/tests/test_session_voucher.py diff --git a/python/src/pay_kit/protocols/mpp/server/__init__.py b/python/src/pay_kit/protocols/mpp/server/__init__.py index 6dd43b442..1ac0175f3 100644 --- a/python/src/pay_kit/protocols/mpp/server/__init__.py +++ b/python/src/pay_kit/protocols/mpp/server/__init__.py @@ -10,15 +10,65 @@ is_service_worker_request, service_worker_js, ) +from pay_kit.protocols.mpp.server.session import ( + DeliveryRequest, + SessionConfig, + SessionServer, + Split, +) +from pay_kit.protocols.mpp.server.session_method import ( + Session, + SessionChallengeOptions, + SessionOptions, + new_session, +) +from pay_kit.protocols.mpp.server.session_routes import ( + RouteResponse, + SessionRoutes, + session_routes, +) +from pay_kit.protocols.mpp.server.session_store import ( + ChannelState, + ChannelStore, + CommittedDelivery, + ListChannelsFilter, + MemoryChannelStore, + PendingDelivery, +) +from pay_kit.protocols.mpp.server.session_stream import ( + MeteredStream, + new_metered_stream, + new_metered_stream_writer, +) __all__ = [ + "ChannelState", + "ChannelStore", "ChargeOptions", + "CommittedDelivery", "Config", + "DeliveryRequest", + "ListChannelsFilter", + "MemoryChannelStore", + "MeteredStream", "Mpp", + "PendingDelivery", + "RouteResponse", + "Session", + "SessionChallengeOptions", + "SessionConfig", + "SessionOptions", + "SessionRoutes", + "SessionServer", + "Split", "accepts_html", "challenge_to_html", "detect_realm", "detect_secret_key", "is_service_worker_request", + "new_metered_stream", + "new_metered_stream_writer", + "new_session", "service_worker_js", + "session_routes", ] diff --git a/python/src/pay_kit/protocols/mpp/server/session.py b/python/src/pay_kit/protocols/mpp/server/session.py new file mode 100644 index 000000000..5b08b3833 --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/server/session.py @@ -0,0 +1,733 @@ +"""Server-side session intent: challenge issuance, voucher verification, and +channel lifecycle management. + +1. The server calls :meth:`SessionServer.build_challenge_request` to produce + the ``SessionRequest`` embedded in a 402 challenge. +2. The client responds with an open action; the server calls + :meth:`SessionServer.process_open` to record the channel. +3. For each subsequent API call the client attaches a voucher action; the + server calls :meth:`SessionServer.verify_voucher` to validate and advance + the settled watermark atomically. +4. At session end the client (or server) triggers close via + :meth:`SessionServer.process_close`; on-chain settlement is driven by the + host once the close-pending state is recorded. + +On-chain verification is a seam in this layer: when +:attr:`SessionConfig.verify_open_tx` / :attr:`SessionConfig.verify_top_up_tx` +are set, :meth:`process_open` (push mode) and :meth:`process_top_up` invoke +them before persisting channel state, binding the payload to the attached +transaction and confirming the signature on-chain. When ``None``, the +transaction signature and deposit amount are trusted as provided, which is +suitable only for unit tests or deployments that verify transactions out of +band. + +Ports ``go/protocols/mpp/server/session.go`` (snake_case of the Go names). The +wire shapes follow the Rust spine ``rust/crates/mpp/src/server/session.rs`` and +``store.rs``. +""" + +from __future__ import annotations + +import time +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import TypeVar + +from pay_kit.protocols.mpp.intents.session import ( + DEFAULT_SESSION_EXPIRES_AT, + ClosePayload, + CommitPayload, + CommitReceipt, + CommitStatus, + MeteringDirective, + OpenPayload, + SessionMode, + SessionPullVoucherStrategy, + SessionRequest, + SessionSplit, + TopUpPayload, + VoucherPayload, +) +from pay_kit.protocols.mpp.server.session_store import ( + ChannelState, + ChannelStore, + CommittedDelivery, + PendingDelivery, +) +from pay_kit.protocols.mpp.server.session_voucher import ( + ChannelState as VoucherChannelState, +) +from pay_kit.protocols.mpp.server.session_voucher import ( + VerifyVoucherArgs, + VoucherVerifyStatus, + verify_session_voucher, + verify_voucher_for_channel, +) + +__all__ = [ + "Split", + "SessionTxVerifier", + "SessionConfig", + "DeliveryRequest", + "SessionServer", +] + +_U64_MAX = (1 << 64) - 1 + +_P = TypeVar("_P") + +# SessionTxVerifier confirms an on-chain transaction referenced by a session +# payload before channel state is persisted. Implementations typically decode +# the attached transaction, bind the payload signature to it, and confirm the +# signature on-chain. This is the seam the on-chain layer plugs into; ``None`` +# skips verification. Mirrors Go ``SessionTxVerifier[P]``; raising signals a +# verification failure. +SessionTxVerifier = Callable[[_P], Awaitable[None]] + + +@dataclass +class Split: + """A payment split committed at channel open; distributed at close. + + Mirrors Go ``Split``. ``recipient`` is a base58 public key (the Go field is + a ``solana.PublicKey``; this layer carries it as its base58 string form). + """ + + # Recipient of this split (base58). + recipient: str + # BPS is the share in basis points. + bps: int = 0 + + +@dataclass +class SessionConfig: + """Server configuration for the session intent. Mirrors Go ``SessionConfig``.""" + + # Operator public key (base58). Shown to clients in the challenge. + operator: str = "" + + # Recipient is the primary payment recipient (base58). + recipient: str = "" + + # MaxCap is the maximum cap the server will offer per session (base units). + # Clients may request a lower cap but not a higher one. + max_cap: int = 0 + + # Currency identifier (e.g. "USDC", mint address). + currency: str = "" + + # Decimals is the token decimals (default 6 for USDC). + decimals: int = 0 + + # Network is the Solana network: "mainnet", "devnet", "localnet". + network: str = "" + + # Splits are optional splits routed to specific recipients at close. + splits: list[Split] = field(default_factory=list) + + # ProgramID is the payment-channel program ID. None defaults to the + # canonical program. + program_id: str | None = None + + # MinVoucherDelta is the minimum voucher increment (base units). 0 = no + # minimum. + min_voucher_delta: int = 0 + + # Modes are the session modes this server accepts, advertised to clients in + # the 402 challenge. An empty list or [push] means only the payment-channel + # push mode is supported. + modes: list[SessionMode] = field(default_factory=list) + + # PullVoucherStrategy is the voucher authority used for pull sessions. + # Required when modes includes pull. + pull_voucher_strategy: SessionPullVoucherStrategy | None = None + + # VerifyOpenTx, when set, confirms the open transaction on-chain (push + # mode) before process_open persists channel state. + verify_open_tx: SessionTxVerifier[OpenPayload] | None = None + + # VerifyTopUpTx, when set, confirms the top-up transaction on-chain before + # process_top_up raises the deposit. + verify_top_up_tx: SessionTxVerifier[TopUpPayload] | None = None + + +@dataclass +class DeliveryRequest: + """A request to reserve a metered delivery for client-side ack/commit. + + Zero/empty values mean "absent" for the optional fields. Mirrors Go + ``DeliveryRequest``. + """ + + # SessionID is the channel/session ID that will pay for the delivery. + session_id: str + + # Amount owed for this delivery in base units. + amount: int = 0 + + # DeliveryID is an optional idempotency key. When empty the server derives + # ":". + delivery_id: str = "" + + # CommitURL is an optional commit endpoint hint surfaced to the client. + commit_url: str = "" + + # Proof is an optional opaque proof surfaced to the client. + proof: str = "" + + # ExpiresAt is an optional directive expiry (Unix seconds). Zero defaults to + # DEFAULT_SESSION_EXPIRES_AT. + expires_at: int = 0 + + +def _fits_in_deposit(cumulative: int, pending_total: int, amount: int, deposit: int) -> bool: + """Report whether cumulative + pending_total + amount <= deposit without + overflowing u64; any overflow is treated as exceeding the deposit. + + Mirrors Go ``fitsInDeposit``. + """ + if pending_total > _U64_MAX - cumulative: + return False + reserved = cumulative + pending_total + if amount > _U64_MAX - reserved: + return False + return reserved + amount <= deposit + + +def _parse_u64(raw: str) -> int: + """Parse a canonical unsigned base-10 ``u64`` (matching Go's + ``strconv.ParseUint(s, 10, 64)``). Raises ``ValueError`` otherwise.""" + if not (raw.isascii() and raw.isdigit()): + raise ValueError(f"invalid u64: {raw}") + value = int(raw, 10) + if value > _U64_MAX: + raise ValueError(f"u64 out of range: {raw}") + return value + + +def _find_pending(deliveries: list[PendingDelivery], delivery_id: str) -> PendingDelivery | None: + """Return the pending delivery with the given id, or None. Mirrors Go ``findPending``.""" + for delivery in deliveries: + if delivery.delivery_id == delivery_id: + return delivery + return None + + +def _find_committed(deliveries: list[CommittedDelivery], delivery_id: str) -> CommittedDelivery | None: + """Return the committed delivery with the given id, or None. Mirrors Go ``findCommitted``.""" + for delivery in deliveries: + if delivery.delivery_id == delivery_id: + return delivery + return None + + +def _commit_receipt( + delivery_id: str, session_id: str, amount: int, cumulative: int, status: CommitStatus +) -> CommitReceipt: + """Build a CommitReceipt with stringified amounts. Mirrors Go ``commitReceipt``.""" + return CommitReceipt( + delivery_id=delivery_id, + session_id=session_id, + amount=str(amount), + cumulative=str(cumulative), + status=status, + ) + + +class SessionServer: + """Server-side session manager. Pluggable over the channel store to support + in-memory testing and production persistence backends. + + Mirrors Go ``SessionServer``. + """ + + def __init__(self, config: SessionConfig, store: ChannelStore) -> None: + # config is the immutable server configuration captured at construction. + self._config = config + # store persists per-channel state; every mutation goes through its + # atomic update_channel so voucher watermarks stay double-spend safe. + self._store = store + + @property + def config(self) -> SessionConfig: + """The immutable server configuration captured at construction. + + Exposed read-only so the HTTP-facing session method (the Go ``Session``, + which reads ``s.core.config`` within the package) can inspect the + configured currency/decimals/network without reaching into a private + field. Mirrors the Go ``SessionServer.config`` field.""" + return self._config + + def store(self) -> ChannelStore: + """Return the channel store backing this server, so hosts can share it + with metering side channels. Mirrors Go ``Store``.""" + return self._store + + def build_challenge_request(self, cap: int) -> SessionRequest: + """Build the ``SessionRequest`` to embed in a 402 challenge. + + ``cap`` is the maximum this session will allow, clamped to + ``SessionConfig.max_cap``. ``min_voucher_delta`` is included only when + positive, ``modes`` is omitted when push-only, and + ``pull_voucher_strategy`` is included only when pull is offered. Mirrors + Go ``BuildChallengeRequest``. + """ + effective_cap = min(cap, self._config.max_cap) + + request = SessionRequest( + cap=str(effective_cap), + currency=self._config.currency, + operator=self._config.operator, + recipient=self._config.recipient, + decimals=self._config.decimals, + ) + if self._config.network != "": + request.network = self._config.network + for split in self._config.splits: + request.splits.append(SessionSplit(recipient=split.recipient, bps=split.bps)) + if self._config.program_id is not None: + request.program_id = self._config.program_id + if self._config.min_voucher_delta > 0: + request.min_voucher_delta = str(self._config.min_voucher_delta) + # Omit modes when only push is supported; clients assume push when modes + # is absent. + if not self._push_only(): + request.modes = list(self._config.modes) + if self._supports_mode("pull") and self._config.pull_voucher_strategy is not None: + request.pull_voucher_strategy = self._config.pull_voucher_strategy + return request + + def _push_only(self) -> bool: + """Report whether the configured modes reduce to push-only. Mirrors Go ``pushOnly``.""" + modes = self._config.modes + return len(modes) == 0 or (len(modes) == 1 and modes[0] == "push") + + def _supports_mode(self, mode: SessionMode) -> bool: + """Report whether the server accepts ``mode``. Empty configured modes + mean push-only. Mirrors Go ``supportsMode``.""" + modes = self._config.modes + if len(modes) == 0: + return mode == "push" + return mode in modes + + async def process_open(self, payload: OpenPayload) -> ChannelState: + """Process an open action and persist the channel state. + + The channel is keyed by ``OpenPayload.session_id`` (channelId first, + then tokenAccount for pull opens). Replayed opens are idempotent: when a + channel already exists for the session id with the same authorized + signer, the existing state is returned unchanged and the voucher + watermark is never reset. Opens for an existing channel are rejected + when the channel is finalized or when the payload's authorized signer + differs from the stored one. Mirrors Go ``ProcessOpen``. + """ + if not self._supports_mode(payload.mode): + raise ValueError(f"session mode {payload.mode!r} is not supported by this challenge") + + session_id = payload.session_id() + deposit = payload.deposit_amount() + if deposit == 0: + raise ValueError("deposit must be greater than zero") + if deposit > self._config.max_cap: + raise ValueError(f"deposit {deposit} exceeds max cap {self._config.max_cap}") + + # On-chain verification seam (push mode only; pull-mode host + # integrations submit server-broadcast transactions or validate + # delegated-token state before invoking this lower-level store method). + if payload.mode == "push" and self._config.verify_open_tx is not None: + try: + await self._config.verify_open_tx(payload) + except Exception as exc: + raise _wrap("open tx verification failed", exc) from exc + + operator = payload.owner + if operator is None: + operator = payload.payer + fresh = ChannelState( + channel_id=session_id, + authorized_signer=payload.authorized_signer, + deposit=deposit, + operator=operator, + ) + + def mutator(existing: ChannelState | None) -> ChannelState: + # Atomic check-and-insert: a replayed open re-passes all checks + # above (the referenced tx is genuinely confirmed), so it MUST NOT + # overwrite existing state; that would reset the voucher watermark + # and erase accepted vouchers before close. + if existing is not None: + if existing.finalized: + raise ValueError(f"channel {session_id} is already finalized") + if existing.authorized_signer != payload.authorized_signer: + raise ValueError(f"channel {session_id} already exists with a different authorized signer") + # Idempotent replay: keep existing state untouched. + return existing + return fresh + + return await self._store.update_channel(session_id, mutator) + + async def verify_voucher(self, payload: VoucherPayload) -> int: + """Verify a voucher, advance the watermark, and return the new + cumulative. + + The full ordered check sequence runs as a preflight outside the store + lock (see :func:`verify_voucher_for_channel`), then the state-dependent + checks are re-applied inside the atomic mutator before the watermark is + persisted. Mirrors Go ``VerifyVoucher``. + """ + voucher = payload.voucher + channel_id = voucher.data.channel_id + + state = await self._store.get_channel(channel_id) + if state is None: + raise ValueError(f"channel {channel_id} not found") + + # Preflight outside the lock (expensive signature check happens before + # touching the store). + result = verify_voucher_for_channel( + VerifyVoucherArgs( + state=_voucher_state(state), + signed=voucher, + deposit=state.deposit, + min_voucher_delta=self._config.min_voucher_delta, + ) + ) + if result.status == VoucherVerifyStatus.REJECTED: + # Surface the stable reject tag ahead of the detail + # (": "). + raise ValueError(f"{result.reason}: {result.detail}") + if result.status == VoucherVerifyStatus.REPLAYED: + return result.new_cumulative + + new_cumulative = result.new_cumulative + new_signature = result.new_signature + new_expires_at = result.new_expires_at + + def mutator(current: ChannelState | None) -> ChannelState: + # Atomic read-modify-write: re-check everything state-dependent + # inside the mutator. + if current is None: + raise ValueError(f"channel {channel_id} not found") + if current.finalized: + raise ValueError(f"channel {channel_id} is already finalized") + if current.close_requested_at is not None: + raise ValueError(f"channel {channel_id} close is pending; no further vouchers accepted") + # Idempotent replay inside the mutator. + if ( + new_cumulative == current.cumulative + and current.highest_voucher_signature is not None + and current.highest_voucher_signature == new_signature + ): + return current + # Concurrent watermark advancement check. + if new_cumulative <= current.cumulative: + raise ValueError("concurrent update: watermark advanced") + nxt = current.clone() + nxt.cumulative = new_cumulative + nxt.highest_voucher_signature = new_signature + nxt.highest_voucher_expires_at = new_expires_at + return nxt + + new_state = await self._store.update_channel(channel_id, mutator) + return new_state.cumulative + + async def process_top_up(self, payload: TopUpPayload) -> ChannelState: + """Process a topUp action: atomically raise the channel's deposit cap. + + The new deposit must exceed the current deposit and must not exceed the + configured max cap. Top-ups are rejected once the channel is finalized + or a close has been requested. Mirrors Go ``ProcessTopUp``. + """ + try: + new_deposit = _parse_u64(payload.new_deposit) + except ValueError as exc: + raise ValueError(f"invalid newDeposit: {payload.new_deposit}") from exc + + # On-chain verification seam (same shape as process_open). + if self._config.verify_top_up_tx is not None: + try: + await self._config.verify_top_up_tx(payload) + except Exception as exc: + raise _wrap("top-up tx verification failed", exc) from exc + + max_cap = self._config.max_cap + channel_id = payload.channel_id + + def mutator(current: ChannelState | None) -> ChannelState: + if current is None: + raise ValueError(f"channel {channel_id} not found") + if current.finalized: + raise ValueError(f"channel {channel_id} is already finalized") + if current.close_requested_at is not None: + raise ValueError(f"channel {channel_id} close is pending; no further top-ups accepted") + if new_deposit <= current.deposit: + raise ValueError(f"new deposit {new_deposit} must exceed current deposit {current.deposit}") + if new_deposit > max_cap: + raise ValueError(f"new deposit {new_deposit} exceeds max cap {max_cap}") + nxt = current.clone() + nxt.deposit = new_deposit + return nxt + + return await self._store.update_channel(channel_id, mutator) + + async def begin_delivery(self, request: DeliveryRequest) -> MeteringDirective: + """Reserve capacity for a delivered message/response and return the + metering directive the client must commit after processing it. + + The reservation requires cumulative + pendingTotal + amount <= deposit, + assigns the next sequence, and defaults the delivery id to + ":". Mirrors Go ``BeginDelivery``. + """ + if request.amount == 0: + raise ValueError("delivery amount must be greater than zero") + + session_id = request.session_id + amount = request.amount + expires_at = request.expires_at + if expires_at == 0: + expires_at = DEFAULT_SESSION_EXPIRES_AT + + directive: MeteringDirective | None = None + + def mutator(current: ChannelState | None) -> ChannelState: + nonlocal directive + if current is None: + raise ValueError(f"channel {session_id} not found") + if current.finalized: + raise ValueError(f"channel {session_id} is already finalized") + if current.close_requested_at is not None: + raise ValueError(f"channel {session_id} close is pending; no further deliveries accepted") + pending_total = sum(d.amount for d in current.pending_deliveries) + if not _fits_in_deposit(current.cumulative, pending_total, amount, current.deposit): + raise ValueError(f"delivery amount {amount} exceeds available deposit") + + sequence = current.next_delivery_sequence + 1 + delivery_id = request.delivery_id + if delivery_id == "": + delivery_id = f"{session_id}:{sequence}" + for delivery in current.pending_deliveries: + if delivery.delivery_id == delivery_id: + raise ValueError(f"delivery {delivery_id} already exists") + for delivery in current.committed_deliveries: + if delivery.delivery_id == delivery_id: + raise ValueError(f"delivery {delivery_id} already exists") + + nxt = current.clone() + nxt.next_delivery_sequence = sequence + nxt.pending_deliveries.append( + PendingDelivery( + delivery_id=delivery_id, + amount=amount, + sequence=sequence, + expires_at=expires_at, + ) + ) + + built = MeteringDirective( + delivery_id=delivery_id, + session_id=session_id, + amount=str(amount), + currency=self._config.currency, + sequence=sequence, + expires_at=expires_at, + ) + if request.commit_url != "": + built.commit_url = request.commit_url + if request.proof != "": + built.proof = request.proof + directive = built + return nxt + + await self._store.update_channel(session_id, mutator) + assert directive is not None + return directive + + async def process_commit(self, payload: CommitPayload) -> CommitReceipt: + """Commit a reserved delivery by verifying the attached voucher and + advancing the settled watermark. + + Replaying a commit for an already-committed delivery (same cumulative + and same signature) returns the cached receipt with status replayed + after re-verifying the voucher signature. Mirrors Go ``ProcessCommit``. + """ + channel_id = payload.voucher.data.channel_id + try: + new_cumulative = _parse_u64(payload.voucher.data.cumulative) + except ValueError as exc: + raise ValueError(f"invalid cumulative in commit voucher: {payload.voucher.data.cumulative}") from exc + + state = await self._store.get_channel(channel_id) + if state is None: + raise ValueError(f"channel {channel_id} not found") + + # Preflight outside the lock. + committed = _find_committed(state.committed_deliveries, payload.delivery_id) + if committed is not None: + if committed.cumulative == new_cumulative and committed.voucher_signature == payload.voucher.signature: + _raise_voucher_error(verify_session_voucher(payload.voucher, state.authorized_signer)) + return _commit_receipt( + payload.delivery_id, channel_id, committed.amount, committed.cumulative, "replayed" + ) + raise ValueError(f"delivery {payload.delivery_id} was already committed with different voucher") + pending = _find_pending(state.pending_deliveries, payload.delivery_id) + if pending is None: + raise ValueError(f"delivery {payload.delivery_id} not found") + now = int(time.time()) + if pending.expires_at <= now: + raise ValueError(f"delivery {payload.delivery_id} has expired") + if new_cumulative <= state.cumulative: + raise ValueError(f"commit cumulative {new_cumulative} must exceed watermark {state.cumulative}") + _raise_voucher_error(verify_session_voucher(payload.voucher, state.authorized_signer)) + + delivery_id = payload.delivery_id + signature = payload.voucher.signature + voucher_expires_at = payload.voucher.data.expires_at + + receipt: list[CommitReceipt] = [] + + def mutator(current: ChannelState | None) -> ChannelState: + if current is None: + raise ValueError(f"channel {channel_id} not found") + if current.finalized: + raise ValueError(f"channel {channel_id} is already finalized") + if current.close_requested_at is not None: + raise ValueError(f"channel {channel_id} close is pending; no further commits accepted") + existing = _find_committed(current.committed_deliveries, delivery_id) + if existing is not None: + if existing.cumulative == new_cumulative and existing.voucher_signature == signature: + receipt.append( + _commit_receipt(delivery_id, channel_id, existing.amount, existing.cumulative, "replayed") + ) + return current + raise ValueError(f"delivery {delivery_id} was already committed with different voucher") + pending_index = -1 + for i, delivery in enumerate(current.pending_deliveries): + if delivery.delivery_id == delivery_id: + pending_index = i + break + if pending_index < 0: + raise ValueError(f"delivery {delivery_id} not found") + reserved = current.pending_deliveries[pending_index] + if reserved.expires_at <= now: + raise ValueError(f"delivery {delivery_id} has expired") + if new_cumulative <= current.cumulative: + raise ValueError(f"commit cumulative {new_cumulative} must exceed watermark {current.cumulative}") + actual_amount = new_cumulative - current.cumulative + if actual_amount > reserved.amount: + raise ValueError(f"commit amount {actual_amount} exceeds reserved amount {reserved.amount}") + + nxt = current.clone() + nxt.pending_deliveries = ( + nxt.pending_deliveries[:pending_index] + nxt.pending_deliveries[pending_index + 1 :] + ) + nxt.cumulative = new_cumulative + nxt.highest_voucher_signature = signature + nxt.highest_voucher_expires_at = voucher_expires_at + nxt.committed_deliveries.append( + CommittedDelivery( + delivery_id=delivery_id, + amount=actual_amount, + cumulative=new_cumulative, + voucher_signature=signature, + ) + ) + receipt.append(_commit_receipt(delivery_id, channel_id, actual_amount, new_cumulative, "committed")) + return nxt + + await self._store.update_channel(channel_id, mutator) + return receipt[0] + + async def process_close(self, payload: ClosePayload) -> ChannelState: + """Process a close action: atomically set close-pending and accept a + final voucher if provided. + + Once close_requested_at is set, vouchers, deliveries, commits, and + top-ups are all rejected, and a second close is rejected with "close + already requested". A non-monotonic final voucher is a hard error + (unless it is an idempotent replay of the current highest voucher) and + leaves the state unchanged. Mirrors Go ``ProcessClose``. + """ + now = int(time.time()) + channel_id = payload.channel_id + voucher = payload.voucher + + def mutator(current: ChannelState | None) -> ChannelState: + if current is None: + raise ValueError(f"channel {channel_id} not found") + if current.finalized: + raise ValueError(f"channel {channel_id} is already finalized") + if current.close_requested_at is not None: + raise ValueError("close already requested") + + nxt = current.clone() + if voucher is not None: + try: + cumulative = _parse_u64(voucher.data.cumulative) + except ValueError as exc: + raise ValueError(f"invalid cumulative in final voucher: {voucher.data.cumulative}") from exc + if cumulative <= current.cumulative: + # Idempotent replay of the current highest voucher is + # allowed; any other non-monotonic final voucher is a hard + # error. + replay = ( + cumulative == current.cumulative + and current.highest_voucher_signature is not None + and current.highest_voucher_signature == voucher.signature + ) + if not replay: + raise ValueError( + f"final voucher cumulative {cumulative} must exceed watermark {current.cumulative}" + ) + if nxt.highest_voucher_expires_at is None: + nxt.highest_voucher_expires_at = voucher.data.expires_at + else: + if cumulative > current.deposit: + raise ValueError("final voucher exceeds deposit") + _raise_voucher_error(verify_session_voucher(voucher, current.authorized_signer)) + nxt.cumulative = cumulative + nxt.highest_voucher_signature = voucher.signature + nxt.highest_voucher_expires_at = voucher.data.expires_at + nxt.close_requested_at = now + return nxt + + return await self._store.update_channel(channel_id, mutator) + + async def mark_finalized(self, channel_id: str) -> None: + """Mark a channel as finalized. Call after the on-chain finalize + transaction confirms. Mirrors Go ``MarkFinalized``.""" + await self._store.mark_finalized(channel_id) + + +def _voucher_state(state: ChannelState) -> VoucherChannelState: + """Project the full store ``ChannelState`` onto the verifier's read-only + subset. The verifier module defines its own ``ChannelState`` carrying only + the fields it reads.""" + return VoucherChannelState( + channel_id=state.channel_id, + authorized_signer=state.authorized_signer, + deposit=state.deposit, + cumulative=state.cumulative, + finalized=state.finalized, + highest_voucher_signature=state.highest_voucher_signature, + highest_voucher_expires_at=state.highest_voucher_expires_at, + close_requested_at=state.close_requested_at, + ) + + +def _raise_voucher_error(err: str | None) -> None: + """Raise when the voucher verifier (string-returning) reports a failure. + + The Python ``verify_session_voucher`` returns an error string (or None); + the Go ``verifySessionVoucher`` returns an ``error``. Convert the string + form to a raised ``ValueError`` so the session paths surface it like Go. + """ + if err is not None: + raise ValueError(err) + + +def _wrap(message: str, exc: Exception) -> Exception: + """Wrap a seam error with a message prefix, mirroring Go's + ``fmt.Errorf("...: %w", err)``: the prefixed message is surfaced and the + original error is preserved as the exception cause (callers can inspect + ``__cause__`` the way Go callers use ``errors.Is``).""" + return ValueError(f"{message}: {exc}") diff --git a/python/src/pay_kit/protocols/mpp/server/session_lifecycle.py b/python/src/pay_kit/protocols/mpp/server/session_lifecycle.py new file mode 100644 index 000000000..52275c334 --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/server/session_lifecycle.py @@ -0,0 +1,107 @@ +"""Per-channel idle-close lifecycle. + +When the server accepts an open, it arms a single-shot timer keyed on the +channel id. Every voucher / commit / topUp :meth:`SessionLifecycle.touch` +resets the timer. When the timer fires, the ``close_on_idle`` handler is +invoked with the channel id so the server can run its close-and-settle path +without waiting for a client close action. + +The idle-close watchdog is an extension beyond the draft MPP spec; without +it, hosts drive close explicitly. + +Port of ``go/protocols/mpp/server/session_lifecycle.go``. Go arms background +goroutine timers via ``time.AfterFunc``; this port uses ``asyncio`` single-shot +timers scheduled on the running event loop, and the handler is an async +coroutine to match the rest of the async server surface. ``close_delay`` is a +duration in seconds (Go uses ``time.Duration``). +""" + +from __future__ import annotations + +import asyncio +import threading +from collections.abc import Awaitable, Callable + +CloseOnIdle = Callable[[str], Awaitable[None]] + + +class SessionLifecycle: + """The idle-close watchdog. + + :meth:`touch` resets the per-channel timer, :meth:`remove_channel` cancels + it, and :meth:`shutdown` cancels everything. + """ + + def __init__(self, close_on_idle: CloseOnIdle, close_delay: float) -> None: + """Create an idle-close watchdog. + + ``close_delay`` <= 0 disables the timer entirely (all operations become + no-ops), the right default for tests and for callers that drive close + explicitly. + + ``close_on_idle`` is awaited with the channel id when a timer fires. + Errors during idle close have no synchronous caller to report to; the + handler is expected to log internally. + """ + # _lock guards _timers and _shutdown. + self._lock = threading.Lock() + # _timers holds the armed single-shot idle timer handle per channel id. + self._timers: dict[str, asyncio.TimerHandle] = {} + # _close_delay is the idle duration before a channel is auto-closed; + # <= 0 disables the watchdog entirely. + self._close_delay = close_delay + # _close_on_idle is awaited with the channel id when its timer fires. + self._close_on_idle = close_on_idle + # _shutdown, once True, turns every later touch into a no-op and stops + # already-fired timers from invoking close_on_idle. + self._shutdown = False + + def touch(self, channel_id: str) -> None: + """Reset the idle timer for ``channel_id``. + + No-op when the close delay is disabled or the lifecycle is shut down. + """ + if self._close_delay <= 0: + return + loop = asyncio.get_event_loop() + with self._lock: + if self._shutdown: + return + self._cancel_locked(channel_id) + self._timers[channel_id] = loop.call_later( + self._close_delay, + self._fire, + channel_id, + ) + + def remove_channel(self, channel_id: str) -> None: + """Cancel the idle timer for ``channel_id``.""" + with self._lock: + self._cancel_locked(channel_id) + + def shutdown(self) -> None: + """Cancel every outstanding timer and disable future touches.""" + with self._lock: + self._shutdown = True + for timer in self._timers.values(): + timer.cancel() + self._timers.clear() + + def _fire(self, channel_id: str) -> None: + """Timer callback: drop the timer and schedule the idle-close handler. + + Mirrors the Go ``time.AfterFunc`` closure: forget the timer, bail if a + shutdown raced in, otherwise dispatch ``close_on_idle``. + """ + with self._lock: + self._timers.pop(channel_id, None) + stopped = self._shutdown + if stopped: + return + asyncio.ensure_future(self._close_on_idle(channel_id)) + + def _cancel_locked(self, channel_id: str) -> None: + """Stop and forget the timer for ``channel_id``. Callers hold ``_lock``.""" + timer = self._timers.pop(channel_id, None) + if timer is not None: + timer.cancel() diff --git a/python/src/pay_kit/protocols/mpp/server/session_method.py b/python/src/pay_kit/protocols/mpp/server/session_method.py new file mode 100644 index 000000000..192f29168 --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/server/session_method.py @@ -0,0 +1,664 @@ +"""HTTP-facing MPP session method handler. + +A :class:`Session` issues HMAC-bound 402 challenges carrying a +:class:`~pay_kit.protocols.mpp.intents.session.SessionRequest` +(:meth:`Session.challenge`), verifies Authorization credentials whose payload is +one of the five session actions (:meth:`Session.verify_credential` dispatching +to open / voucher / commit / topUp / close), and drives the channel lifecycle by +composing the lower-level building blocks: the :class:`SessionServer` core, the +:class:`ChannelStore`, the voucher verifier, the on-chain verifier seams, and +the idle-close watchdog. + +Ports the offline-core surface of ``go/protocols/mpp/server/session_method.go`` +(snake_case of the Go names); the field semantics are pinned by +``rust/crates/mpp/src/server/session.rs``. + +Trust model / on-chain seam: exactly as in the Go port, the RPC client is +optional. With no RPC client the transaction signature and deposit amount are +trusted as provided (offline core); with an RPC client an open's confirmation +signature is checked on-chain before the channel is persisted, and a top-up +signature is confirmed before the deposit is raised. The on-chain check is wired +through the :class:`SessionServer` config seams +(:func:`~pay_kit.protocols.mpp.server.session_onchain.new_open_tx_verifier` / +:func:`~pay_kit.protocols.mpp.server.session_onchain.new_top_up_tx_verifier`), +matching how the Python on-chain layer composes verification. + +Not ported here (their lower-level Python building blocks do not yet exist): the +server-broadcast open path (``OpenTxSubmitterServer`` / ``SubmitOpenTx``), the +on-chain settlement at close (``closeAndSettleChannel`` / +``SettlementInstructions``), and the metering side-channel HTTP routes +(``SessionRoutes``). The idle-close watchdog is wired but, without a settlement +path, its handler is a no-op when no RPC settlement is configured, exactly as +the Go ``closeOnIdle`` returns early when no signer/RPC is set. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any + +from solders.pubkey import Pubkey # type: ignore[import-untyped] + +from pay_kit._paycore.errors import ( + ChallengeExpiredError, + ChallengeMismatchError, + PaymentError, +) +from pay_kit._paycore.solana import MAX_SPLITS +from pay_kit.protocols.mpp.core.expires import minutes +from pay_kit.protocols.mpp.core.types import PaymentChallenge, PaymentCredential, Receipt +from pay_kit.protocols.mpp.intents.session import ( + ClosePayload, + CommitPayload, + OpenPayload, + SessionAction, + SessionMode, + SessionPullVoucherStrategy, + TopUpPayload, + VoucherPayload, +) +from pay_kit.protocols.mpp.server.defaults import detect_realm +from pay_kit.protocols.mpp.server.session import SessionConfig, SessionServer, Split +from pay_kit.protocols.mpp.server.session_lifecycle import SessionLifecycle +from pay_kit.protocols.mpp.server.session_onchain import ( + RpcClient, + confirm_transaction_signature, +) +from pay_kit.protocols.mpp.server.session_store import ChannelStore, MemoryChannelStore + +logger = logging.getLogger(__name__) + +_SECRET_KEY_ENV_VAR = "MPP_SECRET_KEY" +_U64_MAX = (1 << 64) - 1 + +__all__ = [ + "OpenTxSubmitter", + "SessionOptions", + "SessionChallengeOptions", + "Session", + "new_session", +] + + +# OpenTxSubmitter selects who broadcasts a push-mode payment-channel open +# transaction. Mirrors Go ``OpenTxSubmitter``. +OpenTxSubmitter = str + +# The client broadcasts the open transaction itself and the server only verifies +# it. Default. Mirrors Go ``OpenTxSubmitterClient``. +OPEN_TX_SUBMITTER_CLIENT: OpenTxSubmitter = "client" + +# The server completes the fee-payer signature, broadcasts the client-built open +# transaction, and waits for confirmation before persisting channel state. +# Mirrors Go ``OpenTxSubmitterServer``. +OPEN_TX_SUBMITTER_SERVER: OpenTxSubmitter = "server" + + +@dataclass +class SessionOptions: + """Options for :func:`new_session`. Mirrors Go ``SessionOptions``.""" + + # Operator public key (base58), shown to clients in the challenge. + operator: str = "" + # Recipient is the primary payment recipient (base58). Required. + recipient: str = "" + # Cap is the maximum session cap the server will offer (base units). + # Required, must be positive. + cap: int = 0 + # Currency identifier (e.g. "USDC" or an SPL mint address). Default USDC. + currency: str = "" + # Decimals is the token decimals. Default 6. + decimals: int = 0 + # Network is the Solana network. Default "mainnet". + network: str = "" + # SecretKey is the challenge HMAC secret. Defaults to MPP_SECRET_KEY. + secret_key: str = "" + # Realm is the challenge realm. Defaults to detect_realm(). + realm: str = "" + # ProgramID overrides the payment-channels program id. None defaults to the + # canonical program. + program_id: Pubkey | None = None + # MinVoucherDelta is the minimum voucher increment (base units). 0 = no + # minimum. + min_voucher_delta: int = 0 + # Modes are the funding modes advertised to clients. Empty means push only. + modes: list[SessionMode] = field(default_factory=list) + # PullVoucherStrategy is the voucher authority for pull-mode sessions. + # Required when modes includes pull. + pull_voucher_strategy: SessionPullVoucherStrategy | None = None + # Splits are optional basis-point splits distributed at close. Max 8. + splits: list[Split] = field(default_factory=list) + # CloseDelay arms the idle-close watchdog (seconds); zero disables it. + close_delay: float = 0 + # OpenTxSubmitter selects who broadcasts push-mode open transactions. + # Default "client". + open_tx_submitter: OpenTxSubmitter = "" + # Store is the pluggable channel store. Defaults to in-memory. + store: ChannelStore | None = None + # RPC is the optional RPC client used for on-chain checks. None skips every + # on-chain check and trusts payload claims as provided. + rpc: RpcClient | None = None + + +@dataclass +class SessionChallengeOptions: + """Customize a single 402 session challenge. Mirrors Go + ``SessionChallengeOptions``.""" + + # Cap is the requested session cap (base units, decimal string). Empty uses + # the server maximum; larger requests are clamped to it. + cap: str = "" + # Description is a human-readable challenge description. + description: str = "" + # ExternalID is a merchant reference id echoed on the receipt. + external_id: str = "" + # Expires is the challenge expiry (RFC 3339). Default five minutes. + expires: str = "" + + +def _parse_session_u64(value: str, name: str) -> int: + """Parse a non-negative decimal string into a u64, naming the field on + error. Mirrors Go ``parseSessionU64``.""" + if not (value.isascii() and value.isdigit()): + raise ValueError(f"{name} is not an unsigned integer string: {value}") + parsed = int(value, 10) + if parsed > _U64_MAX: + raise ValueError(f"{name} is not an unsigned integer string: {value}") + return parsed + + +async def _try_recent_blockhash(rpc: Any) -> str | None: + """Best-effort fetch of a recent blockhash from the injected RPC client. + + Returns the blockhash string on success or ``None`` on any error/absence; + the prefetch is non-fatal because the client fetches its own blockhash when + the challenge omits one. Mirrors the non-fatal ``GetLatestBlockhash`` branch + in Go ``Challenge``. + """ + getter: Any = getattr(rpc, "get_latest_blockhash", None) + if not callable(getter): + return None + try: + pending: Any = getter("confirmed") + out = await pending + except Exception: + return None + value: Any = getattr(out, "value", None) + blockhash: Any = getattr(value, "blockhash", None) + if isinstance(blockhash, str) and blockhash: + return blockhash + return None + + +def _success_receipt(reference: str, challenge_id: str, external_id: str) -> Receipt: + """Build a success receipt for a session action. Mirrors Go + ``successReceipt``.""" + return Receipt.success( + method="solana", + reference=reference, + challenge_id=challenge_id, + external_id=external_id, + ) + + +class Session: + """The server-side session method handler. Create with :func:`new_session`. + + Mirrors Go ``Session``. + """ + + def __init__( + self, + *, + core: SessionServer, + secret_key: str, + realm: str, + cap: int, + currency: str, + recipient: str, + network: str, + open_tx_submitter: OpenTxSubmitter, + rpc: RpcClient | None, + lifecycle: SessionLifecycle | None, + ) -> None: + # core is the lower-level SessionServer dispatching open / voucher / + # commit / topUp / close against the channel store. + self._core = core + self._secret_key = secret_key + self._realm = realm + # cap is the maximum session cap offered in challenges (base units); + # per-challenge requested caps are clamped to it. + self._cap = cap + self._currency = currency + self._recipient = recipient + self._network = network + self._open_tx_submitter = open_tx_submitter + # rpc is the optional RPC client for on-chain checks; None trusts payload + # claims as provided. + self._rpc = rpc + # lifecycle is the idle-close watchdog; None when close_delay is zero. + self._lifecycle = lifecycle + + def core(self) -> SessionServer: + """Return the underlying :class:`SessionServer` so hosts can reach the + channel store and the lower-level lifecycle methods. Mirrors Go + ``Core``.""" + return self._core + + def shutdown(self) -> None: + """Cancel the idle-close watchdog timers. Hosts should call it when + tearing the session method down. Mirrors Go ``Shutdown``.""" + if self._lifecycle is not None: + self._lifecycle.shutdown() + + def _touch(self, channel_id: str) -> None: + """Reset the idle-close timer for ``channel_id`` when the watchdog is + armed. Mirrors Go ``touch``.""" + if self._lifecycle is not None: + self._lifecycle.touch(channel_id) + + def _supports_mode(self, mode: SessionMode) -> bool: + """Report whether the configured modes accept ``mode``; empty modes mean + push-only. Mirrors the ``core.supportsMode`` check the Go ``handleOpen`` + runs before resolving the channel facts.""" + modes = self._core.config.modes + if not modes: + return mode == "push" + return mode in modes + + async def challenge(self, options: SessionChallengeOptions | None = None) -> PaymentChallenge: + """Build the HMAC-bound 402 challenge embedding a ``SessionRequest``. + + The requested cap is clamped to the server maximum, ``min_voucher_delta`` + is included only when positive, ``modes`` are omitted when push-only, + ``pull_voucher_strategy`` is included only when pull is offered, and a + recent blockhash is prefetched (non-fatally) when an RPC client is + configured. Mirrors Go ``Challenge``. + """ + if options is None: + options = SessionChallengeOptions() + cap_value = self._cap + if options.cap != "": + try: + cap_value = _parse_session_u64(options.cap, "cap") + except ValueError as exc: + raise PaymentError(f"invalid requested cap: {exc}", code="invalid-payload") from exc + request = self._core.build_challenge_request(cap_value) + if options.description != "": + request.description = options.description + if options.external_id != "": + request.external_id = options.external_id + if self._rpc is not None: + # Non-fatal: the client fetches its own blockhash when absent. The + # blockhash source is the injected RPC client, so unit tests stay + # offline. + blockhash = await _try_recent_blockhash(self._rpc) + if blockhash: + request.recent_blockhash = blockhash + + expires = options.expires or minutes(5) + return PaymentChallenge.with_secret_key( + secret_key=self._secret_key, + realm=self._realm, + method="solana", + intent="session", + request=PaymentChallenge.encode_request(request.to_dict()), + expires=expires, + description=options.description, + ) + + async def verify_credential(self, credential: PaymentCredential) -> Receipt: + """Verify a session Authorization credential: Tier-1 HMAC and expiry, the + Tier-2 pinned-field backstop, then dispatch on the payload action (open / + voucher / commit / topUp / close). Mirrors Go ``VerifyCredential``. + """ + challenge = PaymentChallenge( + id=credential.challenge.id, + realm=credential.challenge.realm, + method=credential.challenge.method, + intent=credential.challenge.intent, + request=credential.challenge.request, + expires=credential.challenge.expires, + digest=credential.challenge.digest, + opaque=credential.challenge.opaque, + ) + if not challenge.verify(self._secret_key): + raise ChallengeMismatchError() + if challenge.is_expired(): + raise ChallengeExpiredError(f"challenge expired at {challenge.expires}") + + from pay_kit.protocols.mpp.intents.session import SessionRequest + + request = SessionRequest.from_dict(challenge.decode_request()) + self._verify_pinned_session_fields(credential, request) + + try: + action = SessionAction.from_dict(credential.payload) + except Exception as exc: + raise PaymentError(f"decode session action: {exc}", code="invalid-payload") from exc + + if action.open is not None: + reference = await self._handle_open(action.open) + elif action.voucher is not None: + reference = await self._handle_voucher(action.voucher) + elif action.commit is not None: + reference = await self._handle_commit(action.commit) + elif action.top_up is not None: + reference = await self._handle_top_up(action.top_up) + elif action.close is not None: + reference = await self._handle_close(action.close) + else: + raise PaymentError("unknown session action", code="invalid-payload") + + external_id = request.external_id or "" + return _success_receipt(reference, credential.challenge.id, external_id) + + def _verify_pinned_session_fields(self, credential: PaymentCredential, request: Any) -> None: + """Tier-2 backstop for session credentials: after Tier-1 HMAC confirms + the challenge was issued by this server, fields fixed at construction + time are compared so a credential issued for a different + method/intent/realm or for a different recipient/currency cannot reach + the action handlers. Mirrors Go ``verifyPinnedSessionFields``. + """ + method_name = "solana" + if credential.challenge.method != method_name: + raise PaymentError( + f"credential method '{credential.challenge.method}' does not match this server " + f"(expected '{method_name}')", + code="challenge-route-mismatch", + ) + if credential.challenge.intent.lower() != "session": + raise PaymentError( + f"credential intent '{credential.challenge.intent}' is not a session", + code="challenge-route-mismatch", + ) + if credential.challenge.realm != self._realm: + raise PaymentError( + f"credential realm '{credential.challenge.realm}' does not match this server " + f"(expected '{self._realm}')", + code="challenge-route-mismatch", + ) + if request.currency != self._currency: + raise PaymentError( + f"credential currency '{request.currency}' does not match this server (expected '{self._currency}')", + code="challenge-route-mismatch", + ) + if request.recipient != self._recipient: + raise PaymentError( + "credential recipient does not match this server", + code="recipient-mismatch", + ) + + async def _handle_open(self, payload: OpenPayload) -> str: + """Process an open action: resolve the channel facts, enforce the deposit + invariants, and insert the channel state atomically and idempotently. The + receipt reference is the open signature when one exists, else the channel + id. Mirrors the trusted / client-broadcast path of Go ``handleOpen``. + """ + if self._open_tx_submitter == OPEN_TX_SUBMITTER_SERVER: + # The server-broadcast open requires the SubmitOpenTx building block, + # which is not yet ported to Python. + raise PaymentError( + "openTxSubmitter=server is not supported by this port", + code="invalid-config", + ) + + mode = payload.mode + if not self._supports_mode(mode): + raise PaymentError(f"session mode {mode!r} is not supported by this challenge", code="invalid-payload") + if mode == "pull" and self._core.config.pull_voucher_strategy is None: + raise PaymentError( + "pull-mode open requires a pullVoucherStrategy on the server config", + code="invalid-config", + ) + + # Empty strings count as missing. + has_transaction = payload.transaction is not None and payload.transaction != "" + has_channel_id = payload.channel_id is not None and payload.channel_id != "" + + if has_transaction: + # Payment-channel-backed open verification needs the on-chain open-tx + # verifier seam, which decodes and binds the attached transaction. + # That path lands with the on-chain settlement port. + raise PaymentError( + "open with an attached transaction is not supported by this port", + code="invalid-payload", + ) + if mode == "push" and not has_channel_id: + raise PaymentError("open payload missing transaction or channelId", code="invalid-payload") + + # No transaction in the payload: the client asserts a previously + # broadcast open. With an RPC client the open signature is confirmed + # on-chain before persisting; without one the channelId/deposit fields + # are trusted as-is. (Pull mode without a tx trusts the + # channelId/tokenAccount + approvedAmount.) + if mode == "push" and self._rpc is not None: + await confirm_transaction_signature(self._rpc, payload.signature, "open") + + try: + state = await self._core.process_open(payload) + except ValueError as exc: + raise PaymentError(str(exc), code="invalid-payload") from exc + self._touch(state.channel_id) + if payload.signature != "": + return payload.signature + return state.channel_id + + async def _handle_voucher(self, payload: VoucherPayload) -> str: + """Verify a cumulative voucher and advance the watermark. The receipt + reference is ":". Mirrors Go ``handleVoucher``.""" + channel_id = payload.voucher.data.channel_id + try: + cumulative = await self._core.verify_voucher(payload) + except ValueError as exc: + raise PaymentError(str(exc), code="invalid-payload") from exc + self._touch(channel_id) + return f"{channel_id}:{cumulative}" + + async def _handle_commit(self, payload: CommitPayload) -> str: + """Commit a reserved metered delivery. The receipt reference is + "::". Mirrors Go ``handleCommit``.""" + try: + receipt = await self._core.process_commit(payload) + except ValueError as exc: + raise PaymentError(str(exc), code="invalid-payload") from exc + self._touch(receipt.session_id) + return f"{receipt.session_id}:{receipt.delivery_id}:{receipt.cumulative}" + + async def _handle_top_up(self, payload: TopUpPayload) -> str: + """Raise a channel's deposit after optional on-chain confirmation of the + top-up signature. The receipt reference is the top-up transaction + signature. Mirrors Go ``handleTopUp``.""" + try: + new_deposit = _parse_session_u64(payload.new_deposit, "newDeposit") + except ValueError as exc: + raise PaymentError(str(exc), code="invalid-payload") from exc + if new_deposit > self._cap: + raise PaymentError(f"newDeposit {new_deposit} exceeds cap {self._cap}", code="invalid-payload") + + # Cheap store pre-checks before touching the network. + existing = await self._core.store().get_channel(payload.channel_id) + if existing is None: + raise PaymentError(f"channel {payload.channel_id} not found", code="invalid-payload") + if existing.finalized: + raise PaymentError(f"channel {payload.channel_id} is already finalized", code="invalid-payload") + if existing.close_requested_at is not None: + raise PaymentError( + f"channel {payload.channel_id} close is pending; no further top-ups accepted", + code="invalid-payload", + ) + if self._rpc is not None: + await confirm_transaction_signature(self._rpc, payload.signature, "topUp") + try: + await self._core.process_top_up(payload) + except ValueError as exc: + raise PaymentError(str(exc), code="invalid-payload") from exc + self._touch(payload.channel_id) + return payload.signature + + async def _handle_close(self, payload: ClosePayload) -> str: + """Accept the optional final voucher and flip close-pending atomically. + The receipt reference is the channel id (the on-chain settlement path is + not ported). Mirrors Go ``handleClose``. + + Unlike :meth:`SessionServer.process_close`, where a second close is + always rejected, the close here is re-drivable: when a prior close + flipped the close-pending flag but settlement never recorded a signature + (``settled_signature is None``), the retry proceeds so a transient + settlement failure cannot strand the channel. A close-pending channel + that already recorded a settled signature is not re-drivable and + hard-rejects with "close already requested". The fund-safety final + voucher validation is unchanged from the core path. + """ + import time + + from pay_kit.protocols.mpp.server.session import _parse_u64 + from pay_kit.protocols.mpp.server.session_store import ChannelState + from pay_kit.protocols.mpp.server.session_voucher import verify_session_voucher + + channel_id = payload.channel_id + now = int(time.time()) + voucher = payload.voucher + + def mutator(current: ChannelState | None) -> ChannelState: + if current is None: + raise ValueError(f"channel {channel_id} not found") + if current.finalized: + raise ValueError(f"channel {channel_id} is already finalized") + if current.close_requested_at is not None: + if current.settled_signature is None: + # Re-drivable close: leave state untouched and let the + # settlement retry proceed. + return current.clone() + raise ValueError("close already requested") + + nxt = current.clone() + if voucher is not None: + try: + cumulative = _parse_u64(voucher.data.cumulative) + except ValueError as exc: + raise ValueError(f"invalid cumulative in final voucher: {voucher.data.cumulative}") from exc + if cumulative <= current.cumulative: + # Idempotent replay of the current highest voucher is + # allowed; any other non-monotonic final voucher is a hard + # error. + replay = ( + cumulative == current.cumulative + and current.highest_voucher_signature is not None + and current.highest_voucher_signature == voucher.signature + ) + if not replay: + raise ValueError( + f"final voucher cumulative {cumulative} must exceed watermark {current.cumulative}" + ) + if nxt.highest_voucher_expires_at is None: + nxt.highest_voucher_expires_at = voucher.data.expires_at + else: + if cumulative > current.deposit: + raise ValueError("final voucher exceeds deposit") + err = verify_session_voucher(voucher, current.authorized_signer) + if err is not None: + raise ValueError(err) + nxt.cumulative = cumulative + nxt.highest_voucher_signature = voucher.signature + nxt.highest_voucher_expires_at = voucher.data.expires_at + nxt.close_requested_at = now + return nxt + + try: + await self._core.store().update_channel(channel_id, mutator) + except ValueError as exc: + raise PaymentError(str(exc), code="invalid-payload") from exc + reference = payload.channel_id + if self._lifecycle is not None: + self._lifecycle.remove_channel(payload.channel_id) + return reference + + async def _close_on_idle(self, channel_id: str) -> None: + """Idle-close watchdog handler. Without a ported on-chain settlement + path there is nothing to broadcast, so this is a no-op, exactly as the + Go ``closeOnIdle`` returns early when no signer/RPC is configured. + """ + return None + + +def new_session(options: SessionOptions) -> Session: + """Create the server-side session method. Mirrors Go ``NewSession``.""" + if options.cap == 0: + raise PaymentError("cap must be positive", code="invalid-config") + if options.recipient == "": + raise PaymentError("recipient is required", code="invalid-config") + try: + Pubkey.from_string(options.recipient) + except (ValueError, TypeError) as exc: + raise PaymentError(f"invalid recipient pubkey: {exc}", code="invalid-config") from exc + if len(options.splits) > MAX_SPLITS: + raise PaymentError(f"splits cannot exceed {MAX_SPLITS} entries", code="invalid-config") + + secret_key = options.secret_key + if secret_key == "": + import os + + secret_key = os.environ.get(_SECRET_KEY_ENV_VAR, "") + if secret_key == "": + raise PaymentError("missing secret key", code="invalid-config") + + currency = options.currency or "USDC" + decimals = options.decimals or 6 + network = options.network or "mainnet" + realm = options.realm or detect_realm() + + open_tx_submitter = options.open_tx_submitter + if open_tx_submitter == "": + open_tx_submitter = OPEN_TX_SUBMITTER_CLIENT + elif open_tx_submitter not in (OPEN_TX_SUBMITTER_CLIENT, OPEN_TX_SUBMITTER_SERVER): + raise PaymentError( + f"openTxSubmitter must be '{OPEN_TX_SUBMITTER_CLIENT}' or '{OPEN_TX_SUBMITTER_SERVER}', " + f"got '{open_tx_submitter}'", + code="invalid-config", + ) + + supports_pull = any(mode == "pull" for mode in options.modes) + if supports_pull and options.pull_voucher_strategy is None: + raise PaymentError( + "pullVoucherStrategy is required when modes includes pull", + code="invalid-config", + ) + + store = options.store if options.store is not None else MemoryChannelStore() + + config = SessionConfig( + operator=options.operator, + recipient=options.recipient, + splits=options.splits, + max_cap=options.cap, + currency=currency, + decimals=decimals, + network=network, + program_id=None if options.program_id is None else str(options.program_id), + min_voucher_delta=options.min_voucher_delta, + modes=options.modes, + pull_voucher_strategy=options.pull_voucher_strategy, + ) + # The method layer performs the optional on-chain liveness confirm inline in + # its open / topUp handlers (mirroring Go's NewSession, which leaves the core + # SessionConfig verifier seams unset and confirms in the method), so the core + # is left to trust payload claims; the seam stays available for hosts that + # drive the lower-level SessionServer directly. + core = SessionServer(config, store) + session = Session( + core=core, + secret_key=secret_key, + realm=realm, + cap=options.cap, + currency=currency, + recipient=options.recipient, + network=network, + open_tx_submitter=open_tx_submitter, + rpc=options.rpc, + lifecycle=None, + ) + if options.close_delay > 0: + session._lifecycle = SessionLifecycle(session._close_on_idle, options.close_delay) + return session diff --git a/python/src/pay_kit/protocols/mpp/server/session_onchain.py b/python/src/pay_kit/protocols/mpp/server/session_onchain.py new file mode 100644 index 000000000..30898add9 --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/server/session_onchain.py @@ -0,0 +1,361 @@ +"""On-chain verification and settlement for the session intent. + +Python counterpart of ``go/protocols/mpp/server/session_onchain.go`` (and the +field semantics pinned by ``rust/crates/mpp/src/server/session.rs``). This +module ports the standalone open-transaction verifier and the verifier-seam +factories. + +Trust model: when no verifier is installed (the seam is ``None``), transaction +signatures and deposit amounts are trusted as provided. :func:`verify_open_tx` +always validates an attached open transaction structurally (decode, bind the +payload signature, check the open instruction against the challenge, re-derive +the channel PDA); confirming that the transaction actually landed additionally +requires an RPC client. :func:`new_top_up_tx_verifier` is purely RPC-backed (the +top-up payload carries only a signature, no transaction), so without an RPC +client the top-up seam stays ``None`` and the new deposit is trusted as +provided. + +The ``SessionServer``-bound ``SettlementInstructions`` method from the Go file +lands with the base session server port (not yet ported to Python); the field +semantics here are the self-contained slice that does not depend on it. +""" + +from __future__ import annotations + +import base64 +import struct +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Protocol + +from solders.pubkey import Pubkey # type: ignore[import-untyped] +from solders.signature import Signature # type: ignore[import-untyped] + +from pay_kit._paycore.errors import PaymentError +from pay_kit._paycore.solana import resolve_mint +from pay_kit.protocols.mpp._paymentchannels import PROGRAM_ID, find_channel_pda +from pay_kit.protocols.mpp.intents.session import OpenPayload, TopUpPayload + +__all__ = [ + "VerifyOpenTxExpected", + "VerifyOpenTxResult", + "verify_open_tx", + "new_open_tx_verifier", + "new_top_up_tx_verifier", + "confirm_transaction_signature", + "is_placeholder_signature", +] + +# Payment-channel open instruction discriminator (single-byte Anchor-numeric +# form, not the 8-byte sha256 convention). Matches OPEN_DISCRIMINATOR in the +# vendored Codama clients and ``openInstructionDiscriminator`` in the Go port. +_OPEN_INSTRUCTION_DISCRIMINATOR = 1 + + +class RpcClient(Protocol): + """Minimal RPC seam used for the optional on-chain liveness check. + + Mirrors the ``solanatx.RPCClient`` surface the Go port consumes: + ``get_signature_statuses`` returns the per-signature status list (each entry + is a status dict with an ``err`` field, or ``None`` when unknown).""" + + async def get_signature_statuses(self, signatures: list[str]) -> list[dict | None]: ... + + +#: A verifier seam installed on the session config: validates a payload (open +#: or top-up) and raises on rejection. Mirrors the Go +#: ``SessionTxVerifier[T]`` function type. +OpenTxVerifier = Callable[[OpenPayload], Awaitable[None]] +TopUpTxVerifier = Callable[[TopUpPayload], Awaitable[None]] + + +class OpenVerifierConfig(Protocol): + """The subset of the session config :func:`new_open_tx_verifier` reads. + + Mirrors the fields ``NewOpenTxVerifier`` pulls off ``SessionConfig``: + challenge currency/network/recipient, the deposit cap, and the optional + payment-channels program id override.""" + + currency: str + network: str + recipient: str + max_cap: int + program_id: Pubkey | None + + +@dataclass +class VerifyOpenTxExpected: + """The challenge-side values a client-submitted open transaction is + validated against. Mirrors ``VerifyOpenTxExpected`` in the Go port.""" + + authorized_signer: str + currency: str + recipient: str + network: str + max_cap: int = 0 + mint: str = "" + program_id: Pubkey | None = None + + +@dataclass +class VerifyOpenTxResult: + """The channel facts extracted from a verified open transaction. Mirrors + ``VerifyOpenTxResult`` in the Go port.""" + + channel_id: str + deposit: int + grace_period: int + salt: int + + +def is_placeholder_signature(signature: str) -> bool: + """Report whether ``signature`` is the pending placeholder produced by the + server-completed open flow (an empty string or a run of 40+ ``'1'`` + characters, the base58 encoding of the all-ones marker). Mirrors + ``isPlaceholderSignature`` in the Go port.""" + if signature == "": + return True + if len(signature) < 40: + return False + return signature.count("1") == len(signature) + + +def _decode_transaction(transaction_b64: str) -> tuple[list[str], list, list[str]]: + """Decode a base64 (legacy or v0) transaction into ``(account_keys, + instructions, signatures)`` as base58 strings / compiled-instruction + objects. Mirrors ``solanatx.DecodeTransactionBase64`` consumption in Go.""" + from solders.transaction import Transaction, VersionedTransaction + + from pay_kit._paycore.transaction import is_v0_wire_bytes + + raw = base64.b64decode(transaction_b64, validate=True) + message = None + signatures: list = [] + if is_v0_wire_bytes(raw): + vtx = VersionedTransaction.from_bytes(raw) + message = vtx.message + signatures = list(vtx.signatures) + else: + try: + tx = Transaction.from_bytes(raw) + message = tx.message + signatures = list(tx.signatures) + except Exception: + vtx = VersionedTransaction.from_bytes(raw) + message = vtx.message + signatures = list(vtx.signatures) + account_keys = [str(key) for key in message.account_keys] + instructions = list(message.instructions) + signature_strings = [str(sig) for sig in signatures] + return account_keys, instructions, signature_strings + + +async def verify_open_tx( + expected: VerifyOpenTxExpected, + payload: OpenPayload, + rpc_client: RpcClient | None, +) -> VerifyOpenTxResult: + """Decode and validate a client-submitted payment-channel open transaction + against the session challenge. Mirrors ``VerifyOpenTx`` in the Go port. + + Both legacy and v0 transaction encodings are accepted. The embedded open + instruction must target the configured payment-channels program, the payee + must equal the challenge recipient, the mint must match the challenge + currency/network, the authorizedSigner must match the payload, the deposit + must be positive and within the cap, and the channel account must equal the + PDA re-derived from the instruction's own seeds. + + When the payload carries a non-placeholder signature, it must equal the + transaction's own fee-payer signature. If ``rpc_client`` is non-None, that + bound signature is additionally confirmed on-chain; ``None`` skips the + liveness check (structural validation only). + """ + if not payload.transaction: + raise PaymentError( + "openPayload.transaction is required for push-mode open verification", + code="invalid-payload", + ) + + try: + account_keys, instructions, signatures = _decode_transaction(payload.transaction) + except Exception as exc: + raise PaymentError(f"decode open transaction: {exc}", code="invalid-payload") from exc + + # Bind the claimed signature to this transaction before trusting it. + bound_signature = payload.signature != "" and not is_placeholder_signature(payload.signature) + if bound_signature: + if not signatures or signatures[0] == str(Signature.default()): + raise PaymentError( + "openPayload.signature is set but the transaction carries no fee-payer signature", + code="invalid-payload", + ) + if signatures[0] != payload.signature: + raise PaymentError( + f"openPayload.signature {payload.signature} != transaction signature {signatures[0]}", + code="invalid-payload", + ) + + program_id = expected.program_id if expected.program_id is not None else PROGRAM_ID + expected_mint = expected.mint or resolve_mint(expected.currency, expected.network) + if not expected_mint: + raise PaymentError( + f"could not resolve mint from currency {expected.currency!r}", + code="invalid-payload", + ) + + def account_at(indices: list[int], slot: int, label: str) -> Pubkey: + if slot >= len(indices) or indices[slot] >= len(account_keys): + raise PaymentError( + f"open instruction is missing the {label} account at slot {slot}", + code="invalid-payload", + ) + return Pubkey.from_string(account_keys[indices[slot]]) + + open_ix = None + for ix in instructions: + program_index = int(ix.program_id_index) + if program_index >= len(account_keys) or account_keys[program_index] != str(program_id): + continue + data = bytes(ix.data) + if len(data) < 1 or data[0] != _OPEN_INSTRUCTION_DISCRIMINATOR: + continue + open_ix = ix + break + if open_ix is None: + raise PaymentError("no payment-channels open instruction found", code="invalid-payload") + + # Open instruction account layout (matches the generated client): + # 0 payer, 1 payee, 2 mint, 3 authorizedSigner, 4 channel, + # 5 payerTokenAccount, 6 channelTokenAccount, 7 tokenProgram, ... + accounts = [int(i) for i in open_ix.accounts] + if len(accounts) < 7: + raise PaymentError( + f"open instruction has too few accounts ({len(accounts)})", + code="invalid-payload", + ) + payer = account_at(accounts, 0, "payer") + payee = account_at(accounts, 1, "payee") + mint = account_at(accounts, 2, "mint") + authorized_signer = account_at(accounts, 3, "authorizedSigner") + channel = account_at(accounts, 4, "channel") + + if str(payee) != expected.recipient: + raise PaymentError(f"open payee {payee} != expected recipient {expected.recipient}", code="invalid-payload") + if str(mint) != expected_mint: + raise PaymentError(f"open mint {mint} != expected mint {expected_mint}", code="invalid-payload") + if str(authorized_signer) != expected.authorized_signer: + raise PaymentError( + f"open authorizedSigner {authorized_signer} != expected {expected.authorized_signer}", + code="invalid-payload", + ) + + # Instruction data: [discriminator u8][salt u64][deposit u64][grace u32][recipients]. + data = bytes(open_ix.data) + if len(data) < 1 + 8 + 8 + 4: + raise PaymentError(f"open instruction data too short ({len(data)} bytes)", code="invalid-payload") + salt = struct.unpack_from(" expected.max_cap: + raise PaymentError(f"open deposit {deposit} exceeds max cap {expected.max_cap}", code="invalid-payload") + + # Re-derive the channel PDA from the instruction's own seeds. + derived_channel, _ = find_channel_pda(payer, payee, mint, authorized_signer, salt, program_id) + if derived_channel != channel: + raise PaymentError(f"open channel PDA {channel} != derived {derived_channel}", code="invalid-payload") + if payload.channel_id is not None and payload.channel_id != str(channel): + raise PaymentError( + f"openPayload.channelId {payload.channel_id} != transaction channel {channel}", + code="invalid-payload", + ) + + # Optional liveness check: only when the caller provides an RPC client and + # the client already populated the transaction signature. + if rpc_client is not None and bound_signature: + await confirm_transaction_signature(rpc_client, payload.signature, "open") + + return VerifyOpenTxResult( + channel_id=str(channel), + deposit=deposit, + grace_period=grace_period, + salt=salt, + ) + + +def new_open_tx_verifier(config: OpenVerifierConfig, rpc_client: RpcClient | None) -> OpenTxVerifier: + """Return the on-chain open verifier to install on the session config. + Mirrors ``NewOpenTxVerifier`` in the Go port. + + When the open payload carries a transaction, it is structurally validated + against the challenge via :func:`verify_open_tx` (with an on-chain liveness + check when ``rpc_client`` is non-None). When the payload carries only a + confirmation signature, ``rpc_client`` is required and the signature is + confirmed on-chain via ``getSignatureStatuses``. + """ + + async def verifier(payload: OpenPayload) -> None: + if payload.transaction: + expected = VerifyOpenTxExpected( + authorized_signer=payload.authorized_signer, + currency=config.currency, + max_cap=config.max_cap, + network=config.network, + program_id=config.program_id, + recipient=config.recipient, + ) + await verify_open_tx(expected, payload, rpc_client) + return + if rpc_client is None: + raise PaymentError( + "open verification requires a transaction or an RPC client", + code="invalid-payload", + ) + await confirm_transaction_signature(rpc_client, payload.signature, "open") + + return verifier + + +def new_top_up_tx_verifier(rpc_client: RpcClient | None) -> TopUpTxVerifier | None: + """Return the on-chain top-up verifier to install on the session config: it + confirms the top-up transaction signature on-chain via + ``getSignatureStatuses``. Mirrors ``NewTopUpTxVerifier`` in the Go port. + + A ``None`` ``rpc_client`` returns ``None`` so the seam stays unset, and the + new deposit is trusted as provided; suitable only for unit tests or + deployments that verify transactions out of band. + """ + if rpc_client is None: + return None + + async def verifier(payload: TopUpPayload) -> None: + await confirm_transaction_signature(rpc_client, payload.signature, "top-up") + + return verifier + + +async def confirm_transaction_signature(rpc_client: RpcClient, signature: str, label: str) -> None: + """Check once via ``getSignatureStatuses`` that ``signature`` names a known, + successful transaction. ``label`` names the transaction in error messages + ("open", "top-up"). Mirrors ``confirmTransactionSignature`` in the Go port. + """ + try: + Signature.from_string(signature) + except Exception as exc: + raise PaymentError(f"invalid {label} tx signature {signature!r}: {exc}", code="invalid-payload") from exc + + try: + statuses = await rpc_client.get_signature_statuses([signature]) + except Exception as exc: + raise PaymentError(f"RPC error verifying {label} tx: {exc}", code="transaction-not-found") from exc + + status = statuses[0] if statuses else None + if status is None: + raise PaymentError( + f"{label} tx {signature!r} not found; not yet confirmed or does not exist", + code="transaction-not-found", + ) + if status.get("err") is not None: + raise PaymentError(f"{label} tx {signature!r} failed on-chain: {status['err']}", code="transaction-failed") diff --git a/python/src/pay_kit/protocols/mpp/server/session_routes.py b/python/src/pay_kit/protocols/mpp/server/session_routes.py new file mode 100644 index 000000000..402d73c92 --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/server/session_routes.py @@ -0,0 +1,215 @@ +"""Metering side channel for the session method. + +The reserve/commit side channel is an extension beyond the draft MPP spec: +SessionFetch-style clients POST to ``/__402/session/deliveries`` to reserve +capacity for a metered delivery and to ``/__402/session/commit`` to commit it +with a signed voucher. Hosts mount the two handlers on those paths themselves. + +Ports ``go/protocols/mpp/server/session_routes.go``. The Go ``Routes()`` is a +method on the HTTP-facing ``Session``; the routes only ever touch the +lower-level ``SessionServer`` (the Go ``s.core``) plus an idle-close ``touch`` +hook, so this port builds the handlers over a :class:`SessionServer` directly. + +House style follows the existing server modules (``middleware.py``): handlers +are framework-agnostic. Each takes the HTTP method and the raw request body and +returns a :class:`RouteResponse` carrying the status and a JSON-ready body, so +hosts can adapt it to any ASGI/WSGI framework. +""" + +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any + +from pay_kit.protocols.mpp.intents.session import CommitPayload, SignedVoucher +from pay_kit.protocols.mpp.server.session import DeliveryRequest, SessionServer + +__all__ = [ + "RouteResponse", + "SessionRoutes", + "session_routes", +] + +_U64_MAX = (1 << 64) - 1 + +# A touch hook called with the session id after a successful reserve/commit, so +# a host's idle-close watchdog can be armed. ``None`` disables it. Mirrors the +# Go ``Session.touch``. +TouchFn = Callable[[str], None] + +# A handler takes the request method and the raw request body (the JSON text or +# bytes) and returns a RouteResponse. Mirrors the Go ``http.HandlerFunc`` shape +# (method gating + JSON body decode) in a framework-agnostic form. +RouteHandler = Callable[[str, "str | bytes"], Awaitable["RouteResponse"]] + + +@dataclass +class RouteResponse: + """The result of a side-channel handler: an HTTP status plus a JSON-ready + body. Successful reserves/commits carry the directive/receipt dict; failures + carry ``{"error": message}``, the failure body the side-channel clients + expect (Go ``writeSessionRouteError``). + """ + + status: int + body: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class SessionRoutes: + """The metering side-channel handlers built by :func:`session_routes`. + + Both share the session's channel store, so deliveries see channels opened + through the session method. Mirrors Go ``SessionRoutes``. + """ + + # deliveries reserves capacity for a metered delivery. Mount at + # POST /__402/session/deliveries. + deliveries: RouteHandler + + # commit commits a reserved delivery with a signed voucher. Mount at + # POST /__402/session/commit. + commit: RouteHandler + + +def _parse_session_u64(value: str, name: str) -> int: + """Parse a non-negative decimal string into a u64, naming the field in the + error. Mirrors Go ``parseSessionU64``.""" + if not isinstance(value, str) or not (value.isascii() and value.isdigit()): + raise ValueError(f"{name} is not an unsigned integer string: {value}") + parsed = int(value, 10) + if parsed > _U64_MAX: + raise ValueError(f"{name} is not an unsigned integer string: {value}") + return parsed + + +def _decode_body(raw: str | bytes) -> dict[str, Any]: + """Decode a JSON object request body. Raises on non-object or invalid JSON, + mirroring Go's ``json.NewDecoder(r.Body).Decode(&body)`` into a struct.""" + decoded = json.loads(raw) + if not isinstance(decoded, dict): + raise ValueError("request body must be a JSON object") + return decoded + + +class _DecodeError(ValueError): + """A request-body type mismatch caught at the decode layer. Surfaced as + HTTP 400 "invalid request body", matching Go's ``json.Decode`` rejection of + a JSON value whose type does not match the typed struct field.""" + + +def _string_field(body: dict[str, Any], name: str) -> str: + """Read a JSON string field, defaulting an absent/null value to "". + + Mirrors a Go ``string`` struct field: a present value of any non-string JSON + type (number, bool, object, array) fails ``json.Decode``, so reject it as + ``invalid request body`` before any processing.""" + value = body.get(name) + if value is None: + return "" + if not isinstance(value, str): + raise _DecodeError(name) + return value + + +def _int64_field(body: dict[str, Any], name: str) -> int: + """Read a JSON integer field, defaulting an absent/null value to 0. + + Mirrors a Go ``int64`` struct field: ``json.Decode`` accepts only a JSON + integer. A JSON float (``10.0``/``10.5``), a numeric or non-numeric string + (``"10"``/``"soon"``), or a bool all fail decode, so reject them as + ``invalid request body`` before any processing. (Python parses ``bool`` as a + subclass of ``int`` and JSON integers as ``int``; exclude ``bool`` so only + true integers pass.)""" + value = body.get(name) + if value is None: + return 0 + if isinstance(value, bool) or not isinstance(value, int): + raise _DecodeError(name) + return value + + +def session_routes(core: SessionServer, touch: TouchFn | None = None) -> SessionRoutes: + """Build the metering side-channel handlers for a session server. + + ``touch`` is the idle-close hook called with the session id after a + successful reserve/commit; ``None`` disables it. Mirrors Go + ``Session.Routes`` (whose handlers delegate to ``s.core`` and ``s.touch``). + """ + + def _touch(session_id: str) -> None: + if touch is not None: + touch(session_id) + + async def deliveries(method: str, raw: str | bytes) -> RouteResponse: + if method != "POST": + return _error(405, "POST required") + try: + body = _decode_body(raw) + # Strict typed decode (Go sessionDeliveryRequestBody): reject any + # field whose JSON type does not match the Go struct field before any + # store access, matching Go's json.Decode rejection. + session_id = _string_field(body, "sessionId") + amount_raw = _string_field(body, "amount") + delivery_id = _string_field(body, "deliveryId") + commit_url = _string_field(body, "commitUrl") + proof = _string_field(body, "proof") + expires_at = _int64_field(body, "expiresAt") + except (ValueError, json.JSONDecodeError): + return _error(400, "invalid request body") + if not session_id: + return _error(400, "sessionId required") + try: + amount = _parse_session_u64(amount_raw, "amount") + except ValueError as exc: + return _error(400, str(exc)) + if amount == 0: + return _error(400, "amount must be positive") + try: + directive = await core.begin_delivery( + DeliveryRequest( + session_id=session_id, + amount=amount, + delivery_id=delivery_id, + commit_url=commit_url, + proof=proof, + expires_at=expires_at, + ) + ) + except ValueError as exc: + return _error(400, str(exc)) + _touch(session_id) + return RouteResponse(status=200, body=directive.to_dict()) + + async def commit(method: str, raw: str | bytes) -> RouteResponse: + if method != "POST": + return _error(405, "POST required") + try: + body = _decode_body(raw) + # Strict typed decode (Go sessionCommitRequestBody): deliveryId is a + # string field, so a non-string JSON value is rejected up front. + delivery_id = _string_field(body, "deliveryId") + except (ValueError, json.JSONDecodeError): + return _error(400, "invalid request body") + if not delivery_id: + return _error(400, "deliveryId required") + voucher_raw = body.get("voucher") + if voucher_raw is None: + return _error(400, "voucher required") + voucher = SignedVoucher.from_dict(voucher_raw) + try: + receipt = await core.process_commit(CommitPayload(delivery_id=delivery_id, voucher=voucher)) + except ValueError as exc: + return _error(400, str(exc)) + _touch(receipt.session_id) + return RouteResponse(status=200, body=receipt.to_dict()) + + return SessionRoutes(deliveries=deliveries, commit=commit) + + +def _error(status: int, message: str) -> RouteResponse: + """Build the ``{"error": message}`` failure body the side-channel clients + expect. Mirrors Go ``writeSessionRouteError``.""" + return RouteResponse(status=status, body={"error": message}) diff --git a/python/src/pay_kit/protocols/mpp/server/session_store.py b/python/src/pay_kit/protocols/mpp/server/session_store.py new file mode 100644 index 000000000..a9fd40206 --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/server/session_store.py @@ -0,0 +1,375 @@ +"""Per-channel state store for the MPP session server. + +The in-memory implementation serializes :meth:`MemoryChannelStore.update_channel` +calls per channel id with a per-channel lock, so the read-modify-write sequence +inside the mutator is atomic from the perspective of any other caller targeting +the same channel while updates to different channels run concurrently. + +The voucher verifier (a follow-up ``session_voucher`` port) is intentionally +side-effect-free: it computes a verdict, and the caller persists any accepted +delta through :meth:`ChannelStore.update_channel`. + +This mirrors the Go ``session_store.go`` public interface (snake_case of the Go +names) and the Rust spine wire shape in ``rust/crates/mpp/src/store.rs``. The +``ChannelState`` JSON tags are the shared snake_case wire names so durable +stores interoperate across the language SDKs; the per-delivery records use the +camelCase ``deliveryId``/``expiresAt``/``voucherSignature`` keys from the spine. + +House style follows the existing server modules: plain dataclasses with +``to_dict()``/``from_dict()``, ``asyncio`` locking where Go is concurrent, and +explicit type hints. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass, field, replace +from typing import Any + +__all__ = [ + "PendingDelivery", + "CommittedDelivery", + "ChannelState", + "ListChannelsFilter", + "ChannelMutator", + "ChannelStore", + "MemoryChannelStore", +] + + +@dataclass +class PendingDelivery: + """One delivery the server has reserved against a channel but not yet + received a signed voucher for. + + Mirrors Go ``PendingDelivery`` and rust ``PendingDelivery``; the wire keys + are camelCase (``deliveryId``/``expiresAt``). + """ + + # DeliveryID is the idempotency key for this delivery. + delivery_id: str + # Amount reserved for this delivery in base units. + amount: int = 0 + # Sequence is the monotonic per-channel delivery sequence. + sequence: int = 0 + # ExpiresAt is the Unix timestamp after which the delivery should not be + # committed. + expires_at: int = 0 + + def to_dict(self) -> dict[str, Any]: + return { + "deliveryId": self.delivery_id, + "amount": self.amount, + "sequence": self.sequence, + "expiresAt": self.expires_at, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> PendingDelivery: + return cls( + delivery_id=data.get("deliveryId", ""), + amount=int(data.get("amount", 0)), + sequence=int(data.get("sequence", 0)), + expires_at=int(data.get("expiresAt", 0)), + ) + + +@dataclass +class CommittedDelivery: + """A delivery that has been committed by a signed voucher. Kept for + idempotent commit replay. + + Mirrors Go ``CommittedDelivery`` and rust ``CommittedDelivery``. + """ + + # DeliveryID is the idempotency key for this delivery. + delivery_id: str + # Amount committed for this delivery in base units. + amount: int = 0 + # Cumulative is the channel watermark after this commit. + cumulative: int = 0 + # VoucherSignature is the signature of the committing voucher (base58). + voucher_signature: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "deliveryId": self.delivery_id, + "amount": self.amount, + "cumulative": self.cumulative, + "voucherSignature": self.voucher_signature, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> CommittedDelivery: + return cls( + delivery_id=data.get("deliveryId", ""), + amount=int(data.get("amount", 0)), + cumulative=int(data.get("cumulative", 0)), + voucher_signature=data.get("voucherSignature", ""), + ) + + +@dataclass +class ChannelState: + """Persisted state of a single payment channel from the server's point of + view. + + The JSON tags are the shared snake_case wire names, so durable stores can + interoperate across the language SDKs. Mirrors Go ``ChannelState`` and rust + ``ChannelState``. + """ + + # ChannelID is the on-chain channel address (base58). + # + # Push sessions: the payment-channel address. + # Pull sessions: the FixedDelegation PDA address. + channel_id: str + + # AuthorizedSigner is the public key authorized to sign vouchers for this + # session (base58). + authorized_signer: str + + # Deposit is the total deposit / approved amount locked for this session + # (base units). + deposit: int = 0 + + # Cumulative is the highest cumulative amount accepted by the server (the + # settled watermark). + cumulative: int = 0 + + # Finalized is true once the channel has been finalized on-chain. + finalized: bool = False + + # HighestVoucherSignature is the signature of the highest accepted voucher + # (base58). Stored for idempotent replay detection. + highest_voucher_signature: str | None = None + + # HighestVoucherExpiresAt is the expiry timestamp from the highest accepted + # voucher. Needed when the server later settles that voucher on-chain. + highest_voucher_expires_at: int | None = None + + # CloseRequestedAt is the Unix timestamp (seconds) when cooperative close + # was requested. Once set, no further vouchers are accepted. + close_requested_at: int | None = None + + # SettledSignature is the signature (base58) of the broadcast + # settle-and-distribute transaction. A close-pending channel with no settled + # signature is re-drivable: a close retry may attempt settlement again. + # + # An extension beyond the core channel-state shape, recorded only when this + # server drives on-chain settlement. Serialized with omit-empty so a channel + # state without a settlement round-trips cleanly. + settled_signature: str | None = None + + # Operator is the client wallet pubkey (base58) for pull-mode sessions; + # None for push sessions. + operator: str | None = None + + # NextDeliverySequence is the next server-side metered delivery sequence. + next_delivery_sequence: int = 0 + + # PendingDeliveries are reserved by the server but not yet committed. + pending_deliveries: list[PendingDelivery] = field(default_factory=list) + + # CommittedDeliveries are recently committed deliveries, kept for idempotent + # commit replay. + committed_deliveries: list[CommittedDelivery] = field(default_factory=list) + + def clone(self) -> ChannelState: + """Return a deep copy so callers can never alias store-internal state. + + Mirrors the Go ``clone`` method: scalar/optional fields copy by value, + the two delivery slices copy element-wise with their own dataclass + copies so a returned list cannot mutate the stored one. + """ + return replace( + self, + pending_deliveries=[replace(d) for d in self.pending_deliveries], + committed_deliveries=[replace(d) for d in self.committed_deliveries], + ) + + def to_dict(self) -> dict[str, Any]: + # Go's nil delivery slices serialize to JSON ``null`` (an empty, + # never-populated channel marshals ``"pending_deliveries":null``), while + # a populated slice serializes to an array. Python has no nil/empty + # distinction, so an empty list emits ``None`` to keep durable records + # byte-for-byte identical to the Go server's fresh-open output. Rust's + # ``#[serde(default)]`` decodes that ``null`` back to an empty ``Vec``. + d: dict[str, Any] = { + "channel_id": self.channel_id, + "authorized_signer": self.authorized_signer, + "deposit": self.deposit, + "cumulative": self.cumulative, + "finalized": self.finalized, + "highest_voucher_signature": self.highest_voucher_signature, + "highest_voucher_expires_at": self.highest_voucher_expires_at, + "close_requested_at": self.close_requested_at, + "operator": self.operator, + "next_delivery_sequence": self.next_delivery_sequence, + "pending_deliveries": ([p.to_dict() for p in self.pending_deliveries] if self.pending_deliveries else None), + "committed_deliveries": ( + [c.to_dict() for c in self.committed_deliveries] if self.committed_deliveries else None + ), + } + # settled_signature is omit-empty (Go `omitempty`, rust skip on None). + if self.settled_signature is not None: + d["settled_signature"] = self.settled_signature + return d + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ChannelState: + return cls( + channel_id=data.get("channel_id", ""), + authorized_signer=data.get("authorized_signer", ""), + deposit=int(data.get("deposit", 0)), + cumulative=int(data.get("cumulative", 0)), + finalized=bool(data.get("finalized", False)), + highest_voucher_signature=data.get("highest_voucher_signature"), + highest_voucher_expires_at=( + None if data.get("highest_voucher_expires_at") is None else int(data["highest_voucher_expires_at"]) + ), + close_requested_at=(None if data.get("close_requested_at") is None else int(data["close_requested_at"])), + settled_signature=data.get("settled_signature"), + operator=data.get("operator"), + next_delivery_sequence=int(data.get("next_delivery_sequence", 0)), + # A missing key, explicit JSON ``null`` (Go nil slice), and an empty + # array all decode to an empty list. ``data.get(key) or []`` folds + # ``None`` and ``[]`` together; ``from_dict`` never iterates ``None``. + pending_deliveries=[PendingDelivery.from_dict(p) for p in (data.get("pending_deliveries") or [])], + committed_deliveries=[CommittedDelivery.from_dict(c) for c in (data.get("committed_deliveries") or [])], + ) + + +@dataclass +class ListChannelsFilter: + """Optional filter for :meth:`ChannelStore.list_channels`. + + Mirrors Go ``ListChannelsFilter``. + """ + + # finalized, when non-None, only includes channels matching this finalized + # state. + finalized: bool | None = None + + # close_pending, when non-None, only includes channels whose + # close_requested_at presence matches. + close_pending: bool | None = None + + +# ChannelMutator is handed to update_channel. It receives the current state +# (None if no channel exists) and returns the next state, or raises, in which +# case the stored state is left unchanged. +# +# Implementations MUST guarantee the mutator runs without interleaving with +# other update_channel calls for the same channel id. +ChannelMutator = Callable[["ChannelState | None"], "ChannelState"] + + +class ChannelStore: + """Pluggable store for per-channel session state. + + ``update_channel`` is the only way to mutate a channel: the voucher verifier + always needs an atomic read-modify-write to avoid double-spend under + concurrent vouchers, so no direct put is exposed. + + Mirrors the Go ``ChannelStore`` interface. Defined as an abstract base so + pyright can check structural conformance of implementations. + """ + + async def get_channel(self, channel_id: str) -> ChannelState | None: + """Read a channel. Returns None when it does not exist.""" + raise NotImplementedError + + async def update_channel(self, channel_id: str, mutator: ChannelMutator) -> ChannelState: + """Atomically read-modify-write a channel's state and return the stored + result.""" + raise NotImplementedError + + async def delete_channel(self, channel_id: str) -> None: + """Remove a channel from the store. Deleting a missing channel is a + no-op.""" + raise NotImplementedError + + async def list_channels(self, filter: ListChannelsFilter | None = None) -> list[ChannelState]: + """Return a snapshot list. The filter is applied after read; None means + no filter.""" + raise NotImplementedError + + async def mark_finalized(self, channel_id: str) -> ChannelState: + """Flip finalized to True. Raises when the channel is not found.""" + raise NotImplementedError + + +class MemoryChannelStore(ChannelStore): + """In-memory :class:`ChannelStore` with per-channel locking. + + ``update_channel`` calls for the same channel id run strictly sequentially + while calls for different ids run concurrently. Mirrors the Go + ``MemoryChannelStore``: values are cloned on the way in and out so callers + never share memory with the store. + """ + + def __init__(self) -> None: + # _data maps channel id to stored state. + self._data: dict[str, ChannelState] = {} + # _locks holds the per-channel lock serializing update_channel calls + # for the same channel id. + self._locks: dict[str, asyncio.Lock] = {} + # _mu guards _data and _locks. + self._mu = asyncio.Lock() + + async def _channel_lock(self, channel_id: str) -> asyncio.Lock: + """Return the lock serializing updates for ``channel_id``.""" + async with self._mu: + lock = self._locks.get(channel_id) + if lock is None: + lock = asyncio.Lock() + self._locks[channel_id] = lock + return lock + + async def get_channel(self, channel_id: str) -> ChannelState | None: + async with self._mu: + state = self._data.get(channel_id) + return None if state is None else state.clone() + + async def update_channel(self, channel_id: str, mutator: ChannelMutator) -> ChannelState: + lock = await self._channel_lock(channel_id) + async with lock: + async with self._mu: + current = self._data.get(channel_id) + current_snapshot = None if current is None else current.clone() + + # A mutator error leaves the stored state unchanged and does not + # poison later updates: we only write back on success. + next_state = mutator(current_snapshot) + + async with self._mu: + self._data[channel_id] = next_state.clone() + return next_state + + async def delete_channel(self, channel_id: str) -> None: + async with self._mu: + self._data.pop(channel_id, None) + + async def list_channels(self, filter: ListChannelsFilter | None = None) -> list[ChannelState]: + async with self._mu: + out: list[ChannelState] = [] + for state in self._data.values(): + if filter is not None: + if filter.finalized is not None and state.finalized != filter.finalized: + continue + if filter.close_pending is not None: + close_pending = state.close_requested_at is not None + if close_pending != filter.close_pending: + continue + out.append(state.clone()) + return out + + async def mark_finalized(self, channel_id: str) -> ChannelState: + def mutator(current: ChannelState | None) -> ChannelState: + if current is None: + raise KeyError(f"channel {channel_id} not found") + return replace(current, finalized=True) + + return await self.update_channel(channel_id, mutator) diff --git a/python/src/pay_kit/protocols/mpp/server/session_stream.py b/python/src/pay_kit/protocols/mpp/server/session_stream.py new file mode 100644 index 000000000..b3445d17a --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/server/session_stream.py @@ -0,0 +1,143 @@ +"""Server-side metered SSE stream writer. + +Emits the Server-Sent Event frames the metered session clients decode: +``mpp.metering`` directives, ``mpp.usage`` final-usage events, plain data +payload messages, and the terminal ``[DONE]`` sentinel. The event names are +canonical: they are the ones the SDK session clients parse (this port's +:class:`~pay_kit.protocols.mpp.client.http_stream.SseDecoder` and +:func:`~pay_kit.protocols.mpp.client.http_stream.parse_metered_sse_event` +among them). + +Port of ``go/protocols/mpp/server/session_stream.go``. Go writes to an +``io.Writer`` / ``http.ResponseWriter`` and flushes via ``http.Flusher``; this +port writes to any object exposing a ``write`` method and flushes any that also +expose a ``flush`` method, the duck-typed shape the rest of the Python server +surface uses for HTTP responses. The Rust spine has no server-side writer (it +only decodes on the client); the wire format and the ``[DONE]`` sentinel mirror +what the client decoder consumes. +""" + +from __future__ import annotations + +import json +from typing import Any, Protocol, runtime_checkable + +from pay_kit.protocols.mpp.intents.session import MeteringDirective, MeteringUsage + +__all__ = [ + "DONE_SENTINEL", + "MeteredStream", + "new_metered_stream", + "new_metered_stream_writer", +] + +# DONE_SENTINEL is the terminal data-only message recognized by the metered SSE +# decoders alongside the ``done`` event name. Mirrors Go ``doneSentinel``. +DONE_SENTINEL = "[DONE]" + + +@runtime_checkable +class _Writer(Protocol): + """Anything that accepts encoded SSE frames via ``write``.""" + + def write(self, data: str, /) -> Any: ... + + +@runtime_checkable +class _HttpResponse(Protocol): + """A duck-typed HTTP response: a mutable ``headers`` mapping and ``write``.""" + + headers: Any + + def write(self, data: str, /) -> Any: ... + + +class MeteredStream: + """Writes metered Server-Sent Events to a writer. + + Build with :func:`new_metered_stream` (HTTP responses) or + :func:`new_metered_stream_writer` (raw writers). Every write flushes when + the underlying writer supports it so chunks reach the client as they are + produced. Mirrors Go ``MeteredStream``. + """ + + def __init__(self, writer: _Writer, flush: bool = False) -> None: + """Wrap ``writer``. When ``flush`` is true and the writer exposes a + ``flush`` method, that method is called after every frame.""" + self._writer = writer + self._flush = flush and callable(getattr(writer, "flush", None)) + + def write_event(self, event: str, data: bytes | None) -> None: + """Write one SSE frame with an explicit event name. + + Empty event names emit a default (message) frame. ``data`` must not be + empty; multi-line data is split into one ``data:`` line per line per the + SSE format. Mirrors Go ``WriteEvent``. + """ + if not data: + raise ValueError("SSE event data must not be empty") + frame = "" + if event: + frame = "event: " + event + "\n" + for line in data.split(b"\n"): + frame += "data: " + line.decode("utf-8") + "\n" + frame += "\n" + self._writer.write(frame) + if self._flush: + self._writer.flush() # type: ignore[attr-defined] + + def write_json(self, value: Any) -> None: + """Write a default (message) frame whose data is the JSON encoding of + ``value``. Use for application payload chunks. Mirrors Go ``WriteJSON``.""" + data = json.dumps(value, separators=(",", ":")).encode("utf-8") + self.write_event("", data) + + def write_metering(self, directive: MeteringDirective) -> None: + """Emit an ``mpp.metering`` event carrying the metering directive the + client must commit after processing the paired payload. Mirrors Go + ``WriteMetering``.""" + data = json.dumps(directive.to_dict(), separators=(",", ":")).encode("utf-8") + self.write_event("mpp.metering", data) + + def write_usage(self, usage: MeteringUsage) -> None: + """Emit an ``mpp.usage`` event reporting the final amount owed for a + streamed delivery. The amount must not exceed the amount reserved by the + original directive. Mirrors Go ``WriteUsage``.""" + data = json.dumps(usage.to_dict(), separators=(",", ":")).encode("utf-8") + self.write_event("mpp.usage", data) + + def write_envelope(self, payload: Any, directive: MeteringDirective) -> None: + """Emit the payload as a default data frame followed by its + ``mpp.metering`` directive, the pairing the metered session consumers + expect. Mirrors Go ``WriteEnvelope``.""" + self.write_json(payload) + self.write_metering(directive) + + def write_done(self) -> None: + """Emit the terminal ``[DONE]`` sentinel message. Mirrors Go ``WriteDone``.""" + self.write_event("", DONE_SENTINEL.encode("utf-8")) + + def write_done_event(self) -> None: + """Emit an explicit ``done`` event, the alternative terminal frame the + decoders accept. Mirrors Go ``WriteDoneEvent``.""" + self.write_event("done", DONE_SENTINEL.encode("utf-8")) + + +def new_metered_stream(response: _HttpResponse) -> MeteredStream: + """Prepare ``response`` for Server-Sent Events (Content-Type + ``text/event-stream``, no caching) and return the stream writer. + + The response does not need to support flushing, but streaming is only + incremental when it exposes a ``flush`` method. Mirrors Go + ``NewMeteredStream``. + """ + response.headers["Content-Type"] = "text/event-stream" + response.headers["Cache-Control"] = "no-cache" + response.headers["Connection"] = "keep-alive" + return MeteredStream(response, flush=True) + + +def new_metered_stream_writer(writer: _Writer) -> MeteredStream: + """Wrap a raw writer (no header handling) for transports other than HTTP. + Mirrors Go ``NewMeteredStreamWriter``.""" + return MeteredStream(writer) diff --git a/python/src/pay_kit/protocols/mpp/server/session_voucher.py b/python/src/pay_kit/protocols/mpp/server/session_voucher.py new file mode 100644 index 000000000..e9730e149 --- /dev/null +++ b/python/src/pay_kit/protocols/mpp/server/session_voucher.py @@ -0,0 +1,325 @@ +"""Voucher verifier for the MPP session server. + +Pure function: given a current channel snapshot and a signed voucher, decide +whether to accept (and what the new watermark would be), reject, or treat as an +idempotent replay. The caller persists any accepted delta through the channel +store, re-checking inside the atomic mutator. + +The check sequence (order and operators) is pinned across the language SDKs and +harness-tested:: + + parse u64 -> finalized -> close pending -> idempotent replay (same + cumulative AND same signature, signature re-verified) -> cumulative > + watermark strictly -> cumulative <= deposit -> delta >= min_voucher_delta -> + Ed25519 verify against the stored authorized_signer -> expires_at > now. + +Ports ``go/protocols/mpp/server/session_voucher.go``. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from enum import StrEnum + +from pay_kit.protocols.mpp.intents.session import SignedVoucher + + +class VoucherVerifyStatus(StrEnum): + """The outcome class of a voucher verification. + + Mirrors Go ``VoucherVerifyStatus``. + """ + + #: The voucher advanced the channel watermark. + ACCEPTED = "accepted" + + #: An already-accepted voucher was re-submitted (idempotent). + REPLAYED = "replayed" + + #: The voucher was rejected; see ``VoucherVerifyResult.reason``. + REJECTED = "rejected" + + +class VoucherRejectReason(StrEnum): + """A stable string tag for voucher rejections so the caller can map to HTTP + statuses / log levels without parsing free text. The tag values are stable + across the language SDKs. Mirrors Go ``VoucherRejectReason``. + """ + + #: The delta is below the configured minimum. + BELOW_MIN_DELTA = "below-min-delta" + + #: A close was already requested. + CHANNEL_CLOSE_PENDING = "channel-close-pending" + + #: The channel is already finalized. + CHANNEL_FINALIZED = "channel-finalized" + + #: The cumulative does not strictly exceed the watermark. + CUMULATIVE_NOT_MONOTONIC = "cumulative-not-monotonic" + + #: The cumulative exceeds the deposit cap. + EXCEEDS_DEPOSIT = "exceeds-deposit" + + #: The voucher expiry is not in the future. + EXPIRED = "expired" + + #: The cumulative does not parse as a u64. + INVALID_CUMULATIVE = "invalid-cumulative" + + #: The Ed25519 signature check failed. + INVALID_SIGNATURE = "invalid-signature" + + +@dataclass +class ChannelState: + """The persisted state of a single payment channel from the server's point + of view, as read by the voucher verifier. + + The voucher verifier only reads a subset of the full channel state; this + dataclass carries the fields it needs. Mirrors the verifier-relevant fields + of Go ``ChannelState`` (see ``session_store.go``). + """ + + #: The on-chain channel address (base58). + channel_id: str + + #: The public key authorized to sign vouchers for this session (base58). + authorized_signer: str + + #: The total deposit / approved amount locked for this session (base units). + deposit: int = 0 + + #: The highest cumulative amount accepted by the server (the settled + #: watermark). + cumulative: int = 0 + + #: True once the channel has been finalized on-chain. + finalized: bool = False + + #: The signature of the highest accepted voucher (base58). Stored for + #: idempotent replay detection. + highest_voucher_signature: str | None = None + + #: The expiry timestamp from the highest accepted voucher. + highest_voucher_expires_at: int | None = None + + #: The Unix timestamp (seconds) when cooperative close was requested. Once + #: set, no further vouchers are accepted. + close_requested_at: int | None = None + + +@dataclass +class VoucherVerifyResult: + """The verdict of :func:`verify_voucher_for_channel`. + + ``status`` selects which fields are meaningful: ``new_cumulative`` for + accepted and replayed; ``new_expires_at`` and ``new_signature`` for accepted + only; ``reason`` and ``detail`` for rejected only. Mirrors Go + ``VoucherVerifyResult``. + """ + + #: The outcome class. + status: VoucherVerifyStatus + + #: The watermark to persist (accepted) or the existing watermark (replayed). + new_cumulative: int = 0 + + #: The expiry of the now-highest voucher (accepted only). + new_expires_at: int = 0 + + #: The signature to persist as ``highest_voucher_signature`` (accepted only, + #: base58). + new_signature: str = "" + + #: The stable rejection tag (rejected only). + reason: VoucherRejectReason | None = None + + #: A human-readable rejection detail. Safe to log; not stable. + detail: str = "" + + +@dataclass +class VerifyVoucherArgs: + """The inputs to :func:`verify_voucher_for_channel`. Mirrors Go + ``VerifyVoucherArgs``. + """ + + #: The channel snapshot, typically read just before calling. + state: ChannelState + + #: The voucher being submitted. + signed: SignedVoucher + + #: The authoritative deposit cap. Passed in (rather than read off ``state``) + #: because some callers carry an updated cap after a recent top-up that has + #: not yet been written back into the store. + deposit: int = 0 + + #: The optional minimum delta from the previous cumulative. Zero disables + #: the check. + min_voucher_delta: int = 0 + + #: Overrides the clock (Unix seconds) for deterministic tests. ``None`` + #: defaults to the wall clock. + now_seconds: int | None = None + + +def verify_voucher_for_channel(args: VerifyVoucherArgs) -> VoucherVerifyResult: + """Verify a voucher against a channel snapshot. + + Returns a verdict; the caller is responsible for persisting any accepted + delta via the channel store. The verifier is pure: no store, network, or + clock side effects (the clock is injectable). Mirrors Go + ``VerifyVoucherForChannel``. + """ + state = args.state + signed = args.signed + + # 1. Parse new cumulative from the payload. + try: + new_cumulative = _parse_u64(signed.data.cumulative) + except ValueError: + return _voucher_reject( + VoucherRejectReason.INVALID_CUMULATIVE, + f"invalid cumulative in voucher: {signed.data.cumulative}", + ) + + # 2. Channel must not be finalized. + if state.finalized: + return _voucher_reject( + VoucherRejectReason.CHANNEL_FINALIZED, + f"channel {state.channel_id} is already finalized", + ) + + # 3. Channel must not be in close-pending. + if state.close_requested_at is not None: + return _voucher_reject( + VoucherRejectReason.CHANNEL_CLOSE_PENDING, + f"channel {state.channel_id} close is pending; no further vouchers accepted", + ) + + # 4. Idempotent replay: same cumulative AND same signature. The signature is + # re-verified so a replay of a forged voucher cannot slip through. + if ( + new_cumulative == state.cumulative + and state.highest_voucher_signature is not None + and state.highest_voucher_signature == signed.signature + ): + err = _verify_voucher_signature_bytes(signed, state.authorized_signer) + if err is not None: + return _voucher_reject(VoucherRejectReason.INVALID_SIGNATURE, err) + if signed.data.expires_at <= _voucher_now(args.now_seconds): + return _voucher_reject(VoucherRejectReason.EXPIRED, "voucher has expired") + return VoucherVerifyResult(status=VoucherVerifyStatus.REPLAYED, new_cumulative=new_cumulative) + + # 5. Must strictly exceed the watermark (non-replay case). + if new_cumulative <= state.cumulative: + return _voucher_reject( + VoucherRejectReason.CUMULATIVE_NOT_MONOTONIC, + f"voucher cumulative {new_cumulative} must exceed watermark {state.cumulative}", + ) + + # 6. Must not exceed the deposit. + if new_cumulative > args.deposit: + return _voucher_reject( + VoucherRejectReason.EXCEEDS_DEPOSIT, + f"voucher cumulative {new_cumulative} exceeds deposit {args.deposit}", + ) + + # 7. Min delta check. + delta = new_cumulative - state.cumulative + if args.min_voucher_delta > 0 and delta < args.min_voucher_delta: + return _voucher_reject( + VoucherRejectReason.BELOW_MIN_DELTA, + f"voucher delta {delta} is below minimum {args.min_voucher_delta}", + ) + + # 8. Verify the Ed25519 signature over the 48-byte canonical payload. + err = _verify_voucher_signature_bytes(signed, state.authorized_signer) + if err is not None: + return _voucher_reject(VoucherRejectReason.INVALID_SIGNATURE, err) + + # 9. Expiry. The caller may override now_seconds for deterministic tests. + if signed.data.expires_at <= _voucher_now(args.now_seconds): + return _voucher_reject(VoucherRejectReason.EXPIRED, "voucher has expired") + + return VoucherVerifyResult( + status=VoucherVerifyStatus.ACCEPTED, + new_cumulative=new_cumulative, + new_expires_at=signed.data.expires_at, + new_signature=signed.signature, + ) + + +def _voucher_reject(reason: VoucherRejectReason, detail: str) -> VoucherVerifyResult: + """Build a rejected verdict. Mirrors Go ``voucherReject``.""" + return VoucherVerifyResult(status=VoucherVerifyStatus.REJECTED, reason=reason, detail=detail) + + +def _voucher_now(override: int | None) -> int: + """Return the override when set, otherwise the wall clock in Unix seconds. + + Mirrors Go ``voucherNow``. + """ + if override is not None: + return override + return int(time.time()) + + +_U64_MAX = (1 << 64) - 1 + + +def _parse_u64(raw: str) -> int: + """Parse a canonical unsigned base-10 ``u64``. + + Rejects empty, signed, fractional, non-ASCII-digit, or out-of-range values, + matching Go's ``strconv.ParseUint(s, 10, 64)``. + """ + if not (raw.isascii() and raw.isdigit()): + raise ValueError(f"invalid cumulative {raw!r}") + value = int(raw, 10) + if value > _U64_MAX: + raise ValueError(f"cumulative {raw!r} exceeds u64 range") + return value + + +def _verify_voucher_signature_bytes(signed: SignedVoucher, authorized_signer: str) -> str | None: + """Check the voucher's Ed25519 signature over the canonical 48-byte voucher + payload against the authorized signer (both base58). The expiry check is not + included; callers order it explicitly. + + Returns ``None`` on success or a human-readable error string on failure. + Mirrors Go ``verifyVoucherSignatureBytes``. + """ + from solders.pubkey import Pubkey # type: ignore[import-untyped] + from solders.signature import Signature # type: ignore[import-untyped] + + try: + message = signed.data.message_bytes() + except ValueError as exc: + return str(exc) + try: + signature = Signature.from_string(signed.signature) + except (ValueError, TypeError) as exc: + return f"invalid signature encoding: {exc}" + try: + pubkey = Pubkey.from_string(authorized_signer) + except (ValueError, TypeError) as exc: + return f"invalid authorized signer: {exc}" + if not signature.verify(pubkey, message): + return "voucher signature verification failed" + return None + + +def verify_session_voucher(signed: SignedVoucher, authorized_signer: str) -> str | None: + """Check expiry first (against the wall clock), then the Ed25519 signature. + + Used by the commit and close paths; the voucher handler orders the two + checks itself. Returns ``None`` on success or a human-readable error string + on failure. Mirrors Go ``verifySessionVoucher``. + """ + if signed.data.expires_at <= int(time.time()): + return "voucher has expired" + return _verify_voucher_signature_bytes(signed, authorized_signer) diff --git a/python/tests/test_session_lifecycle.py b/python/tests/test_session_lifecycle.py new file mode 100644 index 000000000..2d754cc15 --- /dev/null +++ b/python/tests/test_session_lifecycle.py @@ -0,0 +1,103 @@ +"""Unit coverage of the SessionLifecycle idle-close watchdog. + +Mirrors ``go/protocols/mpp/server/session_lifecycle_test.go``: zero-delay +disablement, idle firing, touch resets, channel removal, and shutdown. + +The Go watchdog uses ``time.AfterFunc`` on background goroutines; the Python +port uses ``asyncio`` single-shot timers, so each test drives an event loop +and the handler is an async coroutine. Delays are expressed in seconds (Go +uses ``time.Duration``); the millisecond-scale fixtures translate directly. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from pay_kit.protocols.mpp.server.session_lifecycle import SessionLifecycle + + +class _IdleRecorder: + """Collects ``close_on_idle`` invocations, mirroring the Go ``idleRecorder``.""" + + def __init__(self) -> None: + self.fired: list[str] = [] + self.event = asyncio.Event() + + async def handler(self, channel_id: str) -> None: + self.fired.append(channel_id) + self.event.set() + + def count(self) -> int: + return len(self.fired) + + +async def test_zero_delay_disables_timers() -> None: + """Mirrors TestSessionLifecycleZeroDelayDisablesTimers.""" + recorder = _IdleRecorder() + lifecycle = SessionLifecycle(recorder.handler, 0) + lifecycle.touch("c1") + + await asyncio.sleep(0.03) + assert recorder.count() == 0 + + +async def test_fires_after_idle() -> None: + """Mirrors TestSessionLifecycleFiresAfterIdle.""" + recorder = _IdleRecorder() + lifecycle = SessionLifecycle(recorder.handler, 0.01) + try: + lifecycle.touch("c1") + await asyncio.wait_for(recorder.event.wait(), timeout=2.0) + assert recorder.fired[0] == "c1" + finally: + lifecycle.shutdown() + + +async def test_touch_resets_timer() -> None: + """Mirrors TestSessionLifecycleTouchResetsTimer.""" + recorder = _IdleRecorder() + lifecycle = SessionLifecycle(recorder.handler, 0.08) + try: + lifecycle.touch("c1") + for _ in range(3): + await asyncio.sleep(0.03) + lifecycle.touch("c1") + assert recorder.count() == 0 + await asyncio.wait_for(recorder.event.wait(), timeout=2.0) + assert recorder.count() == 1 + finally: + lifecycle.shutdown() + + +async def test_remove_channel_cancels_timer() -> None: + """Mirrors TestSessionLifecycleRemoveChannelCancelsTimer.""" + recorder = _IdleRecorder() + lifecycle = SessionLifecycle(recorder.handler, 0.02) + try: + lifecycle.touch("c1") + lifecycle.remove_channel("c1") + + await asyncio.sleep(0.06) + assert recorder.count() == 0 + finally: + lifecycle.shutdown() + + +async def test_shutdown_cancels_all_timers_and_disables_touch() -> None: + """Mirrors TestSessionLifecycleShutdownCancelsAllTimersAndDisablesTouch.""" + recorder = _IdleRecorder() + lifecycle = SessionLifecycle(recorder.handler, 0.02) + + lifecycle.touch("c1") + lifecycle.touch("c2") + lifecycle.shutdown() + lifecycle.touch("c3") + + await asyncio.sleep(0.06) + assert recorder.count() == 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/python/tests/test_session_method.py b/python/tests/test_session_method.py new file mode 100644 index 000000000..c2fbb2968 --- /dev/null +++ b/python/tests/test_session_method.py @@ -0,0 +1,860 @@ +"""Tests for the HTTP-facing MPP session method handler. + +Mirrors the offline-core behaviors in +``go/protocols/mpp/server/session_method_test.go``: ``NewSession`` validation +and defaults, challenge issuance (canonical shape, cap clamping, pull +advertisement, blockhash prefetch), the Tier-1 + Tier-2 credential checks, and +the five ``verify_credential`` actions (open / voucher / commit / topUp / close) +with their replay and hardening semantics, including the optional RPC liveness +confirm seam. + +The on-chain settlement path at close, the server-broadcast open +(``OpenTxSubmitterServer``), the attached-transaction open verification, and the +metering side-channel HTTP routes from the Go file are not ported here: their +lower-level Python building blocks (``SubmitOpenTx``, ``closeAndSettleChannel`` +/ ``SettlementInstructions``, the open-tx decode through ``handle_open``, and +``SessionRoutes``) are not yet ported. Each test name below maps to the Go test +it mirrors in the docstring. +""" + +from __future__ import annotations + +import pytest +from solders.keypair import Keypair # type: ignore[import-untyped] +from solders.signature import Signature # type: ignore[import-untyped] + +from pay_kit._paycore.errors import PaymentError +from pay_kit.protocols.mpp.core.types import PaymentChallenge, PaymentCredential +from pay_kit.protocols.mpp.intents.session import ( + ClosePayload, + CommitPayload, + OpenPayload, + SessionAction, + SignedVoucher, + TopUpPayload, + VoucherData, + VoucherPayload, +) +from pay_kit.protocols.mpp.server.session import Split +from pay_kit.protocols.mpp.server.session_method import ( + Session, + SessionChallengeOptions, + SessionOptions, + new_session, +) + +SESSION_METHOD_SECRET = "session-method-secret" +SESSION_TEST_RECIPIENT = str(Keypair.from_seed(bytes([7] * 32)).pubkey()) + + +class _TestVoucherSigner: + """An Ed25519 keypair signing canonical 48-byte vouchers. Mirrors + ``testVoucherSigner`` in the Go suite.""" + + def __init__(self, seed: int) -> None: + self._kp = Keypair.from_seed(bytes([seed] * 32)) + + def address(self) -> str: + return str(self._kp.pubkey()) + + def sign_voucher(self, channel_id: str, cumulative: int, expires_at: int) -> SignedVoucher: + data = VoucherData(channel_id=channel_id, cumulative=str(cumulative), expires_at=expires_at) + signature = self._kp.sign_message(data.message_bytes()) + return SignedVoucher(data=data, signature=str(signature)) + + +def _far_future() -> int: + return 4_102_444_800 + + +def _new_wallet() -> str: + import secrets + + return str(Keypair.from_seed(secrets.token_bytes(32)).pubkey()) + + +def _confirmed_signature(fill: int) -> str: + return str(Signature.from_bytes(bytes([fill] * 64))) + + +class _FakeRpc: + """Minimal RPC double: ``get_signature_statuses`` (any signature not seeded + is confirmed) and ``get_latest_blockhash``. Mirrors ``testutil.FakeRPC``.""" + + def __init__(self, blockhash: str = "FakeBlockhash1111111111111111111111111111111") -> None: + self.statuses: dict[str, dict | None] = {} + self.blockhash = blockhash + + async def get_signature_statuses(self, signatures: list[str]) -> list[dict | None]: + out: list[dict | None] = [] + for signature in signatures: + if signature in self.statuses: + out.append(self.statuses[signature]) + else: + out.append({"err": None, "confirmationStatus": "confirmed"}) + return out + + async def get_latest_blockhash(self, commitment: str = "confirmed"): + class _Value: + def __init__(self, blockhash: str) -> None: + self.blockhash = blockhash + + class _Resp: + def __init__(self, blockhash: str) -> None: + self.value = _Value(blockhash) + + return _Resp(self.blockhash) + + +def _new_test_session(**overrides) -> Session: + options = SessionOptions( + operator=SESSION_TEST_RECIPIENT, + recipient=SESSION_TEST_RECIPIENT, + cap=5_000_000, + currency="USDC", + decimals=6, + network="localnet", + secret_key=SESSION_METHOD_SECRET, + realm="api.test", + ) + for key, value in overrides.items(): + setattr(options, key, value) + return new_session(options) + + +async def _session_action_credential(session: Session, action: SessionAction | dict) -> PaymentCredential: + challenge = await session.challenge(SessionChallengeOptions()) + payload = action.to_dict() if isinstance(action, SessionAction) else action + return PaymentCredential(challenge=challenge.to_echo(), payload=payload) + + +async def _verify_session_action(session: Session, action: SessionAction | dict): + credential = await _session_action_credential(session, action) + return await session.verify_credential(credential) + + +async def _open_session_channel( + session: Session, channel_id: str, deposit: int, authorized_signer: str, signature: str +): + payload = OpenPayload.push(channel_id, str(deposit), authorized_signer, signature) + return await _verify_session_action(session, SessionAction.open_action(payload)) + + +async def _open_trusted_channel(session: Session, deposit: int) -> tuple[_TestVoucherSigner, str]: + signer = _TestVoucherSigner(0x21) + channel_id = _new_wallet() + await _open_session_channel(session, channel_id, deposit, signer.address(), _confirmed_signature(0x99)) + return signer, channel_id + + +async def _submit_voucher(session: Session, signer: _TestVoucherSigner, channel_id: str, cumulative: int): + voucher = signer.sign_voucher(channel_id, cumulative, _far_future()) + return await _verify_session_action(session, SessionAction.voucher_action(VoucherPayload(voucher=voucher))) + + +async def _get_channel(session: Session, channel_id: str): + return await session.core().store().get_channel(channel_id) + + +# ── new_session validation (TestNewSessionValidation) ── + + +def test_new_session_validation_zero_cap() -> None: + with pytest.raises(PaymentError, match="cap must be positive"): + new_session(SessionOptions(recipient=SESSION_TEST_RECIPIENT, cap=0, secret_key=SESSION_METHOD_SECRET)) + + +def test_new_session_validation_missing_recipient() -> None: + with pytest.raises(PaymentError, match="recipient is required"): + new_session(SessionOptions(recipient="", cap=1_000, secret_key=SESSION_METHOD_SECRET)) + + +def test_new_session_validation_invalid_recipient() -> None: + with pytest.raises(PaymentError, match="invalid recipient"): + new_session(SessionOptions(recipient="not-base58!", cap=1_000, secret_key=SESSION_METHOD_SECRET)) + + +def test_new_session_validation_too_many_splits() -> None: + splits = [Split(recipient=_new_wallet(), bps=1) for _ in range(9)] + with pytest.raises(PaymentError, match="splits cannot exceed"): + new_session( + SessionOptions(recipient=SESSION_TEST_RECIPIENT, cap=1_000, secret_key=SESSION_METHOD_SECRET, splits=splits) + ) + + +def test_new_session_validation_pull_requires_strategy() -> None: + with pytest.raises(PaymentError, match="pullVoucherStrategy is required"): + new_session( + SessionOptions( + recipient=SESSION_TEST_RECIPIENT, + cap=1_000, + secret_key=SESSION_METHOD_SECRET, + modes=["pull"], + ) + ) + + +def test_new_session_validation_bad_submitter() -> None: + with pytest.raises(PaymentError, match="openTxSubmitter"): + new_session( + SessionOptions( + recipient=SESSION_TEST_RECIPIENT, + cap=1_000, + secret_key=SESSION_METHOD_SECRET, + open_tx_submitter="relay", + ) + ) + + +def test_new_session_validation_missing_secret(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("MPP_SECRET_KEY", raising=False) + with pytest.raises(PaymentError, match="missing secret key"): + new_session(SessionOptions(recipient=SESSION_TEST_RECIPIENT, cap=1_000, secret_key="")) + + +# ── new_session defaults (TestNewSessionDefaults) ── + + +def test_new_session_defaults() -> None: + session = _new_test_session(currency="", decimals=0, network="", open_tx_submitter="") + assert session._currency == "USDC" + assert session._network == "mainnet" + assert session._open_tx_submitter == "client" + assert session.core().config.decimals == 6 + + +# ── challenge ── + + +async def test_session_challenge_canonical_shape() -> None: + """Mirrors TestSessionChallengeCanonicalShape.""" + session = _new_test_session() + challenge = await session.challenge(SessionChallengeOptions(cap="1000000", description="Metered token stream")) + assert challenge.verify(SESSION_METHOD_SECRET) + assert challenge.intent.lower() == "session" + assert challenge.method == "solana" + assert challenge.realm == "api.test" + + from pay_kit.protocols.mpp.intents.session import SessionRequest + + request = SessionRequest.from_dict(challenge.decode_request()) + assert request.cap == "1000000" + assert request.currency == "USDC" + assert request.operator == SESSION_TEST_RECIPIENT + assert request.recipient == SESSION_TEST_RECIPIENT + assert request.network == "localnet" + assert request.decimals == 6 + assert request.description == "Metered token stream" + assert request.modes == [] # omitted when push-only + assert request.recent_blockhash is None # absent without an RPC client + + +async def test_session_challenge_clamps_requested_cap() -> None: + """Mirrors TestSessionChallengeClampsRequestedCap.""" + session = _new_test_session(cap=1_000_000) + challenge = await session.challenge(SessionChallengeOptions(cap="50000000")) + from pay_kit.protocols.mpp.intents.session import SessionRequest + + request = SessionRequest.from_dict(challenge.decode_request()) + assert request.cap == "1000000" + + +async def test_session_challenge_invalid_cap_rejected() -> None: + """Mirrors TestSessionChallengeInvalidCapRejected.""" + session = _new_test_session() + with pytest.raises(PaymentError): + await session.challenge(SessionChallengeOptions(cap="1.5")) + + +async def test_session_challenge_includes_blockhash_with_rpc() -> None: + """Mirrors TestSessionChallengeIncludesBlockhashWithRPC.""" + fake = _FakeRpc() + session = _new_test_session(rpc=fake) + challenge = await session.challenge(SessionChallengeOptions()) + from pay_kit.protocols.mpp.intents.session import SessionRequest + + request = SessionRequest.from_dict(challenge.decode_request()) + assert request.recent_blockhash == fake.blockhash + + +async def test_session_challenge_advertises_pull_strategy() -> None: + """Mirrors TestSessionChallengeAdvertisesPullStrategy.""" + session = _new_test_session(modes=["pull", "push"], pull_voucher_strategy="clientVoucher") + challenge = await session.challenge(SessionChallengeOptions(external_id="ref-7")) + from pay_kit.protocols.mpp.intents.session import SessionRequest + + request = SessionRequest.from_dict(challenge.decode_request()) + assert len(request.modes) == 2 + assert request.pull_voucher_strategy == "clientVoucher" + assert request.external_id == "ref-7" + + +# ── VerifyCredential: tier-1 + tier-2 ── + + +async def test_verify_credential_rejects_tampered_and_expired_challenges() -> None: + """Mirrors TestVerifyCredentialRejectsTamperedAndExpiredChallenges.""" + session = _new_test_session() + signer = _TestVoucherSigner(1) + channel_id = _new_wallet() + action = SessionAction.open_action(OpenPayload.push(channel_id, "1000", signer.address(), "sig")) + + credential = await _session_action_credential(session, action) + credential.challenge.realm = "tampered.example" + with pytest.raises(PaymentError, match="challenge ID mismatch"): + await session.verify_credential(credential) + + request = session.core().build_challenge_request(1_000) + expired = PaymentChallenge.with_secret_key( + secret_key=SESSION_METHOD_SECRET, + realm="api.test", + method="solana", + intent="session", + request=PaymentChallenge.encode_request(request.to_dict()), + expires="2020-01-01T00:00:00Z", + ) + expired_credential = PaymentCredential(challenge=expired.to_echo(), payload=action.to_dict()) + with pytest.raises(PaymentError, match="expired"): + await session.verify_credential(expired_credential) + + +async def test_verify_credential_pinned_field_backstop() -> None: + """Mirrors TestVerifyCredentialPinnedFieldBackstop.""" + session = _new_test_session() + signer = _TestVoucherSigner(1) + action = SessionAction.open_action(OpenPayload.push(_new_wallet(), "1000", signer.address(), "sig")) + + def issue(intent: str, request) -> PaymentCredential: + challenge = PaymentChallenge.with_secret_key( + secret_key=SESSION_METHOD_SECRET, + realm="api.test", + method="solana", + intent=intent, + request=PaymentChallenge.encode_request(request.to_dict()), + expires="2999-01-01T00:00:00Z", + ) + return PaymentCredential(challenge=challenge.to_echo(), payload=action.to_dict()) + + charge_intent = issue("charge", session.core().build_challenge_request(1_000)) + with pytest.raises(PaymentError, match="not a session"): + await session.verify_credential(charge_intent) + + wrong_currency = session.core().build_challenge_request(1_000) + wrong_currency.currency = "USDT" + with pytest.raises(PaymentError, match="currency"): + await session.verify_credential(issue("session", wrong_currency)) + + wrong_recipient = session.core().build_challenge_request(1_000) + wrong_recipient.recipient = _new_wallet() + with pytest.raises(PaymentError, match="recipient"): + await session.verify_credential(issue("session", wrong_recipient)) + + unknown_action = await _session_action_credential(session, {"action": "refund"}) + with pytest.raises(PaymentError, match="decode session action"): + await session.verify_credential(unknown_action) + + +# ── open ── + + +async def test_session_open_trusts_channel_id_and_deposit() -> None: + """Mirrors TestSessionOpenTrustsChannelIDAndDeposit.""" + session = _new_test_session() + signer = _TestVoucherSigner(1) + channel_id = _new_wallet() + receipt = await _open_session_channel(session, channel_id, 1_000_000, signer.address(), "sig-1") + assert receipt.status == "success" + assert receipt.reference == "sig-1" + state = await _get_channel(session, channel_id) + assert state is not None + assert state.deposit == 1_000_000 + assert state.cumulative == 0 + assert state.authorized_signer == signer.address() + + +async def test_session_open_rejects_unadvertised_mode() -> None: + """Mirrors TestSessionOpenRejectsUnadvertisedMode.""" + session = _new_test_session() + signer = _TestVoucherSigner(1) + payload = OpenPayload.pull(_new_wallet(), "1000", _new_wallet(), signer.address(), "sig") + with pytest.raises(PaymentError, match="not supported"): + await _verify_session_action(session, SessionAction.open_action(payload)) + + +async def test_session_open_rejects_bad_deposits() -> None: + """Mirrors TestSessionOpenRejectsBadDeposits.""" + session = _new_test_session(cap=1_000) + signer = _TestVoucherSigner(1) + channel_id = _new_wallet() + + over = OpenPayload.push(channel_id, "10000", signer.address(), "sig") + with pytest.raises(PaymentError, match="exceeds max cap"): + await _verify_session_action(session, SessionAction.open_action(over)) + + zero = OpenPayload.push(channel_id, "0", signer.address(), "sig") + with pytest.raises(PaymentError, match="greater than zero"): + await _verify_session_action(session, SessionAction.open_action(zero)) + + missing = OpenPayload(mode="push", authorized_signer=signer.address(), signature="sig") + with pytest.raises(PaymentError, match="missing transaction or channelId"): + await _verify_session_action(session, SessionAction.open_action(missing)) + + +async def test_session_open_rejects_empty_string_fields() -> None: + """Mirrors TestSessionOpenRejectsEmptyStringFields.""" + session = _new_test_session() + signer = _TestVoucherSigner(1) + + empty_tx = OpenPayload(mode="push", transaction="", authorized_signer=signer.address(), signature="sig") + with pytest.raises(PaymentError, match="missing transaction or channelId"): + await _verify_session_action(session, SessionAction.open_action(empty_tx)) + + empty_both = OpenPayload( + mode="push", transaction="", channel_id="", authorized_signer=signer.address(), signature="sig" + ) + with pytest.raises(PaymentError, match="missing transaction or channelId"): + await _verify_session_action(session, SessionAction.open_action(empty_both)) + + +async def test_session_open_replay_semantics() -> None: + """Mirrors TestSessionOpenReplaySemantics.""" + session = _new_test_session() + signer, channel_id = await _open_trusted_channel(session, 1_000) + await _submit_voucher(session, signer, channel_id, 250) + + # Idempotent replay preserves the watermark. + await _open_session_channel(session, channel_id, 1_000, signer.address(), "open-sig") + state = await _get_channel(session, channel_id) + assert state is not None and state.cumulative == 250 and state.highest_voucher_signature is not None + + # Different authorizedSigner rejects without overwriting. + intruder = _TestVoucherSigner(2) + payload = OpenPayload.push(channel_id, "1000", intruder.address(), "open-sig") + with pytest.raises(PaymentError, match="authorized signer"): + await _verify_session_action(session, SessionAction.open_action(payload)) + state = await _get_channel(session, channel_id) + assert state is not None and state.authorized_signer == signer.address() + + # Finalized channel rejects replays. + await session.core().store().mark_finalized(channel_id) + replay = OpenPayload.push(channel_id, "1000", signer.address(), "open-sig") + with pytest.raises(PaymentError, match="finalized"): + await _verify_session_action(session, SessionAction.open_action(replay)) + + +async def test_session_open_verifies_signature_on_chain() -> None: + """Mirrors TestSessionOpenVerifiesSignatureOnChain.""" + fake = _FakeRpc() + ok_sig = _confirmed_signature(0x11) + ghost_sig = _confirmed_signature(0x22) + failed_sig = _confirmed_signature(0x33) + fake.statuses[ghost_sig] = None + fake.statuses[failed_sig] = {"err": {"InstructionError": [0, "Custom"]}} + + session = _new_test_session(rpc=fake) + signer = _TestVoucherSigner(1) + + channel_id = _new_wallet() + receipt = await _open_session_channel(session, channel_id, 1_000, signer.address(), ok_sig) + assert receipt.reference == ok_sig + + ghost_channel = _new_wallet() + ghost = OpenPayload.push(ghost_channel, "1000", signer.address(), ghost_sig) + with pytest.raises(PaymentError, match="not found"): + await _verify_session_action(session, SessionAction.open_action(ghost)) + assert await _get_channel(session, ghost_channel) is None + + failed = OpenPayload.push(_new_wallet(), "1000", signer.address(), failed_sig) + with pytest.raises(PaymentError, match="failed on-chain"): + await _verify_session_action(session, SessionAction.open_action(failed)) + + +async def test_session_pull_open_prefers_channel_id_over_token_account() -> None: + """Mirrors TestSessionPullOpenPrefersChannelIDOverTokenAccount.""" + session = _new_test_session(modes=["pull"], pull_voucher_strategy="clientVoucher") + signer = _TestVoucherSigner(1) + channel_id = _new_wallet() + token_account = _new_wallet() + + payload = OpenPayload.pull(token_account, "1000", _new_wallet(), signer.address(), "sig-1") + payload.channel_id = channel_id + await _verify_session_action(session, SessionAction.open_action(payload)) + assert await _get_channel(session, channel_id) is not None + assert await _get_channel(session, token_account) is None + state = await _get_channel(session, channel_id) + assert state is not None and state.operator is not None + + +# ── voucher ── + + +async def test_session_voucher_advances_watermark() -> None: + """Mirrors TestSessionVoucherAdvancesWatermark.""" + session = _new_test_session() + signer, channel_id = await _open_trusted_channel(session, 1_000) + + voucher = signer.sign_voucher(channel_id, 250, _far_future()) + receipt = await _verify_session_action(session, SessionAction.voucher_action(VoucherPayload(voucher=voucher))) + assert receipt.reference == f"{channel_id}:250" + state = await _get_channel(session, channel_id) + assert state is not None + assert state.cumulative == 250 + assert state.highest_voucher_signature == voucher.signature + + +async def test_session_voucher_unknown_channel_rejected() -> None: + """Mirrors TestSessionVoucherUnknownChannelRejected.""" + session = _new_test_session() + signer = _TestVoucherSigner(1) + with pytest.raises(PaymentError, match="not found"): + await _submit_voucher(session, signer, _new_wallet(), 100) + + +async def test_session_voucher_non_monotonic_tagged_rejection() -> None: + """Mirrors TestSessionVoucherNonMonotonicTaggedRejection.""" + session = _new_test_session() + signer, channel_id = await _open_trusted_channel(session, 1_000) + await _submit_voucher(session, signer, channel_id, 100) + with pytest.raises(PaymentError, match="cumulative-not-monotonic"): + await _submit_voucher(session, signer, channel_id, 50) + + +async def test_session_voucher_accepts_cumulative_alias_on_the_wire() -> None: + """Mirrors TestSessionVoucherAcceptsCumulativeAliasOnTheWire.""" + session = _new_test_session() + signer, channel_id = await _open_trusted_channel(session, 1_000) + canonical = signer.sign_voucher(channel_id, 250, _far_future()) + + aliased = { + "action": "voucher", + "voucher": { + "data": {"channelId": channel_id, "cumulative": "250", "expiresAt": canonical.data.expires_at}, + "signature": canonical.signature, + }, + } + receipt = await _verify_session_action(session, aliased) + assert receipt.reference == f"{channel_id}:250" + state = await _get_channel(session, channel_id) + assert state is not None and state.cumulative == 250 + + neither = { + "action": "voucher", + "voucher": { + "data": {"channelId": channel_id, "expiresAt": canonical.data.expires_at}, + "signature": canonical.signature, + }, + } + # An absent cumulative decodes to "" and fails the strict u64 parse. + with pytest.raises(PaymentError): + await _verify_session_action(session, neither) + + +# ── topUp ── + + +async def test_session_top_up_updates_deposit() -> None: + """Mirrors TestSessionTopUpUpdatesDeposit.""" + session = _new_test_session() + _, channel_id = await _open_trusted_channel(session, 1_000) + + receipt = await _verify_session_action( + session, + SessionAction.top_up_action(TopUpPayload(channel_id=channel_id, new_deposit="5000", signature="topup-sig")), + ) + assert receipt.reference == "topup-sig" + state = await _get_channel(session, channel_id) + assert state is not None and state.deposit == 5_000 + + +async def test_session_top_up_hardening() -> None: + """Mirrors TestSessionTopUpHardening.""" + session = _new_test_session() + _, channel_id = await _open_trusted_channel(session, 5_000) + + with pytest.raises(PaymentError, match="must exceed current deposit"): + await _verify_session_action( + session, + SessionAction.top_up_action(TopUpPayload(channel_id=channel_id, new_deposit="1000", signature="sig")), + ) + + with pytest.raises(PaymentError, match="exceeds cap"): + await _verify_session_action( + session, + SessionAction.top_up_action(TopUpPayload(channel_id=channel_id, new_deposit="99000000", signature="sig")), + ) + + with pytest.raises(PaymentError, match="not found"): + await _verify_session_action( + session, + SessionAction.top_up_action(TopUpPayload(channel_id=_new_wallet(), new_deposit="9000", signature="sig")), + ) + + # Close-pending blocks top-ups. + await _verify_session_action(session, SessionAction.close_action(ClosePayload(channel_id=channel_id))) + with pytest.raises(PaymentError, match="close is pending"): + await _verify_session_action( + session, + SessionAction.top_up_action(TopUpPayload(channel_id=channel_id, new_deposit="9000", signature="sig")), + ) + + # Finalized blocks top-ups. + _, finalized_channel = await _open_trusted_channel(session, 5_000) + await session.core().store().mark_finalized(finalized_channel) + with pytest.raises(PaymentError, match="finalized"): + await _verify_session_action( + session, + SessionAction.top_up_action( + TopUpPayload(channel_id=finalized_channel, new_deposit="9000", signature="sig") + ), + ) + + +async def test_session_top_up_verifies_signature_on_chain() -> None: + """Mirrors TestSessionTopUpVerifiesSignatureOnChain.""" + fake = _FakeRpc() + open_sig = _confirmed_signature(0x44) + topup_sig = _confirmed_signature(0x55) + ghost_sig = _confirmed_signature(0x66) + fake.statuses[ghost_sig] = None + + session = _new_test_session(rpc=fake) + signer = _TestVoucherSigner(1) + channel_id = _new_wallet() + await _open_session_channel(session, channel_id, 1_000, signer.address(), open_sig) + + receipt = await _verify_session_action( + session, + SessionAction.top_up_action(TopUpPayload(channel_id=channel_id, new_deposit="5000", signature=topup_sig)), + ) + assert receipt.reference == topup_sig + state = await _get_channel(session, channel_id) + assert state is not None and state.deposit == 5_000 + + with pytest.raises(PaymentError, match="not found"): + await _verify_session_action( + session, + SessionAction.top_up_action(TopUpPayload(channel_id=channel_id, new_deposit="9000", signature=ghost_sig)), + ) + state = await _get_channel(session, channel_id) + assert state is not None and state.deposit == 5_000 + + +# ── close ── + + +async def test_session_close_flips_close_pending() -> None: + """Mirrors TestSessionCloseFlipsClosePending.""" + session = _new_test_session() + _, channel_id = await _open_trusted_channel(session, 1_000) + + receipt = await _verify_session_action(session, SessionAction.close_action(ClosePayload(channel_id=channel_id))) + assert receipt.reference == channel_id + state = await _get_channel(session, channel_id) + assert state is not None and state.close_requested_at is not None and not state.finalized + + +async def test_session_close_with_final_voucher_advances_watermark() -> None: + """Mirrors TestSessionCloseWithFinalVoucherAdvancesWatermark.""" + session = _new_test_session() + signer, channel_id = await _open_trusted_channel(session, 1_000) + + final = signer.sign_voucher(channel_id, 750, _far_future()) + await _verify_session_action( + session, SessionAction.close_action(ClosePayload(channel_id=channel_id, voucher=final)) + ) + state = await _get_channel(session, channel_id) + assert state is not None and state.cumulative == 750 and state.close_requested_at is not None + + +async def test_session_close_non_monotonic_final_voucher_hard_error() -> None: + """Mirrors TestSessionCloseNonMonotonicFinalVoucherHardError.""" + session = _new_test_session() + signer, channel_id = await _open_trusted_channel(session, 1_000) + await _submit_voucher(session, signer, channel_id, 250) + + stale = signer.sign_voucher(channel_id, 100, _far_future()) + with pytest.raises(PaymentError, match="must exceed watermark"): + await _verify_session_action( + session, SessionAction.close_action(ClosePayload(channel_id=channel_id, voucher=stale)) + ) + state = await _get_channel(session, channel_id) + assert state is not None and state.close_requested_at is None and state.cumulative == 250 + + +async def test_session_close_accepts_replay_of_highest_voucher() -> None: + """Mirrors TestSessionCloseAcceptsReplayOfHighestVoucher.""" + session = _new_test_session() + signer, channel_id = await _open_trusted_channel(session, 1_000) + voucher = signer.sign_voucher(channel_id, 250, _far_future()) + await _verify_session_action(session, SessionAction.voucher_action(VoucherPayload(voucher=voucher))) + + await _verify_session_action( + session, SessionAction.close_action(ClosePayload(channel_id=channel_id, voucher=voucher)) + ) + state = await _get_channel(session, channel_id) + assert state is not None and state.close_requested_at is not None and state.cumulative == 250 + + +async def test_session_close_second_close_on_closing_channel_redrives() -> None: + """A second close on an already-closing channel that has no settled + signature re-drives (matches Go ``handleClose``) rather than hard-rejecting + with "close already requested". + + Mirrors the re-drivable branch of Go ``handleClose`` (session_method.go + ~681-688): ``CloseRequestedAt != nil && SettledSignature == nil`` leaves the + state untouched and lets the settlement retry proceed. Python's close path + never records a settled signature (the on-chain settlement is not ported), + so the channel stays re-drivable. + """ + session = _new_test_session() + _, channel_id = await _open_trusted_channel(session, 1_000) + + first = await _verify_session_action(session, SessionAction.close_action(ClosePayload(channel_id=channel_id))) + assert first.reference == channel_id + after_first = await _get_channel(session, channel_id) + assert after_first is not None and after_first.close_requested_at is not None + first_close_at = after_first.close_requested_at + + # Second close on the closing channel re-drives instead of raising. + second = await _verify_session_action(session, SessionAction.close_action(ClosePayload(channel_id=channel_id))) + assert second.reference == channel_id + after_second = await _get_channel(session, channel_id) + assert after_second is not None + assert after_second.close_requested_at == first_close_at + assert not after_second.finalized + + +async def test_session_close_settled_double_close_rejected() -> None: + """A close-pending channel that already recorded a settlement signature is + NOT re-drivable: a second close hard-rejects with "close already requested". + + Mirrors Go ``handleClose`` (session_method.go ~681-688) and + TestSessionCloseUnknownChannelAndSettledDoubleClose: the re-drive guard only + fires while ``SettledSignature == nil``. + """ + from pay_kit.protocols.mpp.server.session_store import ChannelState + + session = _new_test_session() + signer = _TestVoucherSigner(0x21) + channel_id = _new_wallet() + + def seed(_current: ChannelState | None) -> ChannelState: + return ChannelState( + channel_id=channel_id, + authorized_signer=signer.address(), + deposit=1_000, + close_requested_at=1, + settled_signature=_confirmed_signature(0xAB), + ) + + await session.core().store().update_channel(channel_id, seed) + + with pytest.raises(PaymentError, match="close already requested"): + await _verify_session_action(session, SessionAction.close_action(ClosePayload(channel_id=channel_id))) + + +# ── method-layer open guards ── + + +async def test_session_open_pull_without_strategy_rejected_at_method_layer() -> None: + """Pull-mode open requires a pull voucher strategy on the server config. + + Mirrors the Go ``handleOpen`` method-layer guard (session_method.go + ~458-460). ``new_session`` rejects pull-without-strategy at construction, so + this builds the core ``SessionServer`` directly with a pull-mode config that + omits the strategy to exercise the method-layer guard in isolation. + """ + from pay_kit.protocols.mpp.server.session import SessionConfig, SessionServer + from pay_kit.protocols.mpp.server.session_store import MemoryChannelStore + + config = SessionConfig( + operator=SESSION_TEST_RECIPIENT, + recipient=SESSION_TEST_RECIPIENT, + max_cap=5_000_000, + currency="USDC", + decimals=6, + network="localnet", + modes=["pull"], + pull_voucher_strategy=None, + ) + core = SessionServer(config, MemoryChannelStore()) + session = Session( + core=core, + secret_key=SESSION_METHOD_SECRET, + realm="api.test", + cap=5_000_000, + currency="USDC", + recipient=SESSION_TEST_RECIPIENT, + network="localnet", + open_tx_submitter="client", + rpc=None, + lifecycle=None, + ) + + signer = _TestVoucherSigner(0x21) + payload = OpenPayload.pull(_new_wallet(), "1000", _new_wallet(), signer.address(), "sig") + with pytest.raises(PaymentError, match="pull-mode open requires a pullVoucherStrategy"): + await _verify_session_action(session, SessionAction.open_action(payload)) + + +# ── commit ── + + +async def test_session_commit_for_reserved_delivery() -> None: + """Mirrors the credential-layer half of TestSessionCommitForReservedDelivery + (the metering reservation runs through the core ``begin_delivery``; the + HTTP ``Routes`` wrapper is not ported).""" + session = _new_test_session() + signer, channel_id = await _open_trusted_channel(session, 1_000) + + from pay_kit.protocols.mpp.server.session import DeliveryRequest + + directive = await session.core().begin_delivery(DeliveryRequest(session_id=channel_id, amount=200)) + assert directive.delivery_id == f"{channel_id}:1" + assert directive.sequence == 1 + assert directive.currency == "USDC" + assert directive.amount == "200" + + voucher = signer.sign_voucher(channel_id, 150, _far_future()) + receipt = await _verify_session_action( + session, SessionAction.commit_action(CommitPayload(delivery_id=directive.delivery_id, voucher=voucher)) + ) + assert receipt.reference == f"{channel_id}:{directive.delivery_id}:150" + state = await _get_channel(session, channel_id) + assert state is not None + assert state.cumulative == 150 + assert len(state.committed_deliveries) == 1 + assert len(state.pending_deliveries) == 0 + + +async def test_session_commit_replay_re_verifies_signature() -> None: + """Mirrors TestSessionCommitReplayReVerifiesSignature.""" + from pay_kit.protocols.mpp.server.session_store import ChannelState, CommittedDelivery + + session = _new_test_session() + signer = _TestVoucherSigner(1) + channel_id = _new_wallet() + forged = _confirmed_signature(0xAA) + + def seed(_current: ChannelState | None) -> ChannelState: + return ChannelState( + channel_id=channel_id, + authorized_signer=signer.address(), + deposit=1_000, + cumulative=50, + next_delivery_sequence=1, + committed_deliveries=[ + CommittedDelivery(delivery_id="d-1", amount=50, cumulative=50, voucher_signature=forged) + ], + ) + + await session.core().store().update_channel(channel_id, seed) + + forged_voucher = SignedVoucher( + data=VoucherData(channel_id=channel_id, cumulative="50", expires_at=_far_future()), + signature=forged, + ) + with pytest.raises(PaymentError, match="signature"): + await _verify_session_action( + session, SessionAction.commit_action(CommitPayload(delivery_id="d-1", voucher=forged_voucher)) + ) diff --git a/python/tests/test_session_onchain.py b/python/tests/test_session_onchain.py new file mode 100644 index 000000000..7408c7079 --- /dev/null +++ b/python/tests/test_session_onchain.py @@ -0,0 +1,481 @@ +"""Tests for the session on-chain open-tx verifier and helpers. + +Mirrors the behaviors in +``go/protocols/mpp/server/session_onchain_test.go`` for the standalone +``verify_open_tx`` slice and the top-up / open verifier seam factories. The +``SessionServer``-bound ``SettlementInstructions`` method and the +``ProcessOpen`` integration tests are not ported here: the base session server +is not yet ported to Python, so the settlement method and the +verifier-through-``ProcessOpen`` paths land with that follow-up. +""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass, replace + +import pytest +from solders.hash import Hash # type: ignore[import-untyped] +from solders.instruction import Instruction # type: ignore[import-untyped] +from solders.keypair import Keypair # type: ignore[import-untyped] +from solders.message import Message, MessageV0 # type: ignore[import-untyped] +from solders.pubkey import Pubkey # type: ignore[import-untyped] +from solders.signature import Signature # type: ignore[import-untyped] +from solders.transaction import ( # type: ignore[import-untyped] + Transaction, + VersionedTransaction, +) + +from pay_kit._paycore.errors import PaymentError +from pay_kit._paycore.solana import TOKEN_PROGRAM +from pay_kit.protocols.mpp._paymentchannels import ( + PROGRAM_ID, + OpenChannelParams, + build_open_instruction, + find_channel_pda, +) +from pay_kit.protocols.mpp.intents.session import OpenPayload, TopUpPayload +from pay_kit.protocols.mpp.server.session_onchain import ( + VerifyOpenTxExpected, + is_placeholder_signature, + new_open_tx_verifier, + new_top_up_tx_verifier, + verify_open_tx, +) + +USDC_MAINNET_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + +OPEN_FIXTURE_SALT = 7 +OPEN_FIXTURE_DEPOSIT = 1_000_000 +OPEN_FIXTURE_GRACE = 900 + + +@dataclass +class OpenTxFixture: + """A freshly built and signed payment-channel open tx plus the challenge + expectations that accept it. Mirrors ``openTxFixture`` in the Go test.""" + + payer: Keypair + payee: Pubkey + authorized: Pubkey + mint: Pubkey + channel: Pubkey + signature: str + payload: OpenPayload + expected: VerifyOpenTxExpected + + +class _FakeRpc: + """Minimal RPC double exposing ``get_signature_statuses`` like the + production ``SolanaRpc``. Mirrors ``testutil.FakeRPC``: any signature not in + ``statuses`` is reported confirmed with no error.""" + + def __init__(self) -> None: + self.statuses: dict[str, dict | None] = {} + + async def get_signature_statuses(self, signatures: list[str]) -> list[dict | None]: + out: list[dict | None] = [] + for signature in signatures: + if signature in self.statuses: + out.append(self.statuses[signature]) + else: + out.append({"err": None, "confirmationStatus": "confirmed"}) + return out + + +def _kp(seed: int) -> Keypair: + return Keypair.from_seed(bytes([seed] * 32)) + + +def _sign_and_attach(fixture: OpenTxFixture, ix: Instruction, v0: bool) -> tuple[str, OpenPayload]: + blockhash = Hash.from_string("EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N") + payer_pubkey = fixture.payer.pubkey() + if v0: + message_v0 = MessageV0.try_compile(payer_pubkey, [ix], [], blockhash) + vtx = VersionedTransaction(message_v0, [fixture.payer]) + encoded = base64.b64encode(bytes(vtx)).decode("ascii") + signature = str(vtx.signatures[0]) + else: + message = Message.new_with_blockhash([ix], payer_pubkey, blockhash) + tx = Transaction([fixture.payer], message, blockhash) + encoded = base64.b64encode(bytes(tx)).decode("ascii") + signature = str(tx.signatures[0]) + payload = OpenPayload.payment_channel( + str(fixture.channel), + str(OPEN_FIXTURE_DEPOSIT), + str(payer_pubkey), + str(fixture.payee), + str(fixture.mint), + OPEN_FIXTURE_SALT, + OPEN_FIXTURE_GRACE, + str(fixture.authorized), + signature, + ).with_transaction(encoded) + return signature, payload + + +def build_open_tx_fixture(v0: bool) -> OpenTxFixture: + payer = _kp(1) + payee = _kp(2).pubkey() + authorized = _kp(3).pubkey() + mint = Pubkey.from_string(USDC_MAINNET_MINT) + channel, _ = find_channel_pda(payer.pubkey(), payee, mint, authorized, OPEN_FIXTURE_SALT, PROGRAM_ID) + ix = build_open_instruction( + OpenChannelParams( + payer=payer.pubkey(), + payee=payee, + mint=mint, + authorized_signer=authorized, + salt=OPEN_FIXTURE_SALT, + deposit=OPEN_FIXTURE_DEPOSIT, + grace_period=OPEN_FIXTURE_GRACE, + token_program=Pubkey.from_string(TOKEN_PROGRAM), + ) + ) + fixture = OpenTxFixture( + payer=payer, + payee=payee, + authorized=authorized, + mint=mint, + channel=channel, + signature="", + payload=OpenPayload.payment_channel("", "", "", "", "", 0, 0, "", ""), + expected=VerifyOpenTxExpected(authorized_signer="", recipient="", currency="", network=""), + ) + fixture.signature, fixture.payload = _sign_and_attach(fixture, ix, v0) + fixture.expected = VerifyOpenTxExpected( + authorized_signer=str(authorized), + currency="USDC", + max_cap=5_000_000, + network="localnet", + recipient=str(payee), + ) + return fixture + + +# -- verify_open_tx: accepted encodings --------------------------------------- + + +async def test_verify_open_tx_accepts_legacy_encoding() -> None: + """Mirrors TestVerifyOpenTxAcceptsLegacyEncoding.""" + fixture = build_open_tx_fixture(v0=False) + result = await verify_open_tx(fixture.expected, fixture.payload, None) + assert result.channel_id == str(fixture.channel) + assert result.deposit == OPEN_FIXTURE_DEPOSIT + assert result.grace_period == OPEN_FIXTURE_GRACE + assert result.salt == OPEN_FIXTURE_SALT + + +async def test_verify_open_tx_accepts_v0_encoding() -> None: + """Mirrors TestVerifyOpenTxAcceptsV0Encoding.""" + fixture = build_open_tx_fixture(v0=True) + # Confirm the fixture really emits the v0 wire prefix before asserting it + # verifies: the message must round-trip through the versioned decoder. + assert fixture.payload.transaction is not None + raw = base64.b64decode(fixture.payload.transaction, validate=True) + vtx = VersionedTransaction.from_bytes(raw) + assert isinstance(vtx.message, MessageV0) + result = await verify_open_tx(fixture.expected, fixture.payload, None) + assert result.channel_id == str(fixture.channel) + + +async def test_verify_open_tx_honors_explicit_mint_and_program_overrides() -> None: + """Mirrors TestVerifyOpenTxHonorsExplicitMintAndProgramOverrides.""" + fixture = build_open_tx_fixture(v0=False) + expected = replace( + fixture.expected, + currency="not-a-currency", + mint=str(fixture.mint), + program_id=PROGRAM_ID, + ) + result = await verify_open_tx(expected, fixture.payload, None) + assert result.channel_id == str(fixture.channel) + + +# -- verify_open_tx: failure modes -------------------------------------------- + + +async def test_verify_open_tx_rejects_undecodable_transaction() -> None: + """Mirrors TestVerifyOpenTxRejectsUndecodableTransaction.""" + fixture = build_open_tx_fixture(v0=False) + fixture.payload.transaction = "not-base64!" + with pytest.raises(PaymentError, match="decode open transaction"): + await verify_open_tx(fixture.expected, fixture.payload, None) + + +async def test_verify_open_tx_requires_transaction() -> None: + """Mirrors TestVerifyOpenTxRequiresTransaction.""" + fixture = build_open_tx_fixture(v0=False) + fixture.payload.transaction = None + with pytest.raises(PaymentError, match="transaction is required"): + await verify_open_tx(fixture.expected, fixture.payload, None) + + +async def test_verify_open_tx_rejects_wrong_payee() -> None: + """Mirrors TestVerifyOpenTxRejectsWrongPayee.""" + fixture = build_open_tx_fixture(v0=False) + expected = replace(fixture.expected, recipient=str(fixture.payer.pubkey())) + with pytest.raises(PaymentError, match="payee"): + await verify_open_tx(expected, fixture.payload, None) + + +async def test_verify_open_tx_rejects_wrong_mint() -> None: + """Mirrors TestVerifyOpenTxRejectsWrongMint.""" + fixture = build_open_tx_fixture(v0=False) + expected = replace(fixture.expected, currency="USDT") + with pytest.raises(PaymentError, match="mint"): + await verify_open_tx(expected, fixture.payload, None) + + +async def test_verify_open_tx_rejects_wrong_authorized_signer() -> None: + """Mirrors TestVerifyOpenTxRejectsWrongAuthorizedSigner.""" + fixture = build_open_tx_fixture(v0=False) + expected = replace(fixture.expected, authorized_signer=str(_kp(99).pubkey())) + with pytest.raises(PaymentError, match="authorizedSigner"): + await verify_open_tx(expected, fixture.payload, None) + + +async def test_verify_open_tx_rejects_over_cap_deposit() -> None: + """Mirrors TestVerifyOpenTxRejectsOverCapDeposit.""" + fixture = build_open_tx_fixture(v0=False) + expected = replace(fixture.expected, max_cap=OPEN_FIXTURE_DEPOSIT - 1) + with pytest.raises(PaymentError, match="exceeds max cap"): + await verify_open_tx(expected, fixture.payload, None) + + +async def test_verify_open_tx_rejects_zero_deposit() -> None: + """Mirrors TestVerifyOpenTxRejectsZeroDeposit.""" + fixture = build_open_tx_fixture(v0=False) + # Rebuild the open instruction with a zero deposit; the channel PDA does not + # embed the deposit, so only the deposit check can reject it. + ix = build_open_instruction( + OpenChannelParams( + payer=fixture.payer.pubkey(), + payee=fixture.payee, + mint=fixture.mint, + authorized_signer=fixture.authorized, + salt=OPEN_FIXTURE_SALT, + deposit=0, + grace_period=OPEN_FIXTURE_GRACE, + token_program=Pubkey.from_string(TOKEN_PROGRAM), + ) + ) + _, fixture.payload = _sign_and_attach(fixture, ix, v0=False) + with pytest.raises(PaymentError, match="greater than zero"): + await verify_open_tx(fixture.expected, fixture.payload, None) + + +async def test_verify_open_tx_rejects_unbound_signature() -> None: + """Mirrors TestVerifyOpenTxRejectsUnboundSignature.""" + fixture = build_open_tx_fixture(v0=False) + unrelated = _kp(50).sign_message(b"unrelated transaction") + fixture.payload.signature = str(unrelated) + with pytest.raises(PaymentError, match="transaction signature"): + await verify_open_tx(fixture.expected, fixture.payload, None) + + +async def test_verify_open_tx_rejects_signature_without_fee_payer_signature() -> None: + """Mirrors TestVerifyOpenTxRejectsSignatureWithoutFeePayerSignature.""" + fixture = build_open_tx_fixture(v0=False) + assert fixture.payload.transaction is not None + raw = base64.b64decode(fixture.payload.transaction, validate=True) + tx = Transaction.from_bytes(raw) + stripped = Transaction.populate(tx.message, [Signature.default()]) + fixture.payload.transaction = base64.b64encode(bytes(stripped)).decode("ascii") + with pytest.raises(PaymentError, match="no fee-payer signature"): + await verify_open_tx(fixture.expected, fixture.payload, None) + + +async def test_verify_open_tx_accepts_placeholder_signature_without_binding() -> None: + """Mirrors TestVerifyOpenTxAcceptsPlaceholderSignatureWithoutBinding.""" + fixture = build_open_tx_fixture(v0=False) + fixture.payload.signature = "1" * 64 + result = await verify_open_tx(fixture.expected, fixture.payload, None) + assert result.channel_id == str(fixture.channel) + + +async def test_verify_open_tx_rejects_missing_open_instruction() -> None: + """Mirrors TestVerifyOpenTxRejectsMissingOpenInstruction.""" + fixture = build_open_tx_fixture(v0=False) + memo = Instruction( + Pubkey.from_string("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"), + b"not an open", + [], + ) + _, fixture.payload = _sign_and_attach(fixture, memo, v0=False) + with pytest.raises(PaymentError, match="no payment-channels open instruction"): + await verify_open_tx(fixture.expected, fixture.payload, None) + + +async def test_verify_open_tx_rejects_channel_pda_mismatch() -> None: + """Mirrors TestVerifyOpenTxRejectsChannelPDAMismatch.""" + fixture = build_open_tx_fixture(v0=False) + ix = build_open_instruction( + OpenChannelParams( + payer=fixture.payer.pubkey(), + payee=fixture.payee, + mint=fixture.mint, + authorized_signer=fixture.authorized, + salt=OPEN_FIXTURE_SALT, + deposit=OPEN_FIXTURE_DEPOSIT, + grace_period=OPEN_FIXTURE_GRACE, + token_program=Pubkey.from_string(TOKEN_PROGRAM), + ) + ) + # Swap the channel account (slot 4) for an unrelated key while keeping the + # instruction data intact: the re-derived PDA must catch it. + accounts = list(ix.accounts) + tampered = accounts[4] + accounts[4] = type(tampered)(_kp(77).pubkey(), tampered.is_signer, tampered.is_writable) + forged = Instruction(ix.program_id, ix.data, accounts) + _, fixture.payload = _sign_and_attach(fixture, forged, v0=False) + with pytest.raises(PaymentError, match="PDA"): + await verify_open_tx(fixture.expected, fixture.payload, None) + + +async def test_verify_open_tx_rejects_payload_channel_id_mismatch() -> None: + """Mirrors TestVerifyOpenTxRejectsPayloadChannelIDMismatch.""" + fixture = build_open_tx_fixture(v0=False) + fixture.payload.channel_id = str(_kp(88).pubkey()) + with pytest.raises(PaymentError, match="channelId"): + await verify_open_tx(fixture.expected, fixture.payload, None) + + +# -- verify_open_tx: RPC liveness --------------------------------------------- + + +async def test_verify_open_tx_confirms_bound_signature_via_rpc() -> None: + """Mirrors TestVerifyOpenTxConfirmsBoundSignatureViaRPC.""" + fixture = build_open_tx_fixture(v0=False) + fake_rpc = _FakeRpc() + result = await verify_open_tx(fixture.expected, fixture.payload, fake_rpc) + assert result.channel_id == str(fixture.channel) + + +async def test_verify_open_tx_surfaces_rpc_failure() -> None: + """Mirrors TestVerifyOpenTxSurfacesRPCFailure.""" + fixture = build_open_tx_fixture(v0=False) + fake_rpc = _FakeRpc() + fake_rpc.statuses[fixture.signature] = {"err": "InstructionError"} + with pytest.raises(PaymentError, match="failed on-chain"): + await verify_open_tx(fixture.expected, fixture.payload, fake_rpc) + + +async def test_verify_open_tx_surfaces_rpc_not_found() -> None: + """Mirrors TestVerifyOpenTxSurfacesRPCNotFound.""" + fixture = build_open_tx_fixture(v0=False) + fake_rpc = _FakeRpc() + fake_rpc.statuses[fixture.signature] = None + with pytest.raises(PaymentError, match="not found"): + await verify_open_tx(fixture.expected, fixture.payload, fake_rpc) + + +def test_is_placeholder_signature() -> None: + """Mirrors TestIsPlaceholderSignature.""" + cases = [ + ("", True), + ("1" * 64, True), + ("1" * 40, True), + ("1" * 39, False), + ("1" * 63 + "2", False), + ("5VERYrealLookingBase58SignatureValue11111111111111111111111111111", False), + ] + for signature, want in cases: + assert is_placeholder_signature(signature) is want + + +# -- new_open_tx_verifier wiring ---------------------------------------------- + + +@dataclass +class _OpenConfig: + """Lightweight stand-in for the not-yet-ported SessionConfig, exposing only + the fields new_open_tx_verifier reads.""" + + currency: str + network: str + recipient: str + max_cap: int + program_id: Pubkey | None = None + + +def _open_session_config(fixture: OpenTxFixture) -> _OpenConfig: + return _OpenConfig( + currency="USDC", + network="localnet", + recipient=str(fixture.payee), + max_cap=5_000_000, + ) + + +async def test_new_open_tx_verifier_accepts_valid_open() -> None: + """Mirrors the verifier-seam half of + TestNewOpenTxVerifierAcceptsValidOpenThroughProcessOpen (the ProcessOpen + integration lands with the base session server port).""" + fixture = build_open_tx_fixture(v0=False) + verifier = new_open_tx_verifier(_open_session_config(fixture), None) + await verifier(fixture.payload) + + +async def test_new_open_tx_verifier_rejects_foreign_recipient() -> None: + """Mirrors the verifier-seam half of + TestNewOpenTxVerifierRejectsForeignRecipientThroughProcessOpen.""" + fixture = build_open_tx_fixture(v0=False) + config = _open_session_config(fixture) + config.recipient = str(fixture.payer.pubkey()) # not the tx payee + verifier = new_open_tx_verifier(config, None) + with pytest.raises(PaymentError, match="payee"): + await verifier(fixture.payload) + + +async def test_new_open_tx_verifier_without_transaction_requires_rpc() -> None: + """Mirrors TestNewOpenTxVerifierWithoutTransactionRequiresRPC.""" + fixture = build_open_tx_fixture(v0=False) + verifier = new_open_tx_verifier(_open_session_config(fixture), None) + fixture.payload.transaction = None + with pytest.raises(PaymentError, match="RPC client"): + await verifier(fixture.payload) + + +async def test_new_open_tx_verifier_without_transaction_confirms_signature() -> None: + """Mirrors TestNewOpenTxVerifierWithoutTransactionConfirmsSignature.""" + fixture = build_open_tx_fixture(v0=False) + verifier = new_open_tx_verifier(_open_session_config(fixture), _FakeRpc()) + fixture.payload.transaction = None + await verifier(fixture.payload) + + +# -- new_top_up_tx_verifier --------------------------------------------------- + + +def test_new_top_up_tx_verifier_none_rpc_disables_the_seam() -> None: + """Mirrors TestNewTopUpTxVerifierNilRPCDisablesTheSeam.""" + assert new_top_up_tx_verifier(None) is None + + +async def test_new_top_up_tx_verifier_confirms_signature() -> None: + """Mirrors TestNewTopUpTxVerifierConfirmsSignature.""" + signature = _kp(20).sign_message(b"top-up") + verifier = new_top_up_tx_verifier(_FakeRpc()) + assert verifier is not None + payload = TopUpPayload(channel_id="chan", new_deposit="2000000", signature=str(signature)) + await verifier(payload) + + +async def test_new_top_up_tx_verifier_surfaces_failure_and_not_found() -> None: + """Mirrors TestNewTopUpTxVerifierSurfacesFailureAndNotFound.""" + signature = str(_kp(21).sign_message(b"top-up")) + fake_rpc = _FakeRpc() + fake_rpc.statuses[signature] = {"err": "InstructionError"} + verifier = new_top_up_tx_verifier(fake_rpc) + assert verifier is not None + payload = TopUpPayload(channel_id="chan", new_deposit="2000000", signature=signature) + with pytest.raises(PaymentError, match="top-up"): + await verifier(payload) + + fake_rpc.statuses[signature] = None + with pytest.raises(PaymentError, match="not found"): + await verifier(payload) + + with pytest.raises(PaymentError, match="invalid top-up tx signature"): + await verifier(TopUpPayload(channel_id="", new_deposit="", signature="not-base58!")) diff --git a/python/tests/test_session_routes.py b/python/tests/test_session_routes.py new file mode 100644 index 000000000..04a288138 --- /dev/null +++ b/python/tests/test_session_routes.py @@ -0,0 +1,290 @@ +"""Metering side-channel route coverage. + +Ports the ``Routes()`` behaviors from ``go/protocols/mpp/server/session_routes.go`` +exercised by ``session_method_test.go`` (``TestSessionRoutesValidation``, +``TestSessionRoutesCommitReplayStatus``, ``TestSessionRoutesShareStoreWithMethod``, +``TestSessionCommitForReservedDelivery``). + +The Go ``Routes()`` is a method on the HTTP-facing ``Session``; the routes only +ever touch the lower-level ``SessionServer`` (``s.core``) plus an idle-close +``touch`` hook, so this port builds the two handlers over a ``SessionServer`` +directly. Handlers are framework-agnostic: each takes the HTTP method and the +raw request body and returns a :class:`RouteResponse` (status + JSON-ready +body), mirroring the existing dict-based server modules. +""" + +from __future__ import annotations + +import json +import time + +from solders.keypair import Keypair # type: ignore[import-untyped] + +from pay_kit.protocols.mpp.intents.session import SignedVoucher, VoucherData +from pay_kit.protocols.mpp.server.session import SessionConfig, SessionServer +from pay_kit.protocols.mpp.server.session_routes import session_routes +from pay_kit.protocols.mpp.server.session_store import MemoryChannelStore + +SESSION_TEST_RECIPIENT = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY" + + +def _config() -> SessionConfig: + return SessionConfig( + operator=SESSION_TEST_RECIPIENT, + recipient=SESSION_TEST_RECIPIENT, + max_cap=10_000_000, + currency="USDC", + decimals=6, + network="localnet", + modes=["push"], + ) + + +class _Signer: + def __init__(self, seed: int = 7) -> None: + self._kp = Keypair.from_seed(bytes([seed] * 32)) + + def address(self) -> str: + return str(self._kp.pubkey()) + + def sign_voucher(self, channel_id: str, cumulative: int, expires_at: int) -> SignedVoucher: + data = VoucherData(channel_id=channel_id, cumulative=str(cumulative), expires_at=expires_at) + return SignedVoucher(data=data, signature=str(self._kp.sign_message(data.message_bytes()))) + + +def _far_future() -> int: + return int(time.time()) + 3600 + + +async def _open(server: SessionServer) -> tuple[_Signer, str]: + from pay_kit.protocols.mpp.intents.session import OpenPayload + + signer = _Signer() + channel_id = str(Keypair().pubkey()) + await server.process_open(OpenPayload.push(channel_id, "1000", signer.address(), "dummy_tx_sig")) + return signer, channel_id + + +def _body(obj: dict) -> str: + return json.dumps(obj) + + +# -- deliveries route -- + + +async def test_deliveries_requires_post() -> None: + """Mirrors the GET deliveries arm of TestSessionRoutesValidation.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.deliveries("GET", _body({"amount": "10", "sessionId": "x"})) + assert resp.status == 405 + + +async def test_deliveries_invalid_body_rejected() -> None: + """Mirrors the invalid-body arm of TestSessionRoutesValidation.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.deliveries("POST", "not-json") + assert resp.status == 400 + assert resp.body["error"] == "invalid request body" + + +async def test_deliveries_missing_session_id_rejected() -> None: + """Mirrors the missing-sessionId arm of TestSessionRoutesValidation.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.deliveries("POST", _body({"amount": "10"})) + assert resp.status == 400 + assert resp.body["error"] == "sessionId required" + + +async def test_deliveries_non_numeric_amount_rejected() -> None: + """Mirrors the non-numeric amount arm of TestSessionRoutesValidation.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.deliveries("POST", _body({"amount": "ten", "sessionId": "x"})) + assert resp.status == 400 + + +async def test_deliveries_zero_amount_rejected() -> None: + """Mirrors the zero-amount arm of TestSessionRoutesValidation.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.deliveries("POST", _body({"amount": "0", "sessionId": "x"})) + assert resp.status == 400 + assert resp.body["error"] == "amount must be positive" + + +async def test_deliveries_unknown_channel_rejected() -> None: + """Mirrors the unknown-channel arm of TestSessionRoutesValidation.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.deliveries("POST", _body({"amount": "10", "sessionId": "ghost"})) + assert resp.status == 400 + + +async def test_deliveries_reserves_and_shares_store() -> None: + """Mirrors TestSessionRoutesShareStoreWithMethod / TestSessionCommitForReservedDelivery reserve.""" + server = SessionServer(_config(), MemoryChannelStore()) + _, channel_id = await _open(server) + routes = session_routes(server) + + resp = await routes.deliveries("POST", _body({"amount": "200", "sessionId": channel_id})) + assert resp.status == 200 + assert resp.body["deliveryId"] == f"{channel_id}:1" + assert resp.body["sequence"] == 1 + assert resp.body["currency"] == "USDC" + assert resp.body["amount"] == "200" + + +async def test_deliveries_touches_session() -> None: + """The Deliveries handler calls touch(sessionId) after a successful reserve.""" + server = SessionServer(_config(), MemoryChannelStore()) + _, channel_id = await _open(server) + touched: list[str] = [] + routes = session_routes(server, touch=touched.append) + + await routes.deliveries("POST", _body({"amount": "100", "sessionId": channel_id})) + assert touched == [channel_id] + + +# -- commit route -- + + +async def test_commit_requires_post() -> None: + """Mirrors the GET commit arm of TestSessionRoutesValidation.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.commit("GET", _body({})) + assert resp.status == 405 + + +async def test_commit_invalid_body_rejected() -> None: + """Mirrors the invalid-body commit arm (session_method_branch_test.go).""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.commit("POST", "not-json") + assert resp.status == 400 + assert resp.body["error"] == "invalid request body" + + +async def test_commit_missing_delivery_id_rejected() -> None: + """Mirrors the missing-deliveryId arm of TestSessionRoutesValidation.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.commit("POST", _body({"voucher": {}})) + assert resp.status == 400 + assert resp.body["error"] == "deliveryId required" + + +async def test_commit_missing_voucher_rejected() -> None: + """Mirrors the missing-voucher arm of TestSessionRoutesValidation.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.commit("POST", _body({"deliveryId": "d-1"})) + assert resp.status == 400 + assert resp.body["error"] == "voucher required" + + +async def test_commit_replay_status() -> None: + """Mirrors TestSessionRoutesCommitReplayStatus.""" + server = SessionServer(_config(), MemoryChannelStore()) + signer, channel_id = await _open(server) + routes = session_routes(server) + + reserve = await routes.deliveries("POST", _body({"amount": "50", "sessionId": channel_id})) + delivery_id = reserve.body["deliveryId"] + voucher = signer.sign_voucher(channel_id, 50, _far_future()) + + commit_body = _body({"deliveryId": delivery_id, "voucher": voucher.to_dict()}) + first = await routes.commit("POST", commit_body) + assert first.status == 200 + assert first.body["status"] == "committed" + assert first.body["amount"] == "50" + + replay = await routes.commit("POST", commit_body) + assert replay.status == 200 + assert replay.body["status"] == "replayed" + + +async def test_commit_touches_session() -> None: + """The Commit handler calls touch(receipt.sessionId) after a successful commit.""" + server = SessionServer(_config(), MemoryChannelStore()) + signer, channel_id = await _open(server) + touched: list[str] = [] + routes = session_routes(server, touch=touched.append) + + reserve = await routes.deliveries("POST", _body({"amount": "50", "sessionId": channel_id})) + delivery_id = reserve.body["deliveryId"] + voucher = signer.sign_voucher(channel_id, 50, _far_future()) + await routes.commit("POST", _body({"deliveryId": delivery_id, "voucher": voucher.to_dict()})) + assert touched[-1] == channel_id + + +# -- strict decode parity -- +# +# Go decodes the request body into a typed struct (sessionDeliveryRequestBody / +# sessionCommitRequestBody) before any processing. A JSON value whose type does +# not match the Go field type fails json.Decode up front, which the handlers map +# to HTTP 400 "invalid request body". The Python port must reject the same +# mismatches at the decode layer, before any store access. Each test below +# pins a divergence where Python was previously too lenient. + + +async def test_deliveries_expires_at_float_rejected() -> None: + """Go expiresAt is int64; a JSON float fails json.Decode -> 400 invalid body.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.deliveries("POST", _body({"amount": "10", "sessionId": "x", "expiresAt": 10.5})) + assert resp.status == 400 + assert resp.body["error"] == "invalid request body" + + +async def test_deliveries_expires_at_numeric_string_rejected() -> None: + """Go expiresAt is int64; a numeric JSON string fails json.Decode -> 400 invalid body.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.deliveries("POST", _body({"amount": "10", "sessionId": "x", "expiresAt": "10"})) + assert resp.status == 400 + assert resp.body["error"] == "invalid request body" + + +async def test_deliveries_expires_at_non_numeric_string_rejected() -> None: + """Go expiresAt is int64; a non-numeric JSON string fails json.Decode -> 400 invalid body, + not a raw ValueError from int().""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.deliveries("POST", _body({"amount": "10", "sessionId": "x", "expiresAt": "soon"})) + assert resp.status == 400 + assert resp.body["error"] == "invalid request body" + + +async def test_deliveries_amount_number_rejected() -> None: + """Go amount is string; a JSON number fails json.Decode -> 400 invalid body.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.deliveries("POST", _body({"amount": 10, "sessionId": "x"})) + assert resp.status == 400 + assert resp.body["error"] == "invalid request body" + + +async def test_deliveries_session_id_number_rejected() -> None: + """Go sessionId is string; a JSON number fails json.Decode -> 400 invalid body, + instead of passing the truthiness guard and hitting the store.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.deliveries("POST", _body({"amount": "10", "sessionId": 5})) + assert resp.status == 400 + assert resp.body["error"] == "invalid request body" + + +async def test_deliveries_delivery_id_number_rejected() -> None: + """Go deliveryId is string; a JSON number fails json.Decode -> 400 invalid body.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.deliveries("POST", _body({"amount": "10", "sessionId": "x", "deliveryId": 7})) + assert resp.status == 400 + assert resp.body["error"] == "invalid request body" + + +async def test_commit_delivery_id_number_rejected() -> None: + """Go commit deliveryId is string; a JSON number fails json.Decode -> 400 invalid body.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + resp = await routes.commit("POST", _body({"deliveryId": 7, "voucher": {}})) + assert resp.status == 400 + assert resp.body["error"] == "invalid request body" + + +async def test_deliveries_expires_at_integer_accepted() -> None: + """A JSON integer expiresAt still decodes (Go int64 accepts integers).""" + server = SessionServer(_config(), MemoryChannelStore()) + _, channel_id = await _open(server) + routes = session_routes(server) + resp = await routes.deliveries( + "POST", _body({"amount": "200", "sessionId": channel_id, "expiresAt": _far_future()}) + ) + assert resp.status == 200 diff --git a/python/tests/test_session_server.py b/python/tests/test_session_server.py new file mode 100644 index 000000000..a7a53b23e --- /dev/null +++ b/python/tests/test_session_server.py @@ -0,0 +1,725 @@ +"""Off-chain session handler coverage. + +Ports ``go/protocols/mpp/server/session_server_test.go``: open, voucher +verification, top-up, delivery begin/commit, close, and challenge-request +building. Each test mirrors a single Go ``Test...`` behavior through the public +:class:`SessionServer` interface. +""" + +from __future__ import annotations + +import time + +import pytest +from solders.keypair import Keypair # type: ignore[import-untyped] + +from pay_kit.protocols.mpp.intents.session import ( + DEFAULT_SESSION_EXPIRES_AT, + ClosePayload, + CommitPayload, + OpenPayload, + SignedVoucher, + TopUpPayload, + VoucherData, + VoucherPayload, +) +from pay_kit.protocols.mpp.server.session import ( + DeliveryRequest, + SessionConfig, + SessionServer, + Split, +) +from pay_kit.protocols.mpp.server.session_store import MemoryChannelStore + +SESSION_TEST_RECIPIENT = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY" + + +def session_test_config() -> SessionConfig: + return SessionConfig( + operator=SESSION_TEST_RECIPIENT, + recipient=SESSION_TEST_RECIPIENT, + max_cap=10_000_000, + currency="USDC", + decimals=6, + network="localnet", + modes=["push"], + ) + + +def new_session_test_server(config: SessionConfig) -> SessionServer: + return SessionServer(config, MemoryChannelStore()) + + +def session_open_payload(channel_id: str, deposit: int, signer: str) -> OpenPayload: + return OpenPayload.push(channel_id, str(deposit), signer, "dummy_tx_sig") + + +class _TestVoucherSigner: + """In-memory Ed25519 keypair for voucher tests. Mirrors Go testVoucherSigner.""" + + def __init__(self, seed: int) -> None: + self._kp = Keypair.from_seed(bytes([seed] * 32)) + + def address(self) -> str: + return str(self._kp.pubkey()) + + def sign_voucher(self, channel_id: str, cumulative: int, expires_at: int) -> SignedVoucher: + data = VoucherData(channel_id=channel_id, cumulative=str(cumulative), expires_at=expires_at) + signature = self._kp.sign_message(data.message_bytes()) + return SignedVoucher(data=data, signature=str(signature)) + + +def _far_future() -> int: + return int(time.time()) + 3600 + + +def _channel_key() -> str: + return str(Keypair().pubkey()) + + +async def _open_test_channel(server: SessionServer, deposit: int) -> tuple[_TestVoucherSigner, str]: + signer = _TestVoucherSigner(seed=7) + channel_id = _channel_key() + await server.process_open(session_open_payload(channel_id, deposit, signer.address())) + return signer, channel_id + + +async def _submit_voucher(server: SessionServer, signer: _TestVoucherSigner, channel_id: str, cumulative: int) -> int: + voucher = signer.sign_voucher(channel_id, cumulative, _far_future()) + return await server.verify_voucher(VoucherPayload(voucher=voucher)) + + +# -- BuildChallengeRequest -- + + +def test_build_challenge_request_canonical_shape() -> None: + """Mirrors TestBuildChallengeRequestCanonicalShape.""" + config = session_test_config() + config.min_voucher_delta = 0 + server = new_session_test_server(config) + + request = server.build_challenge_request(1_000_000) + assert request.cap == "1000000" + assert request.currency == "USDC" + assert request.operator == SESSION_TEST_RECIPIENT + assert request.recipient == SESSION_TEST_RECIPIENT + assert request.decimals == 6 + assert request.network == "localnet" + + wire = request.to_dict() + for absent in ("minVoucherDelta", "modes", "pullVoucherStrategy", "recentBlockhash"): + assert absent not in wire + + +def test_build_challenge_request_clamps_cap_to_max() -> None: + """Mirrors TestBuildChallengeRequestClampsCapToMax.""" + server = new_session_test_server(session_test_config()) + request = server.build_challenge_request(99_000_000) + assert request.cap == "10000000" + + +def test_build_challenge_request_includes_min_voucher_delta_when_positive() -> None: + """Mirrors TestBuildChallengeRequestIncludesMinVoucherDeltaWhenPositive.""" + config = session_test_config() + config.min_voucher_delta = 250 + server = new_session_test_server(config) + request = server.build_challenge_request(1_000) + assert request.min_voucher_delta == "250" + + +def test_build_challenge_request_advertises_pull_mode_and_strategy() -> None: + """Mirrors TestBuildChallengeRequestAdvertisesPullModeAndStrategy.""" + config = session_test_config() + config.modes = ["push", "pull"] + config.pull_voucher_strategy = "clientVoucher" + config.splits = [Split(recipient=SESSION_TEST_RECIPIENT, bps=10)] + server = new_session_test_server(config) + + request = server.build_challenge_request(1_000) + assert len(request.modes) == 2 + assert request.pull_voucher_strategy == "clientVoucher" + assert len(request.splits) == 1 + assert request.splits[0].recipient == SESSION_TEST_RECIPIENT + assert request.splits[0].bps == 10 + + +# -- process_open -- + + +async def test_process_open_stores_state() -> None: + """Mirrors TestProcessOpenStoresState.""" + server = new_session_test_server(session_test_config()) + state = await server.process_open(session_open_payload("chan1", 1_000_000, "signer1")) + assert state.deposit == 1_000_000 + assert state.cumulative == 0 + assert not state.finalized + assert state.authorized_signer == "signer1" + + +async def test_process_open_zero_deposit_rejected() -> None: + """Mirrors TestProcessOpenZeroDepositRejected.""" + server = new_session_test_server(session_test_config()) + with pytest.raises(ValueError): + await server.process_open(session_open_payload("chan1", 0, "signer1")) + + +async def test_process_open_exceeds_cap_rejected() -> None: + """Mirrors TestProcessOpenExceedsCapRejected.""" + server = new_session_test_server(session_test_config()) + with pytest.raises(ValueError): + await server.process_open(session_open_payload("chan1", 20_000_000, "signer1")) + + +async def test_process_open_rejects_unadvertised_pull_mode() -> None: + """Mirrors TestProcessOpenRejectsUnadvertisedPullMode.""" + server = new_session_test_server(session_test_config()) + payload = OpenPayload.payment_channel_with_mode( + "pull", "chan1", "1000000", "payer", SESSION_TEST_RECIPIENT, "mint", 1, 900, "signer1", "pending" + ) + with pytest.raises(ValueError, match="not supported"): + await server.process_open(payload) + + +async def test_process_open_accepts_advertised_pull_client_voucher_channel() -> None: + """Mirrors TestProcessOpenAcceptsAdvertisedPullClientVoucherChannel.""" + config = session_test_config() + config.modes = ["pull"] + config.pull_voucher_strategy = "clientVoucher" + server = new_session_test_server(config) + payload = OpenPayload.payment_channel_with_mode( + "pull", "chan1", "1000000", "payer", SESSION_TEST_RECIPIENT, "mint", 1, 900, "signer1", "pending" + ) + state = await server.process_open(payload) + assert state.channel_id == "chan1" + assert state.deposit == 1_000_000 + assert state.operator == "payer" + + +async def test_process_open_prefers_channel_id_over_token_account() -> None: + """Mirrors TestProcessOpenPrefersChannelIDOverTokenAccount.""" + config = session_test_config() + config.modes = ["pull"] + config.pull_voucher_strategy = "clientVoucher" + server = new_session_test_server(config) + + payload = OpenPayload.pull("token-acct", "1000", "owner", "signer1", "sig") + payload.channel_id = "delegation-pda" + + state = await server.process_open(payload) + assert state.channel_id == "delegation-pda" + assert state.operator == "owner" + + +async def test_process_open_replay_preserves_watermark() -> None: + """Mirrors TestProcessOpenReplayPreservesWatermark.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + await _submit_voucher(server, signer, channel_id, 250) + + replayed = await server.process_open(session_open_payload(channel_id, 1_000_000, signer.address())) + assert replayed.cumulative == 250 + assert replayed.highest_voucher_signature is not None + + +async def test_process_open_replay_with_different_signer_rejected() -> None: + """Mirrors TestProcessOpenReplayWithDifferentSignerRejected.""" + server = new_session_test_server(session_test_config()) + _, channel_id = await _open_test_channel(server, 1_000_000) + + other = _TestVoucherSigner(seed=9) + with pytest.raises(ValueError, match="different authorized signer"): + await server.process_open(session_open_payload(channel_id, 1_000_000, other.address())) + + +async def test_process_open_replay_on_finalized_channel_rejected() -> None: + """Mirrors TestProcessOpenReplayOnFinalizedChannelRejected.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + await server.mark_finalized(channel_id) + with pytest.raises(ValueError, match="finalized"): + await server.process_open(session_open_payload(channel_id, 1_000_000, signer.address())) + + +async def test_process_open_invokes_verify_open_tx_seam_for_push() -> None: + """Mirrors TestProcessOpenInvokesVerifyOpenTxSeamForPush.""" + calls: list[OpenPayload] = [] + + async def verifier(payload: OpenPayload) -> None: + calls.append(payload) + + config = session_test_config() + config.verify_open_tx = verifier + server = new_session_test_server(config) + await server.process_open(session_open_payload("chan1", 1_000, "signer1")) + assert len(calls) == 1 + assert calls[0].signature == "dummy_tx_sig" + + +async def test_process_open_verify_open_tx_error_rejects_without_persisting() -> None: + """Mirrors TestProcessOpenVerifyOpenTxErrorRejectsWithoutPersisting.""" + + async def verifier(_: OpenPayload) -> None: + raise ValueError("tx not found") + + config = session_test_config() + config.verify_open_tx = verifier + server = new_session_test_server(config) + + with pytest.raises(ValueError, match="tx not found"): + await server.process_open(session_open_payload("chan1", 1_000, "signer1")) + state = await server.store().get_channel("chan1") + assert state is None + + +async def test_process_open_skips_verify_open_tx_for_pull() -> None: + """Mirrors TestProcessOpenSkipsVerifyOpenTxForPull.""" + + async def verifier(_: OpenPayload) -> None: + raise AssertionError("verify_open_tx must not run for pull opens") + + config = session_test_config() + config.modes = ["pull"] + config.pull_voucher_strategy = "clientVoucher" + config.verify_open_tx = verifier + server = new_session_test_server(config) + + payload = OpenPayload.pull("token-acct", "1000", "owner", "signer1", "sig") + await server.process_open(payload) + + +# -- verify_voucher -- + + +async def test_verify_voucher_advances_watermark() -> None: + """Mirrors TestVerifyVoucherAdvancesWatermark.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + assert await _submit_voucher(server, signer, channel_id, 100) == 100 + assert await _submit_voucher(server, signer, channel_id, 300) == 300 + + state = await server.store().get_channel(channel_id) + assert state is not None + assert state.cumulative == 300 + assert state.highest_voucher_signature is not None + assert state.highest_voucher_expires_at is not None + + +async def test_verify_voucher_unknown_channel_rejected() -> None: + """Mirrors TestVerifyVoucherUnknownChannelRejected.""" + server = new_session_test_server(session_test_config()) + signer = _TestVoucherSigner(seed=3) + voucher = signer.sign_voucher("11111111111111111111111111111111", 100, _far_future()) + with pytest.raises(ValueError, match="not found"): + await server.verify_voucher(VoucherPayload(voucher=voucher)) + + +async def test_verify_voucher_non_monotonic_rejected() -> None: + """Mirrors TestVerifyVoucherNonMonotonicRejected.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + await _submit_voucher(server, signer, channel_id, 200) + with pytest.raises(ValueError, match="must exceed watermark"): + await _submit_voucher(server, signer, channel_id, 150) + # Equal cumulative with a different signature (different expiry) is not a + # replay and must also be rejected as non-monotonic. + different = signer.sign_voucher(channel_id, 200, _far_future() + 60) + with pytest.raises(ValueError, match="must exceed watermark"): + await server.verify_voucher(VoucherPayload(voucher=different)) + + +async def test_verify_voucher_idempotent_replay_returns_same_cumulative() -> None: + """Mirrors TestVerifyVoucherIdempotentReplayReturnsSameCumulative.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + voucher = signer.sign_voucher(channel_id, 150, _far_future()) + await server.verify_voucher(VoucherPayload(voucher=voucher)) + assert await server.verify_voucher(VoucherPayload(voucher=voucher)) == 150 + + +async def test_verify_voucher_respects_min_voucher_delta() -> None: + """Mirrors TestVerifyVoucherRespectsMinVoucherDelta.""" + config = session_test_config() + config.min_voucher_delta = 100 + server = new_session_test_server(config) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + with pytest.raises(ValueError): + await _submit_voucher(server, signer, channel_id, 50) + assert await _submit_voucher(server, signer, channel_id, 100) == 100 + + +async def test_verify_voucher_accepts_legacy_cumulative_alias() -> None: + """Mirrors TestVerifyVoucherAcceptsLegacyCumulativeAlias.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + signed = signer.sign_voucher(channel_id, 400, _far_future()) + # Re-encode the voucher payload with the legacy "cumulative" wire alias. + wire = { + "voucher": { + "data": {"channelId": channel_id, "cumulative": "400", "expiresAt": signed.data.expires_at}, + "signature": signed.signature, + } + } + payload = VoucherPayload.from_dict(wire) + assert await server.verify_voucher(payload) == 400 + + +# -- process_top_up -- + + +async def test_process_top_up_raises_deposit() -> None: + """Mirrors TestProcessTopUpRaisesDeposit.""" + server = new_session_test_server(session_test_config()) + _, channel_id = await _open_test_channel(server, 1_000_000) + + state = await server.process_top_up( + TopUpPayload(channel_id=channel_id, new_deposit="2000000", signature="topup_sig") + ) + assert state.deposit == 2_000_000 + + +async def test_process_top_up_rejects_non_increasing_deposit() -> None: + """Mirrors TestProcessTopUpRejectsNonIncreasingDeposit.""" + server = new_session_test_server(session_test_config()) + _, channel_id = await _open_test_channel(server, 1_000_000) + + with pytest.raises(ValueError, match="must exceed current deposit"): + await server.process_top_up(TopUpPayload(channel_id=channel_id, new_deposit="1000000", signature="sig")) + + +async def test_process_top_up_rejects_over_max_cap() -> None: + """Mirrors TestProcessTopUpRejectsOverMaxCap.""" + server = new_session_test_server(session_test_config()) + _, channel_id = await _open_test_channel(server, 1_000_000) + + with pytest.raises(ValueError, match="exceeds max cap"): + await server.process_top_up(TopUpPayload(channel_id=channel_id, new_deposit="20000000", signature="sig")) + + +async def test_process_top_up_rejects_when_finalized_or_close_pending() -> None: + """Mirrors TestProcessTopUpRejectsWhenFinalizedOrClosePending.""" + server = new_session_test_server(session_test_config()) + _, channel_id = await _open_test_channel(server, 1_000_000) + await server.process_close(ClosePayload(channel_id=channel_id)) + with pytest.raises(ValueError, match="close is pending"): + await server.process_top_up(TopUpPayload(channel_id=channel_id, new_deposit="2000000", signature="sig")) + + server2 = new_session_test_server(session_test_config()) + _, channel_id2 = await _open_test_channel(server2, 1_000_000) + await server2.mark_finalized(channel_id2) + with pytest.raises(ValueError, match="finalized"): + await server2.process_top_up(TopUpPayload(channel_id=channel_id2, new_deposit="2000000", signature="sig")) + + +async def test_process_top_up_invokes_verify_top_up_tx_seam() -> None: + """Mirrors TestProcessTopUpInvokesVerifyTopUpTxSeam.""" + + async def verifier(payload: TopUpPayload) -> None: + assert payload.signature == "topup_sig" + raise ValueError("topup tx unknown") + + config = session_test_config() + config.verify_top_up_tx = verifier + server = new_session_test_server(config) + _, channel_id = await _open_test_channel(server, 1_000_000) + + with pytest.raises(ValueError, match="topup tx unknown"): + await server.process_top_up(TopUpPayload(channel_id=channel_id, new_deposit="2000000", signature="topup_sig")) + state = await server.store().get_channel(channel_id) + assert state is not None + assert state.deposit == 1_000_000 + + +async def test_voucher_accepted_after_top_up_raises_deposit() -> None: + """Mirrors TestVoucherAcceptedAfterTopUpRaisesDeposit.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000) + + with pytest.raises(ValueError): + await _submit_voucher(server, signer, channel_id, 2_000) + await server.process_top_up(TopUpPayload(channel_id=channel_id, new_deposit="5000", signature="sig")) + assert await _submit_voucher(server, signer, channel_id, 2_000) == 2_000 + + +# -- begin_delivery -- + + +async def test_begin_delivery_assigns_sequence_and_default_delivery_id() -> None: + """Mirrors TestBeginDeliveryAssignsSequenceAndDefaultDeliveryID.""" + server = new_session_test_server(session_test_config()) + _, channel_id = await _open_test_channel(server, 1_000_000) + + first = await server.begin_delivery(DeliveryRequest(session_id=channel_id, amount=100)) + assert first.delivery_id == f"{channel_id}:1" + assert first.sequence == 1 + assert first.amount == "100" + assert first.currency == "USDC" + assert first.session_id == channel_id + assert first.expires_at == DEFAULT_SESSION_EXPIRES_AT + + second = await server.begin_delivery(DeliveryRequest(session_id=channel_id, amount=50)) + assert second.delivery_id == f"{channel_id}:2" + assert second.sequence == 2 + + +async def test_begin_delivery_honors_explicit_fields() -> None: + """Mirrors TestBeginDeliveryHonorsExplicitFields.""" + server = new_session_test_server(session_test_config()) + _, channel_id = await _open_test_channel(server, 1_000_000) + + expires_at = int(time.time()) + 60 + directive = await server.begin_delivery( + DeliveryRequest( + session_id=channel_id, + amount=100, + delivery_id="custom-id", + commit_url="https://example.test/commit", + proof="proof-blob", + expires_at=expires_at, + ) + ) + assert directive.delivery_id == "custom-id" + assert directive.expires_at == expires_at + assert directive.commit_url == "https://example.test/commit" + assert directive.proof == "proof-blob" + + +async def test_begin_delivery_rejects_zero_amount_and_unknown_channel() -> None: + """Mirrors TestBeginDeliveryRejectsZeroAmountAndUnknownChannel.""" + server = new_session_test_server(session_test_config()) + with pytest.raises(ValueError): + await server.begin_delivery(DeliveryRequest(session_id="ghost", amount=0)) + with pytest.raises(ValueError): + await server.begin_delivery(DeliveryRequest(session_id="ghost", amount=5)) + + +async def test_begin_delivery_rejects_duplicate_delivery_id() -> None: + """Mirrors TestBeginDeliveryRejectsDuplicateDeliveryID.""" + server = new_session_test_server(session_test_config()) + _, channel_id = await _open_test_channel(server, 1_000_000) + + await server.begin_delivery(DeliveryRequest(session_id=channel_id, amount=10, delivery_id="dup")) + with pytest.raises(ValueError, match="already exists"): + await server.begin_delivery(DeliveryRequest(session_id=channel_id, amount=10, delivery_id="dup")) + + +async def test_begin_delivery_reservation_math() -> None: + """Mirrors TestBeginDeliveryReservationMath.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000) + + # Advance the watermark to 400 so the reservation has to account for it. + await _submit_voucher(server, signer, channel_id, 400) + # Reserve 500: cumulative 400 + pending 500 = 900 <= 1000. + await server.begin_delivery(DeliveryRequest(session_id=channel_id, amount=500)) + # Reserve 100 more: 400 + 500 + 100 = 1000 <= 1000 (boundary holds). + await server.begin_delivery(DeliveryRequest(session_id=channel_id, amount=100)) + # One more unit must fail: 400 + 600 + 1 > 1000. + with pytest.raises(ValueError, match="exceeds available deposit"): + await server.begin_delivery(DeliveryRequest(session_id=channel_id, amount=1)) + + +async def test_begin_delivery_rejected_when_close_pending() -> None: + """Mirrors TestBeginDeliveryRejectedWhenClosePending.""" + server = new_session_test_server(session_test_config()) + _, channel_id = await _open_test_channel(server, 1_000_000) + await server.process_close(ClosePayload(channel_id=channel_id)) + with pytest.raises(ValueError, match="close is pending"): + await server.begin_delivery(DeliveryRequest(session_id=channel_id, amount=5)) + + +# -- process_commit -- + + +async def test_process_commit_commits_reserved_delivery() -> None: + """Mirrors TestProcessCommitCommitsReservedDelivery.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + directive = await server.begin_delivery(DeliveryRequest(session_id=channel_id, amount=100)) + voucher = signer.sign_voucher(channel_id, 100, _far_future()) + receipt = await server.process_commit(CommitPayload(delivery_id=directive.delivery_id, voucher=voucher)) + assert receipt.status == "committed" + assert receipt.amount == "100" + assert receipt.cumulative == "100" + + state = await server.store().get_channel(channel_id) + assert state is not None + assert state.cumulative == 100 + assert len(state.pending_deliveries) == 0 + assert len(state.committed_deliveries) == 1 + + +async def test_process_commit_replay_returns_cached_receipt() -> None: + """Mirrors TestProcessCommitReplayReturnsCachedReceipt.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + directive = await server.begin_delivery(DeliveryRequest(session_id=channel_id, amount=100)) + voucher = signer.sign_voucher(channel_id, 100, _far_future()) + payload = CommitPayload(delivery_id=directive.delivery_id, voucher=voucher) + + await server.process_commit(payload) + replay = await server.process_commit(payload) + assert replay.status == "replayed" + assert replay.amount == "100" + assert replay.cumulative == "100" + + +async def test_process_commit_replay_with_different_voucher_rejected() -> None: + """Mirrors TestProcessCommitReplayWithDifferentVoucherRejected.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + directive = await server.begin_delivery(DeliveryRequest(session_id=channel_id, amount=200)) + first = signer.sign_voucher(channel_id, 100, _far_future()) + await server.process_commit(CommitPayload(delivery_id=directive.delivery_id, voucher=first)) + + different = signer.sign_voucher(channel_id, 150, _far_future()) + with pytest.raises(ValueError, match="already committed with different voucher"): + await server.process_commit(CommitPayload(delivery_id=directive.delivery_id, voucher=different)) + + +async def test_process_commit_replay_re_verifies_signature() -> None: + """Mirrors TestProcessCommitReplayReVerifiesSignature.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + directive = await server.begin_delivery(DeliveryRequest(session_id=channel_id, amount=100)) + voucher = signer.sign_voucher(channel_id, 100, _far_future()) + await server.process_commit(CommitPayload(delivery_id=directive.delivery_id, voucher=voucher)) + + # Same signature and cumulative, but tampered expiry: the replayed voucher + # no longer verifies and must be rejected. + tampered = SignedVoucher( + data=VoucherData(channel_id=channel_id, cumulative="100", expires_at=voucher.data.expires_at + 1), + signature=voucher.signature, + ) + with pytest.raises(ValueError): + await server.process_commit(CommitPayload(delivery_id=directive.delivery_id, voucher=tampered)) + + +async def test_process_commit_unknown_delivery_rejected() -> None: + """Mirrors TestProcessCommitUnknownDeliveryRejected.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + voucher = signer.sign_voucher(channel_id, 100, _far_future()) + with pytest.raises(ValueError, match="not found"): + await server.process_commit(CommitPayload(delivery_id="ghost", voucher=voucher)) + + +async def test_process_commit_expired_directive_rejected() -> None: + """Mirrors TestProcessCommitExpiredDirectiveRejected.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + directive = await server.begin_delivery( + DeliveryRequest(session_id=channel_id, amount=100, expires_at=int(time.time()) - 10) + ) + voucher = signer.sign_voucher(channel_id, 100, _far_future()) + with pytest.raises(ValueError, match="has expired"): + await server.process_commit(CommitPayload(delivery_id=directive.delivery_id, voucher=voucher)) + + +async def test_process_commit_over_reserved_amount_rejected() -> None: + """Mirrors TestProcessCommitOverReservedAmountRejected.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + directive = await server.begin_delivery(DeliveryRequest(session_id=channel_id, amount=100)) + # The voucher claims 150 against a 100 reservation. + voucher = signer.sign_voucher(channel_id, 150, _far_future()) + with pytest.raises(ValueError, match="exceeds reserved amount"): + await server.process_commit(CommitPayload(delivery_id=directive.delivery_id, voucher=voucher)) + + +# -- process_close -- + + +async def test_process_close_flips_close_pending_and_blocks_further_activity() -> None: + """Mirrors TestProcessCloseFlipsClosePendingAndBlocksFurtherActivity.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + state = await server.process_close(ClosePayload(channel_id=channel_id)) + assert state.close_requested_at is not None + + with pytest.raises(ValueError): + await _submit_voucher(server, signer, channel_id, 100) + with pytest.raises(ValueError): + await server.begin_delivery(DeliveryRequest(session_id=channel_id, amount=1)) + + +async def test_process_close_double_close_rejected() -> None: + """Mirrors TestProcessCloseDoubleCloseRejected.""" + server = new_session_test_server(session_test_config()) + _, channel_id = await _open_test_channel(server, 1_000_000) + + await server.process_close(ClosePayload(channel_id=channel_id)) + with pytest.raises(ValueError, match="close already requested"): + await server.process_close(ClosePayload(channel_id=channel_id)) + + +async def test_process_close_final_voucher_advances_watermark() -> None: + """Mirrors TestProcessCloseFinalVoucherAdvancesWatermark.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + await _submit_voucher(server, signer, channel_id, 100) + final = signer.sign_voucher(channel_id, 500, _far_future()) + state = await server.process_close(ClosePayload(channel_id=channel_id, voucher=final)) + assert state.cumulative == 500 + assert state.highest_voucher_signature == final.signature + + +async def test_process_close_non_monotonic_final_voucher_is_hard_error() -> None: + """Mirrors TestProcessCloseNonMonotonicFinalVoucherIsHardError.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + await _submit_voucher(server, signer, channel_id, 300) + stale = signer.sign_voucher(channel_id, 200, _far_future()) + with pytest.raises(ValueError, match="must exceed watermark"): + await server.process_close(ClosePayload(channel_id=channel_id, voucher=stale)) + + # The failed close must not flip close-pending. + state = await server.store().get_channel(channel_id) + assert state is not None + assert state.close_requested_at is None + assert state.cumulative == 300 + + +async def test_process_close_accepts_replay_of_current_highest_voucher() -> None: + """Mirrors TestProcessCloseAcceptsReplayOfCurrentHighestVoucher.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000_000) + + highest = signer.sign_voucher(channel_id, 300, _far_future()) + await server.verify_voucher(VoucherPayload(voucher=highest)) + state = await server.process_close(ClosePayload(channel_id=channel_id, voucher=highest)) + assert state.close_requested_at is not None + assert state.cumulative == 300 + + +async def test_process_close_final_voucher_exceeding_deposit_rejected() -> None: + """Mirrors TestProcessCloseFinalVoucherExceedingDepositRejected.""" + server = new_session_test_server(session_test_config()) + signer, channel_id = await _open_test_channel(server, 1_000) + + final = signer.sign_voucher(channel_id, 2_000, _far_future()) + with pytest.raises(ValueError, match="exceeds deposit"): + await server.process_close(ClosePayload(channel_id=channel_id, voucher=final)) + + +async def test_process_close_unknown_channel_rejected() -> None: + """Mirrors TestProcessCloseUnknownChannelRejected.""" + server = new_session_test_server(session_test_config()) + with pytest.raises(ValueError, match="not found"): + await server.process_close(ClosePayload(channel_id="ghost")) diff --git a/python/tests/test_session_store.py b/python/tests/test_session_store.py new file mode 100644 index 000000000..93f8bdedc --- /dev/null +++ b/python/tests/test_session_store.py @@ -0,0 +1,310 @@ +"""MemoryChannelStore coverage. + +Mirrors the Go ``session_store_test.go`` behaviors one-for-one: insert-on-missing +updates, prior-write visibility, concurrent update serialization, mutator error +handling (state unchanged, no poisoning), list filtering, delete, finalization, +and clone isolation. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from pay_kit.protocols.mpp.server.session_store import ( + ChannelState, + CommittedDelivery, + ListChannelsFilter, + MemoryChannelStore, + PendingDelivery, +) + + +def _test_channel_state(channel_id: str, deposit: int) -> ChannelState: + return ChannelState( + channel_id=channel_id, + authorized_signer="11111111111111111111111111111111", + deposit=deposit, + ) + + +@pytest.mark.asyncio +async def test_update_channel_inserts_when_missing() -> None: + """Mirrors TestMemoryChannelStoreUpdateChannelInsertsWhenMissing.""" + store = MemoryChannelStore() + + def mutator(current: ChannelState | None) -> ChannelState: + assert current is None + return _test_channel_state("c1", 5) + + result = await store.update_channel("c1", mutator) + assert result.deposit == 5 + + stored = await store.get_channel("c1") + assert stored is not None + assert stored.deposit == 5 + + +@pytest.mark.asyncio +async def test_update_channel_sees_prior_writes() -> None: + """Mirrors TestMemoryChannelStoreUpdateChannelSeesPriorWrites.""" + store = MemoryChannelStore() + + await store.update_channel("c1", lambda _current: _test_channel_state("c1", 1)) + + def mutator(current: ChannelState | None) -> ChannelState: + assert current is not None and current.deposit == 1 + current.deposit = 2 + return current + + nxt = await store.update_channel("c1", mutator) + assert nxt.deposit == 2 + + +@pytest.mark.asyncio +async def test_serializes_concurrent_updates() -> None: + """Mirrors TestMemoryChannelStoreSerializesConcurrentUpdates. + + Fires 50 concurrent increments; each must see the previous value, so the + final cumulative equals the worker count. The per-channel lock makes the + read-modify-write atomic. + """ + store = MemoryChannelStore() + await store.update_channel("c1", lambda _current: _test_channel_state("c1", 1_000_000)) + + workers = 50 + + def increment(current: ChannelState | None) -> ChannelState: + assert current is not None + current.cumulative += 1 + return current + + async def run() -> None: + await store.update_channel("c1", increment) + + await asyncio.gather(*(run() for _ in range(workers))) + + stored = await store.get_channel("c1") + assert stored is not None + assert stored.cumulative == workers + + +@pytest.mark.asyncio +async def test_mutator_error_leaves_state_unchanged() -> None: + """Mirrors TestMemoryChannelStoreMutatorErrorLeavesStateUnchanged. + + A raised mutator error must leave the stored state intact and must not + poison subsequent updates on the same channel. + """ + store = MemoryChannelStore() + + def seed(_current: ChannelState | None) -> ChannelState: + state = _test_channel_state("c1", 1_000_000) + state.cumulative = 7 + return state + + await store.update_channel("c1", seed) + + sentinel = RuntimeError("nope") + + def boom(_current: ChannelState | None) -> ChannelState: + raise sentinel + + with pytest.raises(RuntimeError) as exc: + await store.update_channel("c1", boom) + assert exc.value is sentinel + + stored = await store.get_channel("c1") + assert stored is not None + assert stored.cumulative == 7 + assert stored.deposit == 1_000_000 + + def increment(current: ChannelState | None) -> ChannelState: + assert current is not None + current.cumulative += 1 + return current + + nxt = await store.update_channel("c1", increment) + assert nxt.cumulative == 8 + + +@pytest.mark.asyncio +async def test_list_channels_applies_filters() -> None: + """Mirrors TestMemoryChannelStoreListChannelsAppliesFilters.""" + store = MemoryChannelStore() + + async def must_insert(state: ChannelState) -> None: + await store.update_channel(state.channel_id, lambda _current: state) + + await must_insert(_test_channel_state("a", 1)) + finalized = _test_channel_state("b", 1) + finalized.finalized = True + await must_insert(finalized) + closing = _test_channel_state("c", 1) + closing.close_requested_at = 123 + await must_insert(closing) + + all_channels = await store.list_channels(None) + assert len(all_channels) == 3 + + only_finalized = await store.list_channels(ListChannelsFilter(finalized=True)) + assert len(only_finalized) == 1 + assert only_finalized[0].channel_id == "b" + + close_pending = await store.list_channels(ListChannelsFilter(finalized=False, close_pending=True)) + assert len(close_pending) == 1 + assert close_pending[0].channel_id == "c" + + +@pytest.mark.asyncio +async def test_delete_and_mark_finalized() -> None: + """Mirrors TestMemoryChannelStoreDeleteAndMarkFinalized.""" + store = MemoryChannelStore() + await store.update_channel("c1", lambda _current: _test_channel_state("c1", 1)) + + state = await store.mark_finalized("c1") + assert state.finalized + + stored = await store.get_channel("c1") + assert stored is not None and stored.finalized + + await store.delete_channel("c1") + missing = await store.get_channel("c1") + assert missing is None + + with pytest.raises(KeyError): + await store.mark_finalized("ghost") + + +@pytest.mark.asyncio +async def test_returns_clones() -> None: + """Mirrors TestMemoryChannelStoreReturnsClones. + + Mutating a returned state's optional field or delivery list must not leak + back into the store. + """ + store = MemoryChannelStore() + + def seed(_current: ChannelState | None) -> ChannelState: + state = _test_channel_state("c1", 1) + state.highest_voucher_signature = "sig" + state.pending_deliveries = [PendingDelivery(delivery_id="c1:1", amount=1, sequence=1, expires_at=9)] + return state + + await store.update_channel("c1", seed) + + got = await store.get_channel("c1") + assert got is not None + got.highest_voucher_signature = "tampered" + got.pending_deliveries[0].amount = 99 + + fresh = await store.get_channel("c1") + assert fresh is not None + assert fresh.highest_voucher_signature == "sig" + assert fresh.pending_deliveries[0].amount == 1 + + +# ── JSON null-handling parity with Go nil slices ── + + +def test_from_dict_accepts_null_delivery_lists() -> None: + """A Go-emitted record serializes nil delivery slices to JSON ``null``. + + ``ChannelState.from_dict`` must treat ``null`` for the delivery lists the + same as a missing key or an empty list: an empty list, never ``None``. The + bug iterated ``data.get(key, [])`` which returned ``None`` for an explicit + ``null`` and raised ``TypeError``. + """ + go_record = { + "channel_id": "c1", + "authorized_signer": "signer1", + "deposit": 1_000_000, + "cumulative": 0, + "finalized": False, + "highest_voucher_signature": None, + "highest_voucher_expires_at": None, + "close_requested_at": None, + "operator": None, + "next_delivery_sequence": 0, + "pending_deliveries": None, + "committed_deliveries": None, + } + + state = ChannelState.from_dict(go_record) + assert state.pending_deliveries == [] + assert state.committed_deliveries == [] + + +def test_from_dict_accepts_missing_and_empty_delivery_lists() -> None: + """Missing keys and explicit ``[]`` both decode to an empty list.""" + missing = ChannelState.from_dict({"channel_id": "c1", "authorized_signer": "signer1"}) + assert missing.pending_deliveries == [] + assert missing.committed_deliveries == [] + + empty = ChannelState.from_dict( + { + "channel_id": "c1", + "authorized_signer": "signer1", + "pending_deliveries": [], + "committed_deliveries": [], + } + ) + assert empty.pending_deliveries == [] + assert empty.committed_deliveries == [] + + +def test_to_dict_emits_null_for_empty_delivery_lists() -> None: + """A fresh-open channel has no deliveries; Go serializes its nil slices to + JSON ``null``, so ``to_dict`` must emit ``None`` (not ``[]``) for + byte-for-byte cross-SDK durable records.""" + state = ChannelState(channel_id="c1", authorized_signer="signer1") + + d = state.to_dict() + assert d["pending_deliveries"] is None + assert d["committed_deliveries"] is None + + +def test_to_dict_emits_lists_when_deliveries_present() -> None: + """Non-empty delivery lists still serialize as JSON arrays.""" + state = ChannelState( + channel_id="c1", + authorized_signer="signer1", + pending_deliveries=[PendingDelivery(delivery_id="c1:1", amount=1, sequence=1, expires_at=9)], + committed_deliveries=[CommittedDelivery(delivery_id="c1:1", amount=1, cumulative=1, voucher_signature="sig")], + ) + + d = state.to_dict() + assert d["pending_deliveries"] == [{"deliveryId": "c1:1", "amount": 1, "sequence": 1, "expiresAt": 9}] + assert d["committed_deliveries"] == [ + { + "deliveryId": "c1:1", + "amount": 1, + "cumulative": 1, + "voucherSignature": "sig", + } + ] + + +def test_round_trips_go_style_null_record() -> None: + """A Go-style record with ``null`` delivery lists round-trips: decode then + re-encode reproduces ``null`` for the empty lists, matching Go's nil-slice + serialization.""" + go_record = { + "channel_id": "c1", + "authorized_signer": "signer1", + "deposit": 1_000_000, + "cumulative": 0, + "finalized": False, + "highest_voucher_signature": None, + "highest_voucher_expires_at": None, + "close_requested_at": None, + "operator": None, + "next_delivery_sequence": 0, + "pending_deliveries": None, + "committed_deliveries": None, + } + + re_encoded = ChannelState.from_dict(go_record).to_dict() + assert re_encoded["pending_deliveries"] is None + assert re_encoded["committed_deliveries"] is None diff --git a/python/tests/test_session_stream.py b/python/tests/test_session_stream.py new file mode 100644 index 000000000..3e36ea7b4 --- /dev/null +++ b/python/tests/test_session_stream.py @@ -0,0 +1,139 @@ +"""Round-trips the server-side metered SSE writer through the client metered +SSE decoder (:class:`SseDecoder` + :func:`parse_metered_sse_event`), proving the +emitted frames carry the event names and payloads the metered session clients +consume. + +Port of ``go/protocols/mpp/server/session_stream_test.go``. +""" + +from __future__ import annotations + +import io + +import pytest + +from pay_kit.protocols.mpp.client.http_stream import ( + MeteredSseEvent, + SseDecoder, + parse_metered_sse_event, +) +from pay_kit.protocols.mpp.intents.session import ( + DEFAULT_SESSION_EXPIRES_AT, + MeteringDirective, + MeteringUsage, +) +from pay_kit.protocols.mpp.server.session_stream import ( + new_metered_stream, + new_metered_stream_writer, +) + + +def decode_metered_events(raw: str) -> list[MeteredSseEvent]: + decoder = SseDecoder() + events = decoder.push_chunk(raw.encode("utf-8")) + events.extend(decoder.finish()) + return [parse_metered_sse_event(event) for event in events] + + +class _Recorder: + """Minimal duck-typed HTTP response: a mutable header mapping, a body + buffer, and a flush flag, mirroring ``httptest.ResponseRecorder``.""" + + def __init__(self) -> None: + self.headers: dict[str, str] = {} + self.body = io.StringIO() + self.flushed = False + + def write(self, data: str) -> None: + self.body.write(data) + + def flush(self) -> None: + self.flushed = True + + def body_string(self) -> str: + return self.body.getvalue() + + +def test_metered_stream_splits_multi_line_data() -> None: + """Mirrors Go ``TestMeteredStreamSplitsMultiLineData``: one ``data:`` line + per logical line, terminating blank line, and a clean decoder round-trip.""" + buffer = io.StringIO() + stream = new_metered_stream_writer(buffer) + stream.write_event("note", b"line-1\nline-2") + raw = buffer.getvalue() + assert raw == "event: note\ndata: line-1\ndata: line-2\n\n" + + decoder = SseDecoder() + events = decoder.push_chunk(raw.encode("utf-8")) + assert len(events) == 1 + assert events[0].data == "line-1\nline-2" + + +def test_metered_stream_rejects_empty_data() -> None: + """Mirrors Go ``TestMeteredStreamRejectsEmptyData``: empty data is an error.""" + stream = new_metered_stream_writer(io.StringIO()) + with pytest.raises(ValueError, match="must not be empty"): + stream.write_event("note", None) + + +def test_metered_stream_write_json_marshal_error() -> None: + """Mirrors Go ``TestMeteredStreamWriteJSONMarshalError``: an unserializable + payload surfaces a marshal error.""" + stream = new_metered_stream_writer(io.StringIO()) + with pytest.raises(TypeError): + stream.write_json(lambda: None) + + +def test_metered_stream_done_event_variant() -> None: + """Mirrors Go ``TestMeteredStreamDoneEventVariant``: an explicit ``done`` + event decodes to a single done event.""" + recorder = _Recorder() + stream = new_metered_stream(recorder) + stream.write_done_event() + events = decode_metered_events(recorder.body_string()) + assert len(events) == 1 + assert events[0].kind == "done" + + +def test_metered_stream_round_trips_through_client_decoder() -> None: + """Mirrors Go ``TestMeteredStreamRoundTripsThroughClientDecoder``: an + envelope + usage + done sequence sets the SSE headers, flushes, and decodes + to message / metering / usage / done through the client decoder.""" + recorder = _Recorder() + stream = new_metered_stream(recorder) + + directive = MeteringDirective( + delivery_id="session-1:1", + session_id="session-1", + amount="100", + currency="USDC", + sequence=1, + expires_at=DEFAULT_SESSION_EXPIRES_AT, + ) + usage = MeteringUsage(delivery_id="session-1:1", amount="80") + + stream.write_envelope({"chunk": "A payment channel "}, directive) + stream.write_usage(usage) + stream.write_done() + + assert recorder.headers["Content-Type"] == "text/event-stream" + assert recorder.headers["Cache-Control"] == "no-cache" + assert recorder.flushed + + events = decode_metered_events(recorder.body_string()) + assert len(events) == 4 + + assert events[0].kind == "message" + assert events[0].message == {"chunk": "A payment channel "} + + assert events[1].kind == "metering" + assert events[1].metering is not None + assert events[1].metering.delivery_id == directive.delivery_id + assert events[1].metering.amount == "100" + assert events[1].metering.sequence == 1 + + assert events[2].kind == "usage" + assert events[2].usage is not None + assert events[2].usage.amount == "80" + + assert events[3].kind == "done" diff --git a/python/tests/test_session_voucher.py b/python/tests/test_session_voucher.py new file mode 100644 index 000000000..f08b50b20 --- /dev/null +++ b/python/tests/test_session_voucher.py @@ -0,0 +1,315 @@ +"""Voucher verifier coverage plus adversarial ordering checks. + +Ports ``go/protocols/mpp/server/session_voucher_test.go``. The check sequence +(order and operators) is part of the wire contract and is asserted explicitly. +""" + +from __future__ import annotations + +import time + +from solders.keypair import Keypair # type: ignore[import-untyped] + +from pay_kit.protocols.mpp.intents.session import SignedVoucher, VoucherData +from pay_kit.protocols.mpp.server.session_voucher import ( + ChannelState, + VerifyVoucherArgs, + VoucherRejectReason, + VoucherVerifyStatus, + verify_voucher_for_channel, +) + +TEST_VOUCHER_CHANNEL_ID = "11111111111111111111111111111111" + + +class _TestVoucherSigner: + """In-memory Ed25519 keypair for voucher tests. + + Mirrors the Go ``testVoucherSigner`` helper. + """ + + def __init__(self, seed: int) -> None: + self._kp = Keypair.from_seed(bytes([seed] * 32)) + + def address(self) -> str: + return str(self._kp.pubkey()) + + def sign_voucher(self, channel_id: str, cumulative: int, expires_at: int) -> SignedVoucher: + data = VoucherData( + channel_id=channel_id, + cumulative=str(cumulative), + expires_at=expires_at, + ) + signature = self._kp.sign_message(data.message_bytes()) + return SignedVoucher(data=data, signature=str(signature)) + + +def _far_future() -> int: + return int(time.time()) + 3600 + + +def _voucher_test_state(authorized_signer: str) -> ChannelState: + return ChannelState( + channel_id=TEST_VOUCHER_CHANNEL_ID, + authorized_signer=authorized_signer, + deposit=1_000, + ) + + +def test_verify_voucher_for_channel_happy_path() -> None: + signer = _TestVoucherSigner(1) + state = _voucher_test_state(signer.address()) + expires_at = _far_future() + voucher = signer.sign_voucher(state.channel_id, 100, expires_at) + + result = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=voucher, deposit=state.deposit)) + assert result.status == VoucherVerifyStatus.ACCEPTED + assert result.new_cumulative == 100 + assert result.new_signature == voucher.signature + assert result.new_expires_at == expires_at + + +def test_verify_voucher_for_channel_idempotent_replay() -> None: + signer = _TestVoucherSigner(1) + voucher = signer.sign_voucher(TEST_VOUCHER_CHANNEL_ID, 100, _far_future()) + state = _voucher_test_state(signer.address()) + state.cumulative = 100 + state.highest_voucher_signature = voucher.signature + + result = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=voucher, deposit=1_000)) + assert result.status == VoucherVerifyStatus.REPLAYED + assert result.new_cumulative == 100 + + +def test_verify_voucher_for_channel_replay_re_verifies_signature() -> None: + signer = _TestVoucherSigner(1) + forger = _TestVoucherSigner(2) + # A forged voucher whose signature somehow got persisted as the highest: + # the replay path must still reject it on signature re-verification. + forged = forger.sign_voucher(TEST_VOUCHER_CHANNEL_ID, 100, _far_future()) + state = _voucher_test_state(signer.address()) + state.cumulative = 100 + state.highest_voucher_signature = forged.signature + + result = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=forged, deposit=1_000)) + assert result.status == VoucherVerifyStatus.REJECTED + assert result.reason == VoucherRejectReason.INVALID_SIGNATURE + + +def test_verify_voucher_for_channel_replay_of_expired_voucher_rejected() -> None: + signer = _TestVoucherSigner(1) + past = int(time.time()) - 10 + voucher = signer.sign_voucher(TEST_VOUCHER_CHANNEL_ID, 100, past) + state = _voucher_test_state(signer.address()) + state.cumulative = 100 + state.highest_voucher_signature = voucher.signature + + result = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=voucher, deposit=1_000)) + assert result.status == VoucherVerifyStatus.REJECTED + assert result.reason == VoucherRejectReason.EXPIRED + + +def test_verify_voucher_for_channel_decreasing_cumulative_rejected() -> None: + signer = _TestVoucherSigner(1) + voucher = signer.sign_voucher(TEST_VOUCHER_CHANNEL_ID, 50, _far_future()) + state = _voucher_test_state(signer.address()) + state.cumulative = 100 + + result = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=voucher, deposit=1_000)) + assert result.status == VoucherVerifyStatus.REJECTED + assert result.reason == VoucherRejectReason.CUMULATIVE_NOT_MONOTONIC + + +def test_verify_voucher_for_channel_equal_cumulative_without_matching_signature_rejected() -> None: + signer = _TestVoucherSigner(1) + voucher = signer.sign_voucher(TEST_VOUCHER_CHANNEL_ID, 100, _far_future()) + other_signature = "5J6vbXSpEpGv4VLLqDhuRG6Tbj5n6dgEgvtTwTKpoSjvSwLTW9PSqQc6dpMUDPCvD3KZ5dGsmiTk5jzwYZyD8Xkz" + state = _voucher_test_state(signer.address()) + state.cumulative = 100 + state.highest_voucher_signature = other_signature + + result = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=voucher, deposit=1_000)) + assert result.status == VoucherVerifyStatus.REJECTED + assert result.reason == VoucherRejectReason.CUMULATIVE_NOT_MONOTONIC + + +def test_verify_voucher_for_channel_exceeds_deposit_rejected() -> None: + signer = _TestVoucherSigner(1) + voucher = signer.sign_voucher(TEST_VOUCHER_CHANNEL_ID, 2_000, _far_future()) + state = _voucher_test_state(signer.address()) + + result = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=voucher, deposit=1_000)) + assert result.status == VoucherVerifyStatus.REJECTED + assert result.reason == VoucherRejectReason.EXCEEDS_DEPOSIT + + +def test_verify_voucher_for_channel_below_min_delta_rejected() -> None: + signer = _TestVoucherSigner(1) + voucher = signer.sign_voucher(TEST_VOUCHER_CHANNEL_ID, 5, _far_future()) + state = _voucher_test_state(signer.address()) + + result = verify_voucher_for_channel( + VerifyVoucherArgs(state=state, signed=voucher, deposit=1_000, min_voucher_delta=100) + ) + assert result.status == VoucherVerifyStatus.REJECTED + assert result.reason == VoucherRejectReason.BELOW_MIN_DELTA + + +def test_verify_voucher_for_channel_bad_signature_rejected() -> None: + signer = _TestVoucherSigner(1) + other = _TestVoucherSigner(2) + # Sign with other, but the channel authorizes signer; sig must fail. + voucher = other.sign_voucher(TEST_VOUCHER_CHANNEL_ID, 100, _far_future()) + state = _voucher_test_state(signer.address()) + + result = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=voucher, deposit=1_000)) + assert result.status == VoucherVerifyStatus.REJECTED + assert result.reason == VoucherRejectReason.INVALID_SIGNATURE + + +def test_verify_voucher_for_channel_expired_rejected() -> None: + signer = _TestVoucherSigner(1) + voucher = signer.sign_voucher(TEST_VOUCHER_CHANNEL_ID, 100, int(time.time()) - 10) + state = _voucher_test_state(signer.address()) + + result = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=voucher, deposit=1_000)) + assert result.status == VoucherVerifyStatus.REJECTED + assert result.reason == VoucherRejectReason.EXPIRED + + +def test_verify_voucher_for_channel_finalized_rejected() -> None: + signer = _TestVoucherSigner(1) + voucher = signer.sign_voucher(TEST_VOUCHER_CHANNEL_ID, 100, _far_future()) + state = _voucher_test_state(signer.address()) + state.finalized = True + + result = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=voucher, deposit=1_000)) + assert result.status == VoucherVerifyStatus.REJECTED + assert result.reason == VoucherRejectReason.CHANNEL_FINALIZED + + +def test_verify_voucher_for_channel_close_pending_rejected() -> None: + signer = _TestVoucherSigner(1) + voucher = signer.sign_voucher(TEST_VOUCHER_CHANNEL_ID, 100, _far_future()) + state = _voucher_test_state(signer.address()) + state.close_requested_at = 1 + + result = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=voucher, deposit=1_000)) + assert result.status == VoucherVerifyStatus.REJECTED + assert result.reason == VoucherRejectReason.CHANNEL_CLOSE_PENDING + + +def test_verify_voucher_for_channel_now_seconds_override_is_deterministic() -> None: + signer = _TestVoucherSigner(1) + voucher = signer.sign_voucher(TEST_VOUCHER_CHANNEL_ID, 100, 1_000) + state = _voucher_test_state(signer.address()) + + expired = verify_voucher_for_channel( + VerifyVoucherArgs(state=state, signed=voucher, deposit=1_000, now_seconds=2_000) + ) + assert expired.status == VoucherVerifyStatus.REJECTED + assert expired.reason == VoucherRejectReason.EXPIRED + + fresh = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=voucher, deposit=1_000, now_seconds=500)) + assert fresh.status == VoucherVerifyStatus.ACCEPTED + + +def test_verify_voucher_for_channel_invalid_cumulative_rejected() -> None: + signer = _TestVoucherSigner(1) + real = signer.sign_voucher(TEST_VOUCHER_CHANNEL_ID, 100, _far_future()) + # Tamper the data field after signing; the verifier should reject on parse + # before the signature check. + tampered = SignedVoucher( + data=VoucherData( + channel_id=real.data.channel_id, + cumulative="not-a-number", + expires_at=real.data.expires_at, + ), + signature=real.signature, + ) + state = _voucher_test_state(signer.address()) + + result = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=tampered, deposit=1_000)) + assert result.status == VoucherVerifyStatus.REJECTED + assert result.reason == VoucherRejectReason.INVALID_CUMULATIVE + + +# Ordering checks: each earlier step must win over every later failure present +# in the same voucher. + + +def test_verify_voucher_for_channel_ordering_parse_beats_finalized() -> None: + signer = _TestVoucherSigner(1) + state = _voucher_test_state(signer.address()) + state.finalized = True + voucher = SignedVoucher( + data=VoucherData( + channel_id=state.channel_id, + cumulative="bogus", + expires_at=_far_future(), + ), + signature="sig", + ) + + result = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=voucher, deposit=1_000)) + assert result.reason == VoucherRejectReason.INVALID_CUMULATIVE + + +def test_verify_voucher_for_channel_ordering_finalized_beats_close_pending() -> None: + signer = _TestVoucherSigner(1) + state = _voucher_test_state(signer.address()) + state.finalized = True + state.close_requested_at = 1 + voucher = signer.sign_voucher(state.channel_id, 100, _far_future()) + + result = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=voucher, deposit=1_000)) + assert result.reason == VoucherRejectReason.CHANNEL_FINALIZED + + +def test_verify_voucher_for_channel_ordering_monotonic_beats_deposit() -> None: + signer = _TestVoucherSigner(1) + state = _voucher_test_state(signer.address()) + state.deposit = 10 + state.cumulative = 100 + # Non-monotonic AND over deposit: monotonicity is checked first. + voucher = signer.sign_voucher(state.channel_id, 50, _far_future()) + + result = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=voucher, deposit=10)) + assert result.reason == VoucherRejectReason.CUMULATIVE_NOT_MONOTONIC + + +def test_verify_voucher_for_channel_ordering_deposit_beats_min_delta() -> None: + signer = _TestVoucherSigner(1) + state = _voucher_test_state(signer.address()) + state.deposit = 10 + # Over deposit AND below min delta relative to a large min: deposit wins. + voucher = signer.sign_voucher(state.channel_id, 20, _far_future()) + + result = verify_voucher_for_channel( + VerifyVoucherArgs(state=state, signed=voucher, deposit=10, min_voucher_delta=100) + ) + assert result.reason == VoucherRejectReason.EXCEEDS_DEPOSIT + + +def test_verify_voucher_for_channel_ordering_min_delta_beats_signature() -> None: + signer = _TestVoucherSigner(1) + other = _TestVoucherSigner(2) + state = _voucher_test_state(signer.address()) + # Below min delta AND wrongly signed: min delta is checked first. + voucher = other.sign_voucher(state.channel_id, 5, _far_future()) + + result = verify_voucher_for_channel( + VerifyVoucherArgs(state=state, signed=voucher, deposit=1_000, min_voucher_delta=100) + ) + assert result.reason == VoucherRejectReason.BELOW_MIN_DELTA + + +def test_verify_voucher_for_channel_ordering_signature_beats_expiry() -> None: + signer = _TestVoucherSigner(1) + other = _TestVoucherSigner(2) + state = _voucher_test_state(signer.address()) + # Wrongly signed AND expired: the signature is verified before expiry. + voucher = other.sign_voucher(state.channel_id, 100, int(time.time()) - 10) + + result = verify_voucher_for_channel(VerifyVoucherArgs(state=state, signed=voucher, deposit=1_000)) + assert result.reason == VoucherRejectReason.INVALID_SIGNATURE From cf02db3a09a3c18ebddd4e7108b171ddcad8f4dd Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Sat, 13 Jun 2026 15:31:32 +0300 Subject: [PATCH 17/52] feat(python): playground-api example (FastAPI port of Go #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. --- python/examples/playground_api/README.md | 118 +++++ python/examples/playground_api/__init__.py | 0 python/examples/playground_api/app.py | 400 ++++++++++++++++ python/examples/playground_api/charges.py | 449 ++++++++++++++++++ python/examples/playground_api/constants.py | 31 ++ python/examples/playground_api/docs.py | 153 ++++++ python/examples/playground_api/faucet.py | 72 +++ python/examples/playground_api/main.py | 83 ++++ python/examples/playground_api/sessions.py | 312 ++++++++++++ .../examples/playground_api/subscriptions.py | 32 ++ python/examples/playground_api/utils.py | 77 +++ python/examples/playground_api/x402.py | 147 ++++++ python/examples/playground_api/yahoo.py | 426 +++++++++++++++++ python/pyproject.toml | 2 + python/uv.lock | 21 +- 15 files changed, 2322 insertions(+), 1 deletion(-) create mode 100644 python/examples/playground_api/README.md create mode 100644 python/examples/playground_api/__init__.py create mode 100644 python/examples/playground_api/app.py create mode 100644 python/examples/playground_api/charges.py create mode 100644 python/examples/playground_api/constants.py create mode 100644 python/examples/playground_api/docs.py create mode 100644 python/examples/playground_api/faucet.py create mode 100644 python/examples/playground_api/main.py create mode 100644 python/examples/playground_api/sessions.py create mode 100644 python/examples/playground_api/subscriptions.py create mode 100644 python/examples/playground_api/utils.py create mode 100644 python/examples/playground_api/x402.py create mode 100644 python/examples/playground_api/yahoo.py diff --git a/python/examples/playground_api/README.md b/python/examples/playground_api/README.md new file mode 100644 index 000000000..cc6f26992 --- /dev/null +++ b/python/examples/playground_api/README.md @@ -0,0 +1,118 @@ +# playground-api (Python) + +The Python port of [`go/examples/playground-api`](../../../go/examples/playground-api/), +the HTTP API behind the [pay-kit playground](../../../playground/). It serves +the same endpoints with the same payment gating semantics against the Solana +Payment Sandbox (a hosted test validator, no real funds): + +- **Charges**: `solana.charge` endpoints (stock quote, marketplace purchase + with multi-recipient splits, fortune payment link) gated through the + `pay_kit` umbrella surface, plus a faucet that funds wallets through surfpool + cheatcodes. +- **Sessions**: the in-process MPP session method gating `/sessions/stream` + (pay-per-chunk SSE) and `/sessions/compute` (pay-per-call), with voucher + metering through the `/__402/session/*` side channel and on-chain settlement + via the idle-close watchdog. +- **x402**: two `exact`-scheme demo routes plus the embedded facilitator + endpoints. +- `/api/v1/config`: the endpoint catalog and wallet/network metadata the web + app renders. + +This is the application skeleton: the boot wiring, shared config/state, the +free endpoints (health, config, faucet, docs, SPA, the subscription 501 stub), +and the `register_*` seam the charge / session / x402 feature endpoints plug +into. The feature endpoints are implemented separately. + +## Running + +```sh +pip install -e ".[playground]" +python -m examples.playground_api.main # listens on :3000 +``` + +or directly through uvicorn (no surfnet funding): + +```sh +uvicorn examples.playground_api.app:app --port 3000 +``` + +## Pointing the playground at this server + +Set `PAYKIT_PLAYGROUND_API_URL` and the playground's `pnpm dev` skips launching +its own server; the web app's dev proxy targets this one instead: + +```sh +# terminal 1: the Python API +cd python && PORT=3210 python -m examples.playground_api.main + +# terminal 2: UI only, proxied to the running API +cd playground +PAYKIT_PLAYGROUND_API_URL=http://localhost:3210 pnpm dev +``` + +## Environment variables + +| Variable | Default | Purpose | +|----------|---------|---------| +| `PORT` | `3000` | Listen port | +| `NETWORK` | `localnet` | Solana network tag for MPP / x402 challenges | +| `RPC_URL` | `https://402.surfnet.dev:8899` | Surfpool RPC endpoint (hosted sandbox by default) | +| `RECIPIENT` | (auto-generated) | Solana address that receives payments | +| `FEE_PAYER_KEY` | (auto-generated) | Base58 fee-payer keypair (server signs as fee payer) | +| `MPP_SECRET_KEY` | (random per-boot) | MPP secret key for challenge HMAC | +| `DOCS_ROOT` | (repo `docs/api`) | Overrides the generated-docs directory outside a checkout | + +## Endpoints + +| Method | Path | Gate | +|--------|------|------| +| GET | `/api/v1/health` | free | +| GET | `/api/v1/config` | free | +| 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 | +| POST | `/facilitator/verify`, `/facilitator/settle` | free | +| GET | `/x402/joke`, `/x402/fact` | x402 exact, $0.001 | + +As in the Go example, the stocks-search / stocks-history / weather / fortune +and `/x402/*` routes stay live server-side but are not advertised in the +`/api/v1/config` nav catalog. + +## Differences from the other playground APIs + +Nothing is silently dropped; where the Python SDK lacks a capability the closest +faithful behavior is served and listed here: + +1. **Subscriptions**: the Python SDK does not implement the + `solana.subscription` server method yet, so there is no plan bootstrap and + `GET /api/v1/premium/feed` answers `501 {"error":"not_implemented"}`. The + endpoint catalog omits the subscription entry, so the playground UI renders + its graceful empty state. + +## Layout + +Mirrors the Go module structure as files of one Python package: + +``` +app.py # boot wiring (AppState, create_app), the register_* seam, + # /api/v1/{health,config}, faucet, CORS, SPA, subscription stub +main.py # entrypoint: surfnet funding + uvicorn +docs.py # generated-docs browser with path-escape guard +constants.py # example-specific constants (faucet amounts, USDC decimals/mint) +utils.py # rpcCall, env helpers, receipt logging, JSON error body +``` diff --git a/python/examples/playground_api/__init__.py b/python/examples/playground_api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/examples/playground_api/app.py b/python/examples/playground_api/app.py new file mode 100644 index 000000000..3c8ed3ddd --- /dev/null +++ b/python/examples/playground_api/app.py @@ -0,0 +1,400 @@ +# examples/playground_api/app.py +"""The HTTP API behind the pay-kit playground (FastAPI). + +Serves the playground endpoints with their payment gating (MPP charges through +the ``pay_kit`` umbrella surface, x402 through the x402 adapter, sessions +through the MPP session method), so the playground web app works against it by +only setting ``PAYKIT_PLAYGROUND_API_URL``. + + cd python + uvicorn examples.playground_api.app:app --port 3000 + +Environment: PORT, NETWORK, RPC_URL, RECIPIENT, FEE_PAYER_KEY, MPP_SECRET_KEY. +See README.md for the full table. + +This module owns the boot wiring (:class:`AppState`, :func:`create_app`) and +the free endpoints (health, config catalog, faucet, docs, SPA, the +subscription 501 stub). The charge / session / x402 feature endpoints plug into +the ``register_*`` seam in :func:`create_app` and are filled in separately. +""" + +from __future__ import annotations + +import logging +import os +import secrets +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import FileResponse, JSONResponse, Response +from solders.keypair import Keypair + +import pay_kit +from pay_kit._paycore.network import Network +from pay_kit.signer import LocalSigner + +from .docs import build_docs_router, find_repo_root +from .faucet import register_faucet +from .sessions import register_sessions +from .utils import env_or, json_error, rpc_call +from .x402 import register_x402 + +logger = logging.getLogger("playground-api") + +# Payment headers the browser playground reads off cross-origin responses. +_EXPOSE_HEADERS = "www-authenticate, payment-receipt, x-payment-required, x-payment-response" + + +@dataclass +class AppState: + """Boot configuration shared by every module. + + Mirrors the Go example's ``app`` struct: the raw network tag, RPC endpoint, + settlement recipient, MPP challenge-binding secret, the operator fee-payer + keypair, and the repository root (``None`` outside a checkout, which + disables the docs default root and the SPA file server). + """ + + network: str + rpc_url: str + recipient: str + secret_key: str + fee_payer: LocalSigner + repo_root: str | None = None + port: int = 3000 + + @property + def fee_payer_pubkey(self) -> str: + """Base58 operator pubkey used as the fee payer and health label.""" + return self.fee_payer.pubkey() + + @property + def pay_kit_network(self) -> Network: + """The :class:`Network` enum for the raw ``NETWORK`` tag. + + Surfpool localnet clones mainnet state, so the bare ``localnet`` / + ``devnet`` / ``mainnet`` tags map onto the ``solana_*`` enum members. + """ + return _parse_network(self.network) + + +def _parse_network(tag: str) -> Network: + """Map a raw NETWORK tag (localnet | devnet | mainnet) onto the enum.""" + normalized = tag.strip().lower() + if normalized in ("localnet", "solana_localnet"): + return Network.SOLANA_LOCALNET + if normalized in ("devnet", "solana_devnet"): + return Network.SOLANA_DEVNET + if normalized in ("mainnet", "mainnet-beta", "solana_mainnet"): + return Network.SOLANA_MAINNET + raise ValueError(f"unknown NETWORK tag: {tag!r}") + + +def _random_hex_secret() -> str: + """Generate the per-boot challenge HMAC secret used when MPP_SECRET_KEY is + unset. + """ + return secrets.token_hex(32) + + +def state_from_env() -> AppState: + """Build :class:`AppState` from the environment, matching the Go example's + ``main`` defaults. + """ + network = env_or("NETWORK", "localnet") + # Default to the hosted Solana Payment Sandbox so the playground works + # zero-config: it has the payment-channels program preloaded and supports + # the surfnet cheatcodes the faucet uses. Override RPC_URL to point at a + # local surfpool when you need offline iteration. + rpc_url = env_or("RPC_URL", "https://402.surfnet.dev:8899") + secret_key = os.getenv("MPP_SECRET_KEY") or _random_hex_secret() + port = int(env_or("PORT", "3000")) + + raw_fee_payer = os.getenv("FEE_PAYER_KEY") + fee_payer = LocalSigner.from_base58(raw_fee_payer) if raw_fee_payer else LocalSigner.from_keypair(Keypair()) + recipient = env_or("RECIPIENT", fee_payer.pubkey()) + + return AppState( + network=network, + rpc_url=rpc_url, + recipient=recipient, + secret_key=secret_key, + fee_payer=fee_payer, + repo_root=find_repo_root(), + port=port, + ) + + +# --- endpoint catalog (drives the playground web app's sidebar) ------------- + + +def build_endpoint_list() -> list[dict[str, Any]]: + """Build the ``/api/v1/config`` endpoint catalog. + + The subscription entry is omitted because the Python SDK has no subscription + server method yet (see README.md); the stocks-search / stocks-history / + weather / fortune / x402 routes stay live server-side but are not advertised + in the nav. + """ + return [ + { + "id": "stocks-quote", + "primitive": "charge", + "method": "GET", + "path": "/api/v1/stocks/quote/:symbol", + "title": "Stock quote", + "description": "Real-time price for a single ticker.", + "cost": "0.01 USDC", + "params": [{"name": "symbol", "default": "AAPL"}], + }, + { + "id": "marketplace-buy", + "primitive": "charge", + "method": "GET", + "path": "/api/v1/marketplace/buy/:productId", + "title": "Marketplace purchase", + "description": "Multi-recipient split (seller + platform + referral).", + "cost": "varies", + "params": [ + {"name": "productId", "default": "sol-hoodie"}, + {"name": "referrer", "default": ""}, + ], + }, + { + "id": "sessions-stream", + "primitive": "session", + "method": "GET", + "path": "/sessions/stream", + "title": "Metered stream", + "description": "Pay-per-chunk SSE delivery via session vouchers.", + "cost": "0.0001 USDC / chunk", + "unitPrice": "100", + }, + { + "id": "sessions-compute", + "primitive": "session", + "method": "POST", + "path": "/sessions/compute", + "title": "Pay-per-call compute", + "description": "Voucher-billed inference; cap 0.50 USDC per session.", + "cost": "0.005 USDC / call", + "unitPrice": "5000", + }, + ] + + +# --- free modules: health / config, faucet, subscriptions stub -------------- + + +def register_health_and_config(app: FastAPI, state: AppState) -> None: + """Mount the health check and the endpoint catalog that drives the + playground web app's sidebar. + """ + + @app.get("/api/v1/health") + async def health() -> JSONResponse: + body: dict[str, Any] = { + "ok": True, + "feePayer": state.fee_payer_pubkey, + "recipient": state.recipient, + "network": state.network, + "rpcUrl": state.rpc_url, + } + try: + result = await rpc_call(state.rpc_url, "getBalance", [state.fee_payer_pubkey]) + value = result.get("value") if isinstance(result, dict) else result + if value is not None: + body["feePayerBalance"] = float(value) / 1e9 + except Exception: + # Best-effort balance: omitted when the RPC is unreachable. + pass + return JSONResponse(body) + + @app.get("/api/v1/config") + async def config() -> JSONResponse: + return JSONResponse( + { + "recipient": state.recipient, + "network": state.network, + "rpcUrl": state.rpc_url, + "feePayer": state.fee_payer_pubkey, + "endpoints": build_endpoint_list(), + } + ) + + +def register_subscriptions(app: FastAPI) -> None: + """Mount the documented subscription stub. + + The Python SDK does not implement the ``solana.subscription`` server method + yet, so this keeps the ``/api/v1/premium/feed`` route (nothing is silently + dropped) and answers 501 with an explicit pointer at the gap. The endpoint + catalog omits the subscription entry, so the playground UI renders its + graceful empty state. See README.md. Implemented in :mod:`subscriptions`. + """ + from .subscriptions import register_subscriptions as _register + + _register(app) + + +# --- feature-module registration seam --------------------------------------- +# +# The charge / session / x402 endpoints plug in here. Each registrar takes the +# app and shared state, mounts its routes, and (for sessions) returns a shutdown +# hook. They are intentionally thin stubs in the skeleton and are implemented +# separately. + + +def register_charges(app: FastAPI, state: AppState) -> None: + """Mount the MPP charge-gated endpoints (stocks, weather, marketplace, + fortune). Implemented in :mod:`charges`. + """ + from .charges import register_charges as _register + + _register(app, state) + + +# --- SPA + CORS ------------------------------------------------------------- + + +def register_spa(app: FastAPI, repo_root: str | None) -> None: + """Serve the built playground web app (``playground/app/dist`` at the repo + root) with an index.html catch-all. + + Registered last so the API routes win; the catch-all only sees paths no + earlier route matched. + """ + dist = str(Path(repo_root) / "playground" / "app" / "dist") if repo_root else "" + + @app.get("/{full_path:path}") + async def spa(full_path: str) -> Response: + if dist: + candidate = Path(dist) / full_path + if candidate.is_file(): + return FileResponse(str(candidate)) + index = Path(dist) / "index.html" + if index.is_file(): + return FileResponse(str(index)) + return JSONResponse( + json_error("not found (build playground/app to serve the web app from this server)"), + status_code=404, + ) + + +def _install_cors(app: FastAPI) -> None: + """Set permissive origins and expose the payment headers the web app reads. + + Hand-rolled rather than ``CORSMiddleware`` so the exposed-headers list and + the reflected preflight request headers match the Go example byte for byte. + """ + + @app.middleware("http") + async def cors(request: Request, call_next: Any) -> Response: + if request.method == "OPTIONS": + response: Response = Response(status_code=204) + response.headers["Access-Control-Allow-Methods"] = "GET,HEAD,PUT,PATCH,POST,DELETE" + requested = request.headers.get("Access-Control-Request-Headers") + if requested: + response.headers["Access-Control-Allow-Headers"] = requested + else: + response = await call_next(request) + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Expose-Headers"] = _EXPOSE_HEADERS + return response + + +# --- payment handlers ------------------------------------------------------- + + +def _install_payment_handlers(app: FastAPI) -> None: + """Echo MPP settlement headers onto gated success responses and render + guard ``HTTPException`` bodies as the bare ``{"error": ...}`` shape. + + The umbrella ``RequirePayment`` dependency stashes the verified payment's + settlement headers on ``request.state`` (caveat: it raises a 402 + ``HTTPException`` with the challenge headers/body itself, which FastAPI + serves directly, so only the success-path receipt echo needs wiring here). + Guard dependencies raise ``HTTPException`` with a ``json_error`` dict detail; + Starlette would wrap that as ``{"detail": {...}}``, so unwrap it to match + the Go example's body byte for byte. + """ + from starlette.exceptions import HTTPException as StarletteHTTPException + + @app.middleware("http") + async def _settlement_headers(request: Request, call_next: Any) -> Response: + response: Response = await call_next(request) + settlement = getattr(request.state, "paykit_settlement_headers", None) + if isinstance(settlement, dict): + for name, value in settlement.items(): + response.headers[name] = value + return response + + @app.exception_handler(StarletteHTTPException) + async def _http_exception_handler(_request: Request, exc: StarletteHTTPException) -> Response: + detail = exc.detail + body = detail if isinstance(detail, dict) else {"error": detail} + return JSONResponse(body, status_code=exc.status_code, headers=getattr(exc, "headers", None)) + + +# --- app factory ------------------------------------------------------------ + + +def create_app(state: AppState | None = None) -> FastAPI: + """Wire every module onto one FastAPI app. + + Split from the entrypoint so a smoke test can boot the full route table + against a stub RPC without binding a real port or funding accounts. The + sessions shutdown hook is registered as an ASGI shutdown handler. + """ + if state is None: + state = state_from_env() + + # Configure the umbrella surface so the charge / session registrars share + # one boot. Preflight is skipped here: the live-RPC check belongs to the + # entrypoint's boot path, not the app factory the smoke test reuses. + pay_kit.configure( + network=state.pay_kit_network, + rpc_url=state.rpc_url, + accept=pay_kit.Protocol.MPP, + preflight=False, + operator=pay_kit.Operator( + recipient=state.recipient, + signer=state.fee_payer, + ), + mpp=pay_kit.MppConfig( + realm="PayKit Playground", + challenge_binding_secret=state.secret_key, + ), + ) + + app = FastAPI(title="PayKit Playground (Python)") + _install_cors(app) + _install_payment_handlers(app) + + register_health_and_config(app, state) + register_faucet(app, state) + app.include_router(build_docs_router(state)) + + register_charges(app, state) + register_subscriptions(app) + sessions_shutdown = register_sessions(app, state) + register_x402(app, state) + + # The SPA catch-all must register after every API route. + register_spa(app, state.repo_root) + + @app.on_event("shutdown") + async def _shutdown() -> None: + sessions_shutdown() + yahoo = getattr(app.state, "playground_yahoo", None) + if yahoo is not None: + await yahoo.aclose() + + return app + + +# 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() diff --git a/python/examples/playground_api/charges.py b/python/examples/playground_api/charges.py new file mode 100644 index 000000000..90152d897 --- /dev/null +++ b/python/examples/playground_api/charges.py @@ -0,0 +1,449 @@ +# examples/playground_api/charges.py +"""Charge-gated endpoints: stock data, weather, a marketplace purchase with +multi-recipient splits (all gated through the ``pay_kit`` umbrella surface), +and the fortune payment link served straight from the protocol-layer MPP +server with the HTML challenge page enabled. The 402 challenge fires before +any upstream fetch. + +Mirrors the Go example's ``charges.go``. The umbrella :func:`RequirePayment` +dependency stands in for Go's ``client.RequireFunc``: a per-request gate +builder reads the path/query and returns a fully-validated :class:`Gate`. +Guard dependencies (404 unknown city/product, 400 missing query) are declared +ahead of the payment dependency so they fire before the 402, matching the Go +middleware ordering. + +The fortune route drops to the protocol layer (``protocols.mpp.server.Mpp`` +with HTML enabled) the same way the Go example does, because the umbrella +dispatcher renders the cross-SDK JSON challenge body and dropping down a layer +is the intended escape hatch for the interactive payment page. +""" + +from __future__ import annotations + +import random +from dataclasses import dataclass +from decimal import Decimal +from typing import Any + +from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response + +import pay_kit +from pay_kit import Gate, Price, usd +from pay_kit._paycore.errors import PaymentError +from pay_kit._paycore.rpc import SolanaRpc +from pay_kit._paycore.store import MemoryStore +from pay_kit.fastapi import RequirePayment, payment +from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization +from pay_kit.protocols.mpp.intents.charge import ChargeRequest +from pay_kit.protocols.mpp.server import ( + ChargeOptions, + Mpp, + accepts_html, + challenge_to_html, + is_service_worker_request, + service_worker_js, +) +from pay_kit.protocols.mpp.server import ( + Config as MppConfig, +) + +from . import constants +from .utils import json_error, log_tx +from .yahoo import YahooClient, YahooError + +# --- canned demo data ------------------------------------------------------- + + +@dataclass(frozen=True) +class _WeatherInfo: + """The canned per-city weather payload (whole-degree C, label, % humidity).""" + + temperature: int + conditions: str + humidity: int + + +# The canned weather demo table. +_WEATHER_BY_CITY: dict[str, _WeatherInfo] = { + "san-francisco": _WeatherInfo(15, "Foggy", 85), + "new-york": _WeatherInfo(22, "Partly Cloudy", 60), + "london": _WeatherInfo(12, "Rainy", 90), + "tokyo": _WeatherInfo(26, "Sunny", 55), + "paris": _WeatherInfo(18, "Overcast", 70), + "sydney": _WeatherInfo(24, "Clear", 45), + "berlin": _WeatherInfo(10, "Cloudy", 75), + "dubai": _WeatherInfo(38, "Sunny", 30), +} + + +@dataclass(frozen=True) +class _Product: + """One marketplace catalog entry. + + ``price`` is the seller's list price in USD; the platform and referral + basis-point fees are charged on top of it, not carved out of it. ``seller`` + is the base58 wallet that receives the list price as the charge's primary + pay-to recipient. + """ + + name: str + price: Price + seller: str + description: str + + +# The canned marketplace catalog. +_PRODUCTS: dict[str, _Product] = { + "sol-hoodie": _Product( + name="Solana Hoodie", + price=usd("2.00"), + seller="7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", + description="Premium Solana-branded hoodie", + ), + "validator-mug": _Product( + name="Validator Mug", + price=usd("1.00"), + seller="7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", + description="Ceramic mug for node operators", + ), + "nft-sticker-pack": _Product( + name="NFT Sticker Pack", + price=usd("0.50"), + seller="7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", + description="Holographic sticker collection", + ), +} + +_PLATFORM_FEE_BPS = 500 # 5% +_REFERRAL_FEE_BPS = 200 # 2% + +# The canned fortune-cookie pool. +_FORTUNES = [ + "A beautiful, smart, and loving person will be coming into your life.", + "A faithful friend is a strong defense.", + "A golden egg of opportunity falls into your lap this month.", + "All your hard work will soon pay off.", + "Curiosity kills boredom. Nothing can kill curiosity.", + "Every day in your life is a special occasion.", + "Good news will come to you by mail.", + "If you continually give, you will continually have.", +] + + +def _bps(price: Price, basis_points: int) -> Price: + """Return the given basis-point percentage of a price, e.g. + ``_bps(usd 2.00, 500)`` is ``usd 0.10``. + """ + return usd((price.amount * Decimal(basis_points)) / Decimal(10_000)) + + +def _display_usd(price: Price) -> str: + """Render a price as the playground's two-decimal USDC label.""" + return f"{price.amount:.2f} USDC" + + +def _city_key(city: str) -> str: + """Normalize a city path segment onto the weather table key.""" + return city.lower().replace(" ", "-") + + +# --- registration ----------------------------------------------------------- + + +def register_charges(app: FastAPI, state: Any) -> None: + """Mount every charge-gated endpoint plus the free marketplace catalog. + + ``state`` is the :class:`~examples.playground_api.app.AppState`; typed + loosely to avoid a circular import with the boot module. + """ + platform = state.recipient + accept_default = pay_kit.config().accept + + yahoo = YahooClient() + app.state.playground_yahoo = yahoo # the sessions/app shutdown hook closes it + + def _log_settlement(request: Request) -> None: + """Surface the settlement signature once a gated handler runs.""" + proof = payment(request) + if proof is not None and proof.transaction: + log_tx(request.url.path, proof.transaction) + + # -- guard dependencies (run before the payment gate) -------------------- + + def _require_query(name: str): # noqa: ANN202 (returns a FastAPI dependency) + def guard(request: Request) -> None: + if not request.query_params.get(name): + raise HTTPException(status_code=400, detail=json_error(f"Missing ?{name}= parameter")) + + return guard + + def _require_known_city(city: str) -> None: + if _city_key(city) not in _WEATHER_BY_CITY: + raise HTTPException( + status_code=404, + detail=json_error( + "City not found. Available: san-francisco, new-york, london, " + "tokyo, paris, sydney, berlin, dubai" + ), + ) + + def _require_known_product(productId: str) -> None: # noqa: N803 (mirrors Go path value) + if productId not in _PRODUCTS: + raise HTTPException(status_code=404, detail=json_error("Product not found")) + + # -- gate builders (Go's RequireFunc closures) --------------------------- + + def _static_gate(name: str, amount: str, describe): # noqa: ANN001, ANN202 + def builder(request: Request) -> Gate: + return Gate.build( + name=name, + amount=usd(amount), + description=describe(request), + default_pay_to=platform, + accept_default=accept_default, + ) + + return builder + + def _buy_gate(request: Request) -> Gate: + product = _PRODUCTS[request.path_params["productId"]] # validated by the guard dependency + fee_on_top: dict[str, Price] = {platform: _bps(product.price, _PLATFORM_FEE_BPS)} + referrer = request.query_params.get("referrer", "") + if referrer: + fee_on_top[referrer] = _bps(product.price, _REFERRAL_FEE_BPS) + return Gate.build( + name="marketplaceBuy", + amount=product.price, + pay_to=product.seller, + description=f"Purchase: {product.name}", + fee_on_top=fee_on_top, + default_pay_to=platform, + accept_default=accept_default, + ) + + # -- stocks (Yahoo Finance) ---------------------------------------------- + + gate_stock_quote = RequirePayment( + _static_gate("stockQuote", "0.01", lambda r: f"Stock quote: {r.path_params['symbol']}") + ) + gate_stock_search = RequirePayment( + _static_gate("stockSearch", "0.01", lambda r: f"Stock search: {r.query_params.get('q', '')}") + ) + gate_stock_history = RequirePayment( + _static_gate("stockHistory", "0.05", lambda r: f"Stock history: {r.path_params['symbol']}") + ) + gate_weather = RequirePayment(_static_gate("weather", "0.01", lambda r: f"Weather for {r.path_params['city']}")) + + @app.get("/api/v1/stocks/quote/{symbol}", dependencies=[Depends(gate_stock_quote)]) + async def stock_quote(request: Request, symbol: str) -> Response: + _log_settlement(request) + try: + quote = await yahoo.quote(symbol) + except YahooError: + return JSONResponse(json_error("Failed to fetch quote"), status_code=500) + if quote is None: + # Unknown or delisted symbol: an empty 200 body, the way Express + # serializes res.json(undefined). + return Response(status_code=200, media_type="application/json") + return JSONResponse(quote) + + @app.get( + "/api/v1/stocks/search", + dependencies=[Depends(_require_query("q")), Depends(gate_stock_search)], + ) + async def stock_search(request: Request) -> Response: + _log_settlement(request) + try: + quotes = await yahoo.search(request.query_params.get("q", "")) + except YahooError: + return JSONResponse(json_error("Failed to search"), status_code=500) + return JSONResponse(quotes) + + @app.get("/api/v1/stocks/history/{symbol}", dependencies=[Depends(gate_stock_history)]) + async def stock_history(request: Request, symbol: str) -> Response: + _log_settlement(request) + try: + history = await yahoo.history(symbol, request.query_params.get("range", "")) + except YahooError: + return JSONResponse(json_error("Failed to fetch history"), status_code=500) + return JSONResponse(history) + + # -- weather: unknown cities 404 before the payment gate ----------------- + + @app.get( + "/api/v1/weather/{city}", + dependencies=[Depends(_require_known_city), Depends(gate_weather)], + ) + async def weather(request: Request, city: str) -> Response: + info = _WEATHER_BY_CITY[_city_key(city)] + _log_settlement(request) + return JSONResponse( + { + "city": city, + "temperature": info.temperature, + "conditions": info.conditions, + "humidity": info.humidity, + } + ) + + # -- marketplace: free catalog plus the split purchase ------------------- + + @app.get("/api/v1/marketplace/products") + async def marketplace_products() -> JSONResponse: + listing: list[dict[str, str]] = [] + for product_id in ("sol-hoodie", "validator-mug", "nft-sticker-pack"): + product = _PRODUCTS[product_id] + price_raw = (product.price.amount * (Decimal(10) ** constants.USDC_DECIMALS)).to_integral_value() + listing.append( + { + "id": product_id, + "name": product.name, + "description": product.description, + "price": _display_usd(product.price), + "priceRaw": str(int(price_raw)), + } + ) + return JSONResponse(listing) + + @app.get( + "/api/v1/marketplace/buy/{productId}", + dependencies=[ + Depends(_require_known_product), + Depends(RequirePayment(_buy_gate)), + ], + ) + async def marketplace_buy(request: Request, productId: str) -> Response: # noqa: N803 (mirrors Go path value) + product = _PRODUCTS[productId] + platform_fee = _bps(product.price, _PLATFORM_FEE_BPS) + total = product.price.amount + platform_fee.amount + breakdown: dict[str, str] = { + "seller": _display_usd(product.price), + "platformFee": _display_usd(platform_fee), + } + referrer = request.query_params.get("referrer", "") + if referrer: + referral_fee = _bps(product.price, _REFERRAL_FEE_BPS) + breakdown["referralFee"] = _display_usd(referral_fee) + total += referral_fee.amount + breakdown["total"] = f"{total:.2f} USDC" + _log_settlement(request) + return JSONResponse( + { + "product": product.name, + "breakdown": breakdown, + "status": "purchased", + } + ) + + # -- fortune: a charge payment link with the interactive HTML challenge --- + # + # Stays on the protocol layer directly (Mpp with HTML enabled) because the + # umbrella dispatcher renders the cross-SDK JSON challenge body; dropping + # down a layer is the intended escape hatch. + fortune_mpp = Mpp( + MppConfig( + recipient=state.recipient, + currency="USDC", + decimals=constants.USDC_DECIMALS, + network=state.network, + rpc_url=state.rpc_url, + secret_key=state.secret_key, + html=True, + fee_payer_signer=state.fee_payer, + store=MemoryStore(), + ) + ) + + @app.get("/api/v1/fortune") + async def fortune(request: Request) -> Response: + # The interactive payment page registers its service worker at scope + # "/" from a script served under /api/v1/fortune, which browsers only + # allow with this header. + is_sw = is_service_worker_request(str(request.url)) + sw_header = {"Service-Worker-Allowed": "/"} if is_sw else {} + + challenge = fortune_mpp.charge_with_options("0.01", ChargeOptions(description="Open a fortune cookie")) + + auth_header = request.headers.get("authorization") + if auth_header: + try: + credential = parse_authorization(auth_header) + expected = ChargeRequest.from_dict(challenge.decode_request()) + rpc = SolanaRpc(state.rpc_url) + try: + async with fortune_mpp.using_rpc(rpc): + receipt = await fortune_mpp.verify_credential_with_expected(credential, expected) + finally: + await rpc.aclose() + except PaymentError as err: + return _fortune_challenge(request, challenge, state.network, fortune_mpp.rpc_url, sw_header, err) + except Exception as err: # noqa: BLE001 (framework/parse errors map to a 402) + return _fortune_challenge( + request, + challenge, + state.network, + fortune_mpp.rpc_url, + sw_header, + PaymentError(str(err), code="payment_invalid"), + ) + + fortune_text = random.choice(_FORTUNES) + headers = dict(sw_header) + if receipt.reference: + headers["payment-receipt"] = receipt.reference + log_tx(request.url.path, receipt.reference) + return JSONResponse({"fortune": fortune_text}, headers=headers) + + return _fortune_challenge(request, challenge, state.network, fortune_mpp.rpc_url, sw_header, None) + + +# --- fortune 402 rendering -------------------------------------------------- + + +def _fortune_challenge( + request: Request, + challenge: Any, + network: str, + rpc_url: str, + extra_headers: dict[str, str], + error: PaymentError | None, +) -> Response: + """Render the fortune 402: the service-worker script on the SW request, the + interactive HTML payment page when the client accepts HTML, otherwise the + canonical JSON challenge body. + + Mirrors the protocol-layer ``PaymentMiddleware`` HTML branch the Go example + enables on this route. + """ + www_auth = format_www_authenticate(challenge) + headers = dict(extra_headers) + headers["www-authenticate"] = www_auth + headers["cache-control"] = "no-store" + + if is_service_worker_request(str(request.url)): + # The interactive page fetches its own service worker from this URL. + return PlainTextResponse( + service_worker_js(), + media_type="application/javascript", + headers=headers, + ) + + if accepts_html(request.headers.get("accept")): + body = challenge_to_html(challenge, rpc_url, network) + return HTMLResponse(body, status_code=402, headers=headers) + + code = (error.code if error and error.code else "payment_invalid") or "payment_invalid" + detail = (str(error) if error else "Payment required") or "Payment required" + return JSONResponse( + { + "type": "about:blank", + "title": "Payment Required", + "status": 402, + "code": code, + "error": code, + "detail": detail, + }, + status_code=402, + headers=headers, + ) diff --git a/python/examples/playground_api/constants.py b/python/examples/playground_api/constants.py new file mode 100644 index 000000000..c6dbf1058 --- /dev/null +++ b/python/examples/playground_api/constants.py @@ -0,0 +1,31 @@ +# examples/playground_api/constants.py +"""Example-specific constants. + +Program ids and the USDC mint come straight from ``pay_kit._paycore`` at the +call sites; only knobs without an SDK equivalent live here. Mirrors the Go +example's ``constants.go``. +""" + +from __future__ import annotations + +from pay_kit._paycore.solana import SYSTEM_PROGRAM, TOKEN_PROGRAM, resolve_mint + +# usdc_decimals is the USDC token decimal count. The SDK does not export a +# decimals constant (the umbrella surface, the paycore layer, and the MPP +# protocol adapter all default to 6 internally), so the example pins it locally. +USDC_DECIMALS = 6 + +# sol_fund_lamports is the faucet SOL amount (100 SOL). +SOL_FUND_LAMPORTS = 100_000_000_000 + +# usdc_fund_amount is the faucet USDC amount (100 USDC at 6 decimals). +USDC_FUND_AMOUNT = 100_000_000 + +# usdc_mainnet_mint is the canonical USDC mint. Surfpool clones mainnet state, +# so localnet charges and the faucet settle against the mainnet mint. +USDC_MAINNET_MINT = resolve_mint("USDC", "mainnet") + +# system_program / token_program are re-exported from paycore so the faucet +# cheatcode payloads always agree with the SDK's wire values. +SYSTEM_PROGRAM_ID = SYSTEM_PROGRAM +TOKEN_PROGRAM_ID = TOKEN_PROGRAM diff --git a/python/examples/playground_api/docs.py b/python/examples/playground_api/docs.py new file mode 100644 index 000000000..3ebfe04dc --- /dev/null +++ b/python/examples/playground_api/docs.py @@ -0,0 +1,153 @@ +# examples/playground_api/docs.py +"""Serves the generated API reference markdown from ``/docs/api``, +with a path-escape guard. Override the root with the ``DOCS_ROOT`` env var when +running the app outside the repository checkout. + +Mirrors the Go example's ``docs.go``. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, PlainTextResponse, Response + +from .utils import json_error + +if TYPE_CHECKING: + from .app import AppState + +# doc_langs are the languages the playground docs browser knows about. +DOC_LANGS = ["typescript", "rust", "go", "python", "ruby", "php", "lua", "kotlin", "swift"] + +# Maps a docs language to its justfile recipe suffix. +_DOCS_RECIPE_SLUG = { + "typescript": "ts", + "rust": "rs", + "python": "py", + "ruby": "rb", + "kotlin": "kt", +} + + +def docs_recipe_slug(lang: str) -> str: + """Return the justfile recipe suffix for a docs language.""" + return _DOCS_RECIPE_SLUG.get(lang, lang) + + +def is_doc_lang(lang: str) -> bool: + """Report whether ``lang`` is a known docs language.""" + return lang in DOC_LANGS + + +def docs_root(repo_root: str | None) -> str: + """Resolve the generated-docs directory.""" + override = os.getenv("DOCS_ROOT") + if override: + return override + if not repo_root: + return "" + return str(Path(repo_root) / "docs" / "api") + + +def find_repo_root() -> str | None: + """Walk up from the working directory to the repository root (the directory + containing ``.git`` or the top-level ``justfile``). Returns ``None`` when no + marker is found. + """ + try: + directory = Path.cwd() + except OSError: + return None + while True: + if (directory / ".git").exists() or (directory / "justfile").exists(): + return str(directory) + parent = directory.parent + if parent == directory: + return None + directory = parent + + +def _safe_join(root: str, rel: str) -> str | None: + """Join ``rel`` onto ``root`` and reject any path escaping the root.""" + root_path = Path(root).resolve() + joined = (root_path / rel).resolve() + try: + joined.relative_to(root_path) + except ValueError: + return None + return str(joined) + + +def _build_docs_tree(abs_dir: str, rel_dir: str) -> list[dict[str, Any]]: + """Walk the language docs directory: folders first, then markdown files, + both alphabetical, skipping dotfiles and ``node_modules``. + """ + nodes: list[dict[str, Any]] = [] + for entry in os.scandir(abs_dir): + name = entry.name + if name.startswith(".") or name == "node_modules": + continue + rel_path = f"{rel_dir}/{name}" if rel_dir else name + if entry.is_dir(): + children = _build_docs_tree(entry.path, rel_path) + nodes.append({"name": name, "path": rel_path, "type": "dir", "children": children}) + elif name.endswith(".md"): + nodes.append({"name": name, "path": rel_path, "type": "file"}) + nodes.sort(key=lambda node: (node["type"] != "dir", node["name"])) + return nodes + + +def build_docs_router(state: AppState) -> APIRouter: + """Build the generated-docs browsing endpoints. + + Routes (all free): + - GET /api/v1/docs: per-language availability map + - GET /api/v1/docs/{lang}/tree: the docs file tree + - GET /api/v1/docs/{lang}/file?path=: one markdown file + """ + router = APIRouter() + root = docs_root(state.repo_root) + + @router.get("/api/v1/docs") + async def docs_index() -> JSONResponse: + available = {lang: (Path(root) / lang / "README.md").exists() if root else False for lang in DOC_LANGS} + return JSONResponse({"root": root, "available": available}) + + @router.get("/api/v1/docs/{lang}/tree") + async def docs_tree(lang: str) -> JSONResponse: + if not is_doc_lang(lang): + return JSONResponse(json_error("unknown_lang"), status_code=404) + lang_root = Path(root) / lang + if not lang_root.exists(): + return JSONResponse( + {"error": "not_generated", "hint": f"Run: just docs-{docs_recipe_slug(lang)}"}, + status_code=404, + ) + try: + tree = _build_docs_tree(str(lang_root), "") + except OSError as exc: + return JSONResponse({"error": "tree_failed", "detail": str(exc)}, status_code=500) + return JSONResponse({"lang": lang, "tree": tree}) + + @router.get("/api/v1/docs/{lang}/file") + async def docs_file(lang: str, request: Request) -> Response: + if not is_doc_lang(lang): + return JSONResponse(json_error("unknown_lang"), status_code=404) + rel = request.query_params.get("path") or "README.md" + lang_root = str(Path(root) / lang) + abs_path = _safe_join(lang_root, rel) + if abs_path is None: + return JSONResponse(json_error("unsafe_path"), status_code=400) + if not abs_path.endswith(".md"): + return JSONResponse(json_error("not_markdown"), status_code=400) + try: + content = Path(abs_path).read_text(encoding="utf-8") + except OSError as exc: + return JSONResponse({"error": "not_found", "detail": str(exc)}, status_code=404) + return PlainTextResponse(content, media_type="text/markdown; charset=utf-8") + + return router diff --git a/python/examples/playground_api/faucet.py b/python/examples/playground_api/faucet.py new file mode 100644 index 000000000..5078595f7 --- /dev/null +++ b/python/examples/playground_api/faucet.py @@ -0,0 +1,72 @@ +# examples/playground_api/faucet.py +"""SOL + USDC airdrops via the surfnet cheatcodes. + +Mirrors the Go example's ``faucet.go``: a free status endpoint advertising the +airdrop amounts and an airdrop endpoint that seeds the requested address with +SOL (``surfnet_setAccount``) and USDC (``surfnet_setTokenAccount``). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from . import constants +from .utils import json_error, rpc_call + +if TYPE_CHECKING: + from .app import AppState + + +def register_faucet(app: FastAPI, state: AppState) -> None: + """Mount the faucet status and airdrop endpoints.""" + + @app.get("/api/v1/faucet/status") + async def faucet_status() -> JSONResponse: + return JSONResponse( + { + "solAmount": "100 SOL", + "usdcAmount": "100 USDC", + "usdcMint": constants.USDC_MAINNET_MINT, + } + ) + + @app.post("/api/v1/faucet/airdrop") + async def faucet_airdrop(request: Request) -> JSONResponse: + try: + body = await request.json() + except Exception: + body = None + address = body.get("address") if isinstance(body, dict) else None + if not address: + return JSONResponse(json_error("Missing `address` in request body"), status_code=400) + try: + await rpc_call( + state.rpc_url, + "surfnet_setAccount", + [ + address, + { + "lamports": constants.SOL_FUND_LAMPORTS, + "data": "", + "executable": False, + "owner": constants.SYSTEM_PROGRAM_ID, + "rentEpoch": 0, + }, + ], + ) + await rpc_call( + state.rpc_url, + "surfnet_setTokenAccount", + [ + address, + constants.USDC_MAINNET_MINT, + {"amount": constants.USDC_FUND_AMOUNT, "state": "initialized"}, + constants.TOKEN_PROGRAM_ID, + ], + ) + except Exception as exc: + return JSONResponse({"error": "Airdrop failed", "details": str(exc)}, status_code=500) + return JSONResponse({"ok": True, "sol": "100 SOL", "usdc": "100 USDC"}) diff --git a/python/examples/playground_api/main.py b/python/examples/playground_api/main.py new file mode 100644 index 000000000..0fae93e6b --- /dev/null +++ b/python/examples/playground_api/main.py @@ -0,0 +1,83 @@ +# examples/playground_api/main.py +"""Entrypoint: boot the playground API with best-effort surfnet funding, then +serve it with uvicorn. + + cd python + python -m examples.playground_api.main + +Environment knobs are documented in README.md and resolved in +:func:`app.state_from_env`. +""" + +from __future__ import annotations + +import asyncio +import logging + +from . import constants +from .app import AppState, create_app, state_from_env +from .utils import rpc_call + +logger = logging.getLogger("playground-api") + + +async def bootstrap_funding(state: AppState) -> None: + """Fund the fee payer and recipient on the local surfnet so the demo works + zero-config. + + Best-effort: a warning is logged when the sandbox is unreachable. + """ + try: + await rpc_call( + state.rpc_url, + "surfnet_setAccount", + [ + state.fee_payer_pubkey, + { + "lamports": constants.SOL_FUND_LAMPORTS, + "data": "", + "executable": False, + "owner": constants.SYSTEM_PROGRAM_ID, + "rentEpoch": 0, + }, + ], + ) + await rpc_call( + state.rpc_url, + "surfnet_setTokenAccount", + [ + state.recipient, + constants.USDC_MAINNET_MINT, + {"amount": constants.USDC_FUND_AMOUNT, "state": "initialized"}, + constants.TOKEN_PROGRAM_ID, + ], + ) + except Exception: + logger.warning("Surfpool not reachable; fee payer may not have SOL for fees.") + + +def _log_boot(state: AppState) -> None: + """Print the boot banner mirroring the Go example's startup log.""" + logger.info("PayKit Playground (Python) http://localhost:%d", state.port) + logger.info(" Network %s", state.network) + logger.info(" RPC %s", state.rpc_url) + logger.info(" Recipient %s", state.recipient) + logger.info(" Fee payer %s", state.fee_payer_pubkey) + logger.info(" Plan not bootstrapped (subscriptions are not implemented in the Python SDK)") + logger.info(" Sessions enabled (in-process)") + + +def main() -> None: + """Build state, fund the sandbox, and serve the app.""" + import uvicorn + + logging.basicConfig(level=logging.INFO, format="%(message)s") + state = state_from_env() + asyncio.run(bootstrap_funding(state)) + app = create_app(state) + _log_boot(state) + uvicorn.run(app, host="0.0.0.0", port=state.port, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/python/examples/playground_api/sessions.py b/python/examples/playground_api/sessions.py new file mode 100644 index 000000000..6e73db666 --- /dev/null +++ b/python/examples/playground_api/sessions.py @@ -0,0 +1,312 @@ +# examples/playground_api/sessions.py +"""The two session-gated demo endpoints (FastAPI). + +Both endpoints are driven by the in-process MPP session method +(:func:`pay_kit.protocols.mpp.server.new_session`), the reserve/commit metering +side channel (:func:`pay_kit.protocols.mpp.server.session_routes`), and the +settle-status receipt poll. Both methods share one +:class:`~pay_kit.protocols.mpp.server.MemoryChannelStore` so the receipt +endpoint can read the channel state whichever endpoint opened the channel. + +Ports ``go/examples/playground-api/sessions.go``. The route shapes, handler +bodies, canned token stream, and JSON acks mirror the Go example. + +Differences from the Go example, where the Python SDK port lacks a capability +the closest faithful behavior is served (nothing is silently dropped): + +* The Go example opens channels with ``OpenTxSubmitterServer`` (the server + completes the fee-payer signature and broadcasts the client-built open) and + arms ``CloseDelay`` with ``Signer`` + ``RPC`` so the idle-close watchdog + drives on-chain settle-and-distribute. The Python session method + (:mod:`pay_kit.protocols.mpp.server.session_method`) does not yet port the + server-broadcast open path or the on-chain settlement at close, so this app + uses the default client-broadcast submitter and ``rpc=None`` (the offline + core, which trusts the open signature / deposit as provided). The + ``/sessions/receipt`` poll still resolves from the shared store: it reports + the channel watermark/deposit and a ``settledSignature`` of ``null`` until + the Python settlement path lands. +""" + +from __future__ import annotations + +import asyncio +import datetime +import json +from typing import TYPE_CHECKING, Any + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, StreamingResponse + +from pay_kit._paycore.errors import PaymentError, payment_required_response +from pay_kit.protocols.mpp.core.headers import ( + AUTHORIZATION_HEADER, + PAYMENT_RECEIPT_HEADER, + format_receipt, + format_www_authenticate, + parse_authorization, +) +from pay_kit.protocols.mpp.server import ( + MemoryChannelStore, + RouteResponse, + Session, + SessionChallengeOptions, + SessionOptions, + new_session, + session_routes, +) + +from . import constants +from .utils import json_error, log_payment + +if TYPE_CHECKING: + from .app import AppState + +# token_chunks is the canned token stream payload. Mirrors Go ``tokenChunks``. +TOKEN_CHUNKS = [ + "A payment channel ", + "lets a client and server ", + "authorize many small ", + "off-chain debits ", + "against a single on-chain ", + "deposit, settling the highest ", + "cumulative voucher at close.", +] + + +def _session_gate(session: Session, challenge_options: SessionChallengeOptions): + """Build the session-payment gate for a route, mirroring the Go + ``SessionMiddleware`` behavior inline for FastAPI. + + Returns an async callable taking the request and returning a + ``(credential, receipt)`` pair when payment verifies, or a 402 + :class:`JSONResponse` (with the session challenge in WWW-Authenticate) when + it does not. The challenge is only built when a 402 is actually issued, so + the verify path never prefetches a blockhash. Mirrors Go ``SessionMiddleware``. + """ + + async def gate(request: Request) -> tuple[Any, Any] | JSONResponse: + verification_error: PaymentError | None = None + auth_header = request.headers.get(AUTHORIZATION_HEADER) + if auth_header: + try: + credential = parse_authorization(auth_header) + receipt = await session.verify_credential(credential) + return credential, receipt + except PaymentError as err: + verification_error = err + except Exception as err: # noqa: BLE001 (framework/parse errors) + verification_error = PaymentError(str(err), code="invalid-payload") + + challenge = await session.challenge(challenge_options) + www_auth = format_www_authenticate(challenge) + if verification_error is None: + problem = payment_required_response( + "Payment required", + code="payment_invalid", + challenge_header=www_auth, + ) + else: + problem = payment_required_response( + str(verification_error) or "Payment required", + code=verification_error.code or "payment_invalid", + challenge_header=www_auth, + ) + return JSONResponse( + problem["body"], + status_code=problem["status_code"], + headers=problem["headers"], + ) + + return gate + + +def _receipt_header(receipt: Any) -> dict[str, str]: + """Expose the verified receipt in the Payment-Receipt response header, as + the Go middleware does after a successful verify. Best-effort: a formatting + failure simply omits the header.""" + try: + return {PAYMENT_RECEIPT_HEADER: format_receipt(receipt)} + except Exception: + return {} + + +def _commit_ack(body: dict[str, Any]) -> dict[str, str]: + """Build the minimal CommitReceipt-shaped JSON ack the stream commit handler + returns. Mirrors Go ``commitAck``.""" + amount = body.get("amount") or "0" + return { + "amount": str(amount), + "deliveryId": str(body.get("deliveryId", "")), + "status": "committed", + } + + +async def _route_response(handler: Any, request: Request) -> JSONResponse: + """Adapt a framework-agnostic side-channel :class:`RouteResponse` handler + (method + raw body -> RouteResponse) to a FastAPI ``JSONResponse``.""" + raw = await request.body() + result: RouteResponse = await handler(request.method, raw if raw else b"{}") + return JSONResponse(result.body, status_code=result.status) + + +def register_sessions(app: FastAPI, state: AppState) -> Any: + """Mount the session endpoints and return the watchdog shutdown hook. + + Routes (mirroring Go ``registerSessions``): + + * ``GET /sessions/stream``: pay-per-chunk SSE, cap 1.00 USDC, 0.0001 USDC/chunk + * ``POST /sessions/stream``: voucher commits for the stream endpoint + * ``POST /sessions/compute``: pay-per-call compute, cap 0.50 USDC, 0.005 USDC/call + (also accepts voucher commits) + * ``POST /__402/session/deliveries``: SessionFetch-style delivery reservation + * ``POST /__402/session/commit``: body-voucher commit variant of the above + * ``GET /sessions/receipt/{channel_id}``: settle-status poll for the UI + """ + # Shared store across both session methods so /sessions/receipt can read + # channel state regardless of which endpoint opened the channel. + shared_store = MemoryChannelStore() + + def new_method(cap: int) -> Session: + return new_session( + SessionOptions( + operator=state.fee_payer_pubkey, + recipient=state.recipient, + cap=cap, + currency=constants.USDC_MAINNET_MINT, + decimals=constants.USDC_DECIMALS, + network=state.network, + secret_key=state.secret_key, + # The Python port does not yet support the server-broadcast open + # path (openTxSubmitter=server) or the on-chain settlement at + # close, so this uses the default client-broadcast submitter and + # no RPC client (offline core); see the module docstring. + modes=["pull"], + pull_voucher_strategy="clientVoucher", + store=shared_store, + ) + ) + + stream_session = new_method(1_000_000) # 1.00 USDC + compute_session = new_method(500_000) # 0.50 USDC + + def shutdown() -> None: + stream_session.shutdown() + compute_session.shutdown() + + stream_gate = _session_gate( + stream_session, + SessionChallengeOptions(cap="1000000", description="Metered token stream"), + ) + compute_gate = _session_gate( + compute_session, + SessionChallengeOptions(cap="500000", description="Voucher-billed inference call"), + ) + + # GET /sessions/stream: stream tokens as SSE; each chunk costs 0.0001 USDC. + @app.get("/sessions/stream") + async def sessions_stream_get(request: Request) -> Any: + gated = await stream_gate(request) + if isinstance(gated, JSONResponse): + return gated + _credential, receipt = gated + + async def body() -> Any: + for chunk in TOKEN_CHUNKS: + yield "data: " + json.dumps({"chunk": chunk, "cost": "100"}, separators=(",", ":")) + "\n\n" + await asyncio.sleep(0.08) + yield "data: [DONE]\n\n" + + return StreamingResponse( + body(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + **_receipt_header(receipt), + }, + ) + + # POST /sessions/stream: voucher commits arrive on the URL the session was + # opened against, with the signed voucher in the Authorization credential. + # The gate's verify path applies it; the body is an ack. Mirrors Go. + @app.post("/sessions/stream") + async def sessions_stream_post(request: Request) -> JSONResponse: + gated = await stream_gate(request) + if isinstance(gated, JSONResponse): + return gated + _credential, receipt = gated + try: + body = await request.json() + except Exception: + body = {} + if not isinstance(body, dict): + body = {} + return JSONResponse(_commit_ack(body), headers=_receipt_header(receipt)) + + # POST /sessions/compute: pay-per-call compute; the same handler also accepts + # voucher commits (a deliveryId in the body discriminates). Mirrors Go. + @app.post("/sessions/compute") + async def sessions_compute_post(request: Request) -> JSONResponse: + gated = await compute_gate(request) + if isinstance(gated, JSONResponse): + return gated + _credential, receipt = gated + try: + body = await request.json() + except Exception: + body = {} + if not isinstance(body, dict): + body = {} + delivery_id = str(body.get("deliveryId", "")) + receipt_headers = _receipt_header(receipt) + if delivery_id != "": + return JSONResponse( + {"amount": "0", "deliveryId": delivery_id, "status": "committed"}, + headers=receipt_headers, + ) + log_payment(request.url.path, receipt_headers) + prompt = str(body.get("prompt", "")) + return JSONResponse( + { + "prompt": prompt, + "output": "Echo: " + prompt + " (computed for 0.005 USDC)", + "computedAt": datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + }, + headers=receipt_headers, + ) + + # Side-channel metering routes: SessionFetch-style clients reserve capacity + # for each metered delivery before signing + committing the voucher. Both + # handlers share the methods' channel store. Mirrors Go. + routes = session_routes(stream_session.core(), touch=None) + + @app.post("/__402/session/deliveries") + async def session_deliveries(request: Request) -> JSONResponse: + return await _route_response(routes.deliveries, request) + + @app.post("/__402/session/commit") + async def session_commit(request: Request) -> JSONResponse: + return await _route_response(routes.commit, request) + + # Receipt poll endpoint: the UI hits this after the stream ends to learn the + # on-chain settle signature. With no ported settlement path the settled + # signature stays null; the watermark/deposit are reported from the shared + # store. Mirrors Go's settle-status poll shape. + @app.get("/sessions/receipt/{channel_id}") + async def sessions_receipt(channel_id: str) -> JSONResponse: + if not channel_id: + return JSONResponse(json_error("invalid-channel-id"), status_code=400) + state_channel = await shared_store.get_channel(channel_id) + if state_channel is None: + return JSONResponse(json_error("channel-not-found"), status_code=404) + return JSONResponse( + { + "channelId": state_channel.channel_id, + "cumulative": str(state_channel.cumulative), + "deposit": str(state_channel.deposit), + "finalized": state_channel.finalized, + "settledSignature": state_channel.settled_signature, + } + ) + + return shutdown diff --git a/python/examples/playground_api/subscriptions.py b/python/examples/playground_api/subscriptions.py new file mode 100644 index 000000000..a7697ad9c --- /dev/null +++ b/python/examples/playground_api/subscriptions.py @@ -0,0 +1,32 @@ +# examples/playground_api/subscriptions.py +"""Subscriptions module. The Python SDK does not implement the subscription +server method yet, so this module keeps the ``/api/v1/premium/feed`` route +(nothing is silently dropped) and answers 501 with an explicit pointer at the +gap. The endpoint catalog omits the subscription entry, so the playground UI +renders its graceful empty state. See README.md. + +Mirrors the Go example's ``subscriptions.go``. +""" + +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.responses import JSONResponse + + +def register_subscriptions(app: FastAPI) -> None: + """Mount the documented subscription stub.""" + + @app.get("/api/v1/premium/feed") + async def premium_feed() -> JSONResponse: + return JSONResponse( + { + "error": "not_implemented", + "detail": ( + "The Python SDK does not ship the solana.subscription server method yet; " + "this route exists for parity with go/examples/playground-api and " + "will be gated once the Python subscription intent lands." + ), + }, + status_code=501, + ) diff --git a/python/examples/playground_api/utils.py b/python/examples/playground_api/utils.py new file mode 100644 index 000000000..d6105051d --- /dev/null +++ b/python/examples/playground_api/utils.py @@ -0,0 +1,77 @@ +# examples/playground_api/utils.py +"""Shared helpers: the surfnet JSON-RPC cheatcode caller, the JSON error body, +env lookups, and the settlement / receipt log lines. + +Mirrors the Go example's ``utils.go``. The ANSI color helpers there map onto +the standard ``logging`` records used at boot here. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +import httpx + +from pay_kit.protocols.mpp.core.headers import PAYMENT_RECEIPT_HEADER, parse_receipt + +logger = logging.getLogger("playground-api") + + +def env_or(name: str, fallback: str) -> str: + """Return the environment variable value, or fallback when unset or empty.""" + value = os.getenv(name) + return value if value else fallback + + +async def rpc_call(rpc_url: str, method: str, params: list[Any]) -> Any: + """Perform a JSON-RPC call against the surfnet endpoint and return the raw + result. + + Used for the ``surfnet_*`` cheatcodes the standard RPC client does not + expose (faucet airdrops and the boot-time funding). Raises ``RuntimeError`` + on a JSON-RPC error payload. + """ + payload = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params} + async with httpx.AsyncClient(timeout=8.0) as http: + response = await http.post(rpc_url, json=payload) + response.raise_for_status() + body = response.json() + error = body.get("error") + if error is not None: + raise RuntimeError(f"{method}: {error.get('message', 'rpc error')}") + return body.get("result") + + +def log_tx(path: str, reference: str) -> None: + """Log a settlement-signature link for quick eyeball debugging.""" + studio = env_or("STUDIO_PORT", "18488") + logger.info("ok %s tx: http://localhost:%s/?t=%s", path, studio, reference) + + +def log_payment(path: str, headers: Any) -> None: + """Log the receipt reference from a Payment-Receipt response header, when + present. + + ``headers`` is anything with a case-insensitive ``get`` (e.g. a Starlette + ``Headers`` or a plain dict). + """ + value = headers.get(PAYMENT_RECEIPT_HEADER) + if not value: + return + try: + receipt = parse_receipt(value) + except Exception: + return + if receipt.reference: + log_tx(path, receipt.reference) + + +def json_error(message: str) -> dict[str, str]: + """Build the standard ``{"error": message}`` JSON error body. + + Pair with a FastAPI ``JSONResponse(status_code=..., content=json_error(...))`` + or raise it through ``HTTPException(detail=...)`` at the call site. + """ + return {"error": message} diff --git a/python/examples/playground_api/x402.py b/python/examples/playground_api/x402.py new file mode 100644 index 000000000..82ea7f92b --- /dev/null +++ b/python/examples/playground_api/x402.py @@ -0,0 +1,147 @@ +# examples/playground_api/x402.py +"""The embedded facilitator endpoints plus two x402-gated demo routes. + +The Python x402 adapter only implements self-hosted mode (it verifies and +settles in-process with the operator signer), so the ``/x402/joke`` and +``/x402/fact`` gates here settle locally instead of POSTing to the embedded +facilitator. The facilitator endpoints are still served with the standard +shapes for external x402 clients. See README.md. + +Mirrors the Go example's ``x402.go``. The gated routes are guarded through the +``pay_kit`` umbrella surface with a per-gate ``accept=(Protocol.X402,)``, so the +shared in-process x402 adapter (configured in ``app.create_app`` via the +operator signer / fee payer) issues the 402 challenge and settles the proof. +""" + +from __future__ import annotations + +import random +from typing import TYPE_CHECKING + +from fastapi import Depends, FastAPI, Request +from fastapi.responses import JSONResponse + +import pay_kit +from pay_kit import Gate, Protocol, usd +from pay_kit.fastapi import Payment, RequirePayment, install_exception_handler + +from .utils import rpc_call + +if TYPE_CHECKING: + from .app import AppState + +# jokes is the canned joke pool. +JOKES = [ + "Why do programmers prefer dark mode? Because light attracts bugs.", + "There are 10 types of people: those who understand binary and those who don’t.", + 'A SQL query walks into a bar, sees two tables, and asks: "Can I JOIN you?"', + 'A photon checks into a hotel; the bellhop asks if it has any luggage. "No, I’m traveling light."', +] + +# facts is the canned fun-fact pool. +FACTS = [ + "Honey never spoils. Archaeologists found 3000-year-old honey in Egyptian tombs.", + "Octopuses have three hearts and blue blood.", + 'A group of flamingos is called a "flamboyance".', + "Bananas are berries; strawberries are not.", +] + + +def register_x402(app: FastAPI, state: AppState) -> None: + """Mount the embedded facilitator and the x402-gated routes.""" + # The umbrella exception handler renders the 402 challenge (and settlement + # headers) for the gated routes below; idempotent if already installed. + install_exception_handler(app) + + # --- embedded facilitator ----------------------------------------------- + + @app.get("/facilitator/supported") + async def facilitator_supported() -> JSONResponse: + return JSONResponse( + { + "kinds": [ + { + "scheme": "exact", + "network": "solana-devnet", + "extra": {"feePayer": state.fee_payer_pubkey}, + } + ] + } + ) + + @app.post("/facilitator/verify") + async def facilitator_verify(request: Request) -> JSONResponse: + try: + body = await request.json() + except Exception: + body = None + payment_payload = body.get("paymentPayload") if isinstance(body, dict) else None + payload = payment_payload.get("payload") if isinstance(payment_payload, dict) else None + if not isinstance(payload, dict): + return JSONResponse({"isValid": False, "invalidReason": "Missing payload"}) + payer = "unknown" + authorization = payload.get("authorization") + if isinstance(authorization, dict): + from_field = authorization.get("from") + if isinstance(from_field, str) and from_field: + payer = from_field + return JSONResponse({"isValid": True, "payer": payer}) + + @app.post("/facilitator/settle") + async def facilitator_settle(request: Request) -> JSONResponse: + try: + body = await request.json() + except Exception: + body = None + payment_payload = body.get("paymentPayload") if isinstance(body, dict) else None + payload = payment_payload.get("payload") if isinstance(payment_payload, dict) else None + if not isinstance(payload, dict): + return JSONResponse({"success": False, "errorReason": "Missing payload"}) + transaction = payload.get("transaction") + if not isinstance(transaction, str) or transaction == "": + return JSONResponse({"success": True, "transaction": "local-facilitator-settled"}) + try: + signature = await rpc_call( + state.rpc_url, + "sendTransaction", + [transaction, {"encoding": "base64", "skipPreflight": True}], + ) + except Exception as exc: + return JSONResponse({"success": False, "errorReason": str(exc)}) + return JSONResponse({"success": True, "transaction": signature}) + + # --- x402-gated routes --------------------------------------------------- + # + # A dedicated x402-only config (mirrors the Go example's separate + # ``paykit.New(Accept: [X402])`` client): the process-wide ``configure`` in + # ``app.create_app`` accepts MPP only, so the x402 adapter is not wired on + # the global PayCore. Deriving an x402-accepting copy of that config keeps + # the operator signer / fee payer, network, and RPC, and gates these routes + # through the self-hosted x402 adapter (verify + cosign + broadcast). + x402_config = pay_kit.config().model_copy(update={"accept": (Protocol.X402,)}) + + joke_gate = Gate.build( + name="x402Joke", + amount=usd("0.001"), + description="A random programmer joke", + accept=(Protocol.X402,), + default_pay_to=x402_config.effective_recipient(), + ) + require_joke = Depends(RequirePayment(joke_gate, config=x402_config)) + + @app.get("/x402/joke") + async def x402_joke(payment: Payment = require_joke) -> JSONResponse: + return JSONResponse({"joke": random.choice(JOKES), "source": "x402"}) + + fact_gate = Gate.build( + name="x402Fact", + amount=usd("0.001"), + description="A random fun fact", + accept=(Protocol.X402,), + default_pay_to=x402_config.effective_recipient(), + ) + require_fact = Depends(RequirePayment(fact_gate, config=x402_config)) + + @app.get("/x402/fact") + async def x402_fact(payment: Payment = require_fact) -> JSONResponse: + return JSONResponse({"fact": random.choice(FACTS), "source": "x402"}) diff --git a/python/examples/playground_api/yahoo.py b/python/examples/playground_api/yahoo.py new file mode 100644 index 000000000..8db1febe4 --- /dev/null +++ b/python/examples/playground_api/yahoo.py @@ -0,0 +1,426 @@ +# examples/playground_api/yahoo.py +"""Yahoo Finance client returning the same JSON shapes as the yahoo-finance2 +npm package (v3), which the playground API contract is defined against: the v7 +quote endpoint (crumb-authenticated), the v1 search endpoint, and the v8 chart +endpoint with the package's "array" result layout. + +Epoch-second date fields become ISO-8601 millisecond strings, "low - high" +range strings become {low, high} objects, and chart indicator columns are +zipped into per-day quote rows. Mirrors the Go example's ``yahoo.go``. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import math +import re +from datetime import UTC, datetime +from typing import Any +from urllib.parse import quote as url_quote + +import httpx + +# Sent on every upstream request; Yahoo rejects the default agent on the crumb +# endpoint. +_USER_AGENT = "Mozilla/5.0 (compatible; pay-kit-playground/1.0)" + +# v7 quote fields yahoo-finance2 types as Date (epoch seconds or date strings +# upstream, ISO strings in the response). +_QUOTE_DATE_FIELDS = frozenset( + { + "dividendDate", + "earningsTimestamp", + "earningsTimestampStart", + "earningsTimestampEnd", + "earningsCallTimestampStart", + "earningsCallTimestampEnd", + "expireDate", + "expireIsoDate", + "extendedMarketTime", + "ipoExpectedDate", + "nameChangeDate", + "newListingDate", + "postMarketTime", + "preMarketTime", + "regularMarketTime", + "startDate", + } +) + +# v7 quote fields typed as millisecond dates. +_QUOTE_DATE_MS_FIELDS = frozenset({"firstTradeDateMilliseconds"}) + +# v7 quote fields delivered as "low - high" strings and returned as +# {low, high} objects. +_QUOTE_RANGE_FIELDS = frozenset({"fiftyTwoWeekRange", "regularMarketDayRange"}) + +# search-quote fields typed as dates. +_SEARCH_DATE_FIELDS = frozenset({"newListingDate", "nameChangeDate"}) + +# chart meta fields typed as epoch-second dates. +_CHART_META_DATE_FIELDS = frozenset({"firstTradeDate", "regularMarketTime"}) + +# maps the playground's range parameter onto a day count (unknown ranges fall +# back to 30). +_CHART_RANGE_DAYS = {"1d": 1, "5d": 5, "1mo": 30, "3mo": 90, "6mo": 180, "1y": 365} + +_ISO_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") +_ISO_DATETIME_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$") + + +class YahooError(Exception): + """An upstream Yahoo Finance failure (HTTP, decode, or schema).""" + + +class YahooClient: + """Calls the public Yahoo Finance endpoints, holding the cookie jar and + crumb the v7 quote endpoint requires. + """ + + def __init__(self) -> None: + # A shared cookie jar (httpx persists Set-Cookie across requests on one + # client) backs the crumb and data calls. + self._client = httpx.AsyncClient( + timeout=10.0, + headers={"User-Agent": _USER_AGENT}, + follow_redirects=True, + ) + self._crumb: str | None = None + self._crumb_lock = asyncio.Lock() + + async def aclose(self) -> None: + """Close the underlying HTTP client (the shutdown hook calls this).""" + await self._client.aclose() + + async def _get(self, url: str) -> tuple[bytes, int]: + """Fetch a Yahoo endpoint and return (body, status); raise on non-2xx.""" + response = await self._client.get(url) + body = response.content + if response.status_code != 200: + raise YahooError(f"yahoo finance: HTTP {response.status_code}") + return body, response.status_code + + async def _get_json(self, url: str) -> Any: + """Fetch a Yahoo endpoint and decode the JSON body.""" + body, _ = await self._get(url) + import json + + return json.loads(body) + + async def _get_crumb(self) -> str: + """Return the cached crumb, fetching cookies plus a fresh crumb on first + use. + """ + async with self._crumb_lock: + if self._crumb: + return self._crumb + # Any fc.yahoo.com response sets the session cookie the crumb + # endpoint checks; the 404 body itself is irrelevant, and an HTTP + # error here is expected (the cookie is the point). + with contextlib.suppress(YahooError): + await self._get("https://fc.yahoo.com/") + body, _ = await self._get("https://query1.finance.yahoo.com/v1/test/getcrumb") + crumb = body.decode("utf-8").strip() + if not crumb or "Too Many Requests" in crumb: + raise YahooError("yahoo finance: could not obtain crumb") + self._crumb = crumb + return crumb + + def _invalidate_crumb(self) -> None: + """Drop the cached crumb so the next call refreshes it.""" + self._crumb = None + + async def quote(self, symbol: str) -> dict[str, Any] | None: + """Return the first v7 quote for ``symbol`` with yahoo-finance2's field + coercions applied, or ``None`` when the symbol is unknown or delisted. + """ + crumb = await self._get_crumb() + quote_url = ( + "https://query2.finance.yahoo.com/v7/finance/quote?symbols=" + + url_quote(symbol) + + "&crumb=" + + url_quote(crumb) + ) + try: + body = await self._get_json(quote_url) + except YahooError: + # An expired crumb surfaces as HTTP 401; refresh once and retry. + self._invalidate_crumb() + crumb = await self._get_crumb() + quote_url = ( + "https://query2.finance.yahoo.com/v7/finance/quote?symbols=" + + url_quote(symbol) + + "&crumb=" + + url_quote(crumb) + ) + body = await self._get_json(quote_url) + + finance_error = (body.get("finance") or {}).get("error") if isinstance(body, dict) else None + if isinstance(finance_error, dict) and finance_error.get("description"): + raise YahooError(f"yahoo finance: {finance_error['description']}") + + results = ((body.get("quoteResponse") or {}).get("result") or []) if isinstance(body, dict) else [] + for result in results: + if not isinstance(result, dict): + continue + if result.get("quoteType") == "NONE": + continue + _coerce_quote_fields(result) + return result + return None + + async def search(self, query: str) -> list[dict[str, Any]]: + """Return the search endpoint's quotes array for ``query``, issuing + yahoo-finance2's default request parameters so the result list matches + the package's output. + """ + params = { + "q": query, + "lang": "en-US", + "region": "US", + "quotesCount": "6", + "newsCount": "4", + "enableFuzzyQuery": "false", + "quotesQueryId": "tss_match_phrase_query", + "multiQuoteQueryId": "multi_quote_single_token_query", + "newsQueryId": "news_cie_vespa", + "enableCb": "true", + "enableNavLinks": "true", + "enableEnhancedTrivialQuery": "true", + } + query_string = "&".join(f"{k}={url_quote(v)}" for k, v in params.items()) + search_url = "https://query2.finance.yahoo.com/v1/finance/search?" + query_string + body = await self._get_json(search_url) + quotes = body.get("quotes") or [] if isinstance(body, dict) else [] + for quote in quotes: + if not isinstance(quote, dict): + continue + for field in _SEARCH_DATE_FIELDS: + if field in quote: + quote[field] = _coerce_date(quote[field], in_milliseconds=False) + return quotes + + async def history(self, symbol: str, chart_range: str) -> dict[str, Any]: + """Return the v8 chart result for ``symbol`` over ``chart_range`` in + yahoo-finance2's default "array" layout: the coerced meta object, the + indicator columns zipped into per-day quote rows, and dividend/split + events flattened into arrays. + """ + days = _CHART_RANGE_DAYS.get(chart_range, 30) + now = int(datetime.now(tz=UTC).timestamp()) + period1 = now - days * 24 * 60 * 60 + params = { + "useYfid": "true", + "interval": "1d", + "includePrePost": "true", + "events": "div|split|earn", + "lang": "en-US", + "period1": str(period1), + "period2": str(now), + } + query_string = "&".join(f"{k}={url_quote(v)}" for k, v in params.items()) + chart_url = ( + "https://query2.finance.yahoo.com/v8/finance/chart/" + url_quote(symbol, safe="") + "?" + query_string + ) + body = await self._get_json(chart_url) + chart = body.get("chart") or {} if isinstance(body, dict) else {} + chart_error = chart.get("error") + if isinstance(chart_error, dict) and chart_error.get("description"): + raise YahooError(f"yahoo finance: {chart_error['description']}") + results = chart.get("result") or [] + if not results: + raise YahooError("yahoo finance: empty chart result") + return _chart_to_array_layout(results[0]) + + +def _chart_to_array_layout(result: dict[str, Any]) -> dict[str, Any]: + """Convert one raw v8 chart result into yahoo-finance2's "array" return + shape: {meta, quotes[], events?}. + """ + meta = result.get("meta") if isinstance(result.get("meta"), dict) else {} + _coerce_chart_meta(meta) + out: dict[str, Any] = {"meta": meta, "quotes": []} + + timestamps = result.get("timestamp") or [] + indicators = result.get("indicators") if isinstance(result.get("indicators"), dict) else {} + if timestamps: + quote_columns = _chart_indicator_column(indicators, "quote") + adjclose_columns = _chart_indicator_column(indicators, "adjclose") + adjclose = adjclose_columns.get("adjclose") if adjclose_columns else None + + quotes: list[dict[str, Any]] = [] + for i, timestamp in enumerate(timestamps): + row: dict[str, Any] = { + "date": _coerce_date(timestamp, in_milliseconds=False), + "high": _column_value(quote_columns, "high", i), + "volume": _column_value(quote_columns, "volume", i), + "open": _column_value(quote_columns, "open", i), + "low": _column_value(quote_columns, "low", i), + "close": _column_value(quote_columns, "close", i), + } + if adjclose is not None and i < len(adjclose): + row["adjclose"] = adjclose[i] + quotes.append(row) + out["quotes"] = quotes + + raw_events = result.get("events") + if isinstance(raw_events, dict): + events: dict[str, Any] = {} + for kind in ("dividends", "splits"): + by_timestamp = raw_events.get(kind) + if not isinstance(by_timestamp, dict): + continue + # JS object iteration yields integer-like keys in ascending numeric + # order; Yahoo keys these maps by epoch seconds. + keys = sorted(by_timestamp.keys(), key=lambda k: int(k)) + items: list[Any] = [] + for key in keys: + item = by_timestamp[key] + if isinstance(item, dict) and "date" in item: + item["date"] = _coerce_date(item["date"], in_milliseconds=False) + items.append(item) + events[kind] = items + out["events"] = events + return out + + +def _chart_indicator_column(indicators: dict[str, Any], name: str) -> dict[str, Any] | None: + """Return indicators.[0] as a column map.""" + rows = indicators.get(name) if isinstance(indicators, dict) else None + if not isinstance(rows, list) or not rows: + if name == "quote": + raise YahooError("yahoo finance: chart result missing quote indicators") + return None + columns = rows[0] + return columns if isinstance(columns, dict) else {} + + +def _column_value(columns: dict[str, Any] | None, name: str, i: int) -> Any: + """Return column[i] or None when the column is missing/short.""" + if not columns: + return None + values = columns.get(name) + if not isinstance(values, list) or i >= len(values): + return None + return values[i] + + +def _coerce_chart_meta(meta: dict[str, Any]) -> None: + """Apply yahoo-finance2's date coercions to the chart meta object, + including the nested trading-period blocks. + """ + if not meta: + return + for field in _CHART_META_DATE_FIELDS: + value = meta.get(field) + if value is not None and field in meta: + meta[field] = _coerce_date(value, in_milliseconds=False) + + current = meta.get("currentTradingPeriod") + if isinstance(current, dict): + for key in ("pre", "regular", "post"): + period = current.get(key) + if isinstance(period, dict): + _coerce_trading_period(period) + + periods = meta.get("tradingPeriods") + if isinstance(periods, dict): + for rows in periods.values(): + _coerce_trading_period_rows(rows) + elif isinstance(periods, list): + _coerce_trading_period_rows(periods) + + +def _coerce_trading_period_rows(rows: Any) -> None: + """Coerce a [[tradingPeriod]] nest.""" + if not isinstance(rows, list): + return + for inner in rows: + if not isinstance(inner, list): + continue + for entry in inner: + if isinstance(entry, dict): + _coerce_trading_period(entry) + + +def _coerce_trading_period(period: dict[str, Any]) -> None: + """Coerce one {timezone, start, end, gmtoffset} block.""" + for key in ("start", "end"): + value = period.get(key) + if value is not None and key in period: + period[key] = _coerce_date(value, in_milliseconds=False) + + +def _coerce_quote_fields(result: dict[str, Any]) -> None: + """Apply the quote schema's date and range coercions to one v7 quote result + in place. + """ + for field, value in list(result.items()): + if field in _QUOTE_DATE_FIELDS: + result[field] = _coerce_date(value, in_milliseconds=False) + elif field in _QUOTE_DATE_MS_FIELDS: + result[field] = _coerce_date(value, in_milliseconds=True) + elif field in _QUOTE_RANGE_FIELDS: + result[field] = _coerce_range(value) + + +def _coerce_date(value: Any, *, in_milliseconds: bool) -> str: + """Convert a Yahoo date value (epoch number, {raw} wrapper, or date string) + into the ISO-8601 millisecond string a serialized JS Date produces. + ``in_milliseconds`` flags fields already scaled to milliseconds. + """ + if isinstance(value, bool): + # bool is an int subclass in Python; reject it before the numeric arm. + raise YahooError(f"yahoo finance: unexpected date value {value!r}") + if isinstance(value, (int, float)): + millis = value if in_milliseconds else value * 1000 + return _format_js_date(int(millis)) + if isinstance(value, dict): + raw = value.get("raw") + if isinstance(raw, (int, float)) and not isinstance(raw, bool): + return _format_js_date(int(raw * 1000)) + if isinstance(value, str): + if _ISO_DATE_PATTERN.match(value): + dt = datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=UTC) + return _format_js_date(int(dt.timestamp() * 1000)) + if _ISO_DATETIME_PATTERN.match(value): + normalized = value.replace("Z", "+00:00") + dt = datetime.fromisoformat(normalized) + return _format_js_date(int(dt.timestamp() * 1000)) + raise YahooError(f"yahoo finance: unexpected date value {value!r}") + + +def _format_js_date(unix_milli: int) -> str: + """Render epoch milliseconds the way Date.prototype.toJSON does: UTC with + exactly three fractional digits. + """ + dt = datetime.fromtimestamp(unix_milli / 1000, tz=UTC) + return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{unix_milli % 1000:03d}Z" + + +def _coerce_range(value: Any) -> Any: + """Convert a "low - high" string into the {low, high} object yahoo-finance2 + returns; pre-shaped objects pass through. + """ + if isinstance(value, dict): + return value + if isinstance(value, str): + parts = value.split("-", 1) + if len(parts) == 2: + low = _parse_float_prefix(parts[0]) + high = _parse_float_prefix(parts[1]) + if low is not None and high is not None: + return {"low": low, "high": high} + raise YahooError(f"yahoo finance: unexpected range value {value!r}") + + +def _parse_float_prefix(text: str) -> float | None: + """Parse a float like JS parseFloat: surrounding whitespace is ignored.""" + try: + result = float(text.strip()) + except ValueError: + return None + if math.isnan(result): + return None + return result diff --git a/python/pyproject.toml b/python/pyproject.toml index 9e72177af..d1f4aac19 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -24,6 +24,8 @@ dependencies = [ fastapi = ["fastapi>=0.110"] flask = ["flask>=3"] django = ["django>=4.2"] +# The playground-api example: a FastAPI app served with uvicorn. +playground = ["fastapi>=0.110", "uvicorn>=0.30"] dev = [ "pytest>=8", "pytest-asyncio>=0.24", diff --git a/python/uv.lock b/python/uv.lock index d987f0784..7507669df 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1200,6 +1200,10 @@ fastapi = [ flask = [ { name = "flask" }, ] +playground = [ + { name = "fastapi" }, + { name = "uvicorn" }, +] [package.metadata] requires-dist = [ @@ -1207,6 +1211,7 @@ requires-dist = [ { name = "borsh-construct", specifier = ">=0.1" }, { name = "django", marker = "extra == 'django'", specifier = ">=4.2" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.110" }, + { name = "fastapi", marker = "extra == 'playground'", specifier = ">=0.110" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3" }, { name = "httpx", specifier = ">=0.27" }, { name = "pydantic", specifier = ">=2" }, @@ -1219,8 +1224,9 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8" }, { name = "solana", specifier = ">=0.35" }, { name = "solders", specifier = ">=0.22" }, + { name = "uvicorn", marker = "extra == 'playground'", specifier = ">=0.30" }, ] -provides-extras = ["fastapi", "flask", "django", "dev"] +provides-extras = ["fastapi", "flask", "django", "playground", "dev"] [[package]] name = "solders" @@ -1408,6 +1414,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] + [[package]] name = "watchdog" version = "6.0.0" From e8a897607ab505880c350415969da53b42bb06fb Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Mon, 15 Jun 2026 17:46:12 +0300 Subject: [PATCH 18/52] refactor(python): align playground-api with the TS reference (review) 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. --- python/examples/playground_api/app.py | 232 ++++------- python/examples/playground_api/charges.py | 193 ++++------ python/examples/playground_api/docs.py | 64 +--- python/examples/playground_api/faucet.py | 16 +- python/examples/playground_api/sessions.py | 176 +++------ python/examples/playground_api/x402.py | 97 ++--- python/examples/playground_api/yahoo.py | 426 --------------------- 7 files changed, 272 insertions(+), 932 deletions(-) delete mode 100644 python/examples/playground_api/yahoo.py diff --git a/python/examples/playground_api/app.py b/python/examples/playground_api/app.py index 3c8ed3ddd..f91c53197 100644 --- a/python/examples/playground_api/app.py +++ b/python/examples/playground_api/app.py @@ -13,9 +13,9 @@ See README.md for the full table. This module owns the boot wiring (:class:`AppState`, :func:`create_app`) and -the free endpoints (health, config catalog, faucet, docs, SPA, the -subscription 501 stub). The charge / session / x402 feature endpoints plug into -the ``register_*`` seam in :func:`create_app` and are filled in separately. +the free hub endpoints (health, config catalog). The charge / session / x402 / +faucet / docs / subscription feature endpoints plug into the ``register_*`` seam +in :func:`create_app` and are filled in separately. """ from __future__ import annotations @@ -28,11 +28,14 @@ from typing import Any from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse, Response from solders.keypair import Keypair +from starlette.exceptions import HTTPException as StarletteHTTPException import pay_kit from pay_kit._paycore.network import Network +from pay_kit.fastapi import install_exception_handler from pay_kit.signer import LocalSigner from .docs import build_docs_router, find_repo_root @@ -44,17 +47,17 @@ logger = logging.getLogger("playground-api") # Payment headers the browser playground reads off cross-origin responses. -_EXPOSE_HEADERS = "www-authenticate, payment-receipt, x-payment-required, x-payment-response" +_EXPOSE_HEADERS = ["www-authenticate", "payment-receipt", "x-payment-required", "x-payment-response"] @dataclass class AppState: """Boot configuration shared by every module. - Mirrors the Go example's ``app`` struct: the raw network tag, RPC endpoint, - settlement recipient, MPP challenge-binding secret, the operator fee-payer - keypair, and the repository root (``None`` outside a checkout, which - disables the docs default root and the SPA file server). + Mirrors the TS example's module-level env constants: the raw network tag, + RPC endpoint, settlement recipient, MPP challenge-binding secret, the + operator fee-payer keypair, and the repository root (``None`` outside a + checkout, which disables the docs default root and the SPA file server). """ network: str @@ -77,53 +80,34 @@ def pay_kit_network(self) -> Network: Surfpool localnet clones mainnet state, so the bare ``localnet`` / ``devnet`` / ``mainnet`` tags map onto the ``solana_*`` enum members. """ - return _parse_network(self.network) - - -def _parse_network(tag: str) -> Network: - """Map a raw NETWORK tag (localnet | devnet | mainnet) onto the enum.""" - normalized = tag.strip().lower() - if normalized in ("localnet", "solana_localnet"): - return Network.SOLANA_LOCALNET - if normalized in ("devnet", "solana_devnet"): - return Network.SOLANA_DEVNET - if normalized in ("mainnet", "mainnet-beta", "solana_mainnet"): - return Network.SOLANA_MAINNET - raise ValueError(f"unknown NETWORK tag: {tag!r}") - - -def _random_hex_secret() -> str: - """Generate the per-boot challenge HMAC secret used when MPP_SECRET_KEY is - unset. - """ - return secrets.token_hex(32) + normalized = self.network.strip().lower() + if normalized in ("localnet", "solana_localnet"): + return Network.SOLANA_LOCALNET + if normalized in ("devnet", "solana_devnet"): + return Network.SOLANA_DEVNET + if normalized in ("mainnet", "mainnet-beta", "solana_mainnet"): + return Network.SOLANA_MAINNET + raise ValueError(f"unknown NETWORK tag: {self.network!r}") def state_from_env() -> AppState: - """Build :class:`AppState` from the environment, matching the Go example's - ``main`` defaults. + """Build :class:`AppState` from the environment, matching the TS example's + module-level defaults. """ - network = env_or("NETWORK", "localnet") - # Default to the hosted Solana Payment Sandbox so the playground works - # zero-config: it has the payment-channels program preloaded and supports - # the surfnet cheatcodes the faucet uses. Override RPC_URL to point at a - # local surfpool when you need offline iteration. - rpc_url = env_or("RPC_URL", "https://402.surfnet.dev:8899") - secret_key = os.getenv("MPP_SECRET_KEY") or _random_hex_secret() - port = int(env_or("PORT", "3000")) - raw_fee_payer = os.getenv("FEE_PAYER_KEY") fee_payer = LocalSigner.from_base58(raw_fee_payer) if raw_fee_payer else LocalSigner.from_keypair(Keypair()) - recipient = env_or("RECIPIENT", fee_payer.pubkey()) - return AppState( - network=network, - rpc_url=rpc_url, - recipient=recipient, - secret_key=secret_key, + network=env_or("NETWORK", "localnet"), + # Default to the hosted Solana Payment Sandbox so the playground works + # zero-config: it has the payment-channels program preloaded and + # supports the surfnet cheatcodes the faucet uses. Override RPC_URL to + # point at a local surfpool when you need offline iteration. + rpc_url=env_or("RPC_URL", "https://402.surfnet.dev:8899"), + recipient=env_or("RECIPIENT", fee_payer.pubkey()), + secret_key=os.getenv("MPP_SECRET_KEY") or secrets.token_hex(32), fee_payer=fee_payer, repo_root=find_repo_root(), - port=port, + port=int(env_or("PORT", "3000")), ) @@ -133,10 +117,10 @@ def state_from_env() -> AppState: def build_endpoint_list() -> list[dict[str, Any]]: """Build the ``/api/v1/config`` endpoint catalog. - The subscription entry is omitted because the Python SDK has no subscription - server method yet (see README.md); the stocks-search / stocks-history / - weather / fortune / x402 routes stay live server-side but are not advertised - in the nav. + Declared inline, mirroring the TS ``buildEndpointList``. The subscription + entry is omitted because the Python SDK has no subscription server method + yet (see README.md); the stocks-search / stocks-history / weather / fortune + / x402 routes stay live server-side but are not advertised in the nav. """ return [ { @@ -185,12 +169,15 @@ def build_endpoint_list() -> list[dict[str, Any]]: ] -# --- free modules: health / config, faucet, subscriptions stub -------------- +# --- hub endpoints: health / config ----------------------------------------- + +def register_hub(app: FastAPI, state: AppState) -> None: + """Mount the health check and the endpoint catalog driving the web app's + sidebar. -def register_health_and_config(app: FastAPI, state: AppState) -> None: - """Mount the health check and the endpoint catalog that drives the - playground web app's sidebar. + The RPC url is intentionally omitted from both responses: it is operator + infrastructure that should not be exposed to the browser. """ @app.get("/api/v1/health") @@ -200,7 +187,6 @@ async def health() -> JSONResponse: "feePayer": state.fee_payer_pubkey, "recipient": state.recipient, "network": state.network, - "rpcUrl": state.rpc_url, } try: result = await rpc_call(state.rpc_url, "getBalance", [state.fee_payer_pubkey]) @@ -218,33 +204,18 @@ async def config() -> JSONResponse: { "recipient": state.recipient, "network": state.network, - "rpcUrl": state.rpc_url, "feePayer": state.fee_payer_pubkey, "endpoints": build_endpoint_list(), } ) -def register_subscriptions(app: FastAPI) -> None: - """Mount the documented subscription stub. - - The Python SDK does not implement the ``solana.subscription`` server method - yet, so this keeps the ``/api/v1/premium/feed`` route (nothing is silently - dropped) and answers 501 with an explicit pointer at the gap. The endpoint - catalog omits the subscription entry, so the playground UI renders its - graceful empty state. See README.md. Implemented in :mod:`subscriptions`. - """ - from .subscriptions import register_subscriptions as _register - - _register(app) - - # --- feature-module registration seam --------------------------------------- # -# The charge / session / x402 endpoints plug in here. Each registrar takes the -# app and shared state, mounts its routes, and (for sessions) returns a shutdown -# hook. They are intentionally thin stubs in the skeleton and are implemented -# separately. +# The charge / session / subscription endpoints plug in here. faucet, docs, and +# x402 register from their own modules directly in create_app. Each registrar +# takes the app and shared state and mounts its routes; sessions returns a +# shutdown hook. def register_charges(app: FastAPI, state: AppState) -> None: @@ -256,7 +227,21 @@ def register_charges(app: FastAPI, state: AppState) -> None: _register(app, state) -# --- SPA + CORS ------------------------------------------------------------- +def register_subscriptions(app: FastAPI) -> None: + """Mount the documented subscription stub. + + The Python SDK has no ``solana.subscription`` server method yet, so the + ``/api/v1/premium/feed`` route answers 501 with a pointer at the gap and the + catalog omits the subscription entry (the UI renders its empty state). TS + gates the real route behind ``if (plan)``. Implemented in + :mod:`subscriptions`. + """ + from .subscriptions import register_subscriptions as _register + + _register(app) + + +# --- SPA static serving ------------------------------------------------------ def register_spa(app: FastAPI, repo_root: str | None) -> None: @@ -266,15 +251,15 @@ def register_spa(app: FastAPI, repo_root: str | None) -> None: Registered last so the API routes win; the catch-all only sees paths no earlier route matched. """ - dist = str(Path(repo_root) / "playground" / "app" / "dist") if repo_root else "" + dist = Path(repo_root) / "playground" / "app" / "dist" if repo_root else None @app.get("/{full_path:path}") async def spa(full_path: str) -> Response: if dist: - candidate = Path(dist) / full_path + candidate = dist / full_path if candidate.is_file(): return FileResponse(str(candidate)) - index = Path(dist) / "index.html" + index = dist / "index.html" if index.is_file(): return FileResponse(str(index)) return JSONResponse( @@ -283,61 +268,6 @@ async def spa(full_path: str) -> Response: ) -def _install_cors(app: FastAPI) -> None: - """Set permissive origins and expose the payment headers the web app reads. - - Hand-rolled rather than ``CORSMiddleware`` so the exposed-headers list and - the reflected preflight request headers match the Go example byte for byte. - """ - - @app.middleware("http") - async def cors(request: Request, call_next: Any) -> Response: - if request.method == "OPTIONS": - response: Response = Response(status_code=204) - response.headers["Access-Control-Allow-Methods"] = "GET,HEAD,PUT,PATCH,POST,DELETE" - requested = request.headers.get("Access-Control-Request-Headers") - if requested: - response.headers["Access-Control-Allow-Headers"] = requested - else: - response = await call_next(request) - response.headers["Access-Control-Allow-Origin"] = "*" - response.headers["Access-Control-Expose-Headers"] = _EXPOSE_HEADERS - return response - - -# --- payment handlers ------------------------------------------------------- - - -def _install_payment_handlers(app: FastAPI) -> None: - """Echo MPP settlement headers onto gated success responses and render - guard ``HTTPException`` bodies as the bare ``{"error": ...}`` shape. - - The umbrella ``RequirePayment`` dependency stashes the verified payment's - settlement headers on ``request.state`` (caveat: it raises a 402 - ``HTTPException`` with the challenge headers/body itself, which FastAPI - serves directly, so only the success-path receipt echo needs wiring here). - Guard dependencies raise ``HTTPException`` with a ``json_error`` dict detail; - Starlette would wrap that as ``{"detail": {...}}``, so unwrap it to match - the Go example's body byte for byte. - """ - from starlette.exceptions import HTTPException as StarletteHTTPException - - @app.middleware("http") - async def _settlement_headers(request: Request, call_next: Any) -> Response: - response: Response = await call_next(request) - settlement = getattr(request.state, "paykit_settlement_headers", None) - if isinstance(settlement, dict): - for name, value in settlement.items(): - response.headers[name] = value - return response - - @app.exception_handler(StarletteHTTPException) - async def _http_exception_handler(_request: Request, exc: StarletteHTTPException) -> Response: - detail = exc.detail - body = detail if isinstance(detail, dict) else {"error": detail} - return JSONResponse(body, status_code=exc.status_code, headers=getattr(exc, "headers", None)) - - # --- app factory ------------------------------------------------------------ @@ -359,24 +289,33 @@ def create_app(state: AppState | None = None) -> FastAPI: rpc_url=state.rpc_url, accept=pay_kit.Protocol.MPP, preflight=False, - operator=pay_kit.Operator( - recipient=state.recipient, - signer=state.fee_payer, - ), - mpp=pay_kit.MppConfig( - realm="PayKit Playground", - challenge_binding_secret=state.secret_key, - ), + operator=pay_kit.Operator(recipient=state.recipient, signer=state.fee_payer), + mpp=pay_kit.MppConfig(realm="PayKit Playground", challenge_binding_secret=state.secret_key), ) app = FastAPI(title="PayKit Playground (Python)") - _install_cors(app) - _install_payment_handlers(app) + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + expose_headers=_EXPOSE_HEADERS, + ) + # SDK shim: maps PayKitError (incl. the 402 challenge) to its HTTP response + # and echoes settlement headers onto gated success responses. + install_exception_handler(app) - register_health_and_config(app, state) + # Guard handlers raise HTTPException(detail=json_error(...)); Starlette would + # wrap the dict as {"detail": {...}}, so unwrap it to the bare {"error": ...} + # shape the TS/Go examples return. + @app.exception_handler(StarletteHTTPException) + async def _http_exception_handler(_request: Request, exc: StarletteHTTPException) -> Response: + body = exc.detail if isinstance(exc.detail, dict) else {"error": exc.detail} + return JSONResponse(body, status_code=exc.status_code, headers=getattr(exc, "headers", None)) + + register_hub(app, state) register_faucet(app, state) app.include_router(build_docs_router(state)) - register_charges(app, state) register_subscriptions(app) sessions_shutdown = register_sessions(app, state) @@ -388,9 +327,6 @@ def create_app(state: AppState | None = None) -> FastAPI: @app.on_event("shutdown") async def _shutdown() -> None: sessions_shutdown() - yahoo = getattr(app.state, "playground_yahoo", None) - if yahoo is not None: - await yahoo.aclose() return app diff --git a/python/examples/playground_api/charges.py b/python/examples/playground_api/charges.py index 90152d897..2ed02c7c2 100644 --- a/python/examples/playground_api/charges.py +++ b/python/examples/playground_api/charges.py @@ -3,19 +3,23 @@ multi-recipient splits (all gated through the ``pay_kit`` umbrella surface), and the fortune payment link served straight from the protocol-layer MPP server with the HTML challenge page enabled. The 402 challenge fires before -any upstream fetch. - -Mirrors the Go example's ``charges.go``. The umbrella :func:`RequirePayment` -dependency stands in for Go's ``client.RequireFunc``: a per-request gate -builder reads the path/query and returns a fully-validated :class:`Gate`. -Guard dependencies (404 unknown city/product, 400 missing query) are declared -ahead of the payment dependency so they fire before the 402, matching the Go -middleware ordering. - -The fortune route drops to the protocol layer (``protocols.mpp.server.Mpp`` -with HTML enabled) the same way the Go example does, because the umbrella -dispatcher renders the cross-SDK JSON challenge body and dropping down a layer -is the intended escape hatch for the interactive payment page. +any handler body runs. + +Mirrors the TypeScript example's ``modules/charges.ts``. The umbrella +:func:`RequirePayment` dependency stands in for Express's ``requirePayment`` +middleware: a per-request gate builder reads the path/query and returns a +fully-validated :class:`Gate`. Guard dependencies (404 unknown city/product, +400 missing query) are declared ahead of the payment dependency so they fire +before the 402, matching the Express middleware ordering. + +The stock routes serve canned demo data rather than a live market feed: the +example exists to exercise the payment gate, and the Python playground stays +network-free like its weather / marketplace / x402 routes. + +The fortune route drops to the protocol layer (:class:`Mpp` with HTML enabled) +because the umbrella dispatcher renders the cross-SDK JSON challenge body; +dropping down a layer to serve the interactive payment page is the intended +escape hatch. """ from __future__ import annotations @@ -50,30 +54,19 @@ from . import constants from .utils import json_error, log_tx -from .yahoo import YahooClient, YahooError # --- canned demo data ------------------------------------------------------- - -@dataclass(frozen=True) -class _WeatherInfo: - """The canned per-city weather payload (whole-degree C, label, % humidity).""" - - temperature: int - conditions: str - humidity: int - - -# The canned weather demo table. -_WEATHER_BY_CITY: dict[str, _WeatherInfo] = { - "san-francisco": _WeatherInfo(15, "Foggy", 85), - "new-york": _WeatherInfo(22, "Partly Cloudy", 60), - "london": _WeatherInfo(12, "Rainy", 90), - "tokyo": _WeatherInfo(26, "Sunny", 55), - "paris": _WeatherInfo(18, "Overcast", 70), - "sydney": _WeatherInfo(24, "Clear", 45), - "berlin": _WeatherInfo(10, "Cloudy", 75), - "dubai": _WeatherInfo(38, "Sunny", 30), +# The canned per-city weather payload: whole-degree C, sky label, % humidity. +_WEATHER_BY_CITY: dict[str, dict[str, Any]] = { + "san-francisco": {"temperature": 15, "conditions": "Foggy", "humidity": 85}, + "new-york": {"temperature": 22, "conditions": "Partly Cloudy", "humidity": 60}, + "london": {"temperature": 12, "conditions": "Rainy", "humidity": 90}, + "tokyo": {"temperature": 26, "conditions": "Sunny", "humidity": 55}, + "paris": {"temperature": 18, "conditions": "Overcast", "humidity": 70}, + "sydney": {"temperature": 24, "conditions": "Clear", "humidity": 45}, + "berlin": {"temperature": 10, "conditions": "Cloudy", "humidity": 75}, + "dubai": {"temperature": 38, "conditions": "Sunny", "humidity": 30}, } @@ -82,9 +75,8 @@ class _Product: """One marketplace catalog entry. ``price`` is the seller's list price in USD; the platform and referral - basis-point fees are charged on top of it, not carved out of it. ``seller`` - is the base58 wallet that receives the list price as the charge's primary - pay-to recipient. + basis-point fees are charged on top of it, not carved out. ``seller`` is the + base58 wallet that receives the list price as the charge's primary pay-to. """ name: str @@ -160,9 +152,6 @@ def register_charges(app: FastAPI, state: Any) -> None: platform = state.recipient accept_default = pay_kit.config().accept - yahoo = YahooClient() - app.state.playground_yahoo = yahoo # the sessions/app shutdown hook closes it - def _log_settlement(request: Request) -> None: """Surface the settlement signature once a gated handler runs.""" proof = payment(request) @@ -182,17 +171,14 @@ def _require_known_city(city: str) -> None: if _city_key(city) not in _WEATHER_BY_CITY: raise HTTPException( status_code=404, - detail=json_error( - "City not found. Available: san-francisco, new-york, london, " - "tokyo, paris, sydney, berlin, dubai" - ), + detail=json_error(f"City not found. Available: {', '.join(_WEATHER_BY_CITY)}"), ) - def _require_known_product(productId: str) -> None: # noqa: N803 (mirrors Go path value) + def _require_known_product(productId: str) -> None: # noqa: N803 (mirrors the route path param) if productId not in _PRODUCTS: raise HTTPException(status_code=404, detail=json_error("Product not found")) - # -- gate builders (Go's RequireFunc closures) --------------------------- + # -- gate builders ------------------------------------------------------- def _static_gate(name: str, amount: str, describe): # noqa: ANN001, ANN202 def builder(request: Request) -> Gate: @@ -222,7 +208,7 @@ def _buy_gate(request: Request) -> Gate: accept_default=accept_default, ) - # -- stocks (Yahoo Finance) ---------------------------------------------- + # -- stocks (canned demo quotes) ----------------------------------------- gate_stock_quote = RequirePayment( _static_gate("stockQuote", "0.01", lambda r: f"Stock quote: {r.path_params['symbol']}") @@ -236,38 +222,23 @@ def _buy_gate(request: Request) -> Gate: gate_weather = RequirePayment(_static_gate("weather", "0.01", lambda r: f"Weather for {r.path_params['city']}")) @app.get("/api/v1/stocks/quote/{symbol}", dependencies=[Depends(gate_stock_quote)]) - async def stock_quote(request: Request, symbol: str) -> Response: + async def stock_quote(request: Request, symbol: str) -> JSONResponse: _log_settlement(request) - try: - quote = await yahoo.quote(symbol) - except YahooError: - return JSONResponse(json_error("Failed to fetch quote"), status_code=500) - if quote is None: - # Unknown or delisted symbol: an empty 200 body, the way Express - # serializes res.json(undefined). - return Response(status_code=200, media_type="application/json") - return JSONResponse(quote) + return JSONResponse({"symbol": symbol.upper(), "regularMarketPrice": 150.0, "currency": "USD"}) @app.get( "/api/v1/stocks/search", dependencies=[Depends(_require_query("q")), Depends(gate_stock_search)], ) - async def stock_search(request: Request) -> Response: + async def stock_search(request: Request) -> JSONResponse: _log_settlement(request) - try: - quotes = await yahoo.search(request.query_params.get("q", "")) - except YahooError: - return JSONResponse(json_error("Failed to search"), status_code=500) - return JSONResponse(quotes) + q = request.query_params.get("q", "") + return JSONResponse([{"symbol": q.upper(), "shortname": q.upper(), "exchange": "NMS"}]) @app.get("/api/v1/stocks/history/{symbol}", dependencies=[Depends(gate_stock_history)]) - async def stock_history(request: Request, symbol: str) -> Response: + async def stock_history(request: Request, symbol: str) -> JSONResponse: _log_settlement(request) - try: - history = await yahoo.history(symbol, request.query_params.get("range", "")) - except YahooError: - return JSONResponse(json_error("Failed to fetch history"), status_code=500) - return JSONResponse(history) + return JSONResponse({"symbol": symbol.upper(), "quotes": [{"close": 150.0}]}) # -- weather: unknown cities 404 before the payment gate ----------------- @@ -275,72 +246,51 @@ async def stock_history(request: Request, symbol: str) -> Response: "/api/v1/weather/{city}", dependencies=[Depends(_require_known_city), Depends(gate_weather)], ) - async def weather(request: Request, city: str) -> Response: - info = _WEATHER_BY_CITY[_city_key(city)] + async def weather(request: Request, city: str) -> JSONResponse: _log_settlement(request) - return JSONResponse( - { - "city": city, - "temperature": info.temperature, - "conditions": info.conditions, - "humidity": info.humidity, - } - ) + return JSONResponse({"city": city, **_WEATHER_BY_CITY[_city_key(city)]}) # -- marketplace: free catalog plus the split purchase ------------------- @app.get("/api/v1/marketplace/products") async def marketplace_products() -> JSONResponse: - listing: list[dict[str, str]] = [] - for product_id in ("sol-hoodie", "validator-mug", "nft-sticker-pack"): - product = _PRODUCTS[product_id] - price_raw = (product.price.amount * (Decimal(10) ** constants.USDC_DECIMALS)).to_integral_value() - listing.append( - { - "id": product_id, - "name": product.name, - "description": product.description, - "price": _display_usd(product.price), - "priceRaw": str(int(price_raw)), - } - ) + listing = [ + { + "id": product_id, + "name": product.name, + "description": product.description, + "price": _display_usd(product.price), + "priceRaw": str(int(product.price.amount * (Decimal(10) ** constants.USDC_DECIMALS))), + } + for product_id, product in _PRODUCTS.items() + ] return JSONResponse(listing) @app.get( "/api/v1/marketplace/buy/{productId}", - dependencies=[ - Depends(_require_known_product), - Depends(RequirePayment(_buy_gate)), - ], + dependencies=[Depends(_require_known_product), Depends(RequirePayment(_buy_gate))], ) - async def marketplace_buy(request: Request, productId: str) -> Response: # noqa: N803 (mirrors Go path value) + async def marketplace_buy(request: Request, productId: str) -> JSONResponse: # noqa: N803 (route path param) product = _PRODUCTS[productId] platform_fee = _bps(product.price, _PLATFORM_FEE_BPS) total = product.price.amount + platform_fee.amount - breakdown: dict[str, str] = { + breakdown = { "seller": _display_usd(product.price), "platformFee": _display_usd(platform_fee), } - referrer = request.query_params.get("referrer", "") - if referrer: + if request.query_params.get("referrer"): referral_fee = _bps(product.price, _REFERRAL_FEE_BPS) breakdown["referralFee"] = _display_usd(referral_fee) total += referral_fee.amount breakdown["total"] = f"{total:.2f} USDC" _log_settlement(request) - return JSONResponse( - { - "product": product.name, - "breakdown": breakdown, - "status": "purchased", - } - ) + return JSONResponse({"product": product.name, "breakdown": breakdown, "status": "purchased"}) # -- fortune: a charge payment link with the interactive HTML challenge --- # # Stays on the protocol layer directly (Mpp with HTML enabled) because the # umbrella dispatcher renders the cross-SDK JSON challenge body; dropping - # down a layer is the intended escape hatch. + # down a layer is the intended escape hatch for the interactive page. fortune_mpp = Mpp( MppConfig( recipient=state.recipient, @@ -360,9 +310,7 @@ async def fortune(request: Request) -> Response: # The interactive payment page registers its service worker at scope # "/" from a script served under /api/v1/fortune, which browsers only # allow with this header. - is_sw = is_service_worker_request(str(request.url)) - sw_header = {"Service-Worker-Allowed": "/"} if is_sw else {} - + sw_header = {"Service-Worker-Allowed": "/"} if is_service_worker_request(str(request.url)) else {} challenge = fortune_mpp.charge_with_options("0.01", ChargeOptions(description="Open a fortune cookie")) auth_header = request.headers.get("authorization") @@ -388,12 +336,11 @@ async def fortune(request: Request) -> Response: PaymentError(str(err), code="payment_invalid"), ) - fortune_text = random.choice(_FORTUNES) headers = dict(sw_header) if receipt.reference: headers["payment-receipt"] = receipt.reference log_tx(request.url.path, receipt.reference) - return JSONResponse({"fortune": fortune_text}, headers=headers) + return JSONResponse({"fortune": random.choice(_FORTUNES)}, headers=headers) return _fortune_challenge(request, challenge, state.network, fortune_mpp.rpc_url, sw_header, None) @@ -412,29 +359,19 @@ def _fortune_challenge( """Render the fortune 402: the service-worker script on the SW request, the interactive HTML payment page when the client accepts HTML, otherwise the canonical JSON challenge body. - - Mirrors the protocol-layer ``PaymentMiddleware`` HTML branch the Go example - enables on this route. """ - www_auth = format_www_authenticate(challenge) headers = dict(extra_headers) - headers["www-authenticate"] = www_auth + headers["www-authenticate"] = format_www_authenticate(challenge) headers["cache-control"] = "no-store" if is_service_worker_request(str(request.url)): - # The interactive page fetches its own service worker from this URL. - return PlainTextResponse( - service_worker_js(), - media_type="application/javascript", - headers=headers, - ) + return PlainTextResponse(service_worker_js(), media_type="application/javascript", headers=headers) if accepts_html(request.headers.get("accept")): - body = challenge_to_html(challenge, rpc_url, network) - return HTMLResponse(body, status_code=402, headers=headers) + return HTMLResponse(challenge_to_html(challenge, rpc_url, network), status_code=402, headers=headers) - code = (error.code if error and error.code else "payment_invalid") or "payment_invalid" - detail = (str(error) if error else "Payment required") or "Payment required" + code = (error.code if error and error.code else None) or "payment_invalid" + detail = (str(error) if error else None) or "Payment required" return JSONResponse( { "type": "about:blank", diff --git a/python/examples/playground_api/docs.py b/python/examples/playground_api/docs.py index 3ebfe04dc..b5778abcb 100644 --- a/python/examples/playground_api/docs.py +++ b/python/examples/playground_api/docs.py @@ -2,8 +2,6 @@ """Serves the generated API reference markdown from ``/docs/api``, with a path-escape guard. Override the root with the ``DOCS_ROOT`` env var when running the app outside the repository checkout. - -Mirrors the Go example's ``docs.go``. """ from __future__ import annotations @@ -20,27 +18,15 @@ if TYPE_CHECKING: from .app import AppState -# doc_langs are the languages the playground docs browser knows about. +# Languages the playground docs browser knows about. DOC_LANGS = ["typescript", "rust", "go", "python", "ruby", "php", "lua", "kotlin", "swift"] # Maps a docs language to its justfile recipe suffix. -_DOCS_RECIPE_SLUG = { - "typescript": "ts", - "rust": "rs", - "python": "py", - "ruby": "rb", - "kotlin": "kt", -} +_RECIPE_SLUG = {"typescript": "ts", "rust": "rs", "python": "py", "ruby": "rb", "kotlin": "kt"} def docs_recipe_slug(lang: str) -> str: - """Return the justfile recipe suffix for a docs language.""" - return _DOCS_RECIPE_SLUG.get(lang, lang) - - -def is_doc_lang(lang: str) -> bool: - """Report whether ``lang`` is a known docs language.""" - return lang in DOC_LANGS + return _RECIPE_SLUG.get(lang, lang) def docs_root(repo_root: str | None) -> str: @@ -48,31 +34,24 @@ def docs_root(repo_root: str | None) -> str: override = os.getenv("DOCS_ROOT") if override: return override - if not repo_root: - return "" - return str(Path(repo_root) / "docs" / "api") + return str(Path(repo_root) / "docs" / "api") if repo_root else "" def find_repo_root() -> str | None: - """Walk up from the working directory to the repository root (the directory - containing ``.git`` or the top-level ``justfile``). Returns ``None`` when no - marker is found. + """Walk up from the working directory to the checkout root (the directory + holding ``.git`` or the top-level ``justfile``); ``None`` when not found. """ - try: - directory = Path.cwd() - except OSError: - return None + directory = Path.cwd() while True: if (directory / ".git").exists() or (directory / "justfile").exists(): return str(directory) - parent = directory.parent - if parent == directory: + if directory.parent == directory: return None - directory = parent + directory = directory.parent def _safe_join(root: str, rel: str) -> str | None: - """Join ``rel`` onto ``root`` and reject any path escaping the root.""" + """Join ``rel`` onto ``root``, rejecting any path that escapes the root.""" root_path = Path(root).resolve() joined = (root_path / rel).resolve() try: @@ -82,7 +61,7 @@ def _safe_join(root: str, rel: str) -> str | None: return str(joined) -def _build_docs_tree(abs_dir: str, rel_dir: str) -> list[dict[str, Any]]: +def _build_tree(abs_dir: str, rel_dir: str) -> list[dict[str, Any]]: """Walk the language docs directory: folders first, then markdown files, both alphabetical, skipping dotfiles and ``node_modules``. """ @@ -93,7 +72,7 @@ def _build_docs_tree(abs_dir: str, rel_dir: str) -> list[dict[str, Any]]: continue rel_path = f"{rel_dir}/{name}" if rel_dir else name if entry.is_dir(): - children = _build_docs_tree(entry.path, rel_path) + children = _build_tree(entry.path, rel_path) nodes.append({"name": name, "path": rel_path, "type": "dir", "children": children}) elif name.endswith(".md"): nodes.append({"name": name, "path": rel_path, "type": "file"}) @@ -102,24 +81,18 @@ def _build_docs_tree(abs_dir: str, rel_dir: str) -> list[dict[str, Any]]: def build_docs_router(state: AppState) -> APIRouter: - """Build the generated-docs browsing endpoints. - - Routes (all free): - - GET /api/v1/docs: per-language availability map - - GET /api/v1/docs/{lang}/tree: the docs file tree - - GET /api/v1/docs/{lang}/file?path=: one markdown file - """ + """Mount the generated-docs browsing endpoints (all free).""" router = APIRouter() root = docs_root(state.repo_root) @router.get("/api/v1/docs") async def docs_index() -> JSONResponse: - available = {lang: (Path(root) / lang / "README.md").exists() if root else False for lang in DOC_LANGS} + available = {lang: bool(root) and (Path(root) / lang / "README.md").exists() for lang in DOC_LANGS} return JSONResponse({"root": root, "available": available}) @router.get("/api/v1/docs/{lang}/tree") async def docs_tree(lang: str) -> JSONResponse: - if not is_doc_lang(lang): + if lang not in DOC_LANGS: return JSONResponse(json_error("unknown_lang"), status_code=404) lang_root = Path(root) / lang if not lang_root.exists(): @@ -128,18 +101,17 @@ async def docs_tree(lang: str) -> JSONResponse: status_code=404, ) try: - tree = _build_docs_tree(str(lang_root), "") + tree = _build_tree(str(lang_root), "") except OSError as exc: return JSONResponse({"error": "tree_failed", "detail": str(exc)}, status_code=500) return JSONResponse({"lang": lang, "tree": tree}) @router.get("/api/v1/docs/{lang}/file") async def docs_file(lang: str, request: Request) -> Response: - if not is_doc_lang(lang): + if lang not in DOC_LANGS: return JSONResponse(json_error("unknown_lang"), status_code=404) rel = request.query_params.get("path") or "README.md" - lang_root = str(Path(root) / lang) - abs_path = _safe_join(lang_root, rel) + abs_path = _safe_join(str(Path(root) / lang), rel) if abs_path is None: return JSONResponse(json_error("unsafe_path"), status_code=400) if not abs_path.endswith(".md"): diff --git a/python/examples/playground_api/faucet.py b/python/examples/playground_api/faucet.py index 5078595f7..16630b2d1 100644 --- a/python/examples/playground_api/faucet.py +++ b/python/examples/playground_api/faucet.py @@ -1,10 +1,5 @@ # examples/playground_api/faucet.py -"""SOL + USDC airdrops via the surfnet cheatcodes. - -Mirrors the Go example's ``faucet.go``: a free status endpoint advertising the -airdrop amounts and an airdrop endpoint that seeds the requested address with -SOL (``surfnet_setAccount``) and USDC (``surfnet_setTokenAccount``). -""" +"""SOL + USDC airdrops via the surfnet cheatcodes.""" from __future__ import annotations @@ -21,8 +16,6 @@ def register_faucet(app: FastAPI, state: AppState) -> None: - """Mount the faucet status and airdrop endpoints.""" - @app.get("/api/v1/faucet/status") async def faucet_status() -> JSONResponse: return JSONResponse( @@ -35,11 +28,8 @@ async def faucet_status() -> JSONResponse: @app.post("/api/v1/faucet/airdrop") async def faucet_airdrop(request: Request) -> JSONResponse: - try: - body = await request.json() - except Exception: - body = None - address = body.get("address") if isinstance(body, dict) else None + body = await request.json() + address = body.get("address") if not address: return JSONResponse(json_error("Missing `address` in request body"), status_code=400) try: diff --git a/python/examples/playground_api/sessions.py b/python/examples/playground_api/sessions.py index 6e73db666..68c1d945e 100644 --- a/python/examples/playground_api/sessions.py +++ b/python/examples/playground_api/sessions.py @@ -1,30 +1,20 @@ # examples/playground_api/sessions.py -"""The two session-gated demo endpoints (FastAPI). +"""The session-gated demo endpoints (FastAPI). -Both endpoints are driven by the in-process MPP session method +Driven by the in-process MPP session method (:func:`pay_kit.protocols.mpp.server.new_session`), the reserve/commit metering side channel (:func:`pay_kit.protocols.mpp.server.session_routes`), and the settle-status receipt poll. Both methods share one :class:`~pay_kit.protocols.mpp.server.MemoryChannelStore` so the receipt -endpoint can read the channel state whichever endpoint opened the channel. - -Ports ``go/examples/playground-api/sessions.go``. The route shapes, handler -bodies, canned token stream, and JSON acks mirror the Go example. - -Differences from the Go example, where the Python SDK port lacks a capability -the closest faithful behavior is served (nothing is silently dropped): - -* The Go example opens channels with ``OpenTxSubmitterServer`` (the server - completes the fee-payer signature and broadcasts the client-built open) and - arms ``CloseDelay`` with ``Signer`` + ``RPC`` so the idle-close watchdog - drives on-chain settle-and-distribute. The Python session method - (:mod:`pay_kit.protocols.mpp.server.session_method`) does not yet port the - server-broadcast open path or the on-chain settlement at close, so this app - uses the default client-broadcast submitter and ``rpc=None`` (the offline - core, which trusts the open signature / deposit as provided). The - ``/sessions/receipt`` poll still resolves from the shared store: it reports - the channel watermark/deposit and a ``settledSignature`` of ``null`` until - the Python settlement path lands. +endpoint reads channel state whichever endpoint opened the channel. + +Faithful port of ``typescript/examples/playground-api/modules/sessions.ts``. +The Python SDK does not ship the TS ``Mppx.session()`` request handler, so the +402 gate is built inline from :meth:`Session.challenge` and +:meth:`Session.verify_credential`. The server-broadcast open path +(``openTxSubmitter=server``) and the on-chain settle-at-close are not ported, so +this uses the default client-broadcast submitter and ``rpc=None``; +``settledSignature`` stays ``null`` until that lands. """ from __future__ import annotations @@ -61,7 +51,7 @@ if TYPE_CHECKING: from .app import AppState -# token_chunks is the canned token stream payload. Mirrors Go ``tokenChunks``. +# The canned token-stream payload. Mirrors TS ``TOKEN_CHUNKS``. TOKEN_CHUNKS = [ "A payment channel ", "lets a client and server ", @@ -73,77 +63,63 @@ ] -def _session_gate(session: Session, challenge_options: SessionChallengeOptions): - """Build the session-payment gate for a route, mirroring the Go - ``SessionMiddleware`` behavior inline for FastAPI. +def _session_gate(session: Session, options: SessionChallengeOptions): + """Build the 402 gate for a route (the Python SDK has no ``SessionMiddleware``). - Returns an async callable taking the request and returning a - ``(credential, receipt)`` pair when payment verifies, or a 402 - :class:`JSONResponse` (with the session challenge in WWW-Authenticate) when - it does not. The challenge is only built when a 402 is actually issued, so - the verify path never prefetches a blockhash. Mirrors Go ``SessionMiddleware``. + Returns an async callable that yields the verified-receipt response headers + when an Authorization credential verifies, or a 402 :class:`JSONResponse` + (challenge in WWW-Authenticate) otherwise. The challenge is built only when a + 402 is issued, so the verify path never prefetches a blockhash. """ - async def gate(request: Request) -> tuple[Any, Any] | JSONResponse: - verification_error: PaymentError | None = None + async def gate(request: Request) -> dict[str, str] | JSONResponse: + error: PaymentError | None = None auth_header = request.headers.get(AUTHORIZATION_HEADER) if auth_header: try: - credential = parse_authorization(auth_header) - receipt = await session.verify_credential(credential) - return credential, receipt + receipt = await session.verify_credential(parse_authorization(auth_header)) + try: + return {PAYMENT_RECEIPT_HEADER: format_receipt(receipt)} + except Exception: + return {} except PaymentError as err: - verification_error = err - except Exception as err: # noqa: BLE001 (framework/parse errors) - verification_error = PaymentError(str(err), code="invalid-payload") - - challenge = await session.challenge(challenge_options) - www_auth = format_www_authenticate(challenge) - if verification_error is None: - problem = payment_required_response( - "Payment required", - code="payment_invalid", - challenge_header=www_auth, - ) - else: - problem = payment_required_response( - str(verification_error) or "Payment required", - code=verification_error.code or "payment_invalid", - challenge_header=www_auth, - ) - return JSONResponse( - problem["body"], - status_code=problem["status_code"], - headers=problem["headers"], + error = err + except Exception as err: # noqa: BLE001 (framework/parse errors map to a 402) + error = PaymentError(str(err), code="invalid-payload") + + challenge = await session.challenge(options) + problem = payment_required_response( + str(error) if error else "Payment required", + code=(error.code if error and error.code else "payment_invalid"), + challenge_header=format_www_authenticate(challenge), ) + return JSONResponse(problem["body"], status_code=problem["status_code"], headers=problem["headers"]) return gate -def _receipt_header(receipt: Any) -> dict[str, str]: - """Expose the verified receipt in the Payment-Receipt response header, as - the Go middleware does after a successful verify. Best-effort: a formatting - failure simply omits the header.""" +async def _body(request: Request) -> dict[str, Any]: + """Best-effort JSON object request body.""" try: - return {PAYMENT_RECEIPT_HEADER: format_receipt(receipt)} + body = await request.json() except Exception: return {} + return body if isinstance(body, dict) else {} def _commit_ack(body: dict[str, Any]) -> dict[str, str]: - """Build the minimal CommitReceipt-shaped JSON ack the stream commit handler - returns. Mirrors Go ``commitAck``.""" - amount = body.get("amount") or "0" + """The minimal CommitReceipt-shaped ack a voucher commit returns. Mirrors TS + ``commitAck``.""" return { - "amount": str(amount), + "amount": str(body.get("amount") or "0"), "deliveryId": str(body.get("deliveryId", "")), "status": "committed", } async def _route_response(handler: Any, request: Request) -> JSONResponse: - """Adapt a framework-agnostic side-channel :class:`RouteResponse` handler - (method + raw body -> RouteResponse) to a FastAPI ``JSONResponse``.""" + """Adapt a framework-agnostic side-channel :class:`RouteResponse` handler to a + FastAPI ``JSONResponse``.""" raw = await request.body() result: RouteResponse = await handler(request.method, raw if raw else b"{}") return JSONResponse(result.body, status_code=result.status) @@ -152,18 +128,18 @@ async def _route_response(handler: Any, request: Request) -> JSONResponse: def register_sessions(app: FastAPI, state: AppState) -> Any: """Mount the session endpoints and return the watchdog shutdown hook. - Routes (mirroring Go ``registerSessions``): + Routes (mirroring TS ``registerSessions``): * ``GET /sessions/stream``: pay-per-chunk SSE, cap 1.00 USDC, 0.0001 USDC/chunk * ``POST /sessions/stream``: voucher commits for the stream endpoint * ``POST /sessions/compute``: pay-per-call compute, cap 0.50 USDC, 0.005 USDC/call (also accepts voucher commits) - * ``POST /__402/session/deliveries``: SessionFetch-style delivery reservation + * ``POST /__402/session/deliveries``: SessionFetch delivery reservation * ``POST /__402/session/commit``: body-voucher commit variant of the above * ``GET /sessions/receipt/{channel_id}``: settle-status poll for the UI """ - # Shared store across both session methods so /sessions/receipt can read - # channel state regardless of which endpoint opened the channel. + # Shared store so /sessions/receipt reads channel state regardless of which + # endpoint opened the channel. shared_store = MemoryChannelStore() def new_method(cap: int) -> Session: @@ -176,10 +152,6 @@ def new_method(cap: int) -> Session: decimals=constants.USDC_DECIMALS, network=state.network, secret_key=state.secret_key, - # The Python port does not yet support the server-broadcast open - # path (openTxSubmitter=server) or the on-chain settlement at - # close, so this uses the default client-broadcast submitter and - # no RPC client (offline core); see the module docstring. modes=["pull"], pull_voucher_strategy="clientVoucher", store=shared_store, @@ -208,7 +180,6 @@ async def sessions_stream_get(request: Request) -> Any: gated = await stream_gate(request) if isinstance(gated, JSONResponse): return gated - _credential, receipt = gated async def body() -> Any: for chunk in TOKEN_CHUNKS: @@ -219,52 +190,33 @@ async def body() -> Any: return StreamingResponse( body(), media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - **_receipt_header(receipt), - }, + headers={"Cache-Control": "no-cache", "Connection": "keep-alive", **gated}, ) # POST /sessions/stream: voucher commits arrive on the URL the session was - # opened against, with the signed voucher in the Authorization credential. - # The gate's verify path applies it; the body is an ack. Mirrors Go. + # opened against; the gate's verify path applies the voucher, the body is an ack. @app.post("/sessions/stream") async def sessions_stream_post(request: Request) -> JSONResponse: gated = await stream_gate(request) if isinstance(gated, JSONResponse): return gated - _credential, receipt = gated - try: - body = await request.json() - except Exception: - body = {} - if not isinstance(body, dict): - body = {} - return JSONResponse(_commit_ack(body), headers=_receipt_header(receipt)) - - # POST /sessions/compute: pay-per-call compute; the same handler also accepts - # voucher commits (a deliveryId in the body discriminates). Mirrors Go. + return JSONResponse(_commit_ack(await _body(request)), headers=gated) + + # POST /sessions/compute: pay-per-call compute; the same handler accepts + # voucher commits (a deliveryId in the body discriminates). @app.post("/sessions/compute") async def sessions_compute_post(request: Request) -> JSONResponse: gated = await compute_gate(request) if isinstance(gated, JSONResponse): return gated - _credential, receipt = gated - try: - body = await request.json() - except Exception: - body = {} - if not isinstance(body, dict): - body = {} + body = await _body(request) delivery_id = str(body.get("deliveryId", "")) - receipt_headers = _receipt_header(receipt) if delivery_id != "": return JSONResponse( {"amount": "0", "deliveryId": delivery_id, "status": "committed"}, - headers=receipt_headers, + headers=gated, ) - log_payment(request.url.path, receipt_headers) + log_payment(request.url.path, gated) prompt = str(body.get("prompt", "")) return JSONResponse( { @@ -272,12 +224,11 @@ async def sessions_compute_post(request: Request) -> JSONResponse: "output": "Echo: " + prompt + " (computed for 0.005 USDC)", "computedAt": datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), }, - headers=receipt_headers, + headers=gated, ) - # Side-channel metering routes: SessionFetch-style clients reserve capacity - # for each metered delivery before signing + committing the voucher. Both - # handlers share the methods' channel store. Mirrors Go. + # Side-channel metering routes: clients reserve capacity for each metered + # delivery before signing + committing the voucher. Both share the store. routes = session_routes(stream_session.core(), touch=None) @app.post("/__402/session/deliveries") @@ -288,10 +239,9 @@ async def session_deliveries(request: Request) -> JSONResponse: async def session_commit(request: Request) -> JSONResponse: return await _route_response(routes.commit, request) - # Receipt poll endpoint: the UI hits this after the stream ends to learn the - # on-chain settle signature. With no ported settlement path the settled - # signature stays null; the watermark/deposit are reported from the shared - # store. Mirrors Go's settle-status poll shape. + # Receipt poll: the UI hits this after the stream ends to learn the settle + # signature. With no ported settlement path it stays null; the + # watermark/deposit come from the shared store. @app.get("/sessions/receipt/{channel_id}") async def sessions_receipt(channel_id: str) -> JSONResponse: if not channel_id: diff --git a/python/examples/playground_api/x402.py b/python/examples/playground_api/x402.py index 82ea7f92b..184dc55ef 100644 --- a/python/examples/playground_api/x402.py +++ b/python/examples/playground_api/x402.py @@ -1,16 +1,11 @@ # examples/playground_api/x402.py """The embedded facilitator endpoints plus two x402-gated demo routes. -The Python x402 adapter only implements self-hosted mode (it verifies and -settles in-process with the operator signer), so the ``/x402/joke`` and -``/x402/fact`` gates here settle locally instead of POSTing to the embedded -facilitator. The facilitator endpoints are still served with the standard -shapes for external x402 clients. See README.md. - -Mirrors the Go example's ``x402.go``. The gated routes are guarded through the -``pay_kit`` umbrella surface with a per-gate ``accept=(Protocol.X402,)``, so the -shared in-process x402 adapter (configured in ``app.create_app`` via the -operator signer / fee payer) issues the 402 challenge and settles the proof. +The gated routes run through the ``pay_kit`` x402 surface: a dedicated +x402-only config (the process-wide ``configure`` accepts MPP only) gates the +routes through the self-hosted x402 adapter, which settles in-process with the +operator signer. The facilitator endpoints are served with the standard shapes +for external x402 clients. Mirrors the Go example's ``x402.go``. """ from __future__ import annotations @@ -30,7 +25,6 @@ if TYPE_CHECKING: from .app import AppState -# jokes is the canned joke pool. JOKES = [ "Why do programmers prefer dark mode? Because light attracts bugs.", "There are 10 types of people: those who understand binary and those who don’t.", @@ -38,7 +32,6 @@ 'A photon checks into a hotel; the bellhop asks if it has any luggage. "No, I’m traveling light."', ] -# facts is the canned fun-fact pool. FACTS = [ "Honey never spoils. Archaeologists found 3000-year-old honey in Egyptian tombs.", "Octopuses have three hearts and blue blood.", @@ -49,8 +42,6 @@ def register_x402(app: FastAPI, state: AppState) -> None: """Mount the embedded facilitator and the x402-gated routes.""" - # The umbrella exception handler renders the 402 challenge (and settlement - # headers) for the gated routes below; idempotent if already installed. install_exception_handler(app) # --- embedded facilitator ----------------------------------------------- @@ -71,34 +62,20 @@ async def facilitator_supported() -> JSONResponse: @app.post("/facilitator/verify") async def facilitator_verify(request: Request) -> JSONResponse: - try: - body = await request.json() - except Exception: - body = None - payment_payload = body.get("paymentPayload") if isinstance(body, dict) else None - payload = payment_payload.get("payload") if isinstance(payment_payload, dict) else None - if not isinstance(payload, dict): + payload = await _payload(request) + if payload is None: return JSONResponse({"isValid": False, "invalidReason": "Missing payload"}) - payer = "unknown" authorization = payload.get("authorization") - if isinstance(authorization, dict): - from_field = authorization.get("from") - if isinstance(from_field, str) and from_field: - payer = from_field - return JSONResponse({"isValid": True, "payer": payer}) + payer = authorization.get("from") if isinstance(authorization, dict) else None + return JSONResponse({"isValid": True, "payer": payer or "unknown"}) @app.post("/facilitator/settle") async def facilitator_settle(request: Request) -> JSONResponse: - try: - body = await request.json() - except Exception: - body = None - payment_payload = body.get("paymentPayload") if isinstance(body, dict) else None - payload = payment_payload.get("payload") if isinstance(payment_payload, dict) else None - if not isinstance(payload, dict): + payload = await _payload(request) + if payload is None: return JSONResponse({"success": False, "errorReason": "Missing payload"}) transaction = payload.get("transaction") - if not isinstance(transaction, str) or transaction == "": + if not transaction: return JSONResponse({"success": True, "transaction": "local-facilitator-settled"}) try: signature = await rpc_call( @@ -112,36 +89,40 @@ async def facilitator_settle(request: Request) -> JSONResponse: # --- x402-gated routes --------------------------------------------------- # - # A dedicated x402-only config (mirrors the Go example's separate - # ``paykit.New(Accept: [X402])`` client): the process-wide ``configure`` in - # ``app.create_app`` accepts MPP only, so the x402 adapter is not wired on - # the global PayCore. Deriving an x402-accepting copy of that config keeps - # the operator signer / fee payer, network, and RPC, and gates these routes - # through the self-hosted x402 adapter (verify + cosign + broadcast). + # The process-wide config accepts MPP only, so derive an x402-accepting copy + # (keeping the operator signer / fee payer, network, and RPC) and gate these + # routes through the self-hosted x402 adapter, mirroring the Go example's + # separate ``paykit.New(Accept: [X402])`` client. x402_config = pay_kit.config().model_copy(update={"accept": (Protocol.X402,)}) - joke_gate = Gate.build( - name="x402Joke", - amount=usd("0.001"), - description="A random programmer joke", - accept=(Protocol.X402,), - default_pay_to=x402_config.effective_recipient(), - ) - require_joke = Depends(RequirePayment(joke_gate, config=x402_config)) + def _gate(name: str, description: str) -> object: + gate = Gate.build( + name=name, + amount=usd("0.001"), + description=description, + accept=(Protocol.X402,), + default_pay_to=x402_config.effective_recipient(), + ) + return Depends(RequirePayment(gate, config=x402_config)) + + require_joke = _gate("x402Joke", "A random programmer joke") + require_fact = _gate("x402Fact", "A random fun fact") @app.get("/x402/joke") async def x402_joke(payment: Payment = require_joke) -> JSONResponse: return JSONResponse({"joke": random.choice(JOKES), "source": "x402"}) - fact_gate = Gate.build( - name="x402Fact", - amount=usd("0.001"), - description="A random fun fact", - accept=(Protocol.X402,), - default_pay_to=x402_config.effective_recipient(), - ) - require_fact = Depends(RequirePayment(fact_gate, config=x402_config)) - @app.get("/x402/fact") async def x402_fact(payment: Payment = require_fact) -> JSONResponse: return JSONResponse({"fact": random.choice(FACTS), "source": "x402"}) + + +async def _payload(request: Request) -> dict | None: + """Extract ``paymentPayload.payload`` from the request body, or ``None``.""" + try: + body = await request.json() + except Exception: + return None + payment_payload = body.get("paymentPayload") if isinstance(body, dict) else None + payload = payment_payload.get("payload") if isinstance(payment_payload, dict) else None + return payload if isinstance(payload, dict) else None diff --git a/python/examples/playground_api/yahoo.py b/python/examples/playground_api/yahoo.py deleted file mode 100644 index 8db1febe4..000000000 --- a/python/examples/playground_api/yahoo.py +++ /dev/null @@ -1,426 +0,0 @@ -# examples/playground_api/yahoo.py -"""Yahoo Finance client returning the same JSON shapes as the yahoo-finance2 -npm package (v3), which the playground API contract is defined against: the v7 -quote endpoint (crumb-authenticated), the v1 search endpoint, and the v8 chart -endpoint with the package's "array" result layout. - -Epoch-second date fields become ISO-8601 millisecond strings, "low - high" -range strings become {low, high} objects, and chart indicator columns are -zipped into per-day quote rows. Mirrors the Go example's ``yahoo.go``. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import math -import re -from datetime import UTC, datetime -from typing import Any -from urllib.parse import quote as url_quote - -import httpx - -# Sent on every upstream request; Yahoo rejects the default agent on the crumb -# endpoint. -_USER_AGENT = "Mozilla/5.0 (compatible; pay-kit-playground/1.0)" - -# v7 quote fields yahoo-finance2 types as Date (epoch seconds or date strings -# upstream, ISO strings in the response). -_QUOTE_DATE_FIELDS = frozenset( - { - "dividendDate", - "earningsTimestamp", - "earningsTimestampStart", - "earningsTimestampEnd", - "earningsCallTimestampStart", - "earningsCallTimestampEnd", - "expireDate", - "expireIsoDate", - "extendedMarketTime", - "ipoExpectedDate", - "nameChangeDate", - "newListingDate", - "postMarketTime", - "preMarketTime", - "regularMarketTime", - "startDate", - } -) - -# v7 quote fields typed as millisecond dates. -_QUOTE_DATE_MS_FIELDS = frozenset({"firstTradeDateMilliseconds"}) - -# v7 quote fields delivered as "low - high" strings and returned as -# {low, high} objects. -_QUOTE_RANGE_FIELDS = frozenset({"fiftyTwoWeekRange", "regularMarketDayRange"}) - -# search-quote fields typed as dates. -_SEARCH_DATE_FIELDS = frozenset({"newListingDate", "nameChangeDate"}) - -# chart meta fields typed as epoch-second dates. -_CHART_META_DATE_FIELDS = frozenset({"firstTradeDate", "regularMarketTime"}) - -# maps the playground's range parameter onto a day count (unknown ranges fall -# back to 30). -_CHART_RANGE_DAYS = {"1d": 1, "5d": 5, "1mo": 30, "3mo": 90, "6mo": 180, "1y": 365} - -_ISO_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") -_ISO_DATETIME_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$") - - -class YahooError(Exception): - """An upstream Yahoo Finance failure (HTTP, decode, or schema).""" - - -class YahooClient: - """Calls the public Yahoo Finance endpoints, holding the cookie jar and - crumb the v7 quote endpoint requires. - """ - - def __init__(self) -> None: - # A shared cookie jar (httpx persists Set-Cookie across requests on one - # client) backs the crumb and data calls. - self._client = httpx.AsyncClient( - timeout=10.0, - headers={"User-Agent": _USER_AGENT}, - follow_redirects=True, - ) - self._crumb: str | None = None - self._crumb_lock = asyncio.Lock() - - async def aclose(self) -> None: - """Close the underlying HTTP client (the shutdown hook calls this).""" - await self._client.aclose() - - async def _get(self, url: str) -> tuple[bytes, int]: - """Fetch a Yahoo endpoint and return (body, status); raise on non-2xx.""" - response = await self._client.get(url) - body = response.content - if response.status_code != 200: - raise YahooError(f"yahoo finance: HTTP {response.status_code}") - return body, response.status_code - - async def _get_json(self, url: str) -> Any: - """Fetch a Yahoo endpoint and decode the JSON body.""" - body, _ = await self._get(url) - import json - - return json.loads(body) - - async def _get_crumb(self) -> str: - """Return the cached crumb, fetching cookies plus a fresh crumb on first - use. - """ - async with self._crumb_lock: - if self._crumb: - return self._crumb - # Any fc.yahoo.com response sets the session cookie the crumb - # endpoint checks; the 404 body itself is irrelevant, and an HTTP - # error here is expected (the cookie is the point). - with contextlib.suppress(YahooError): - await self._get("https://fc.yahoo.com/") - body, _ = await self._get("https://query1.finance.yahoo.com/v1/test/getcrumb") - crumb = body.decode("utf-8").strip() - if not crumb or "Too Many Requests" in crumb: - raise YahooError("yahoo finance: could not obtain crumb") - self._crumb = crumb - return crumb - - def _invalidate_crumb(self) -> None: - """Drop the cached crumb so the next call refreshes it.""" - self._crumb = None - - async def quote(self, symbol: str) -> dict[str, Any] | None: - """Return the first v7 quote for ``symbol`` with yahoo-finance2's field - coercions applied, or ``None`` when the symbol is unknown or delisted. - """ - crumb = await self._get_crumb() - quote_url = ( - "https://query2.finance.yahoo.com/v7/finance/quote?symbols=" - + url_quote(symbol) - + "&crumb=" - + url_quote(crumb) - ) - try: - body = await self._get_json(quote_url) - except YahooError: - # An expired crumb surfaces as HTTP 401; refresh once and retry. - self._invalidate_crumb() - crumb = await self._get_crumb() - quote_url = ( - "https://query2.finance.yahoo.com/v7/finance/quote?symbols=" - + url_quote(symbol) - + "&crumb=" - + url_quote(crumb) - ) - body = await self._get_json(quote_url) - - finance_error = (body.get("finance") or {}).get("error") if isinstance(body, dict) else None - if isinstance(finance_error, dict) and finance_error.get("description"): - raise YahooError(f"yahoo finance: {finance_error['description']}") - - results = ((body.get("quoteResponse") or {}).get("result") or []) if isinstance(body, dict) else [] - for result in results: - if not isinstance(result, dict): - continue - if result.get("quoteType") == "NONE": - continue - _coerce_quote_fields(result) - return result - return None - - async def search(self, query: str) -> list[dict[str, Any]]: - """Return the search endpoint's quotes array for ``query``, issuing - yahoo-finance2's default request parameters so the result list matches - the package's output. - """ - params = { - "q": query, - "lang": "en-US", - "region": "US", - "quotesCount": "6", - "newsCount": "4", - "enableFuzzyQuery": "false", - "quotesQueryId": "tss_match_phrase_query", - "multiQuoteQueryId": "multi_quote_single_token_query", - "newsQueryId": "news_cie_vespa", - "enableCb": "true", - "enableNavLinks": "true", - "enableEnhancedTrivialQuery": "true", - } - query_string = "&".join(f"{k}={url_quote(v)}" for k, v in params.items()) - search_url = "https://query2.finance.yahoo.com/v1/finance/search?" + query_string - body = await self._get_json(search_url) - quotes = body.get("quotes") or [] if isinstance(body, dict) else [] - for quote in quotes: - if not isinstance(quote, dict): - continue - for field in _SEARCH_DATE_FIELDS: - if field in quote: - quote[field] = _coerce_date(quote[field], in_milliseconds=False) - return quotes - - async def history(self, symbol: str, chart_range: str) -> dict[str, Any]: - """Return the v8 chart result for ``symbol`` over ``chart_range`` in - yahoo-finance2's default "array" layout: the coerced meta object, the - indicator columns zipped into per-day quote rows, and dividend/split - events flattened into arrays. - """ - days = _CHART_RANGE_DAYS.get(chart_range, 30) - now = int(datetime.now(tz=UTC).timestamp()) - period1 = now - days * 24 * 60 * 60 - params = { - "useYfid": "true", - "interval": "1d", - "includePrePost": "true", - "events": "div|split|earn", - "lang": "en-US", - "period1": str(period1), - "period2": str(now), - } - query_string = "&".join(f"{k}={url_quote(v)}" for k, v in params.items()) - chart_url = ( - "https://query2.finance.yahoo.com/v8/finance/chart/" + url_quote(symbol, safe="") + "?" + query_string - ) - body = await self._get_json(chart_url) - chart = body.get("chart") or {} if isinstance(body, dict) else {} - chart_error = chart.get("error") - if isinstance(chart_error, dict) and chart_error.get("description"): - raise YahooError(f"yahoo finance: {chart_error['description']}") - results = chart.get("result") or [] - if not results: - raise YahooError("yahoo finance: empty chart result") - return _chart_to_array_layout(results[0]) - - -def _chart_to_array_layout(result: dict[str, Any]) -> dict[str, Any]: - """Convert one raw v8 chart result into yahoo-finance2's "array" return - shape: {meta, quotes[], events?}. - """ - meta = result.get("meta") if isinstance(result.get("meta"), dict) else {} - _coerce_chart_meta(meta) - out: dict[str, Any] = {"meta": meta, "quotes": []} - - timestamps = result.get("timestamp") or [] - indicators = result.get("indicators") if isinstance(result.get("indicators"), dict) else {} - if timestamps: - quote_columns = _chart_indicator_column(indicators, "quote") - adjclose_columns = _chart_indicator_column(indicators, "adjclose") - adjclose = adjclose_columns.get("adjclose") if adjclose_columns else None - - quotes: list[dict[str, Any]] = [] - for i, timestamp in enumerate(timestamps): - row: dict[str, Any] = { - "date": _coerce_date(timestamp, in_milliseconds=False), - "high": _column_value(quote_columns, "high", i), - "volume": _column_value(quote_columns, "volume", i), - "open": _column_value(quote_columns, "open", i), - "low": _column_value(quote_columns, "low", i), - "close": _column_value(quote_columns, "close", i), - } - if adjclose is not None and i < len(adjclose): - row["adjclose"] = adjclose[i] - quotes.append(row) - out["quotes"] = quotes - - raw_events = result.get("events") - if isinstance(raw_events, dict): - events: dict[str, Any] = {} - for kind in ("dividends", "splits"): - by_timestamp = raw_events.get(kind) - if not isinstance(by_timestamp, dict): - continue - # JS object iteration yields integer-like keys in ascending numeric - # order; Yahoo keys these maps by epoch seconds. - keys = sorted(by_timestamp.keys(), key=lambda k: int(k)) - items: list[Any] = [] - for key in keys: - item = by_timestamp[key] - if isinstance(item, dict) and "date" in item: - item["date"] = _coerce_date(item["date"], in_milliseconds=False) - items.append(item) - events[kind] = items - out["events"] = events - return out - - -def _chart_indicator_column(indicators: dict[str, Any], name: str) -> dict[str, Any] | None: - """Return indicators.[0] as a column map.""" - rows = indicators.get(name) if isinstance(indicators, dict) else None - if not isinstance(rows, list) or not rows: - if name == "quote": - raise YahooError("yahoo finance: chart result missing quote indicators") - return None - columns = rows[0] - return columns if isinstance(columns, dict) else {} - - -def _column_value(columns: dict[str, Any] | None, name: str, i: int) -> Any: - """Return column[i] or None when the column is missing/short.""" - if not columns: - return None - values = columns.get(name) - if not isinstance(values, list) or i >= len(values): - return None - return values[i] - - -def _coerce_chart_meta(meta: dict[str, Any]) -> None: - """Apply yahoo-finance2's date coercions to the chart meta object, - including the nested trading-period blocks. - """ - if not meta: - return - for field in _CHART_META_DATE_FIELDS: - value = meta.get(field) - if value is not None and field in meta: - meta[field] = _coerce_date(value, in_milliseconds=False) - - current = meta.get("currentTradingPeriod") - if isinstance(current, dict): - for key in ("pre", "regular", "post"): - period = current.get(key) - if isinstance(period, dict): - _coerce_trading_period(period) - - periods = meta.get("tradingPeriods") - if isinstance(periods, dict): - for rows in periods.values(): - _coerce_trading_period_rows(rows) - elif isinstance(periods, list): - _coerce_trading_period_rows(periods) - - -def _coerce_trading_period_rows(rows: Any) -> None: - """Coerce a [[tradingPeriod]] nest.""" - if not isinstance(rows, list): - return - for inner in rows: - if not isinstance(inner, list): - continue - for entry in inner: - if isinstance(entry, dict): - _coerce_trading_period(entry) - - -def _coerce_trading_period(period: dict[str, Any]) -> None: - """Coerce one {timezone, start, end, gmtoffset} block.""" - for key in ("start", "end"): - value = period.get(key) - if value is not None and key in period: - period[key] = _coerce_date(value, in_milliseconds=False) - - -def _coerce_quote_fields(result: dict[str, Any]) -> None: - """Apply the quote schema's date and range coercions to one v7 quote result - in place. - """ - for field, value in list(result.items()): - if field in _QUOTE_DATE_FIELDS: - result[field] = _coerce_date(value, in_milliseconds=False) - elif field in _QUOTE_DATE_MS_FIELDS: - result[field] = _coerce_date(value, in_milliseconds=True) - elif field in _QUOTE_RANGE_FIELDS: - result[field] = _coerce_range(value) - - -def _coerce_date(value: Any, *, in_milliseconds: bool) -> str: - """Convert a Yahoo date value (epoch number, {raw} wrapper, or date string) - into the ISO-8601 millisecond string a serialized JS Date produces. - ``in_milliseconds`` flags fields already scaled to milliseconds. - """ - if isinstance(value, bool): - # bool is an int subclass in Python; reject it before the numeric arm. - raise YahooError(f"yahoo finance: unexpected date value {value!r}") - if isinstance(value, (int, float)): - millis = value if in_milliseconds else value * 1000 - return _format_js_date(int(millis)) - if isinstance(value, dict): - raw = value.get("raw") - if isinstance(raw, (int, float)) and not isinstance(raw, bool): - return _format_js_date(int(raw * 1000)) - if isinstance(value, str): - if _ISO_DATE_PATTERN.match(value): - dt = datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=UTC) - return _format_js_date(int(dt.timestamp() * 1000)) - if _ISO_DATETIME_PATTERN.match(value): - normalized = value.replace("Z", "+00:00") - dt = datetime.fromisoformat(normalized) - return _format_js_date(int(dt.timestamp() * 1000)) - raise YahooError(f"yahoo finance: unexpected date value {value!r}") - - -def _format_js_date(unix_milli: int) -> str: - """Render epoch milliseconds the way Date.prototype.toJSON does: UTC with - exactly three fractional digits. - """ - dt = datetime.fromtimestamp(unix_milli / 1000, tz=UTC) - return dt.strftime("%Y-%m-%dT%H:%M:%S.") + f"{unix_milli % 1000:03d}Z" - - -def _coerce_range(value: Any) -> Any: - """Convert a "low - high" string into the {low, high} object yahoo-finance2 - returns; pre-shaped objects pass through. - """ - if isinstance(value, dict): - return value - if isinstance(value, str): - parts = value.split("-", 1) - if len(parts) == 2: - low = _parse_float_prefix(parts[0]) - high = _parse_float_prefix(parts[1]) - if low is not None and high is not None: - return {"low": low, "high": high} - raise YahooError(f"yahoo finance: unexpected range value {value!r}") - - -def _parse_float_prefix(text: str) -> float | None: - """Parse a float like JS parseFloat: surrounding whitespace is ignored.""" - try: - result = float(text.strip()) - except ValueError: - return None - if math.isnan(result): - return None - return result From d3455300d1b1c6a65cc2a018e4ae2cc2846474c2 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Mon, 15 Jun 2026 18:09:53 +0300 Subject: [PATCH 19/52] docs(python): self-standing docstrings; playground uses yfinance Address Ludo's review on PR #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. --- python/examples/playground_api/charges.py | 71 ++- python/pyproject.toml | 6 +- .../pay_kit/protocols/mpp/_paymentchannels.py | 146 ++++-- .../protocols/mpp/client/http_stream.py | 46 +- .../protocols/mpp/client/payment_channels.py | 124 +++--- .../pay_kit/protocols/mpp/client/session.py | 104 ++--- .../protocols/mpp/client/session_consumer.py | 41 +- .../pay_kit/protocols/mpp/intents/__init__.py | 2 +- .../pay_kit/protocols/mpp/intents/session.py | 173 ++++---- .../pay_kit/protocols/mpp/server/session.py | 71 ++- .../protocols/mpp/server/session_lifecycle.py | 12 +- .../protocols/mpp/server/session_method.py | 105 ++--- .../protocols/mpp/server/session_onchain.py | 43 +- .../protocols/mpp/server/session_routes.py | 61 ++- .../protocols/mpp/server/session_store.py | 63 ++- .../protocols/mpp/server/session_stream.py | 37 +- .../protocols/mpp/server/session_voucher.py | 41 +- python/uv.lock | 416 +++++++++++++++++- 18 files changed, 1017 insertions(+), 545 deletions(-) diff --git a/python/examples/playground_api/charges.py b/python/examples/playground_api/charges.py index 2ed02c7c2..dbacea566 100644 --- a/python/examples/playground_api/charges.py +++ b/python/examples/playground_api/charges.py @@ -12,9 +12,11 @@ 400 missing query) are declared ahead of the payment dependency so they fire before the 402, matching the Express middleware ordering. -The stock routes serve canned demo data rather than a live market feed: the -example exists to exercise the payment gate, and the Python playground stays -network-free like its weather / marketplace / x402 routes. +The stock routes pull live market data from the ``yfinance`` library (the +Python counterpart to the TypeScript example's ``yahoo-finance2``): quote via +``Ticker.info``, search via ``yf.Search``, history via ``Ticker.history``. Any +lookup failure degrades gracefully so the example still runs offline: an +unknown symbol is a 404 and a network outage returns a small safe default. The fortune route drops to the protocol layer (:class:`Mpp` with HTML enabled) because the umbrella dispatcher renders the cross-SDK JSON challenge body; @@ -30,6 +32,7 @@ from typing import Any from fastapi import Depends, FastAPI, HTTPException, Request +from fastapi.concurrency import run_in_threadpool from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response import pay_kit @@ -140,6 +143,61 @@ def _city_key(city: str) -> str: return city.lower().replace(" ", "-") +# --- live stock data via the yfinance library -------------------------------- + +# History ranges accepted by yfinance's ``period=`` argument. Anything else +# falls back to one month. +_HISTORY_RANGES = {"1d", "5d", "1mo", "3mo", "6mo", "1y"} + + +def _quote(symbol: str) -> dict[str, Any]: + """Live quote via ``Ticker.info``; 404 for an unknown symbol, a safe default + when the network is unreachable so the example still runs offline. + """ + import yfinance as yf + + sym = symbol.upper() + try: + info = yf.Ticker(sym).info + except Exception: # noqa: BLE001 (offline / lib error -> safe default) + return {"symbol": sym, "regularMarketPrice": 150.0, "currency": "USD"} + price = info.get("regularMarketPrice") or info.get("currentPrice") + if price is None: + raise HTTPException(status_code=404, detail=json_error(f"Unknown symbol: {sym}")) + return info + + +def _search(query: str) -> list[dict[str, Any]]: + """Live symbol search via ``yf.Search``; an empty list when offline.""" + import yfinance as yf + + try: + return yf.Search(query).quotes + except Exception: # noqa: BLE001 (offline / lib error -> empty result set) + return [] + + +def _history(symbol: str, range_: str) -> dict[str, Any]: + """Live OHLC history via ``Ticker.history``; a safe single-point default + when offline, a 404 when the symbol has no data. + """ + import yfinance as yf + + sym = symbol.upper() + period = range_ if range_ in _HISTORY_RANGES else "1mo" + try: + frame = yf.Ticker(sym).history(period=period) + except Exception: # noqa: BLE001 (offline / lib error -> safe default) + return {"symbol": sym, "quotes": [{"close": 150.0}]} + if frame.empty: + raise HTTPException(status_code=404, detail=json_error(f"Unknown symbol: {sym}")) + quotes = [ + {"date": index.isoformat(), "close": round(float(row["Close"]), 4)} + for index, row in frame.iterrows() + ] + return {"symbol": sym, "quotes": quotes} + + # --- registration ----------------------------------------------------------- @@ -224,7 +282,7 @@ def _buy_gate(request: Request) -> Gate: @app.get("/api/v1/stocks/quote/{symbol}", dependencies=[Depends(gate_stock_quote)]) async def stock_quote(request: Request, symbol: str) -> JSONResponse: _log_settlement(request) - return JSONResponse({"symbol": symbol.upper(), "regularMarketPrice": 150.0, "currency": "USD"}) + return JSONResponse(await run_in_threadpool(_quote, symbol)) @app.get( "/api/v1/stocks/search", @@ -233,12 +291,13 @@ async def stock_quote(request: Request, symbol: str) -> JSONResponse: async def stock_search(request: Request) -> JSONResponse: _log_settlement(request) q = request.query_params.get("q", "") - return JSONResponse([{"symbol": q.upper(), "shortname": q.upper(), "exchange": "NMS"}]) + return JSONResponse(await run_in_threadpool(_search, q)) @app.get("/api/v1/stocks/history/{symbol}", dependencies=[Depends(gate_stock_history)]) async def stock_history(request: Request, symbol: str) -> JSONResponse: _log_settlement(request) - return JSONResponse({"symbol": symbol.upper(), "quotes": [{"close": 150.0}]}) + range_ = request.query_params.get("range", "1mo") + return JSONResponse(await run_in_threadpool(_history, symbol, range_)) # -- weather: unknown cities 404 before the payment gate ----------------- diff --git a/python/pyproject.toml b/python/pyproject.toml index d1f4aac19..f50b4499d 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -24,8 +24,10 @@ dependencies = [ fastapi = ["fastapi>=0.110"] flask = ["flask>=3"] django = ["django>=4.2"] -# The playground-api example: a FastAPI app served with uvicorn. -playground = ["fastapi>=0.110", "uvicorn>=0.30"] +# The playground-api example: a FastAPI app served with uvicorn. yfinance backs +# the live stock routes (the python counterpart to the TS example's +# yahoo-finance2); lookups degrade gracefully so the example runs offline. +playground = ["fastapi>=0.110", "uvicorn>=0.30", "yfinance>=0.2.40"] dev = [ "pytest>=8", "pytest-asyncio>=0.24", diff --git a/python/src/pay_kit/protocols/mpp/_paymentchannels.py b/python/src/pay_kit/protocols/mpp/_paymentchannels.py index f9de1b90a..4558619d1 100644 --- a/python/src/pay_kit/protocols/mpp/_paymentchannels.py +++ b/python/src/pay_kit/protocols/mpp/_paymentchannels.py @@ -1,16 +1,13 @@ """On-chain glue for the payment-channels program. -This module is the Python counterpart of -``rust/crates/mpp/src/program/payment_channels.rs`` and -``go/paycore/paymentchannels/paymentchannels.go``: PDA derivation, associated -token derivation, voucher preimage bytes, and convenience instruction builders -for the push-mode session flow (``open`` + ``topUp``). +Provides PDA derivation, associated token derivation, voucher preimage bytes, +and convenience instruction builders for the push-mode session flow +(``open`` + ``topUp``). Instruction data and account metas are produced by the codama-py generated client under :mod:`pay_kit.protocols.programs.paymentchannels` (rendered from -``idl/payment-channels.json`` by ``skills/pay-sdk-implementation/codegen``), -the same architecture as the Rust and Go ports. This module only adds what the -IDL cannot express: +``idl/payment-channels.json`` by ``skills/pay-sdk-implementation/codegen``). +This module only adds what the IDL cannot express: - The production program id (``GuoKrza...``) overrides the IDL placeholder (``CQAyft83tN1w2bRofB5PZ79eVDU2xZUVo43LU1qL4zRg``), which is not the deployed @@ -18,7 +15,7 @@ The generated PDA helpers pin the placeholder and take no program id parameter, so the event-authority derivation stays here. - The channel PDA is not declared in the IDL's ``pdas`` section, so its - derivation is hand-written, mirroring ``find_channel_pda`` in the Rust spine. + derivation is hand-written here. The payment-channels program uses a single-byte instruction discriminator (``open`` = 1, ``topUp`` = 3), not the 8-byte Anchor discriminator; the @@ -67,20 +64,20 @@ # Canonical payment-channels program id deployed to the network. The IDL # placeholder ``CQAyft83tN1w2bRofB5PZ79eVDU2xZUVo43LU1qL4zRg`` is NOT the -# production deployment; mirrors ``PAYMENT_CHANNELS_PROGRAM_ID`` in the Rust -# spine and ``ProgramID`` in the Go port. +# production deployment and must not be used for derivation or instruction +# emission. PAYMENT_CHANNELS_PROGRAM_ID = "GuoKrzaBiZnW5DvJ3yZVE7xHqbcBvaX9SH6P6Cn9gNvc" #: Parsed production program id used for derivation and instruction emission. PROGRAM_ID = Pubkey.from_string(PAYMENT_CHANNELS_PROGRAM_ID) -# Channel PDA seed prefix. Mirrors ``CHANNEL_SEED`` in the Rust spine. +# Channel PDA seed prefix. _CHANNEL_SEED = b"channel" -# Event-authority PDA seed prefix. Mirrors ``EVENT_AUTHORITY_SEED`` in Rust. +# Event-authority PDA seed prefix. _EVENT_AUTHORITY_SEED = b"event_authority" -# Rent sysvar id. Mirrors ``RENT_SYSVAR_ID`` in the Rust spine. +# Rent sysvar id. _RENT_SYSVAR_ID = "SysvarRent111111111111111111111111111111111" @@ -88,7 +85,10 @@ class Distribution: """A single payout recipient and its basis-point share. - Mirrors the ``Distribution`` struct in the Rust spine. + Attributes: + recipient: The account that receives this share of channel payouts. + bps: The recipient's share expressed in basis points (1/100th of a + percent). """ recipient: Pubkey @@ -99,9 +99,26 @@ class Distribution: class OpenChannelParams: """Inputs required to build an ``open`` instruction. - Mirrors ``OpenChannelParams`` in the Rust spine. ``token_program`` defaults - to the SPL Token program; pass the Token-2022 program id for Token-2022 - mints. + ``token_program`` defaults to the SPL Token program; pass the Token-2022 + program id for Token-2022 mints. + + Attributes: + payer: The account funding the channel and signing the open. + payee: The counterparty the channel pays out to. + mint: The SPL token mint the channel is denominated in. + authorized_signer: The key authorized to sign vouchers that redeem + funds from the channel. + salt: A caller-chosen u64 that disambiguates channels sharing the same + payer, payee, mint, and signer; part of the channel PDA seeds. + deposit: The initial token amount deposited into the channel. + grace_period: Seconds the payee retains to redeem after the channel + closes before funds are reclaimable by the payer. + recipients: Optional payout split; each entry's basis points apportion + the channel's payouts. Empty means a single implicit payee. + token_program: The token program owning the mint (SPL Token or + Token-2022). + program_id: The payment-channels program the instruction targets; + defaults to the production deployment. """ payer: Pubkey @@ -120,7 +137,13 @@ class OpenChannelParams: class TopUpParams: """Inputs required to build a ``topUp`` instruction. - Mirrors the ``build_top_up_instruction`` arguments in the Rust spine. + Attributes: + payer: The account adding funds to the channel and signing the top-up. + channel: The channel PDA being funded. + mint: The SPL token mint the channel is denominated in. + amount: The token amount to add to the channel's balance. + token_program: The token program owning the mint (SPL Token or + Token-2022). """ payer: Pubkey @@ -133,10 +156,19 @@ class TopUpParams: def voucher_message_bytes(channel_id: Pubkey, cumulative: int, expires_at: int) -> bytes: """Return the 48-byte voucher preimage signed by the authorized signer. + The signer signs this exact byte string to authorize redeeming + ``cumulative`` lamports from the channel up to ``expires_at``. + Layout: ``channelId`` (32) || ``cumulativeAmount`` as little-endian u64 (offset 32) || ``expiresAt`` as little-endian i64 (offset 40). Encoded by - the generated ``VoucherArgs`` Borsh layout, the exact counterpart of the - Rust spine delegating to its generated ``VoucherArgs``. + the generated ``VoucherArgs`` Borsh layout. + + Args: + channel_id: The channel PDA the voucher authorizes spending from. + cumulative: The running total amount the voucher authorizes, encoded as + a little-endian u64. + expires_at: Unix timestamp after which the voucher is no longer valid, + encoded as a little-endian i64. Raises: ValueError: if ``channel_id`` does not encode to exactly 32 bytes. @@ -165,9 +197,24 @@ def find_channel_pda( ) -> tuple[Pubkey, int]: """Derive the channel PDA, defaulting to the production program id. + The channel address is fully determined by its payer, payee, mint, + authorized signer, and salt, so the same inputs always resolve to the same + channel. + Seeds: ``["channel", payer, payee, mint, authorizedSigner, salt u64 LE]``. - Mirrors ``find_channel_pda`` in the Rust spine, which takes the program id - as its final parameter. + + Args: + payer: The account funding the channel. + payee: The counterparty the channel pays out to. + mint: The SPL token mint the channel is denominated in. + authorized_signer: The key authorized to sign vouchers for the channel. + salt: A caller-chosen u64 disambiguating channels with otherwise + identical seeds, packed little-endian into the seeds. + program_id: The payment-channels program to derive against; defaults to + the production deployment. + + Returns: + A ``(pubkey, bump)`` pair for the derived channel PDA. """ return Pubkey.find_program_address( [ @@ -185,9 +232,19 @@ def find_channel_pda( def find_event_authority_pda(program_id: Pubkey = PROGRAM_ID) -> tuple[Pubkey, int]: """Derive the event-authority PDA, defaulting to the production program id. - Seeds: ``["event_authority"]``. Mirrors ``find_event_authority_pda`` in the - Rust spine. Stays hand-written because the generated helper derives against - the IDL placeholder program id and takes no override. + The event authority is the program-signed account the program uses to emit + its CPI event log. + + Seeds: ``["event_authority"]``. Derived here rather than via the generated + helper because that helper pins the IDL placeholder program id and accepts + no override. + + Args: + program_id: The payment-channels program to derive against; defaults to + the production deployment. + + Returns: + A ``(pubkey, bump)`` pair for the derived event-authority PDA. """ return Pubkey.find_program_address([_EVENT_AUTHORITY_SEED], program_id) @@ -200,8 +257,15 @@ def find_associated_token_address( """Derive the associated token account address for ``(owner, mint, program)``. Seeds: ``[owner, token_program, mint]`` under the associated-token program. - Mirrors ``find_associated_token_address`` in the Rust spine and ``derive_ata`` - in :mod:`pay_kit._paycore.mints`. + + Args: + owner: The account that owns the token account. + mint: The SPL token mint the account holds. + token_program: The token program owning the mint (SPL Token or + Token-2022). + + Returns: + A ``(pubkey, bump)`` pair for the derived associated token account. """ return Pubkey.find_program_address( [bytes(owner), bytes(token_program), bytes(mint)], @@ -210,12 +274,20 @@ def find_associated_token_address( def build_open_instruction(params: OpenChannelParams) -> Instruction: - """Build the ``open`` instruction with accounts in the exact Rust order. + """Build the ``open`` instruction that creates and funds a channel. Derives the channel PDA, the payer and channel ATAs, and the event-authority PDA, then delegates encoding and the 13 account metas to the generated - ``Open`` builder against the production program id. Mirrors - ``build_open_instruction`` in the Rust spine. + ``Open`` builder against the target program id. The account metas are + emitted in the fixed order the program expects. + + Args: + params: The payer, payee, mint, signer, salt, deposit, grace period, + recipient split, token program, and target program id for the + channel to open. + + Returns: + The assembled ``open`` instruction ready to add to a transaction. """ channel, _ = find_channel_pda( params.payer, @@ -257,11 +329,19 @@ def build_open_instruction(params: OpenChannelParams) -> Instruction: def build_top_up_instruction(params: TopUpParams) -> Instruction: - """Build the ``topUp`` instruction with accounts in the exact Rust order. + """Build the ``topUp`` instruction that adds funds to an open channel. Derives the payer and channel ATAs, then delegates encoding and the 6 account metas to the generated ``TopUp`` builder against the production - program id. Mirrors ``build_top_up_instruction`` in the Rust spine. + program id. The account metas are emitted in the fixed order the program + expects. + + Args: + params: The payer, channel, mint, amount, and token program for the + top-up. + + Returns: + The assembled ``topUp`` instruction ready to add to a transaction. """ payer_token_account, _ = find_associated_token_address(params.payer, params.mint, params.token_program) channel_token_account, _ = find_associated_token_address(params.channel, params.mint, params.token_program) diff --git a/python/src/pay_kit/protocols/mpp/client/http_stream.py b/python/src/pay_kit/protocols/mpp/client/http_stream.py index b26dc066c..6b511d9a3 100644 --- a/python/src/pay_kit/protocols/mpp/client/http_stream.py +++ b/python/src/pay_kit/protocols/mpp/client/http_stream.py @@ -5,11 +5,9 @@ :class:`SseDecoder` plus the metered event state machine), then layers a small httpx adapter on top for applications that want batteries included. -Mirrors ``rust/crates/mpp/src/client/http_stream.rs``; the TypeScript -counterpart is ``typescript/packages/mpp/src/client/HttpStream.ts``. The -metering rules enforced here are the skill-mandated pair: a usage event's -``deliveryId`` must match the live directive, and usage overrides only the -amount, never the ``deliveryId``. +Two metering invariants are enforced while folding a stream: a usage event's +``deliveryId`` must match the live metering directive, and a usage event may +override only the committed amount, never the ``deliveryId``. """ from __future__ import annotations @@ -41,11 +39,15 @@ @dataclass class SseEvent: - """A parsed Server-Sent Event frame. Mirrors rust ``SseEvent``.""" + """A parsed Server-Sent Event frame.""" + #: Event name from the ``event:`` field, or ``None`` for a default message. event: str | None = None + #: Concatenated ``data:`` field payload (lines joined with newlines). data: str = "" + #: Last-event-id from the ``id:`` field, if present. id: str | None = None + #: Reconnection delay in milliseconds from the ``retry:`` field, if present. retry: int | None = None @@ -54,7 +56,6 @@ class SseDecoder: Feed raw HTTP chunks with :meth:`push_chunk`. It returns all complete events decoded from that chunk and retains partial data internally. - Mirrors rust ``SseDecoder``. """ def __init__(self) -> None: @@ -136,14 +137,19 @@ def _dispatch_current(self) -> SseEvent | None: class MeteredSseEvent: """A parsed metered SSE event, tagged by ``kind``. - Mirrors the rust ``MeteredSseEvent`` enum: exactly one payload field is set - for the matching ``kind`` (``done`` carries none). + Exactly one payload field is set for the matching ``kind``; a ``done`` event + carries no payload. """ + #: Which kind of event this is, selecting the populated payload field. kind: Literal["metering", "usage", "message", "done", "other"] + #: Metering directive payload, set when ``kind`` is ``"metering"``. metering: MeteringDirective | None = None + #: Usage payload, set when ``kind`` is ``"usage"``. usage: MeteringUsage | None = None + #: Decoded JSON message payload, set when ``kind`` is ``"message"``. message: Any = None + #: Raw passthrough frame, set when ``kind`` is ``"other"``. other: SseEvent | None = None @@ -152,8 +158,8 @@ def parse_metered_sse_event(event: SseEvent) -> MeteredSseEvent: Recognizes the canonical event names ``mpp.metering``/``metering`` and ``mpp.usage``/``usage``, the ``done`` event, and the ``[DONE]`` sentinel on - a plain message. Mirrors rust ``parse_metered_sse_event``; message payloads - are decoded as JSON values rather than a typed parameter. + a plain message. Message payloads are decoded as JSON values; any other + event name passes through unchanged as an ``"other"`` frame. """ event_name = event.event if event.event is not None else "message" if event_name in ("mpp.metering", "metering"): @@ -184,7 +190,8 @@ def parse_metered_sse_event(event: SseEvent) -> MeteredSseEvent: class _MeteredStreamState: """Directive/usage/done bookkeeping shared by the stream wrappers. - Mirrors rust ``MeteredStreamState``. + Tracks the live metering directive, the final usage amount once one + arrives, and whether the stream has ended. """ def __init__(self) -> None: @@ -232,11 +239,12 @@ class MeteredSseSession: Feed decoded frames with :meth:`accept_event`; call :meth:`ack` after the stream ends to sign and commit a voucher for the metered amount (the reserved directive amount, or the final usage amount when one arrived). - Mirrors rust ``MeteredSseSession``. + This wrapper does not own its frame source: the caller decodes and feeds + frames in. """ def __init__(self, consumer: SessionConsumer) -> None: - """Wrap a consumer; mirrors rust ``SessionConsumer::metered_sse``.""" + """Wrap a session consumer with fresh metering state.""" self._consumer = consumer self._state = _MeteredStreamState() @@ -263,9 +271,9 @@ def ack(self) -> CommitReceipt: class MeteredSseStream: """A metered SSE stream over any iterable of raw byte chunks. - Works with ``httpx.Response.iter_bytes()`` or any other chunk source; the - rust counterpart (``ReqwestMeteredSseStream``) binds to reqwest, Python - stays transport-neutral. Iterate with :meth:`next` (or as an iterator) and + Works with ``httpx.Response.iter_bytes()`` or any other source of raw byte + chunks; the stream stays transport-neutral and owns its own decoder and + metering state. Iterate with :meth:`next` (or as an iterator) and call :meth:`ack` once to commit. """ @@ -323,8 +331,8 @@ class HttpCommitTransport: Posts the :class:`CommitPayload` JSON to the directive ``commitUrl`` (or a default), optionally with an Authorization header, and decodes the - :class:`CommitReceipt`. Mirrors rust ``HttpCommitTransport``; pass an - ``httpx.Client`` to control timeouts or inject a mock transport in tests. + :class:`CommitReceipt`. Pass an ``httpx.Client`` to control timeouts or to + inject a mock transport in tests; one is created lazily when omitted. """ def __init__( diff --git a/python/src/pay_kit/protocols/mpp/client/payment_channels.py b/python/src/pay_kit/protocols/mpp/client/payment_channels.py index bee1caccb..8b3ce454c 100644 --- a/python/src/pay_kit/protocols/mpp/client/payment_channels.py +++ b/python/src/pay_kit/protocols/mpp/client/payment_channels.py @@ -5,9 +5,7 @@ from a :class:`~pay_kit.protocols.mpp.intents.session.SessionRequest` challenge (mint from the currency, deposit from the cap, token program from the currency, splits, salt) and assembles the partially signed open transaction the -operator broadcasts. Mirrors ``rust/crates/mpp/src/client/payment_channels.rs`` -line by line; the TypeScript counterpart is -``typescript/packages/mpp/src/client/PaymentChannels.ts``. +operator broadcasts. Encoding boundary: the open transaction travels as standard-alphabet base64 WITH padding (it is an opaque transaction, not part of the canonical-JSON @@ -64,12 +62,14 @@ "unique_salt", ] -#: Default payment-channel close grace period (seconds). Mirrors rust -#: ``DEFAULT_GRACE_PERIOD_SECONDS`` and the TypeScript client. +#: Default payment-channel close grace period, in seconds, applied to a derived +#: open when the caller does not override it. DEFAULT_GRACE_PERIOD_SECONDS = 900 -#: Placeholder signature used while the operator still needs to submit the -#: server-broadcast open transaction. Mirrors rust ``PENDING_SERVER_SIGNATURE``. +#: Placeholder signature carried by an open action while the operator still +#: needs to submit the server-broadcast open transaction. The all-ones base58 +#: value is the default (zero) Solana signature, used as a sentinel until the +#: real on-chain signature is known. PENDING_SERVER_SIGNATURE = "1111111111111111111111111111111111111111111111111111111111111111" _U64_MAX = 2**64 - 1 @@ -78,19 +78,20 @@ def unique_salt() -> int: """Return a random u64 channel salt. - Mirrors rust ``unique_salt`` (which reads eight unique bytes little-endian); - Python draws the eight bytes from the CSPRNG. + Draws eight bytes from the cryptographically secure RNG and interprets them + little-endian. The salt distinguishes channels that share the same payer, + payee, mint, and authorized signer so each derives a distinct channel PDA. """ return int.from_bytes(secrets.token_bytes(8), "little") def generate_authorized_signer() -> Keypair: - """Generate an ephemeral session signing key (canonical-flow step 2). + """Generate an ephemeral session signing key. The keypair's public key becomes the channel ``authorizedSigner``; pass the keypair to the openers (or directly to :class:`ActiveSession`) as the - session signer. Mirrors the TypeScript openers' ``generateKeyPairSigner`` - call; the rust openers take an externally supplied ``SolanaSigner``. + session signer. Use this when the caller does not already hold a dedicated + signer for the session and wants one minted on the spot. """ return Keypair() @@ -99,25 +100,41 @@ def generate_authorized_signer() -> Keypair: class PaymentChannelOpen: """A fully derived payment-channel open: addresses plus channel parameters. - Mirrors rust ``PaymentChannelOpen``. + Holds everything needed to open one channel: the derived channel PDA, the + payer and payee, the SPL mint and its token program, the authorized session + signer, the salt that made the PDA unique, the deposit amount and close + grace period (both in base units / seconds), the payout split recipients, + and the on-chain program that owns the channel. """ + #: Derived on-chain channel account address (the channel PDA). channel_id: Pubkey + #: Account that funds the deposit and signs the open transaction. payer: Pubkey + #: Account that receives the channel payouts. payee: Pubkey + #: SPL token mint the channel is denominated in. mint: Pubkey + #: Ephemeral public key authorized to sign vouchers against the channel. authorized_signer: Pubkey + #: u64 salt that makes the channel PDA unique for a given key tuple. salt: int + #: Amount, in token base units, deposited into the channel on open. deposit: int + #: Close grace period, in seconds, before the channel can be torn down. grace_period: int + #: Payout split recipients and their basis-point shares. recipients: list[Distribution] + #: SPL token program owning the mint (classic Token or Token-2022). token_program: Pubkey + #: On-chain program that owns the channel account. program_id: Pubkey def open_channel_params(self) -> OpenChannelParams: """Return the instruction-builder params for this open. - Mirrors rust ``PaymentChannelOpen::open_channel_params``. + Repackages the derived addresses and parameters into the argument + object the low-level open-instruction builder expects. """ return OpenChannelParams( payer=self.payer, @@ -135,7 +152,9 @@ def open_channel_params(self) -> OpenChannelParams: def open_payload(self, mode: SessionMode, signature: str) -> OpenPayload: """Build the open action payload carrying the full channel parameters. - Mirrors rust ``PaymentChannelOpen::open_payload``. + Serializes the channel's addresses, deposit, salt, grace period, and + authorized signer into the ``open`` payload sent to the operator, + tagged with the session ``mode`` and the open-transaction ``signature``. """ return OpenPayload.payment_channel_with_mode( mode, @@ -153,13 +172,11 @@ def open_payload(self, mode: SessionMode, signature: str) -> OpenPayload: @dataclass class PaymentChannelOpenTransaction: - """A built open transaction plus the channel it opens. - - ``transaction`` is standard base64 with padding. Mirrors rust - ``PaymentChannelOpenTransaction``. - """ + """A built open transaction plus the channel it opens.""" + #: Derived on-chain channel account address the transaction opens. channel_id: Pubkey + #: Serialized open transaction as standard-alphabet base64 with padding. transaction: str @@ -173,54 +190,62 @@ class PaymentChannelOpenOptions: ``programId`` (else the production program), ``recipients`` to the challenge ``splits``, ``salt`` to :func:`unique_salt`, and ``token_program`` to the program resolved from the challenge currency - (Token-2022 for PYUSD/USDG/CASH). Mirrors rust - ``PaymentChannelOpenOptions``. + (Token-2022 for PYUSD/USDG/CASH). """ + #: Deposit in token base units; defaults to the challenge cap. deposit: int | None = None + #: Close grace period in seconds; defaults to :data:`DEFAULT_GRACE_PERIOD_SECONDS`. grace_period: int | None = None + #: Owning program; defaults to the challenge ``programId`` or the production program. program_id: Pubkey | None = None + #: Payout split recipients; defaults to the challenge ``splits``. recipients: list[Distribution] | None = None + #: Channel salt; defaults to a fresh value from :func:`unique_salt`. salt: int | None = None + #: SPL token program; defaults to the program resolved from the currency. token_program: Pubkey | None = None @dataclass class PaymentChannelSessionOpen: - """A derived open, the session tracking it, and the open action to send. - - Mirrors rust ``PaymentChannelSessionOpen``. - """ + """A derived open, the session tracking it, and the open action to send.""" + #: The fully derived channel open (addresses and parameters). open: PaymentChannelOpen + #: Live session keyed to the derived channel, ready to issue vouchers. session: ActiveSession + #: Open action to send to the operator to register the channel. action: SessionAction @dataclass class PaymentChannelSessionOpenOptions: - """Options for :func:`create_payment_channel_session_opener`. - - Mirrors rust ``PaymentChannelSessionOpenOptions``. - """ + """Options for :func:`create_payment_channel_session_opener`.""" + #: Overrides for deriving the channel open. open: PaymentChannelOpenOptions = field(default_factory=PaymentChannelOpenOptions) + #: Open-action signature; defaults to :data:`PENDING_SERVER_SIGNATURE`. signature: str | None = None + #: Initial cumulative spent amount for the session; defaults to 0. cumulative: int | None = None + #: Session expiry as a Unix timestamp; defaults to the session default. expires_at: int | None = None @dataclass class ServerOpenedPaymentChannelSessionOpenOptions: - """Options for :func:`create_server_opened_payment_channel_session_opener`. - - Mirrors rust ``ServerOpenedPaymentChannelSessionOpenOptions``. - """ + """Options for :func:`create_server_opened_payment_channel_session_opener`.""" + #: Overrides for deriving the channel open. open: PaymentChannelOpenOptions = field(default_factory=PaymentChannelOpenOptions) + #: Channel payer; defaults to the challenge ``operator`` (server-funded). payer: Pubkey | None = None + #: Open-action signature; defaults to :data:`PENDING_SERVER_SIGNATURE`. signature: str | None = None + #: Initial cumulative spent amount for the session; defaults to 0. cumulative: int | None = None + #: Session expiry as a Unix timestamp; defaults to the session default. expires_at: int | None = None @@ -235,8 +260,8 @@ def derive_payment_channel_open( Resolves the mint from the challenge currency (localnet falls back to the mainnet mint), the deposit from the cap when no explicit deposit is given, the token program from the currency, the recipients from the challenge - splits, and a random salt; then derives the channel PDA. Mirrors rust - ``derive_payment_channel_open``. + splits, and a random salt; then derives the channel PDA. Any field set on + ``options`` overrides the corresponding challenge-derived default. """ options = options if options is not None else PaymentChannelOpenOptions() network = request.network if request.network is not None else "mainnet" @@ -297,8 +322,7 @@ def build_open_payment_channel_transaction( The fee payer defaults to the challenge ``operator`` and its signature slot is intentionally left empty: the payer partial-signs only its own slot and - the server completes the fee-payer signature before broadcasting. Mirrors - rust ``build_open_payment_channel_transaction``. + the server completes the fee-payer signature before broadcasting. """ if fee_payer is None: fee_payer = _parse_pubkey(request.operator, "operator") @@ -324,8 +348,7 @@ def create_payment_channel_session_opener( partially signed open transaction (fee payer = operator), attaches it to the open action, and returns the :class:`ActiveSession` keyed to the derived channel. The action signature defaults to - :data:`PENDING_SERVER_SIGNATURE` until the operator broadcasts. Mirrors - rust ``create_payment_channel_session_opener``. + :data:`PENDING_SERVER_SIGNATURE` until the operator broadcasts. """ options = options if options is not None else PaymentChannelSessionOpenOptions() _ensure_client_voucher_pull(request) @@ -352,8 +375,7 @@ def create_server_opened_payment_channel_session_opener( """Open a pull/clientVoucher session whose channel the operator funds. No transaction is built: the payer defaults to the challenge ``operator`` - and the server constructs, funds, and broadcasts the open itself. Mirrors - rust ``create_server_opened_payment_channel_session_opener``. + and the server constructs, funds, and broadcasts the open itself. """ options = options if options is not None else ServerOpenedPaymentChannelSessionOpenOptions() _ensure_client_voucher_pull(request) @@ -374,10 +396,9 @@ def _build_open_payment_channel_tx( ) -> PaymentChannelOpenTransaction: """Assemble the open message and partial-sign only the payer slot. - Mirrors rust ``build_open_payment_channel_tx``: a legacy transaction whose - fee payer is the operator (signature slot left as the default placeholder) - and whose payer signature is filled in, serialized as standard base64 with - padding. + Builds a legacy transaction whose fee payer is the operator (its signature + slot left as the default placeholder) and whose payer signature slot is + filled in, serialized as standard-alphabet base64 with padding. """ blockhash = recent_blockhash if isinstance(recent_blockhash, Hash) else Hash.from_string(recent_blockhash) ix = build_open_instruction(open_.open_channel_params()) @@ -402,7 +423,8 @@ def _build_open_payment_channel_tx( def _ensure_client_voucher_pull(request: SessionRequest) -> None: """Require the challenge to advertise pull mode with clientVoucher. - Mirrors rust ``ensure_client_voucher_pull``. + Raises ``ValueError`` if the challenge does not list ``pull`` among its + modes, or if its pull voucher strategy is not ``clientVoucher``. """ if "pull" not in request.modes: raise ValueError("session challenge does not advertise pull mode") @@ -418,7 +440,9 @@ def _configured_session( ) -> ActiveSession: """Build the opener's ActiveSession with resume options applied. - Mirrors rust ``configure_session``. + Keys the session to the derived channel and the session signer, applying + the supplied expiry and cumulative-spent values or their defaults when + ``None`` (useful for resuming a session at a known cumulative amount). """ return ActiveSession( channel_id, @@ -443,7 +467,7 @@ def _signer_pubkey(signer: Any) -> Pubkey: def _parse_u64_string(value: str, label: str) -> int: - """Parse a u64 decimal string, mirroring rust ``parse_u64_string``.""" + """Parse a u64 decimal string, raising a labeled ``ValueError`` on failure.""" try: return _parse_base_units(value) except ValueError as exc: @@ -451,7 +475,7 @@ def _parse_u64_string(value: str, label: str) -> int: def _parse_pubkey(value: str, label: str) -> Pubkey: - """Parse a base58 pubkey, mirroring rust ``parse_pubkey``.""" + """Parse a base58 pubkey, raising a labeled ``ValueError`` on failure.""" try: return Pubkey.from_string(value) except (ValueError, TypeError) as exc: diff --git a/python/src/pay_kit/protocols/mpp/client/session.py b/python/src/pay_kit/protocols/mpp/client/session.py index 948b68e2f..46ffe6320 100644 --- a/python/src/pay_kit/protocols/mpp/client/session.py +++ b/python/src/pay_kit/protocols/mpp/client/session.py @@ -14,10 +14,9 @@ path are out of scope, but the wire fields stay present so the action union round-trips. -Behavior mirrors the Rust spine in ``rust/crates/mpp/src/client/session.rs`` and -the parity-verified Go port in -``go/protocols/mpp/client/session.go`` so the cross-language SDKs produce -byte-identical voucher signatures and credentials. +Voucher signatures and credentials are byte-deterministic: the signed preimage +and the JCS-canonicalized credential are fully specified by the MPP session +intent, so a given session state always produces the same bytes on the wire. The signer is duck-typed against the pay_kit signer contract shared with the charge client: ``pubkey() -> str`` (base58 public key) and @@ -59,15 +58,15 @@ #: Default voucher expiry: 2100-01-01T00:00:00Z. Stays below JavaScript's max #: safe integer so JSON intermediaries do not round it before the credential is -#: decoded. Mirrors rust ``DEFAULT_VOUCHER_EXPIRES_AT``. +#: decoded. DEFAULT_VOUCHER_EXPIRES_AT = DEFAULT_SESSION_EXPIRES_AT # The session intent name carried in a challenge ``intent`` field. _SESSION_INTENT = "session" # u64 upper bound; the cumulative watermark is a Solana base-unit amount and the -# voucher preimage packs it as a little-endian u64. Mirrors the Go overflow -# guard (``addCumulative``) which rejects a wrapped watermark. +# voucher preimage packs it as a little-endian u64. A computed watermark that +# exceeds this bound is rejected rather than allowed to wrap. _U64_MAX = (1 << 64) - 1 @@ -75,10 +74,10 @@ class VoucherSigner(Protocol): """Minimal Ed25519 message-signing surface for voucher signing. - Mirrors the Go ``VoucherSigner`` alias (``solanatx.Signer``) and the pay_kit - :class:`pay_kit.signer.LocalSigner` duck type shared with the charge client. - A solders ``Keypair`` also satisfies this shape via ``pubkey()`` plus - ``sign_message()``; :func:`_sign_base58` bridges the two method names. + Satisfied by the pay_kit :class:`pay_kit.signer.LocalSigner` duck type shared + with the charge client. A solders ``Keypair`` also satisfies this shape via + ``pubkey()`` plus ``sign_message()``; :func:`_sign_base58` bridges the two + method names. """ def pubkey(self) -> Any: @@ -94,9 +93,9 @@ def _sign_base58(signer: Any, message: bytes) -> str: """Sign ``message`` and return the signature as base58. Accepts both the pay_kit signer (``sign(bytes) -> bytes``) and a solders - ``Keypair`` (``sign_message(bytes) -> Signature``). The Go port returns - ``sig.String()`` (base58); this normalizes the 64-byte signature to the same - base58 form via the solders ``Signature`` type. + ``Keypair`` (``sign_message(bytes) -> Signature``). The 64-byte signature is + normalized to its base58 string form via the solders ``Signature`` type, + which is the encoding the credential carries on the wire. """ from solders.signature import Signature # type: ignore[import-untyped] @@ -123,7 +122,6 @@ class ActiveSession: ``authorizedSigner`` passed to the server in the open action. Not safe for concurrent use; serialize access or guard it with a lock. - Mirrors rust ``ActiveSession``. """ def __init__( @@ -142,9 +140,8 @@ def __init__( method); ``expires_at`` is the Unix timestamp applied to newly signed vouchers, defaulting to :data:`DEFAULT_VOUCHER_EXPIRES_AT`; ``cumulative`` seeds the watermark when resuming a known channel - position (the payment-channel openers use it, mirroring the rust - ``configure_session`` helper writing the public ``cumulative`` field). - Mirrors rust ``ActiveSession::new`` / ``new_with_expiry``. + position (the payment-channel openers use it to write the starting + ``cumulative`` value). """ self._channel_id = channel_id self._signer = signer @@ -154,11 +151,7 @@ def __init__( @classmethod def at_expiry(cls, channel_id: Any, signer: VoucherSigner | Any, expires_at: int) -> ActiveSession: - """Create a session tracker with an explicit voucher expiry. - - Mirrors rust ``ActiveSession::new_with_expiry`` and the Go - ``NewActiveSessionAt`` constructor. - """ + """Create a session tracker with an explicit voucher expiry.""" return cls(channel_id, signer, expires_at) @property @@ -183,7 +176,7 @@ def channel_id(self) -> Any: @property def channel_id_string(self) -> str: - """Channel address as base58. Mirrors rust ``channel_id_str``.""" + """Channel address as base58.""" return str(self._channel_id) @property @@ -192,10 +185,7 @@ def authorized_signer(self) -> str: return _pubkey_str(self._signer) def set_expires_at(self, expires_at: int) -> None: - """Update the expiry timestamp used for subsequent vouchers. - - Mirrors rust ``ActiveSession::set_expires_at``. - """ + """Update the expiry timestamp used for subsequent vouchers.""" self._expires_at = expires_at # -- voucher signing ---------------------------------------------------- @@ -206,7 +196,7 @@ def prepare_voucher(self, cumulative: int) -> SignedVoucher: This keeps ack/commit transports safe to retry: a failed commit can be resent with the same cumulative amount without the local state drifting ahead of the server. ``cumulative`` MUST strictly exceed the current - watermark. Mirrors rust ``ActiveSession::prepare_voucher``. + watermark. """ if cumulative <= self._cumulative: raise ValueError(f"voucher cumulative {cumulative} must exceed current watermark {self._cumulative}") @@ -226,8 +216,6 @@ def prepare_voucher(self, cumulative: int) -> SignedVoucher: def prepare_increment(self, amount: int) -> SignedVoucher: """Sign a voucher adding ``amount`` to the current cumulative without advancing the watermark. - - Mirrors rust ``ActiveSession::prepare_increment``. """ return self.prepare_voucher(self._add_cumulative(amount)) @@ -237,8 +225,7 @@ def record_voucher(self, voucher: SignedVoucher) -> None: The voucher's channel MUST match this session and its cumulative MUST strictly exceed the current watermark; the nonce advances to the larger of the current nonce and the voucher nonce (current nonce + 1 when the - voucher carries none), exactly mirroring rust - ``ActiveSession::record_voucher``. + voucher carries none). """ if voucher.data.channel_id != self.channel_id_string: raise ValueError( @@ -258,27 +245,20 @@ def sign_voucher(self, cumulative: int) -> SignedVoucher: """Sign a voucher with an absolute cumulative amount and advance the local watermark. - ``cumulative`` MUST strictly exceed the current watermark. Mirrors rust - ``ActiveSession::sign_voucher``. + ``cumulative`` MUST strictly exceed the current watermark. """ voucher = self.prepare_voucher(cumulative) self.record_voucher(voucher) return voucher def sign_increment(self, amount: int) -> SignedVoucher: - """Sign a voucher adding ``amount`` to the current cumulative. - - Mirrors rust ``ActiveSession::sign_increment``. - """ + """Sign a voucher adding ``amount`` to the current cumulative.""" return self.sign_voucher(self._add_cumulative(amount)) # -- action builders ---------------------------------------------------- def voucher_action(self, amount: int) -> SessionAction: - """Sign a fresh increment and wrap it as a voucher action. - - Mirrors rust ``ActiveSession::voucher_action``. - """ + """Sign a fresh increment and wrap it as a voucher action.""" voucher = self.sign_increment(amount) return SessionAction.voucher_action(VoucherPayload(voucher=voucher)) @@ -287,7 +267,7 @@ def close_action(self, final_increment: int = 0) -> SessionAction: When ``final_increment`` is greater than zero it signs one last voucher for the remaining balance before closing; otherwise the close carries no - voucher. Mirrors rust ``ActiveSession::close_action``. + voucher. """ payload = ClosePayload(channel_id=self.channel_id_string) if final_increment > 0: @@ -298,8 +278,7 @@ def open_action(self, deposit: int, open_tx_signature: str) -> SessionAction: """Build a push-mode open action. Call this after the on-chain open transaction has confirmed; the session - channel ID MUST match the confirmed channel address. Mirrors rust - ``ActiveSession::open_action``. + channel ID MUST match the confirmed channel address. """ return SessionAction.open_action( OpenPayload.push( @@ -322,8 +301,6 @@ def open_payment_channel_action( ) -> SessionAction: """Build a payment-channel push open action carrying the full channel parameters. - - Mirrors rust ``ActiveSession::open_payment_channel_action``. """ return self.open_payment_channel_action_with_mode( "push", deposit, payer, payee, mint, salt, grace_period, open_tx_signature @@ -340,10 +317,7 @@ def open_payment_channel_action_with_mode( grace_period: int, open_tx_signature: str, ) -> SessionAction: - """Build a payment-channel open action with an explicit submission mode. - - Mirrors rust ``ActiveSession::open_payment_channel_action_with_mode``. - """ + """Build a payment-channel open action with an explicit submission mode.""" return SessionAction.open_action( OpenPayload.payment_channel_with_mode( mode, @@ -369,7 +343,7 @@ def open_pull_action( The session channel ID is used as the token account, so callers should construct the :class:`ActiveSession` with the delegated token-account - pubkey as the channel ID. Mirrors rust ``ActiveSession::open_pull_action``. + pubkey as the channel ID. """ return SessionAction.open_action( OpenPayload.pull( @@ -382,10 +356,7 @@ def open_pull_action( ) def top_up_action(self, new_deposit: int, topup_tx_signature: str) -> SessionAction: - """Build a top-up action after a top-up transaction confirms. - - Mirrors rust ``ActiveSession::topup_action``. - """ + """Build a top-up action after a top-up transaction confirms.""" return SessionAction.top_up_action( TopUpPayload( channel_id=self.channel_id_string, @@ -404,8 +375,7 @@ def serialize_session_credential( """Build the ``Payment `` Authorization header value. Echoes ``challenge`` and JCS-canonicalizes the credential, reusing the - same core wire layer the charge client uses. Mirrors the Go - ``SerializeSessionCredential`` free function. + same core wire layer the charge client uses. """ return serialize_session_credential(challenge, action) @@ -414,9 +384,8 @@ def serialize_session_credential( def _add_cumulative(self, amount: int) -> int: """Add ``amount`` to the current watermark, rejecting u64 overflow. - Mirrors the Go ``addCumulative`` guard so a wrapped watermark can never - be signed; rust relies on debug-mode overflow panics for the same - invariant. + Guards against a wrapped watermark ever being signed: if the sum exceeds + the u64 range the voucher would pack, it raises instead of wrapping. """ nxt = self._cumulative + amount if nxt > _U64_MAX: @@ -431,9 +400,8 @@ def serialize_session_credential( """Build an Authorization header value for a session action. Echoes the challenge and JCS-canonicalizes the credential, producing - ``"Payment "``. Mirrors the Go - ``SerializeSessionCredential`` and the credential framing rust uses for - session actions (``format_authorization``). + ``"Payment "``, the credential framing the + MPP "Payment" HTTP auth scheme defines for session actions. """ credential = PaymentCredential( challenge=challenge.to_echo(), @@ -446,7 +414,7 @@ def parse_session_challenge(header: str) -> tuple[PaymentChallenge, SessionReque """Parse a WWW-Authenticate header into the challenge and session request. Rejects non-session intents so callers do not accidentally treat a charge - challenge as a session. Mirrors the Go ``ParseSessionChallenge``. + challenge as a session. """ challenge = parse_www_authenticate(header) if challenge.intent != _SESSION_INTENT: @@ -459,8 +427,6 @@ def session_request_modes(request: SessionRequest) -> list[SessionMode]: """Return the funding modes advertised by a session challenge. ``modes`` omitted or empty means push-only; an explicit ``[]`` therefore - decodes the same as a missing field. Mirrors the TypeScript - ``sessionRequestModes`` helper; the rust client checks ``modes`` membership - directly with the same default semantics. + decodes the same as a missing field, yielding ``["push"]``. """ return list(request.modes) if request.modes else ["push"] diff --git a/python/src/pay_kit/protocols/mpp/client/session_consumer.py b/python/src/pay_kit/protocols/mpp/client/session_consumer.py index bc73d7be5..c2c1418da 100644 --- a/python/src/pay_kit/protocols/mpp/client/session_consumer.py +++ b/python/src/pay_kit/protocols/mpp/client/session_consumer.py @@ -4,10 +4,6 @@ so applications can process delivered messages and call ``ack``/``commit`` instead of manually signing and posting vouchers. A failed commit never advances the local watermark, so the same directive can be retried safely. - -Behavior mirrors the Rust spine in -``rust/crates/mpp/src/client/session_consumer.rs`` and the parity-verified Go -port in ``go/protocols/mpp/client/session_consumer.go``. """ from __future__ import annotations @@ -40,9 +36,8 @@ class CommitTransport(Protocol): ``commit_url``, ``proof``, or other routing hints without those fields being repeated in the signed commit body. - The ``commit`` signature mirrors the Rust ``CommitTransport`` trait exactly: - it takes the directive and the :class:`CommitPayload` and returns a - :class:`CommitReceipt`. + The ``commit`` method takes the directive and the :class:`CommitPayload` and + returns a :class:`CommitReceipt`. """ def commit(self, directive: MeteringDirective, payload: CommitPayload) -> CommitReceipt: @@ -54,17 +49,17 @@ class SessionConsumer: """Client-side consumer for session-metered deliveries. Not safe for concurrent use; the underlying :class:`ActiveSession` watermark - is advanced inside :meth:`commit_directive`. Mirrors rust ``SessionConsumer``. + is advanced inside :meth:`commit_directive`. """ def __init__(self, session: ActiveSession, transport: CommitTransport) -> None: - """Wrap a session and a commit transport. Mirrors rust ``SessionConsumer::new``.""" + """Wrap a session and a commit transport.""" self._session = session self._transport = transport @property def session(self) -> ActiveSession: - """The wrapped session. Mirrors rust ``SessionConsumer::session``.""" + """The wrapped session.""" return self._session @property @@ -76,8 +71,7 @@ def accept(self, envelope: MeteredEnvelope) -> MeteredDelivery[Any]: """Validate an envelope and return a delivery handle with ``ack``/``commit``. The directive is validated up front so a mismatched session is rejected - before the application processes the payload. Mirrors rust - ``SessionConsumer::accept``. + before the application processes the payload. """ self._validate_directive(envelope.metering) return MeteredDelivery(consumer=self, payload=envelope.payload, metering=envelope.metering) @@ -91,11 +85,10 @@ def commit_directive(self, directive: MeteringDirective) -> CommitReceipt: split makes a failed commit safe to retry without double-counting: the voucher is prepared (no watermark advance), sent, and recorded once the transport returns a receipt. The prepared voucher is recorded for both - ``committed`` and ``replayed`` receipts, exactly mirroring rust - ``SessionConsumer::commit_directive`` and the TypeScript - ``SessionConsumer``: the server's deliveryId dedupe keeps the settled - amount authoritative on its side, and the locally signed cumulative - stays the client watermark so subsequent vouchers remain monotonic. + ``committed`` and ``replayed`` receipts: the server's deliveryId dedupe + keeps the settled amount authoritative on its side, and the locally + signed cumulative stays the client watermark so subsequent vouchers + remain monotonic. """ self._validate_directive(directive) amount = directive.amount_base_units() @@ -121,7 +114,7 @@ class MeteredDelivery(Generic[P]): """A delivered payload paired with its metering directive. Call :meth:`ack` (or its :meth:`commit` alias) after the application has - processed :attr:`payload`. Mirrors rust ``MeteredDelivery``. + processed :attr:`payload`. """ def __init__(self, consumer: SessionConsumer, payload: P, metering: MeteringDirective) -> None: @@ -140,19 +133,13 @@ def metering(self) -> MeteringDirective: return self._metering def ack(self) -> CommitReceipt: - """Sign and commit a voucher for the directive amount. - - Mirrors rust ``MeteredDelivery::ack``. - """ + """Sign and commit a voucher for the directive amount.""" return self._consumer.commit_directive(self._metering) def commit(self) -> CommitReceipt: - """Alias for :meth:`ack`. Mirrors rust ``MeteredDelivery::commit``.""" + """Alias for :meth:`ack`.""" return self.ack() def into_parts(self) -> tuple[P, MeteringDirective]: - """Return the payload and metering directive without committing. - - Mirrors rust ``MeteredDelivery::into_parts``. - """ + """Return the payload and metering directive without committing.""" return self._payload, self._metering diff --git a/python/src/pay_kit/protocols/mpp/intents/__init__.py b/python/src/pay_kit/protocols/mpp/intents/__init__.py index bc234496a..ed8eae73d 100644 --- a/python/src/pay_kit/protocols/mpp/intents/__init__.py +++ b/python/src/pay_kit/protocols/mpp/intents/__init__.py @@ -6,7 +6,7 @@ :class:`SessionAction` credential union, signed vouchers, and the metering types). It also re-exports the :func:`parse_units` helper that converts a human-readable decimal amount into base units at the SDK boundary. The wire -format mirrors ``rust/crates/mpp/src/protocol/intents/{charge,session}.rs``. +format is defined by the MPP specification's charge and session intents. The individual intent modules (:mod:`pay_kit.protocols.mpp.intents.charge`, diff --git a/python/src/pay_kit/protocols/mpp/intents/session.py b/python/src/pay_kit/protocols/mpp/intents/session.py index d0d36b195..aefb9dfe0 100644 --- a/python/src/pay_kit/protocols/mpp/intents/session.py +++ b/python/src/pay_kit/protocols/mpp/intents/session.py @@ -2,14 +2,14 @@ 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 -``rust/crates/mpp/src/protocol/intents/session.rs`` and the parity-verified Go -port in ``go/protocols/mpp/intents/session.go``. - -The house style is plain :func:`dataclasses.dataclass` with explicit -``to_dict()``/``from_dict()`` helpers, camelCase on the wire, and omit-empty by -conditional inclusion in ``to_dict()``. ``parse_units`` is re-exported from the -charge intent so callers keep a stable amount-parsing entry point. +on-chain payment-channels program. The wire format is defined by the MPP +specification's session intent. + +Types are plain :func:`dataclasses.dataclass` with explicit +``to_dict()``/``from_dict()`` helpers, camelCase field names on the wire, and +omit-empty behaviour implemented by conditional inclusion in ``to_dict()``. +``parse_units`` is re-exported from the charge intent so callers keep a stable +amount-parsing entry point. """ from __future__ import annotations @@ -44,8 +44,7 @@ # Default session voucher/directive expiry: 2100-01-01T00:00:00Z. # # This stays below JavaScript's max safe integer so JSON intermediaries do not -# round it before the credential is decoded. Mirrors rust -# DEFAULT_SESSION_EXPIRES_AT. +# round it before the credential is decoded. DEFAULT_SESSION_EXPIRES_AT = 4_102_444_800 _U64_MAX = 2**64 - 1 @@ -54,10 +53,10 @@ def _parse_base_units(raw: object) -> int: """Parse a canonical unsigned base-unit decimal string into a ``u64``. - Rejects empty, signed, fractional, non-ASCII-digit, or out-of-range values, - matching the rust/Go typed ``u64`` parsers so a malformed amount (e.g. - ``"-1"``) cannot slip past zero/max-cap checks or fail later when packed for - Solana. + Rejects empty, signed, fractional, non-ASCII-digit, or out-of-range values. + The amount is validated up front as a typed ``u64`` so a malformed value + (e.g. ``"-1"``) cannot slip past zero/max-cap checks or fail later when + packed for Solana. """ s = str(raw) if not (s.isascii() and s.isdigit()): @@ -70,18 +69,19 @@ def _parse_base_units(raw: object) -> int: # On-chain funding mechanism for a session. Advertised by the server in # ``SessionRequest.modes``; the client picks the mode it will use in its open -# action. Mirrors rust SessionMode (rename_all="camelCase"). +# action. Encoded on the wire as the camelCase string ``"push"`` or ``"pull"``. SessionMode = Literal["push", "pull"] -# Voucher authority used when ``"pull"`` mode is advertised. Mirrors rust -# SessionPullVoucherStrategy (rename_all="camelCase"). +# Voucher authority used when ``"pull"`` mode is advertised. Encoded on the wire +# as the camelCase string ``"clientVoucher"`` or ``"operatedVoucher"``. SessionPullVoucherStrategy = Literal["clientVoucher", "operatedVoucher"] -# Commit receipt status. Mirrors rust CommitStatus (rename_all="camelCase"). +# Commit receipt status. Encoded on the wire as the camelCase string +# ``"committed"`` or ``"replayed"``. CommitStatus = Literal["committed", "replayed"] -# Action discriminator values. Note ``"topUp"`` is camelCase on the wire, just -# like rust's serde(tag="action", rename_all="camelCase"). +# Action discriminator values. Note ``"topUp"`` is camelCase on the wire, in +# line with the rest of the session field naming. _SessionActionTag = Literal["open", "voucher", "commit", "topUp", "close"] @@ -90,7 +90,8 @@ class SessionSplit: """A payment split committed at channel open; distributed to a specific recipient when the channel closes. - Mirrors rust ``SessionSplit``. + ``recipient`` is the destination address and ``bps`` is that recipient's + share of the settled amount in basis points (hundredths of a percent). """ recipient: str @@ -108,9 +109,11 @@ def from_dict(cls, data: dict[str, Any]) -> SessionSplit: class SessionRequest: """Session intent request, the payload embedded in a 402 challenge. - Describes the channel parameters: cap, currency, splits, network, etc. - Mirrors rust ``SessionRequest``; optional fields are omitted from - ``to_dict()`` when ``None`` and ``splits``/``modes`` are omitted when empty. + Describes the channel parameters the server is offering: the spending cap, + currency and decimals, recipient and operator addresses, optional payment + splits, network, program id, and the funding modes the server supports. + Optional fields are omitted from ``to_dict()`` when ``None`` and + ``splits``/``modes`` are omitted when empty. """ cap: str @@ -160,9 +163,9 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> SessionRequest: decimals = data.get("decimals") - # rust deserializes ``modes`` as Vec and - # ``pullVoucherStrategy`` as an enum, so unknown variants fail at - # decode; mirror that instead of deferring the failure downstream. + # ``modes`` and ``pullVoucherStrategy`` are validated against their known + # values at decode time, so an unknown variant fails here rather than + # being deferred to a downstream consumer. modes: list[SessionMode] = [] for mode in data.get("modes", []): if mode not in ("push", "pull"): @@ -192,9 +195,8 @@ def from_dict(cls, data: dict[str, Any]) -> SessionRequest: def _salt_to_wire(salt: int | None) -> str | None: """Serialize an optional salt as a decimal string. - Mirrors rust ``serialize_optional_u64_as_string``: authorization headers are - JSON canonicalized and arbitrary ``u64`` values are not safe JSON numbers, - so the salt always travels as a decimal string. + Authorization headers are JSON canonicalized and an arbitrary ``u64`` is not + a safe JSON number, so the salt always travels as a decimal string. """ if salt is None: return None @@ -204,16 +206,15 @@ def _salt_to_wire(salt: int | None) -> str | None: def _salt_from_wire(value: Any) -> int | None: """Parse an optional salt from a decimal string or a JSON number. - Mirrors rust ``deserialize_optional_u64_from_string_or_number``. ``int`` is - accepted directly (no float precision loss because Python ints are - arbitrary precision); strings are parsed as base-10 integers. + ``int`` is accepted directly (no float precision loss because Python ints + are arbitrary precision); strings are parsed as base-10 integers. """ if value is None: return None if isinstance(value, bool): raise ValueError("salt must be a decimal string or unsigned 64-bit integer") - # rust deserializes salt as u64 and rejects negative / out-of-range values; - # match that here rather than letting a malformed salt fail later inside + # The salt is validated as a ``u64``, rejecting negative or out-of-range + # values here rather than letting a malformed salt fail later inside # struct.pack. Accept an int directly (no float precision loss) or a strict # unsigned-decimal string. if isinstance(value, int): @@ -235,9 +236,12 @@ class OpenPayload: Use :meth:`push`, :meth:`payment_channel`, :meth:`payment_channel_with_mode`, or :meth:`pull` to construct. Inspect :attr:`mode` to distinguish variants on the server. ``mode`` is required: :meth:`from_dict` raises when it is absent. + Push-mode fields (``channel_id``, ``deposit``, ``payer``, ``payee``, + ``mint``, ``salt``, ``grace_period``, ``transaction``) and pull-mode fields + (``token_account``, ``approved_amount``, ``owner``, ``init_multi_delegate_tx``, + ``update_delegation_tx``) are populated according to the selected mode. ``salt`` serializes as a decimal string and decodes from string or number. - Mirrors rust ``OpenPayload``. """ mode: SessionMode @@ -269,7 +273,8 @@ def push( ) -> OpenPayload: """Construct a **push** payment-channel open payload. - Mirrors rust ``OpenPayload::push``. + Sets ``mode`` to ``"push"`` and records the channel id and deposit along + with the authorized signer and its signature. """ return cls( mode="push", @@ -292,9 +297,11 @@ def payment_channel( authorized_signer: str, signature: str, ) -> OpenPayload: - """Construct a payment-channel **push** open payload. + """Construct a payment-channel **push** open payload with full channel + details. - Mirrors rust ``OpenPayload::payment_channel``. + Records the full set of payment-channel fields (channel id, deposit, + payer, payee, mint, salt, grace period) in ``"push"`` mode. """ return cls.payment_channel_with_mode( "push", @@ -326,7 +333,8 @@ def payment_channel_with_mode( """Construct a payment-channel open payload with an explicit submission mode. - Mirrors rust ``OpenPayload::payment_channel_with_mode``. + Like :meth:`payment_channel` but lets the caller choose the ``mode`` + (``"push"`` or ``"pull"``) under which the channel fields are submitted. """ return cls( mode=mode, @@ -352,7 +360,9 @@ def pull( ) -> OpenPayload: """Construct a **pull** (SPL delegation) open payload. - Mirrors rust ``OpenPayload::pull``. + Sets ``mode`` to ``"pull"`` and records the delegated token account, the + approved amount, and the owner along with the authorized signer and its + signature. """ return cls( mode="pull", @@ -366,7 +376,8 @@ def pull( def with_transaction(self, tx_base64: str) -> OpenPayload: """Attach a signed open transaction for operator/server broadcast. - Mirrors rust ``OpenPayload::with_transaction``. + Stores the base64-encoded transaction in ``transaction`` and returns + ``self`` for chaining. """ self.transaction = tx_base64 return self @@ -375,7 +386,8 @@ def with_init_tx(self, tx_base64: str) -> OpenPayload: """Attach a pre-signed ``InitMultiDelegate`` + ``CreateFixedDelegation`` transaction. - Mirrors rust ``OpenPayload::with_init_tx``. + Stores the base64-encoded transaction in ``init_multi_delegate_tx`` and + returns ``self`` for chaining. """ self.init_multi_delegate_tx = tx_base64 return self @@ -384,7 +396,8 @@ def with_update_tx(self, tx_base64: str) -> OpenPayload: """Attach a pre-signed ``CreateFixedDelegation`` (cap update) transaction. - Mirrors rust ``OpenPayload::with_update_tx``. + Stores the base64-encoded transaction in ``update_delegation_tx`` and + returns ``self`` for chaining. """ self.update_delegation_tx = tx_base64 return self @@ -395,7 +408,7 @@ def session_id(self) -> str: - Payment channel: ``channel_id`` - Operated-voucher pull: ``token_account`` - Mirrors rust ``OpenPayload::session_id``. + Raises when the required identifier for the current mode is absent. """ if self.channel_id is not None: return self.channel_id @@ -410,7 +423,9 @@ def session_id(self) -> str: def deposit_amount(self) -> int: """Deposit / approved amount for this open (base units). - Mirrors rust ``OpenPayload::deposit_amount``. + Returns ``deposit`` in push mode and ``approved_amount`` in pull mode, + parsed as a ``u64``. Raises when the required amount for the current + mode is absent or malformed. """ if self.deposit is not None: raw = self.deposit @@ -465,8 +480,8 @@ def from_dict(cls, data: dict[str, Any]) -> OpenPayload: mode = data.get("mode") if not mode: raise ValueError("open payload: missing mode") - # rust deserializes ``mode`` as the SessionMode enum, rejecting unknown - # variants at decode; mirror that rather than failing later inside + # ``mode`` is validated against the known session modes at decode time, + # rejecting unknown variants here rather than failing later inside # session_id()/deposit_amount(). if mode not in ("push", "pull"): raise ValueError(f"open payload: unknown mode {mode!r}") @@ -497,7 +512,7 @@ class VoucherData: Serialized as the on-chain ``VoucherArgs`` layout before signing: ``channel_id || cumulative_amount_le || expires_at_le``. The wire field for the cumulative amount is ``cumulativeAmount`` with a ``cumulative`` decode - alias. Mirrors rust ``VoucherData``. + alias. ``nonce`` is optional and omitted from the wire when ``None``. """ channel_id: str @@ -543,12 +558,10 @@ def message_bytes(self) -> bytes: Layout (exactly 48 bytes): ``channel_id``\\ (32, base58-decoded) || ``cumulative_amount`` little-endian ``u64`` (offset 32) || ``expires_at`` little-endian ``i64`` (offset 40). Delegates to the canonical packer so - the 48-byte layout has a single source of truth, mirroring rust - ``VoucherData::message_bytes`` (which calls ``voucher_message_bytes``). + the 48-byte layout has a single source of truth. """ - # Lazy import so the module imports without solders installed, matching - # the charge intent's discipline (no cycle: the glue does not import the - # intent layer). + # Lazy import so the module imports without solders installed (no cycle: + # the glue does not import the intent layer). from solders.pubkey import Pubkey # type: ignore[import-untyped] from pay_kit.protocols.mpp._paymentchannels import voucher_message_bytes @@ -570,7 +583,8 @@ class SignedVoucher: Vouchers are cumulative: the server always uses the latest valid voucher it has received. The client MUST increment ``cumulative`` with each request. - Mirrors rust ``SignedVoucher``. + ``signature`` is the client's Ed25519 signature over the voucher's + ``message_bytes``. """ data: VoucherData @@ -591,7 +605,8 @@ def from_dict(cls, data: dict[str, Any]) -> SignedVoucher: class VoucherPayload: """Payload for the ``voucher`` action (per-request micropayment). - Mirrors rust ``VoucherPayload``. + Carries the single :class:`SignedVoucher` the client presents to authorize a + request against an open channel. """ voucher: SignedVoucher @@ -608,7 +623,8 @@ def from_dict(cls, data: dict[str, Any]) -> VoucherPayload: class CommitPayload: """Payload for the ``commit`` action. - Mirrors rust ``CommitPayload``. + Acknowledges a specific delivery (``delivery_id``) by submitting the + :class:`SignedVoucher` that pays for it. """ delivery_id: str @@ -629,7 +645,8 @@ def from_dict(cls, data: dict[str, Any]) -> CommitPayload: class TopUpPayload: """Payload for the ``topUp`` action. - Mirrors rust ``TopUpPayload``. + Raises the deposit backing an open channel (``channel_id``) to + ``new_deposit``, authorized by ``signature``. """ channel_id: str @@ -656,7 +673,8 @@ def from_dict(cls, data: dict[str, Any]) -> TopUpPayload: class ClosePayload: """Payload for the ``close`` action. - Mirrors rust ``ClosePayload``; ``voucher`` is omitted when ``None``. + Closes the channel identified by ``channel_id``. The final + :class:`SignedVoucher` is optional and omitted from the wire when ``None``. """ channel_id: str @@ -684,8 +702,7 @@ class SessionAction: Serialized as a tagged object with ``"action": "open" | "voucher" | "commit" | "topUp" | "close"`` and the payload fields flattened alongside the discriminator. Exactly one payload is - set for a valid action. Mirrors rust ``SessionAction`` - (serde tag="action", rename_all="camelCase"). + set for a valid action. """ open: OpenPayload | None = None @@ -722,8 +739,8 @@ def close_action(cls, payload: ClosePayload) -> SessionAction: def to_dict(self) -> dict[str, Any]: """Flatten the active payload alongside an ``"action"`` discriminator. - Mirrors rust's ``#[serde(tag="action")]`` enum encoding. Exactly one - variant must be set. + The active variant's fields are emitted at the top level next to the + ``"action"`` tag. Exactly one variant must be set, otherwise this raises. """ variants: list[tuple[_SessionActionTag, dict[str, Any]]] = [] if self.open is not None: @@ -747,8 +764,7 @@ def to_dict(self) -> dict[str, Any]: def from_dict(cls, data: dict[str, Any]) -> SessionAction: """Read the ``"action"`` discriminator and decode the flattened payload. - Mirrors rust's ``#[serde(tag="action")]`` decoding: an empty discriminator - and an unknown action both raise. + An empty discriminator and an unknown action both raise. """ action = data.get("action") if not action: @@ -772,8 +788,8 @@ class MeteringDirective: Clients treat this like an offset in a message log: once the message has been processed successfully, ``ack``/``commit`` signs a voucher for ``amount`` and - sends a :class:`CommitPayload` back to the server. Mirrors rust - ``MeteringDirective``. + sends a :class:`CommitPayload` back to the server. ``commit_url`` and + ``proof`` are optional and omitted from the wire when ``None``. """ delivery_id: str @@ -788,7 +804,7 @@ class MeteringDirective: def amount_base_units(self) -> int: """Parse ``amount`` as base units. - Mirrors rust ``MeteringDirective::amount_base_units``. + Returns the reserved amount as a ``u64``, raising when it is malformed. """ try: return _parse_base_units(self.amount) @@ -829,7 +845,8 @@ class MeteringUsage: """Final usage reported by a streaming response. The amount MUST be less than or equal to the amount reserved by the original - :class:`MeteringDirective`. Mirrors rust ``MeteringUsage``. + :class:`MeteringDirective`. ``delivery_id`` ties the usage back to that + directive. """ delivery_id: str @@ -838,7 +855,7 @@ class MeteringUsage: def amount_base_units(self) -> int: """Parse ``amount`` as base units. - Mirrors rust ``MeteringUsage::amount_base_units``. + Returns the reported usage as a ``u64``, raising when it is malformed. """ try: return _parse_base_units(self.amount) @@ -857,8 +874,9 @@ def from_dict(cls, data: dict[str, Any]) -> MeteringUsage: class MeteredEnvelope: """A payload paired with the metering directive required to acknowledge it. - The payload is left as an opaque value (any JSON-serializable object), - mirroring rust ``MeteredEnvelope``. + The payload is left as an opaque value (any JSON-serializable object) and + ``metering`` carries the :class:`MeteringDirective` the client must commit + against once the payload is processed. """ payload: Any @@ -879,7 +897,10 @@ def from_dict(cls, data: dict[str, Any]) -> MeteredEnvelope: class CommitReceipt: """Result returned after a delivery commit is accepted. - Mirrors rust ``CommitReceipt``. + Reports the committed ``delivery_id`` and ``session_id``, the ``amount`` + charged for this commit, the running ``cumulative`` total, and a ``status`` + of ``"committed"`` (newly applied) or ``"replayed"`` (a duplicate that was + deduplicated server-side). """ delivery_id: str @@ -913,9 +934,9 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> CommitReceipt: - # rust deserializes ``status`` as the CommitStatus enum, so a missing - # or unknown status fails at decode; mirror that so a malformed receipt - # can never advance client state. + # ``status`` is validated against the known commit statuses at decode + # time, so a missing or unknown status fails here and a malformed + # receipt can never advance client state. status = data.get("status") if status not in ("committed", "replayed"): raise ValueError(f"commit receipt: unknown status {status!r}") diff --git a/python/src/pay_kit/protocols/mpp/server/session.py b/python/src/pay_kit/protocols/mpp/server/session.py index 5b08b3833..648b74885 100644 --- a/python/src/pay_kit/protocols/mpp/server/session.py +++ b/python/src/pay_kit/protocols/mpp/server/session.py @@ -20,10 +20,6 @@ transaction signature and deposit amount are trusted as provided, which is suitable only for unit tests or deployments that verify transactions out of band. - -Ports ``go/protocols/mpp/server/session.go`` (snake_case of the Go names). The -wire shapes follow the Rust spine ``rust/crates/mpp/src/server/session.rs`` and -``store.rs``. """ from __future__ import annotations @@ -80,8 +76,7 @@ # payload before channel state is persisted. Implementations typically decode # the attached transaction, bind the payload signature to it, and confirm the # signature on-chain. This is the seam the on-chain layer plugs into; ``None`` -# skips verification. Mirrors Go ``SessionTxVerifier[P]``; raising signals a -# verification failure. +# skips verification. Raising signals a verification failure. SessionTxVerifier = Callable[[_P], Awaitable[None]] @@ -89,8 +84,7 @@ class Split: """A payment split committed at channel open; distributed at close. - Mirrors Go ``Split``. ``recipient`` is a base58 public key (the Go field is - a ``solana.PublicKey``; this layer carries it as its base58 string form). + ``recipient`` is a public key carried in its base58 string form. """ # Recipient of this split (base58). @@ -101,7 +95,7 @@ class Split: @dataclass class SessionConfig: - """Server configuration for the session intent. Mirrors Go ``SessionConfig``.""" + """Server configuration for the session intent.""" # Operator public key (base58). Shown to clients in the challenge. operator: str = "" @@ -155,8 +149,7 @@ class SessionConfig: class DeliveryRequest: """A request to reserve a metered delivery for client-side ack/commit. - Zero/empty values mean "absent" for the optional fields. Mirrors Go - ``DeliveryRequest``. + Zero/empty values mean "absent" for the optional fields. """ # SessionID is the channel/session ID that will pay for the delivery. @@ -183,8 +176,6 @@ class DeliveryRequest: def _fits_in_deposit(cumulative: int, pending_total: int, amount: int, deposit: int) -> bool: """Report whether cumulative + pending_total + amount <= deposit without overflowing u64; any overflow is treated as exceeding the deposit. - - Mirrors Go ``fitsInDeposit``. """ if pending_total > _U64_MAX - cumulative: return False @@ -195,8 +186,9 @@ def _fits_in_deposit(cumulative: int, pending_total: int, amount: int, deposit: def _parse_u64(raw: str) -> int: - """Parse a canonical unsigned base-10 ``u64`` (matching Go's - ``strconv.ParseUint(s, 10, 64)``). Raises ``ValueError`` otherwise.""" + """Parse a canonical unsigned base-10 ``u64``: the string must be all ASCII + decimal digits and fit within the 64-bit unsigned range. Raises + ``ValueError`` otherwise.""" if not (raw.isascii() and raw.isdigit()): raise ValueError(f"invalid u64: {raw}") value = int(raw, 10) @@ -206,7 +198,7 @@ def _parse_u64(raw: str) -> int: def _find_pending(deliveries: list[PendingDelivery], delivery_id: str) -> PendingDelivery | None: - """Return the pending delivery with the given id, or None. Mirrors Go ``findPending``.""" + """Return the pending delivery with the given id, or None.""" for delivery in deliveries: if delivery.delivery_id == delivery_id: return delivery @@ -214,7 +206,7 @@ def _find_pending(deliveries: list[PendingDelivery], delivery_id: str) -> Pendin def _find_committed(deliveries: list[CommittedDelivery], delivery_id: str) -> CommittedDelivery | None: - """Return the committed delivery with the given id, or None. Mirrors Go ``findCommitted``.""" + """Return the committed delivery with the given id, or None.""" for delivery in deliveries: if delivery.delivery_id == delivery_id: return delivery @@ -224,7 +216,7 @@ def _find_committed(deliveries: list[CommittedDelivery], delivery_id: str) -> Co def _commit_receipt( delivery_id: str, session_id: str, amount: int, cumulative: int, status: CommitStatus ) -> CommitReceipt: - """Build a CommitReceipt with stringified amounts. Mirrors Go ``commitReceipt``.""" + """Build a CommitReceipt with stringified amounts.""" return CommitReceipt( delivery_id=delivery_id, session_id=session_id, @@ -237,8 +229,6 @@ def _commit_receipt( class SessionServer: """Server-side session manager. Pluggable over the channel store to support in-memory testing and production persistence backends. - - Mirrors Go ``SessionServer``. """ def __init__(self, config: SessionConfig, store: ChannelStore) -> None: @@ -252,15 +242,14 @@ def __init__(self, config: SessionConfig, store: ChannelStore) -> None: def config(self) -> SessionConfig: """The immutable server configuration captured at construction. - Exposed read-only so the HTTP-facing session method (the Go ``Session``, - which reads ``s.core.config`` within the package) can inspect the + Exposed read-only so the HTTP-facing session method can inspect the configured currency/decimals/network without reaching into a private - field. Mirrors the Go ``SessionServer.config`` field.""" + field.""" return self._config def store(self) -> ChannelStore: """Return the channel store backing this server, so hosts can share it - with metering side channels. Mirrors Go ``Store``.""" + with metering side channels.""" return self._store def build_challenge_request(self, cap: int) -> SessionRequest: @@ -269,8 +258,7 @@ def build_challenge_request(self, cap: int) -> SessionRequest: ``cap`` is the maximum this session will allow, clamped to ``SessionConfig.max_cap``. ``min_voucher_delta`` is included only when positive, ``modes`` is omitted when push-only, and - ``pull_voucher_strategy`` is included only when pull is offered. Mirrors - Go ``BuildChallengeRequest``. + ``pull_voucher_strategy`` is included only when pull is offered. """ effective_cap = min(cap, self._config.max_cap) @@ -298,13 +286,13 @@ def build_challenge_request(self, cap: int) -> SessionRequest: return request def _push_only(self) -> bool: - """Report whether the configured modes reduce to push-only. Mirrors Go ``pushOnly``.""" + """Report whether the configured modes reduce to push-only.""" modes = self._config.modes return len(modes) == 0 or (len(modes) == 1 and modes[0] == "push") def _supports_mode(self, mode: SessionMode) -> bool: """Report whether the server accepts ``mode``. Empty configured modes - mean push-only. Mirrors Go ``supportsMode``.""" + mean push-only.""" modes = self._config.modes if len(modes) == 0: return mode == "push" @@ -319,7 +307,7 @@ async def process_open(self, payload: OpenPayload) -> ChannelState: signer, the existing state is returned unchanged and the voucher watermark is never reset. Opens for an existing channel are rejected when the channel is finalized or when the payload's authorized signer - differs from the stored one. Mirrors Go ``ProcessOpen``. + differs from the stored one. """ if not self._supports_mode(payload.mode): raise ValueError(f"session mode {payload.mode!r} is not supported by this challenge") @@ -373,7 +361,7 @@ async def verify_voucher(self, payload: VoucherPayload) -> int: The full ordered check sequence runs as a preflight outside the store lock (see :func:`verify_voucher_for_channel`), then the state-dependent checks are re-applied inside the atomic mutator before the watermark is - persisted. Mirrors Go ``VerifyVoucher``. + persisted. """ voucher = payload.voucher channel_id = voucher.data.channel_id @@ -436,7 +424,7 @@ async def process_top_up(self, payload: TopUpPayload) -> ChannelState: The new deposit must exceed the current deposit and must not exceed the configured max cap. Top-ups are rejected once the channel is finalized - or a close has been requested. Mirrors Go ``ProcessTopUp``. + or a close has been requested. """ try: new_deposit = _parse_u64(payload.new_deposit) @@ -476,7 +464,7 @@ async def begin_delivery(self, request: DeliveryRequest) -> MeteringDirective: The reservation requires cumulative + pendingTotal + amount <= deposit, assigns the next sequence, and defaults the delivery id to - ":". Mirrors Go ``BeginDelivery``. + ":". """ if request.amount == 0: raise ValueError("delivery amount must be greater than zero") @@ -548,7 +536,7 @@ async def process_commit(self, payload: CommitPayload) -> CommitReceipt: Replaying a commit for an already-committed delivery (same cumulative and same signature) returns the cached receipt with status replayed - after re-verifying the voucher signature. Mirrors Go ``ProcessCommit``. + after re-verifying the voucher signature. """ channel_id = payload.voucher.data.channel_id try: @@ -645,7 +633,7 @@ async def process_close(self, payload: ClosePayload) -> ChannelState: top-ups are all rejected, and a second close is rejected with "close already requested". A non-monotonic final voucher is a hard error (unless it is an idempotent replay of the current highest voucher) and - leaves the state unchanged. Mirrors Go ``ProcessClose``. + leaves the state unchanged. """ now = int(time.time()) channel_id = payload.channel_id @@ -694,7 +682,7 @@ def mutator(current: ChannelState | None) -> ChannelState: async def mark_finalized(self, channel_id: str) -> None: """Mark a channel as finalized. Call after the on-chain finalize - transaction confirms. Mirrors Go ``MarkFinalized``.""" + transaction confirms.""" await self._store.mark_finalized(channel_id) @@ -717,17 +705,16 @@ def _voucher_state(state: ChannelState) -> VoucherChannelState: def _raise_voucher_error(err: str | None) -> None: """Raise when the voucher verifier (string-returning) reports a failure. - The Python ``verify_session_voucher`` returns an error string (or None); - the Go ``verifySessionVoucher`` returns an ``error``. Convert the string - form to a raised ``ValueError`` so the session paths surface it like Go. + ``verify_session_voucher`` returns an error string (or None); convert the + string form to a raised ``ValueError`` so the session paths surface + verification failures as exceptions. """ if err is not None: raise ValueError(err) def _wrap(message: str, exc: Exception) -> Exception: - """Wrap a seam error with a message prefix, mirroring Go's - ``fmt.Errorf("...: %w", err)``: the prefixed message is surfaced and the - original error is preserved as the exception cause (callers can inspect - ``__cause__`` the way Go callers use ``errors.Is``).""" + """Wrap a seam error with a message prefix: the prefixed message is + surfaced and the original error is preserved as the exception cause, so + callers can inspect ``__cause__`` to recover the underlying failure.""" return ValueError(f"{message}: {exc}") diff --git a/python/src/pay_kit/protocols/mpp/server/session_lifecycle.py b/python/src/pay_kit/protocols/mpp/server/session_lifecycle.py index 52275c334..d441a16df 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_lifecycle.py +++ b/python/src/pay_kit/protocols/mpp/server/session_lifecycle.py @@ -9,11 +9,9 @@ The idle-close watchdog is an extension beyond the draft MPP spec; without it, hosts drive close explicitly. -Port of ``go/protocols/mpp/server/session_lifecycle.go``. Go arms background -goroutine timers via ``time.AfterFunc``; this port uses ``asyncio`` single-shot -timers scheduled on the running event loop, and the handler is an async -coroutine to match the rest of the async server surface. ``close_delay`` is a -duration in seconds (Go uses ``time.Duration``). +Single-shot ``asyncio`` timers are scheduled on the running event loop, and the +handler is an async coroutine to match the rest of the async server surface. +``close_delay`` is a duration in seconds. """ from __future__ import annotations @@ -90,8 +88,8 @@ def shutdown(self) -> None: def _fire(self, channel_id: str) -> None: """Timer callback: drop the timer and schedule the idle-close handler. - Mirrors the Go ``time.AfterFunc`` closure: forget the timer, bail if a - shutdown raced in, otherwise dispatch ``close_on_idle``. + Forget the timer, bail if a shutdown raced in, otherwise dispatch + ``close_on_idle``. """ with self._lock: self._timers.pop(channel_id, None) diff --git a/python/src/pay_kit/protocols/mpp/server/session_method.py b/python/src/pay_kit/protocols/mpp/server/session_method.py index 192f29168..673287e8c 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_method.py +++ b/python/src/pay_kit/protocols/mpp/server/session_method.py @@ -9,27 +9,21 @@ :class:`ChannelStore`, the voucher verifier, the on-chain verifier seams, and the idle-close watchdog. -Ports the offline-core surface of ``go/protocols/mpp/server/session_method.go`` -(snake_case of the Go names); the field semantics are pinned by -``rust/crates/mpp/src/server/session.rs``. - -Trust model / on-chain seam: exactly as in the Go port, the RPC client is -optional. With no RPC client the transaction signature and deposit amount are -trusted as provided (offline core); with an RPC client an open's confirmation -signature is checked on-chain before the channel is persisted, and a top-up -signature is confirmed before the deposit is raised. The on-chain check is wired -through the :class:`SessionServer` config seams +Trust model / on-chain seam: the RPC client is optional. With no RPC client the +transaction signature and deposit amount are trusted as provided (offline +core); with an RPC client an open's confirmation signature is checked on-chain +before the channel is persisted, and a top-up signature is confirmed before the +deposit is raised. The on-chain check is wired through the +:class:`SessionServer` config seams (:func:`~pay_kit.protocols.mpp.server.session_onchain.new_open_tx_verifier` / -:func:`~pay_kit.protocols.mpp.server.session_onchain.new_top_up_tx_verifier`), -matching how the Python on-chain layer composes verification. - -Not ported here (their lower-level Python building blocks do not yet exist): the -server-broadcast open path (``OpenTxSubmitterServer`` / ``SubmitOpenTx``), the -on-chain settlement at close (``closeAndSettleChannel`` / -``SettlementInstructions``), and the metering side-channel HTTP routes -(``SessionRoutes``). The idle-close watchdog is wired but, without a settlement -path, its handler is a no-op when no RPC settlement is configured, exactly as -the Go ``closeOnIdle`` returns early when no signer/RPC is set. +:func:`~pay_kit.protocols.mpp.server.session_onchain.new_top_up_tx_verifier`). + +This handler implements the offline-core surface. Not yet implemented (their +lower-level building blocks do not exist yet): the server-broadcast open path +(server-side fee-payer signing and open-tx submission), on-chain settlement at +close, and the metering side-channel HTTP routes. The idle-close watchdog is +wired but, without a settlement path, its handler is a no-op when no RPC +settlement is configured. """ from __future__ import annotations @@ -82,22 +76,21 @@ # OpenTxSubmitter selects who broadcasts a push-mode payment-channel open -# transaction. Mirrors Go ``OpenTxSubmitter``. +# transaction. OpenTxSubmitter = str # The client broadcasts the open transaction itself and the server only verifies -# it. Default. Mirrors Go ``OpenTxSubmitterClient``. +# it. Default. OPEN_TX_SUBMITTER_CLIENT: OpenTxSubmitter = "client" # The server completes the fee-payer signature, broadcasts the client-built open # transaction, and waits for confirmation before persisting channel state. -# Mirrors Go ``OpenTxSubmitterServer``. OPEN_TX_SUBMITTER_SERVER: OpenTxSubmitter = "server" @dataclass class SessionOptions: - """Options for :func:`new_session`. Mirrors Go ``SessionOptions``.""" + """Options for :func:`new_session`.""" # Operator public key (base58), shown to clients in the challenge. operator: str = "" @@ -143,8 +136,7 @@ class SessionOptions: @dataclass class SessionChallengeOptions: - """Customize a single 402 session challenge. Mirrors Go - ``SessionChallengeOptions``.""" + """Customize a single 402 session challenge.""" # Cap is the requested session cap (base units, decimal string). Empty uses # the server maximum; larger requests are clamped to it. @@ -159,7 +151,7 @@ class SessionChallengeOptions: def _parse_session_u64(value: str, name: str) -> int: """Parse a non-negative decimal string into a u64, naming the field on - error. Mirrors Go ``parseSessionU64``.""" + error.""" if not (value.isascii() and value.isdigit()): raise ValueError(f"{name} is not an unsigned integer string: {value}") parsed = int(value, 10) @@ -173,8 +165,7 @@ async def _try_recent_blockhash(rpc: Any) -> str | None: Returns the blockhash string on success or ``None`` on any error/absence; the prefetch is non-fatal because the client fetches its own blockhash when - the challenge omits one. Mirrors the non-fatal ``GetLatestBlockhash`` branch - in Go ``Challenge``. + the challenge omits one. """ getter: Any = getattr(rpc, "get_latest_blockhash", None) if not callable(getter): @@ -192,8 +183,7 @@ async def _try_recent_blockhash(rpc: Any) -> str | None: def _success_receipt(reference: str, challenge_id: str, external_id: str) -> Receipt: - """Build a success receipt for a session action. Mirrors Go - ``successReceipt``.""" + """Build a success receipt for a session action.""" return Receipt.success( method="solana", reference=reference, @@ -203,10 +193,7 @@ def _success_receipt(reference: str, challenge_id: str, external_id: str) -> Rec class Session: - """The server-side session method handler. Create with :func:`new_session`. - - Mirrors Go ``Session``. - """ + """The server-side session method handler. Create with :func:`new_session`.""" def __init__( self, @@ -242,26 +229,24 @@ def __init__( def core(self) -> SessionServer: """Return the underlying :class:`SessionServer` so hosts can reach the - channel store and the lower-level lifecycle methods. Mirrors Go - ``Core``.""" + channel store and the lower-level lifecycle methods.""" return self._core def shutdown(self) -> None: """Cancel the idle-close watchdog timers. Hosts should call it when - tearing the session method down. Mirrors Go ``Shutdown``.""" + tearing the session method down.""" if self._lifecycle is not None: self._lifecycle.shutdown() def _touch(self, channel_id: str) -> None: """Reset the idle-close timer for ``channel_id`` when the watchdog is - armed. Mirrors Go ``touch``.""" + armed.""" if self._lifecycle is not None: self._lifecycle.touch(channel_id) def _supports_mode(self, mode: SessionMode) -> bool: """Report whether the configured modes accept ``mode``; empty modes mean - push-only. Mirrors the ``core.supportsMode`` check the Go ``handleOpen`` - runs before resolving the channel facts.""" + push-only. Checked before resolving the channel facts in an open.""" modes = self._core.config.modes if not modes: return mode == "push" @@ -274,7 +259,7 @@ async def challenge(self, options: SessionChallengeOptions | None = None) -> Pay is included only when positive, ``modes`` are omitted when push-only, ``pull_voucher_strategy`` is included only when pull is offered, and a recent blockhash is prefetched (non-fatally) when an RPC client is - configured. Mirrors Go ``Challenge``. + configured. """ if options is None: options = SessionChallengeOptions() @@ -311,7 +296,7 @@ async def challenge(self, options: SessionChallengeOptions | None = None) -> Pay async def verify_credential(self, credential: PaymentCredential) -> Receipt: """Verify a session Authorization credential: Tier-1 HMAC and expiry, the Tier-2 pinned-field backstop, then dispatch on the payload action (open / - voucher / commit / topUp / close). Mirrors Go ``VerifyCredential``. + voucher / commit / topUp / close). """ challenge = PaymentChallenge( id=credential.challenge.id, @@ -359,7 +344,7 @@ def _verify_pinned_session_fields(self, credential: PaymentCredential, request: the challenge was issued by this server, fields fixed at construction time are compared so a credential issued for a different method/intent/realm or for a different recipient/currency cannot reach - the action handlers. Mirrors Go ``verifyPinnedSessionFields``. + the action handlers. """ method_name = "solana" if credential.challenge.method != method_name: @@ -394,11 +379,11 @@ async def _handle_open(self, payload: OpenPayload) -> str: """Process an open action: resolve the channel facts, enforce the deposit invariants, and insert the channel state atomically and idempotently. The receipt reference is the open signature when one exists, else the channel - id. Mirrors the trusted / client-broadcast path of Go ``handleOpen``. + id. This implements the trusted / client-broadcast open path. """ if self._open_tx_submitter == OPEN_TX_SUBMITTER_SERVER: - # The server-broadcast open requires the SubmitOpenTx building block, - # which is not yet ported to Python. + # The server-broadcast open requires the open-tx submission building + # block, which is not yet implemented. raise PaymentError( "openTxSubmitter=server is not supported by this port", code="invalid-config", @@ -420,7 +405,7 @@ async def _handle_open(self, payload: OpenPayload) -> str: if has_transaction: # Payment-channel-backed open verification needs the on-chain open-tx # verifier seam, which decodes and binds the attached transaction. - # That path lands with the on-chain settlement port. + # That path lands with the on-chain settlement layer. raise PaymentError( "open with an attached transaction is not supported by this port", code="invalid-payload", @@ -447,7 +432,7 @@ async def _handle_open(self, payload: OpenPayload) -> str: async def _handle_voucher(self, payload: VoucherPayload) -> str: """Verify a cumulative voucher and advance the watermark. The receipt - reference is ":". Mirrors Go ``handleVoucher``.""" + reference is ":".""" channel_id = payload.voucher.data.channel_id try: cumulative = await self._core.verify_voucher(payload) @@ -458,7 +443,7 @@ async def _handle_voucher(self, payload: VoucherPayload) -> str: async def _handle_commit(self, payload: CommitPayload) -> str: """Commit a reserved metered delivery. The receipt reference is - "::". Mirrors Go ``handleCommit``.""" + "::".""" try: receipt = await self._core.process_commit(payload) except ValueError as exc: @@ -469,7 +454,7 @@ async def _handle_commit(self, payload: CommitPayload) -> str: async def _handle_top_up(self, payload: TopUpPayload) -> str: """Raise a channel's deposit after optional on-chain confirmation of the top-up signature. The receipt reference is the top-up transaction - signature. Mirrors Go ``handleTopUp``.""" + signature.""" try: new_deposit = _parse_session_u64(payload.new_deposit, "newDeposit") except ValueError as exc: @@ -500,7 +485,7 @@ async def _handle_top_up(self, payload: TopUpPayload) -> str: async def _handle_close(self, payload: ClosePayload) -> str: """Accept the optional final voucher and flip close-pending atomically. The receipt reference is the channel id (the on-chain settlement path is - not ported). Mirrors Go ``handleClose``. + not implemented here). Unlike :meth:`SessionServer.process_close`, where a second close is always rejected, the close here is re-drivable: when a prior close @@ -576,15 +561,15 @@ def mutator(current: ChannelState | None) -> ChannelState: return reference async def _close_on_idle(self, channel_id: str) -> None: - """Idle-close watchdog handler. Without a ported on-chain settlement - path there is nothing to broadcast, so this is a no-op, exactly as the - Go ``closeOnIdle`` returns early when no signer/RPC is configured. + """Idle-close watchdog handler. Without an on-chain settlement path + there is nothing to broadcast, so this is a no-op; it returns early when + no signer/RPC is configured. """ return None def new_session(options: SessionOptions) -> Session: - """Create the server-side session method. Mirrors Go ``NewSession``.""" + """Create the server-side session method.""" if options.cap == 0: raise PaymentError("cap must be positive", code="invalid-config") if options.recipient == "": @@ -642,10 +627,10 @@ def new_session(options: SessionOptions) -> Session: pull_voucher_strategy=options.pull_voucher_strategy, ) # The method layer performs the optional on-chain liveness confirm inline in - # its open / topUp handlers (mirroring Go's NewSession, which leaves the core - # SessionConfig verifier seams unset and confirms in the method), so the core - # is left to trust payload claims; the seam stays available for hosts that - # drive the lower-level SessionServer directly. + # its open / topUp handlers, leaving the core SessionConfig verifier seams + # unset and confirming in the method, so the core is left to trust payload + # claims; the seam stays available for hosts that drive the lower-level + # SessionServer directly. core = SessionServer(config, store) session = Session( core=core, diff --git a/python/src/pay_kit/protocols/mpp/server/session_onchain.py b/python/src/pay_kit/protocols/mpp/server/session_onchain.py index 30898add9..9b35f8225 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_onchain.py +++ b/python/src/pay_kit/protocols/mpp/server/session_onchain.py @@ -1,9 +1,8 @@ """On-chain verification and settlement for the session intent. -Python counterpart of ``go/protocols/mpp/server/session_onchain.go`` (and the -field semantics pinned by ``rust/crates/mpp/src/server/session.rs``). This -module ports the standalone open-transaction verifier and the verifier-seam -factories. +Provides the standalone open-transaction verifier and the verifier-seam +factories the session server installs to validate client-submitted on-chain +activity. Trust model: when no verifier is installed (the seam is ``None``), transaction signatures and deposit amounts are trusted as provided. :func:`verify_open_tx` @@ -14,10 +13,6 @@ top-up payload carries only a signature, no transaction), so without an RPC client the top-up seam stays ``None`` and the new deposit is trusted as provided. - -The ``SessionServer``-bound ``SettlementInstructions`` method from the Go file -lands with the base session server port (not yet ported to Python); the field -semantics here are the self-contained slice that does not depend on it. """ from __future__ import annotations @@ -47,15 +42,13 @@ ] # Payment-channel open instruction discriminator (single-byte Anchor-numeric -# form, not the 8-byte sha256 convention). Matches OPEN_DISCRIMINATOR in the -# vendored Codama clients and ``openInstructionDiscriminator`` in the Go port. +# form, not the 8-byte sha256 convention). _OPEN_INSTRUCTION_DISCRIMINATOR = 1 class RpcClient(Protocol): """Minimal RPC seam used for the optional on-chain liveness check. - Mirrors the ``solanatx.RPCClient`` surface the Go port consumes: ``get_signature_statuses`` returns the per-signature status list (each entry is a status dict with an ``err`` field, or ``None`` when unknown).""" @@ -63,17 +56,14 @@ async def get_signature_statuses(self, signatures: list[str]) -> list[dict | Non #: A verifier seam installed on the session config: validates a payload (open -#: or top-up) and raises on rejection. Mirrors the Go -#: ``SessionTxVerifier[T]`` function type. +#: or top-up) and raises on rejection. OpenTxVerifier = Callable[[OpenPayload], Awaitable[None]] TopUpTxVerifier = Callable[[TopUpPayload], Awaitable[None]] class OpenVerifierConfig(Protocol): - """The subset of the session config :func:`new_open_tx_verifier` reads. - - Mirrors the fields ``NewOpenTxVerifier`` pulls off ``SessionConfig``: - challenge currency/network/recipient, the deposit cap, and the optional + """The subset of the session config :func:`new_open_tx_verifier` reads: + the challenge currency/network/recipient, the deposit cap, and the optional payment-channels program id override.""" currency: str @@ -86,7 +76,7 @@ class OpenVerifierConfig(Protocol): @dataclass class VerifyOpenTxExpected: """The challenge-side values a client-submitted open transaction is - validated against. Mirrors ``VerifyOpenTxExpected`` in the Go port.""" + validated against.""" authorized_signer: str currency: str @@ -99,8 +89,7 @@ class VerifyOpenTxExpected: @dataclass class VerifyOpenTxResult: - """The channel facts extracted from a verified open transaction. Mirrors - ``VerifyOpenTxResult`` in the Go port.""" + """The channel facts extracted from a verified open transaction.""" channel_id: str deposit: int @@ -111,8 +100,7 @@ class VerifyOpenTxResult: def is_placeholder_signature(signature: str) -> bool: """Report whether ``signature`` is the pending placeholder produced by the server-completed open flow (an empty string or a run of 40+ ``'1'`` - characters, the base58 encoding of the all-ones marker). Mirrors - ``isPlaceholderSignature`` in the Go port.""" + characters, the base58 encoding of the all-ones marker).""" if signature == "": return True if len(signature) < 40: @@ -123,7 +111,7 @@ def is_placeholder_signature(signature: str) -> bool: def _decode_transaction(transaction_b64: str) -> tuple[list[str], list, list[str]]: """Decode a base64 (legacy or v0) transaction into ``(account_keys, instructions, signatures)`` as base58 strings / compiled-instruction - objects. Mirrors ``solanatx.DecodeTransactionBase64`` consumption in Go.""" + objects.""" from solders.transaction import Transaction, VersionedTransaction from pay_kit._paycore.transaction import is_v0_wire_bytes @@ -156,7 +144,7 @@ async def verify_open_tx( rpc_client: RpcClient | None, ) -> VerifyOpenTxResult: """Decode and validate a client-submitted payment-channel open transaction - against the session challenge. Mirrors ``VerifyOpenTx`` in the Go port. + against the session challenge. Both legacy and v0 transaction encodings are accepted. The embedded open instruction must target the configured payment-channels program, the payee @@ -224,7 +212,7 @@ def account_at(indices: list[int], slot: int, label: str) -> Pubkey: if open_ix is None: raise PaymentError("no payment-channels open instruction found", code="invalid-payload") - # Open instruction account layout (matches the generated client): + # Open instruction account layout: # 0 payer, 1 payee, 2 mint, 3 authorizedSigner, 4 channel, # 5 payerTokenAccount, 6 channelTokenAccount, 7 tokenProgram, ... accounts = [int(i) for i in open_ix.accounts] @@ -287,7 +275,6 @@ def account_at(indices: list[int], slot: int, label: str) -> Pubkey: def new_open_tx_verifier(config: OpenVerifierConfig, rpc_client: RpcClient | None) -> OpenTxVerifier: """Return the on-chain open verifier to install on the session config. - Mirrors ``NewOpenTxVerifier`` in the Go port. When the open payload carries a transaction, it is structurally validated against the challenge via :func:`verify_open_tx` (with an on-chain liveness @@ -321,7 +308,7 @@ async def verifier(payload: OpenPayload) -> None: def new_top_up_tx_verifier(rpc_client: RpcClient | None) -> TopUpTxVerifier | None: """Return the on-chain top-up verifier to install on the session config: it confirms the top-up transaction signature on-chain via - ``getSignatureStatuses``. Mirrors ``NewTopUpTxVerifier`` in the Go port. + ``getSignatureStatuses``. A ``None`` ``rpc_client`` returns ``None`` so the seam stays unset, and the new deposit is trusted as provided; suitable only for unit tests or @@ -339,7 +326,7 @@ async def verifier(payload: TopUpPayload) -> None: async def confirm_transaction_signature(rpc_client: RpcClient, signature: str, label: str) -> None: """Check once via ``getSignatureStatuses`` that ``signature`` names a known, successful transaction. ``label`` names the transaction in error messages - ("open", "top-up"). Mirrors ``confirmTransactionSignature`` in the Go port. + ("open", "top-up"). """ try: Signature.from_string(signature) diff --git a/python/src/pay_kit/protocols/mpp/server/session_routes.py b/python/src/pay_kit/protocols/mpp/server/session_routes.py index 402d73c92..7b2dafadf 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_routes.py +++ b/python/src/pay_kit/protocols/mpp/server/session_routes.py @@ -5,15 +5,13 @@ capacity for a metered delivery and to ``/__402/session/commit`` to commit it with a signed voucher. Hosts mount the two handlers on those paths themselves. -Ports ``go/protocols/mpp/server/session_routes.go``. The Go ``Routes()`` is a -method on the HTTP-facing ``Session``; the routes only ever touch the -lower-level ``SessionServer`` (the Go ``s.core``) plus an idle-close ``touch`` -hook, so this port builds the handlers over a :class:`SessionServer` directly. - -House style follows the existing server modules (``middleware.py``): handlers -are framework-agnostic. Each takes the HTTP method and the raw request body and -returns a :class:`RouteResponse` carrying the status and a JSON-ready body, so -hosts can adapt it to any ASGI/WSGI framework. +The handlers only ever touch the lower-level :class:`SessionServer` plus an +idle-close ``touch`` hook, so they are built over a :class:`SessionServer` +directly. + +The handlers are framework-agnostic. Each takes the HTTP method and the raw +request body and returns a :class:`RouteResponse` carrying the status and a +JSON-ready body, so hosts can adapt it to any ASGI/WSGI framework. """ from __future__ import annotations @@ -35,13 +33,12 @@ _U64_MAX = (1 << 64) - 1 # A touch hook called with the session id after a successful reserve/commit, so -# a host's idle-close watchdog can be armed. ``None`` disables it. Mirrors the -# Go ``Session.touch``. +# a host's idle-close watchdog can be armed. ``None`` disables it. TouchFn = Callable[[str], None] # A handler takes the request method and the raw request body (the JSON text or -# bytes) and returns a RouteResponse. Mirrors the Go ``http.HandlerFunc`` shape -# (method gating + JSON body decode) in a framework-agnostic form. +# bytes) and returns a RouteResponse: method gating plus JSON body decode in a +# framework-agnostic form. RouteHandler = Callable[[str, "str | bytes"], Awaitable["RouteResponse"]] @@ -50,7 +47,7 @@ class RouteResponse: """The result of a side-channel handler: an HTTP status plus a JSON-ready body. Successful reserves/commits carry the directive/receipt dict; failures carry ``{"error": message}``, the failure body the side-channel clients - expect (Go ``writeSessionRouteError``). + expect. """ status: int @@ -62,7 +59,7 @@ class SessionRoutes: """The metering side-channel handlers built by :func:`session_routes`. Both share the session's channel store, so deliveries see channels opened - through the session method. Mirrors Go ``SessionRoutes``. + through the session method. """ # deliveries reserves capacity for a metered delivery. Mount at @@ -76,7 +73,7 @@ class SessionRoutes: def _parse_session_u64(value: str, name: str) -> int: """Parse a non-negative decimal string into a u64, naming the field in the - error. Mirrors Go ``parseSessionU64``.""" + error.""" if not isinstance(value, str) or not (value.isascii() and value.isdigit()): raise ValueError(f"{name} is not an unsigned integer string: {value}") parsed = int(value, 10) @@ -86,8 +83,8 @@ def _parse_session_u64(value: str, name: str) -> int: def _decode_body(raw: str | bytes) -> dict[str, Any]: - """Decode a JSON object request body. Raises on non-object or invalid JSON, - mirroring Go's ``json.NewDecoder(r.Body).Decode(&body)`` into a struct.""" + """Decode a JSON object request body. Raises on a non-object value or + invalid JSON.""" decoded = json.loads(raw) if not isinstance(decoded, dict): raise ValueError("request body must be a JSON object") @@ -96,16 +93,15 @@ def _decode_body(raw: str | bytes) -> dict[str, Any]: class _DecodeError(ValueError): """A request-body type mismatch caught at the decode layer. Surfaced as - HTTP 400 "invalid request body", matching Go's ``json.Decode`` rejection of - a JSON value whose type does not match the typed struct field.""" + HTTP 400 "invalid request body": a JSON value whose type does not match the + expected typed field is rejected before any processing.""" def _string_field(body: dict[str, Any], name: str) -> str: """Read a JSON string field, defaulting an absent/null value to "". - Mirrors a Go ``string`` struct field: a present value of any non-string JSON - type (number, bool, object, array) fails ``json.Decode``, so reject it as - ``invalid request body`` before any processing.""" + A present value of any non-string JSON type (number, bool, object, array) + is rejected as ``invalid request body`` before any processing.""" value = body.get(name) if value is None: return "" @@ -117,9 +113,8 @@ def _string_field(body: dict[str, Any], name: str) -> str: def _int64_field(body: dict[str, Any], name: str) -> int: """Read a JSON integer field, defaulting an absent/null value to 0. - Mirrors a Go ``int64`` struct field: ``json.Decode`` accepts only a JSON - integer. A JSON float (``10.0``/``10.5``), a numeric or non-numeric string - (``"10"``/``"soon"``), or a bool all fail decode, so reject them as + Only a JSON integer is accepted. A JSON float (``10.0``/``10.5``), a numeric + or non-numeric string (``"10"``/``"soon"``), or a bool is rejected as ``invalid request body`` before any processing. (Python parses ``bool`` as a subclass of ``int`` and JSON integers as ``int``; exclude ``bool`` so only true integers pass.)""" @@ -135,8 +130,7 @@ def session_routes(core: SessionServer, touch: TouchFn | None = None) -> Session """Build the metering side-channel handlers for a session server. ``touch`` is the idle-close hook called with the session id after a - successful reserve/commit; ``None`` disables it. Mirrors Go - ``Session.Routes`` (whose handlers delegate to ``s.core`` and ``s.touch``). + successful reserve/commit; ``None`` disables it. """ def _touch(session_id: str) -> None: @@ -148,9 +142,8 @@ async def deliveries(method: str, raw: str | bytes) -> RouteResponse: return _error(405, "POST required") try: body = _decode_body(raw) - # Strict typed decode (Go sessionDeliveryRequestBody): reject any - # field whose JSON type does not match the Go struct field before any - # store access, matching Go's json.Decode rejection. + # Strict typed decode: reject any field whose JSON type does not + # match the expected field type before any store access. session_id = _string_field(body, "sessionId") amount_raw = _string_field(body, "amount") delivery_id = _string_field(body, "deliveryId") @@ -188,8 +181,8 @@ async def commit(method: str, raw: str | bytes) -> RouteResponse: return _error(405, "POST required") try: body = _decode_body(raw) - # Strict typed decode (Go sessionCommitRequestBody): deliveryId is a - # string field, so a non-string JSON value is rejected up front. + # Strict typed decode: deliveryId is a string field, so a non-string + # JSON value is rejected up front. delivery_id = _string_field(body, "deliveryId") except (ValueError, json.JSONDecodeError): return _error(400, "invalid request body") @@ -211,5 +204,5 @@ async def commit(method: str, raw: str | bytes) -> RouteResponse: def _error(status: int, message: str) -> RouteResponse: """Build the ``{"error": message}`` failure body the side-channel clients - expect. Mirrors Go ``writeSessionRouteError``.""" + expect.""" return RouteResponse(status=status, body={"error": message}) diff --git a/python/src/pay_kit/protocols/mpp/server/session_store.py b/python/src/pay_kit/protocols/mpp/server/session_store.py index a9fd40206..6a620908f 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_store.py +++ b/python/src/pay_kit/protocols/mpp/server/session_store.py @@ -5,19 +5,16 @@ inside the mutator is atomic from the perspective of any other caller targeting the same channel while updates to different channels run concurrently. -The voucher verifier (a follow-up ``session_voucher`` port) is intentionally -side-effect-free: it computes a verdict, and the caller persists any accepted -delta through :meth:`ChannelStore.update_channel`. +The voucher verifier is intentionally side-effect-free: it computes a verdict, +and the caller persists any accepted delta through +:meth:`ChannelStore.update_channel`. -This mirrors the Go ``session_store.go`` public interface (snake_case of the Go -names) and the Rust spine wire shape in ``rust/crates/mpp/src/store.rs``. The -``ChannelState`` JSON tags are the shared snake_case wire names so durable +The ``ChannelState`` JSON tags are the shared snake_case wire names so durable stores interoperate across the language SDKs; the per-delivery records use the -camelCase ``deliveryId``/``expiresAt``/``voucherSignature`` keys from the spine. +camelCase ``deliveryId``/``expiresAt``/``voucherSignature`` keys. -House style follows the existing server modules: plain dataclasses with -``to_dict()``/``from_dict()``, ``asyncio`` locking where Go is concurrent, and -explicit type hints. +The module uses plain dataclasses with ``to_dict()``/``from_dict()``, +``asyncio`` locking for concurrent access, and explicit type hints. """ from __future__ import annotations @@ -43,8 +40,7 @@ class PendingDelivery: """One delivery the server has reserved against a channel but not yet received a signed voucher for. - Mirrors Go ``PendingDelivery`` and rust ``PendingDelivery``; the wire keys - are camelCase (``deliveryId``/``expiresAt``). + The wire keys are camelCase (``deliveryId``/``expiresAt``). """ # DeliveryID is the idempotency key for this delivery. @@ -79,8 +75,6 @@ def from_dict(cls, data: dict[str, Any]) -> PendingDelivery: class CommittedDelivery: """A delivery that has been committed by a signed voucher. Kept for idempotent commit replay. - - Mirrors Go ``CommittedDelivery`` and rust ``CommittedDelivery``. """ # DeliveryID is the idempotency key for this delivery. @@ -116,8 +110,7 @@ class ChannelState: view. The JSON tags are the shared snake_case wire names, so durable stores can - interoperate across the language SDKs. Mirrors Go ``ChannelState`` and rust - ``ChannelState``. + interoperate across the language SDKs. """ # ChannelID is the on-chain channel address (base58). @@ -179,9 +172,9 @@ class ChannelState: def clone(self) -> ChannelState: """Return a deep copy so callers can never alias store-internal state. - Mirrors the Go ``clone`` method: scalar/optional fields copy by value, - the two delivery slices copy element-wise with their own dataclass - copies so a returned list cannot mutate the stored one. + Scalar/optional fields copy by value; the two delivery slices copy + element-wise with their own dataclass copies so a returned list cannot + mutate the stored one. """ return replace( self, @@ -190,12 +183,12 @@ def clone(self) -> ChannelState: ) def to_dict(self) -> dict[str, Any]: - # Go's nil delivery slices serialize to JSON ``null`` (an empty, - # never-populated channel marshals ``"pending_deliveries":null``), while - # a populated slice serializes to an array. Python has no nil/empty + # An empty, never-populated delivery slice serializes to JSON ``null`` + # (a fresh-open channel marshals ``"pending_deliveries":null``), while a + # populated slice serializes to an array. Python has no nil/empty # distinction, so an empty list emits ``None`` to keep durable records - # byte-for-byte identical to the Go server's fresh-open output. Rust's - # ``#[serde(default)]`` decodes that ``null`` back to an empty ``Vec``. + # byte-for-byte identical across SDKs; a decoder treating absent/null as + # an empty list round-trips it cleanly. d: dict[str, Any] = { "channel_id": self.channel_id, "authorized_signer": self.authorized_signer, @@ -212,7 +205,7 @@ def to_dict(self) -> dict[str, Any]: [c.to_dict() for c in self.committed_deliveries] if self.committed_deliveries else None ), } - # settled_signature is omit-empty (Go `omitempty`, rust skip on None). + # settled_signature is omitted from the wire form when unset. if self.settled_signature is not None: d["settled_signature"] = self.settled_signature return d @@ -233,9 +226,9 @@ def from_dict(cls, data: dict[str, Any]) -> ChannelState: settled_signature=data.get("settled_signature"), operator=data.get("operator"), next_delivery_sequence=int(data.get("next_delivery_sequence", 0)), - # A missing key, explicit JSON ``null`` (Go nil slice), and an empty - # array all decode to an empty list. ``data.get(key) or []`` folds - # ``None`` and ``[]`` together; ``from_dict`` never iterates ``None``. + # A missing key, explicit JSON ``null``, and an empty array all + # decode to an empty list. ``data.get(key) or []`` folds ``None`` + # and ``[]`` together; ``from_dict`` never iterates ``None``. pending_deliveries=[PendingDelivery.from_dict(p) for p in (data.get("pending_deliveries") or [])], committed_deliveries=[CommittedDelivery.from_dict(c) for c in (data.get("committed_deliveries") or [])], ) @@ -243,10 +236,7 @@ def from_dict(cls, data: dict[str, Any]) -> ChannelState: @dataclass class ListChannelsFilter: - """Optional filter for :meth:`ChannelStore.list_channels`. - - Mirrors Go ``ListChannelsFilter``. - """ + """Optional filter for :meth:`ChannelStore.list_channels`.""" # finalized, when non-None, only includes channels matching this finalized # state. @@ -273,8 +263,8 @@ class ChannelStore: always needs an atomic read-modify-write to avoid double-spend under concurrent vouchers, so no direct put is exposed. - Mirrors the Go ``ChannelStore`` interface. Defined as an abstract base so - pyright can check structural conformance of implementations. + Defined as an abstract base so pyright can check structural conformance of + implementations. """ async def get_channel(self, channel_id: str) -> ChannelState | None: @@ -305,9 +295,8 @@ class MemoryChannelStore(ChannelStore): """In-memory :class:`ChannelStore` with per-channel locking. ``update_channel`` calls for the same channel id run strictly sequentially - while calls for different ids run concurrently. Mirrors the Go - ``MemoryChannelStore``: values are cloned on the way in and out so callers - never share memory with the store. + while calls for different ids run concurrently. Values are cloned on the way + in and out so callers never share memory with the store. """ def __init__(self) -> None: diff --git a/python/src/pay_kit/protocols/mpp/server/session_stream.py b/python/src/pay_kit/protocols/mpp/server/session_stream.py index b3445d17a..a93a98199 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_stream.py +++ b/python/src/pay_kit/protocols/mpp/server/session_stream.py @@ -3,18 +3,14 @@ Emits the Server-Sent Event frames the metered session clients decode: ``mpp.metering`` directives, ``mpp.usage`` final-usage events, plain data payload messages, and the terminal ``[DONE]`` sentinel. The event names are -canonical: they are the ones the SDK session clients parse (this port's +canonical: they are the ones the SDK session clients parse (this package's :class:`~pay_kit.protocols.mpp.client.http_stream.SseDecoder` and :func:`~pay_kit.protocols.mpp.client.http_stream.parse_metered_sse_event` among them). -Port of ``go/protocols/mpp/server/session_stream.go``. Go writes to an -``io.Writer`` / ``http.ResponseWriter`` and flushes via ``http.Flusher``; this -port writes to any object exposing a ``write`` method and flushes any that also -expose a ``flush`` method, the duck-typed shape the rest of the Python server -surface uses for HTTP responses. The Rust spine has no server-side writer (it -only decodes on the client); the wire format and the ``[DONE]`` sentinel mirror -what the client decoder consumes. +The stream writes to any object exposing a ``write`` method and flushes any +that also expose a ``flush`` method, the duck-typed shape the rest of the +Python server surface uses for HTTP responses. """ from __future__ import annotations @@ -32,7 +28,7 @@ ] # DONE_SENTINEL is the terminal data-only message recognized by the metered SSE -# decoders alongside the ``done`` event name. Mirrors Go ``doneSentinel``. +# decoders alongside the ``done`` event name. DONE_SENTINEL = "[DONE]" @@ -58,7 +54,7 @@ class MeteredStream: Build with :func:`new_metered_stream` (HTTP responses) or :func:`new_metered_stream_writer` (raw writers). Every write flushes when the underlying writer supports it so chunks reach the client as they are - produced. Mirrors Go ``MeteredStream``. + produced. """ def __init__(self, writer: _Writer, flush: bool = False) -> None: @@ -72,7 +68,7 @@ def write_event(self, event: str, data: bytes | None) -> None: Empty event names emit a default (message) frame. ``data`` must not be empty; multi-line data is split into one ``data:`` line per line per the - SSE format. Mirrors Go ``WriteEvent``. + SSE format. """ if not data: raise ValueError("SSE event data must not be empty") @@ -88,38 +84,37 @@ def write_event(self, event: str, data: bytes | None) -> None: def write_json(self, value: Any) -> None: """Write a default (message) frame whose data is the JSON encoding of - ``value``. Use for application payload chunks. Mirrors Go ``WriteJSON``.""" + ``value``. Use for application payload chunks.""" data = json.dumps(value, separators=(",", ":")).encode("utf-8") self.write_event("", data) def write_metering(self, directive: MeteringDirective) -> None: """Emit an ``mpp.metering`` event carrying the metering directive the - client must commit after processing the paired payload. Mirrors Go - ``WriteMetering``.""" + client must commit after processing the paired payload.""" data = json.dumps(directive.to_dict(), separators=(",", ":")).encode("utf-8") self.write_event("mpp.metering", data) def write_usage(self, usage: MeteringUsage) -> None: """Emit an ``mpp.usage`` event reporting the final amount owed for a streamed delivery. The amount must not exceed the amount reserved by the - original directive. Mirrors Go ``WriteUsage``.""" + original directive.""" data = json.dumps(usage.to_dict(), separators=(",", ":")).encode("utf-8") self.write_event("mpp.usage", data) def write_envelope(self, payload: Any, directive: MeteringDirective) -> None: """Emit the payload as a default data frame followed by its ``mpp.metering`` directive, the pairing the metered session consumers - expect. Mirrors Go ``WriteEnvelope``.""" + expect.""" self.write_json(payload) self.write_metering(directive) def write_done(self) -> None: - """Emit the terminal ``[DONE]`` sentinel message. Mirrors Go ``WriteDone``.""" + """Emit the terminal ``[DONE]`` sentinel message.""" self.write_event("", DONE_SENTINEL.encode("utf-8")) def write_done_event(self) -> None: """Emit an explicit ``done`` event, the alternative terminal frame the - decoders accept. Mirrors Go ``WriteDoneEvent``.""" + decoders accept.""" self.write_event("done", DONE_SENTINEL.encode("utf-8")) @@ -128,8 +123,7 @@ def new_metered_stream(response: _HttpResponse) -> MeteredStream: ``text/event-stream``, no caching) and return the stream writer. The response does not need to support flushing, but streaming is only - incremental when it exposes a ``flush`` method. Mirrors Go - ``NewMeteredStream``. + incremental when it exposes a ``flush`` method. """ response.headers["Content-Type"] = "text/event-stream" response.headers["Cache-Control"] = "no-cache" @@ -138,6 +132,5 @@ def new_metered_stream(response: _HttpResponse) -> MeteredStream: def new_metered_stream_writer(writer: _Writer) -> MeteredStream: - """Wrap a raw writer (no header handling) for transports other than HTTP. - Mirrors Go ``NewMeteredStreamWriter``.""" + """Wrap a raw writer (no header handling) for transports other than HTTP.""" return MeteredStream(writer) diff --git a/python/src/pay_kit/protocols/mpp/server/session_voucher.py b/python/src/pay_kit/protocols/mpp/server/session_voucher.py index e9730e149..87cb51e6e 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_voucher.py +++ b/python/src/pay_kit/protocols/mpp/server/session_voucher.py @@ -5,15 +5,13 @@ idempotent replay. The caller persists any accepted delta through the channel store, re-checking inside the atomic mutator. -The check sequence (order and operators) is pinned across the language SDKs and -harness-tested:: +The check sequence (order and operators) is normative and must be applied in +exactly this order:: parse u64 -> finalized -> close pending -> idempotent replay (same cumulative AND same signature, signature re-verified) -> cumulative > watermark strictly -> cumulative <= deposit -> delta >= min_voucher_delta -> Ed25519 verify against the stored authorized_signer -> expires_at > now. - -Ports ``go/protocols/mpp/server/session_voucher.go``. """ from __future__ import annotations @@ -26,10 +24,7 @@ class VoucherVerifyStatus(StrEnum): - """The outcome class of a voucher verification. - - Mirrors Go ``VoucherVerifyStatus``. - """ + """The outcome class of a voucher verification.""" #: The voucher advanced the channel watermark. ACCEPTED = "accepted" @@ -43,8 +38,8 @@ class VoucherVerifyStatus(StrEnum): class VoucherRejectReason(StrEnum): """A stable string tag for voucher rejections so the caller can map to HTTP - statuses / log levels without parsing free text. The tag values are stable - across the language SDKs. Mirrors Go ``VoucherRejectReason``. + statuses / log levels without parsing free text. The tag values are part of + the wire contract and must not change. """ #: The delta is below the configured minimum. @@ -78,8 +73,7 @@ class ChannelState: of view, as read by the voucher verifier. The voucher verifier only reads a subset of the full channel state; this - dataclass carries the fields it needs. Mirrors the verifier-relevant fields - of Go ``ChannelState`` (see ``session_store.go``). + dataclass carries the fields it needs. """ #: The on-chain channel address (base58). @@ -116,8 +110,7 @@ class VoucherVerifyResult: ``status`` selects which fields are meaningful: ``new_cumulative`` for accepted and replayed; ``new_expires_at`` and ``new_signature`` for accepted - only; ``reason`` and ``detail`` for rejected only. Mirrors Go - ``VoucherVerifyResult``. + only; ``reason`` and ``detail`` for rejected only. """ #: The outcome class. @@ -142,9 +135,7 @@ class VoucherVerifyResult: @dataclass class VerifyVoucherArgs: - """The inputs to :func:`verify_voucher_for_channel`. Mirrors Go - ``VerifyVoucherArgs``. - """ + """The inputs to :func:`verify_voucher_for_channel`.""" #: The channel snapshot, typically read just before calling. state: ChannelState @@ -171,8 +162,7 @@ def verify_voucher_for_channel(args: VerifyVoucherArgs) -> VoucherVerifyResult: Returns a verdict; the caller is responsible for persisting any accepted delta via the channel store. The verifier is pure: no store, network, or - clock side effects (the clock is injectable). Mirrors Go - ``VerifyVoucherForChannel``. + clock side effects (the clock is injectable). """ state = args.state signed = args.signed @@ -254,15 +244,12 @@ def verify_voucher_for_channel(args: VerifyVoucherArgs) -> VoucherVerifyResult: def _voucher_reject(reason: VoucherRejectReason, detail: str) -> VoucherVerifyResult: - """Build a rejected verdict. Mirrors Go ``voucherReject``.""" + """Build a rejected verdict.""" return VoucherVerifyResult(status=VoucherVerifyStatus.REJECTED, reason=reason, detail=detail) def _voucher_now(override: int | None) -> int: - """Return the override when set, otherwise the wall clock in Unix seconds. - - Mirrors Go ``voucherNow``. - """ + """Return the override when set, otherwise the wall clock in Unix seconds.""" if override is not None: return override return int(time.time()) @@ -274,8 +261,7 @@ def _voucher_now(override: int | None) -> int: def _parse_u64(raw: str) -> int: """Parse a canonical unsigned base-10 ``u64``. - Rejects empty, signed, fractional, non-ASCII-digit, or out-of-range values, - matching Go's ``strconv.ParseUint(s, 10, 64)``. + Rejects empty, signed, fractional, non-ASCII-digit, or out-of-range values. """ if not (raw.isascii() and raw.isdigit()): raise ValueError(f"invalid cumulative {raw!r}") @@ -291,7 +277,6 @@ def _verify_voucher_signature_bytes(signed: SignedVoucher, authorized_signer: st included; callers order it explicitly. Returns ``None`` on success or a human-readable error string on failure. - Mirrors Go ``verifyVoucherSignatureBytes``. """ from solders.pubkey import Pubkey # type: ignore[import-untyped] from solders.signature import Signature # type: ignore[import-untyped] @@ -318,7 +303,7 @@ def verify_session_voucher(signed: SignedVoucher, authorized_signer: str) -> str Used by the commit and close paths; the voucher handler orders the two checks itself. Returns ``None`` on success or a human-readable error string - on failure. Mirrors Go ``verifySessionVoucher``. + on failure. """ if signed.data.expires_at <= int(time.time()): return "voucher has expired" diff --git a/python/uv.lock b/python/uv.lock index 7507669df..2f3fc45bc 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -2,8 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11" resolution-markers = [ - "python_full_version >= '3.12'", - "python_full_version < '3.12'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] [[package]] @@ -119,6 +126,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/13/35a9ee917ef05d734b0aa75400cfff44325594894d8d928d36b4dc0030d1/based58-0.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:ab85804a401a7b5a7141fbb14ef5b5f7d85288357d1d3f0085d47e616cef8f5a", size = 141512, upload-time = "2022-04-24T09:14:49.942Z" }, ] +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + [[package]] name = "black" version = "23.12.1" @@ -174,6 +194,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -406,6 +496,39 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "curl-cffi" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/5b/89fcfebd3e5e85134147ac99e9f2b2271165fd4d71984fc65da5f17819b7/curl_cffi-0.15.0.tar.gz", hash = "sha256:ea0c67652bf6893d34ee0f82c944f37e488f6147e9421bef1771cc6545b02ded", size = 196437, upload-time = "2026-04-03T11:12:31.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/42/54ddd442c795f30ce5dd4e49f87ce77505958d3777cd96a91567a3975d2a/curl_cffi-0.15.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bda66404010e9ed743b1b83c20c86f24fe21a9a6873e17479d6e67e29d8ded28", size = 2795267, upload-time = "2026-04-03T11:11:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/2d/3915e238579b3c5a92cead5c79130c3b8d20caaba7616cc4d894650e1d6b/curl_cffi-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a25620d9bf989c9c029a7d1642999c4c265abb0bad811deb2f77b0b5b2b12e5b", size = 2573544, upload-time = "2026-04-03T11:11:47.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b3/9d2f1057749a1b07ba1989db3c1503ce8bed998310bae9aea2c43aa64f20/curl_cffi-0.15.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:582e570aa2586b96ed47cf4a17586b9a3c462cbe43f780487c3dc245c6ef1527", size = 10515369, upload-time = "2026-04-03T11:11:50.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1d/6d10dded5ce3fd8157e558ebd97d09e551b77a62cdc1c31e93d0a633cee5/curl_cffi-0.15.0-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:838e48212447d9c81364b04707a5c861daf08f8320f9ecb3406a8919d1d5c3b3", size = 10160045, upload-time = "2026-04-03T11:11:52.664Z" }, + { url = "https://files.pythonhosted.org/packages/5c/12/c70b835487ace3b9ba1502631912e3440082b8ae3a162f60b59cb0b6444d/curl_cffi-0.15.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b6c847d86283b07ae69bb72c82eb8a59242277142aa35b89850f89e792a02fc", size = 11090433, upload-time = "2026-04-03T11:11:55.049Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/78edcc4f71934225db99df68197a107386d59080742fc7bf6bb4d007924f/curl_cffi-0.15.0-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e5e69eee735f659287e2c84444319d68a1fa68dd37abf228943a4074864283a", size = 10479178, upload-time = "2026-04-03T11:11:57.685Z" }, + { url = "https://files.pythonhosted.org/packages/5b/84/1e101c1acb1ea2f0b4992f5c3024f596d8e21db0d53540b9d583f673c4e7/curl_cffi-0.15.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1323950224db24f4c510d010b3affa02196ca853fb424191fa917a513d3f4b", size = 10317051, upload-time = "2026-04-03T11:12:00.295Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/8ef236b22a6c23d096c85a1dc507efe37bfdfc7a2f8a4b34efb590197369/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:41f80170ba844009273b2660da1964ec31e99e5719d16b3422ada87177e32e13", size = 11299660, upload-time = "2026-04-03T11:12:02.791Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/56aeb055d962da87a1be0d74c6c644e251c7e88129b5471dc44ac724e678/curl_cffi-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1977e1e12cfb5c11352cbb74acef1bed24eb7d226dab61ca57c168c21acd4d61", size = 11945049, upload-time = "2026-04-03T11:12:05.912Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8c/2abf99a38d6340d66cf0557e0c750ef3f8883dfc5d450087e01c85861343/curl_cffi-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:5a0c1896a0d5a5ac1eb89cd24b008d2b718dd1df6fd2f75451b59ca66e49e572", size = 1661649, upload-time = "2026-04-03T11:12:07.948Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/dfd54f2240d3a9b96d77bacc62b97813b35e2aa8ecf5cd5013c683f1ba96/curl_cffi-0.15.0-cp310-abi3-win_arm64.whl", hash = "sha256:a6d57f8389273a3a1f94370473c74897467bcc36af0a17336989780c507fa43d", size = 1410741, upload-time = "2026-04-03T11:12:10.073Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/c24df8a4fc22fa84070dcd94abeba43c15e08cc09e35869565c0bad196fd/curl_cffi-0.15.0-cp313-abi3-android_24_arm64_v8a.whl", hash = "sha256:4682dc38d4336e0eb0b185374db90a760efde63cbea994b4e63f3521d44c4c92", size = 7190427, upload-time = "2026-04-03T11:12:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/11/56/132225cb3491d07cc6adcce5fe395e059bde87c68cff1ef87a31c88c7819/curl_cffi-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:967ad7355bd8e9586f8c2d02eaa99953747549e7ea4a9b25cd53353e6b67fe6d", size = 2795723, upload-time = "2026-04-03T11:12:13.668Z" }, + { url = "https://files.pythonhosted.org/packages/07/8f/f4f83cd303bef7e8f1749512e5dd157e7e5d08b0a36c8211f9640a2757bf/curl_cffi-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e63539d0d839d0a8c5eacf86229bc68c57803547f35e0db7ee0986328b478c3", size = 2573739, upload-time = "2026-04-03T11:12:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/643d65c7fc9acd742876aa55c2d7823c438cb7665810acd2e66c9976c4d9/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08c799b89740b9bc49c09fbc3d5907f13ac1f845ca52620507ef9466d4639dd5", size = 10521046, upload-time = "2026-04-03T11:12:17.034Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0b/9b8037113c93f4c5323096163471fa7c35c7676c3f608eeaf1287cd99d58/curl_cffi-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b7a92767a888ee90147e18964b396d8435ff42737030d6fb00824ffd6094805", size = 11096115, upload-time = "2026-04-03T11:12:19.694Z" }, + { url = "https://files.pythonhosted.org/packages/5f/96/fff2fcbd924ef4042e0d67379f751a8a4e3186a91e75e35a4cf218b306ee/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:829cc357061ecb99cc2d406301f609a039e05665322f5c025ec67c38b0dc49ce", size = 11305346, upload-time = "2026-04-03T11:12:22.151Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/304b253a45ab28691c8c5e8cca1e6cbb9cf8e46dfceae4648dd536f75e73/curl_cffi-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:408d6f14e346841cd889c2e0962832bb235ba3b6749ebf609f347f747da5e60f", size = 11949834, upload-time = "2026-04-03T11:12:24.986Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/4723d92f08259c707a974aba27a08d0a822b9555e35ca581bf18d055a364/curl_cffi-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b624c7ce087bfda967a013ed0a64702a525444e5b6e97d23534d567ccc6525aa", size = 1702771, upload-time = "2026-04-03T11:12:28.201Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/36bbe06d66fa2b765e4a07199f643a59a9cd1a754207a96335402a9520f4/curl_cffi-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0b6c0543b993996670e9e4b78e305a2d60809d5681903ffb5568e21a387434d3", size = 1466312, upload-time = "2026-04-03T11:12:30.054Z" }, +] + [[package]] name = "databind" version = "4.5.5" @@ -463,7 +586,9 @@ name = "django" version = "5.2.14" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version < '3.12'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "asgiref", marker = "python_full_version < '3.12'" }, @@ -480,7 +605,12 @@ name = "django" version = "6.0.5" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "asgiref", marker = "python_full_version >= '3.12'" }, @@ -644,6 +774,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/ed/05aebce69f78c104feff2ffcdd5a6f9d668a208aba3a8bf56e3750809fd8/jsonalias-0.1.1-py3-none-any.whl", hash = "sha256:a56d2888e6397812c606156504e861e8ec00e188005af149f003c787db3d3f18", size = 1312, upload-time = "2022-10-28T22:57:54.763Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -718,6 +860,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "multitasking" +version = "0.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/ac2cc9307fb15cc28ed6d4a9266b216c83ee7fe64299f0264047982bce88/multitasking-0.0.13.tar.gz", hash = "sha256:d896b5df877c9ca5eeddbf0e5994124694d6cb535aba698fb23344c7025155a1", size = 20585, upload-time = "2026-04-23T12:14:15.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/1c/24dbf69b247f287401c904a396233a43c89fd4fb9b7cd2e50e430e9cd57c/multitasking-0.0.13-py3-none-any.whl", hash = "sha256:ec9243af140c67bfe52dc98d7173c294512735a88e8425c458b250db99dc2b48", size = 16380, upload-time = "2026-04-23T12:14:13.776Z" }, +] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -767,6 +927,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/58/eab08df9dbd69d9e21fc5e7be6f67454f386336ec71e6b64e378a2dddea4/nr.util-0.8.12-py3-none-any.whl", hash = "sha256:91da02ac9795eb8e015372275c1efe54bac9051231ee9b0e7e6f96b0b4e7d2bb", size = 90319, upload-time = "2022-06-20T13:29:27.312Z" }, ] +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -776,6 +1015,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -785,6 +1084,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "peewee" +version = "4.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/b9/754f7bb306a9257f79ce895c768dcfe9c5bdc111d6e7ec4241350251357c/peewee-4.0.8.tar.gz", hash = "sha256:56c155143980d036fad39ffd09e40b30c3ee19ed9393da896e779f905209b3bc", size = 752132, upload-time = "2026-06-13T19:05:14.537Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/76/e8d5f4515b66e6cdf47694da19bb0f0e1425467024e71a553859cc671655/peewee-4.0.8-py3-none-any.whl", hash = "sha256:8efa73c8f5d4cb1c711378d1b0d547a9314a20387cdf66fc2d47c7f4c679911f", size = 155009, upload-time = "2026-06-13T19:05:13.118Z" }, +] + [[package]] name = "platformdirs" version = "4.10.0" @@ -803,6 +1111,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -1047,6 +1379,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -1056,6 +1400,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1126,6 +1479,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "ruff" version = "0.15.14" @@ -1151,6 +1517,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "solana" version = "0.36.6" @@ -1203,6 +1578,7 @@ flask = [ playground = [ { name = "fastapi" }, { name = "uvicorn" }, + { name = "yfinance" }, ] [package.metadata] @@ -1225,6 +1601,7 @@ requires-dist = [ { name = "solana", specifier = ">=0.35" }, { name = "solders", specifier = ">=0.22" }, { name = "uvicorn", marker = "extra == 'playground'", specifier = ">=0.30" }, + { name = "yfinance", marker = "extra == 'playground'", specifier = ">=0.2.40" }, ] provides-extras = ["fastapi", "flask", "django", "playground", "dev"] @@ -1248,6 +1625,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/e8/dc992f677762ea2de44b7768120d95887ef39fab10d6f29fb53e6a9882c1/solders-0.26.0-cp37-abi3-win_amd64.whl", hash = "sha256:5466616610170aab08c627ae01724e425bcf90085bc574da682e9f3bd954900b", size = 5480492, upload-time = "2025-02-18T19:23:53.285Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + [[package]] name = "sqlparse" version = "0.5.5" @@ -1594,3 +1980,25 @@ sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25 wheels = [ { url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" }, ] + +[[package]] +name = "yfinance" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "curl-cffi" }, + { name = "multitasking" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "peewee" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pytz" }, + { name = "requests" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/d2/941eea19644200c3f82e1be35a1faa94f1149760a0b6f43e3633bfa052d5/yfinance-1.4.1.tar.gz", hash = "sha256:9acecec3036b4aa96d1e3120ff85ca4f6f81d239d968f56b6eb7877f89fea7a3", size = 153823, upload-time = "2026-05-28T20:03:05.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/ec/8f432c0370e667fb0d8a54ffb75d7737c9224d68eca0db91ee1bd84f74ee/yfinance-1.4.1-py2.py3-none-any.whl", hash = "sha256:1e1c506ca81dc15635380e7129813a5b32da80201af9bb404cac5d528ecfddc3", size = 137770, upload-time = "2026-05-28T20:03:04.584Z" }, +] From b5331737d7ba8dcb3b87b9a1a5f4c74bb3121bb8 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 16 Jun 2026 20:17:12 +0300 Subject: [PATCH 20/52] feat(python/fastapi): add pay_kit.fastapi.install() one-call server setup Ludo (#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. --- python/examples/playground_api/app.py | 31 +++----------- python/examples/playground_api/x402.py | 4 +- python/src/pay_kit/fastapi.py | 56 +++++++++++++++++++++++++- python/tests/test_pk_frameworks.py | 38 +++++++++++++++++ 4 files changed, 100 insertions(+), 29 deletions(-) diff --git a/python/examples/playground_api/app.py b/python/examples/playground_api/app.py index f91c53197..eb11f90a0 100644 --- a/python/examples/playground_api/app.py +++ b/python/examples/playground_api/app.py @@ -27,15 +27,13 @@ from pathlib import Path from typing import Any -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware +from fastapi import FastAPI from fastapi.responses import FileResponse, JSONResponse, Response from solders.keypair import Keypair -from starlette.exceptions import HTTPException as StarletteHTTPException import pay_kit from pay_kit._paycore.network import Network -from pay_kit.fastapi import install_exception_handler +from pay_kit.fastapi import install from pay_kit.signer import LocalSigner from .docs import build_docs_router, find_repo_root @@ -46,9 +44,6 @@ logger = logging.getLogger("playground-api") -# Payment headers the browser playground reads off cross-origin responses. -_EXPOSE_HEADERS = ["www-authenticate", "payment-receipt", "x-payment-required", "x-payment-response"] - @dataclass class AppState: @@ -294,24 +289,10 @@ def create_app(state: AppState | None = None) -> FastAPI: ) app = FastAPI(title="PayKit Playground (Python)") - app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], - expose_headers=_EXPOSE_HEADERS, - ) - # SDK shim: maps PayKitError (incl. the 402 challenge) to its HTTP response - # and echoes settlement headers onto gated success responses. - install_exception_handler(app) - - # Guard handlers raise HTTPException(detail=json_error(...)); Starlette would - # wrap the dict as {"detail": {...}}, so unwrap it to the bare {"error": ...} - # shape the TS/Go examples return. - @app.exception_handler(StarletteHTTPException) - async def _http_exception_handler(_request: Request, exc: StarletteHTTPException) -> Response: - body = exc.detail if isinstance(exc.detail, dict) else {"error": exc.detail} - return JSONResponse(body, status_code=exc.status_code, headers=getattr(exc, "headers", None)) + # One-call pay-kit setup: payment-header CORS, the PayKitError -> HTTP + # response mapping + settlement-header echo, and the bare-dict HTTPException + # shape the guards rely on. + install(app) register_hub(app, state) register_faucet(app, state) diff --git a/python/examples/playground_api/x402.py b/python/examples/playground_api/x402.py index 184dc55ef..c0729d50f 100644 --- a/python/examples/playground_api/x402.py +++ b/python/examples/playground_api/x402.py @@ -18,7 +18,7 @@ import pay_kit from pay_kit import Gate, Protocol, usd -from pay_kit.fastapi import Payment, RequirePayment, install_exception_handler +from pay_kit.fastapi import Payment, RequirePayment from .utils import rpc_call @@ -42,8 +42,6 @@ def register_x402(app: FastAPI, state: AppState) -> None: """Mount the embedded facilitator and the x402-gated routes.""" - install_exception_handler(app) - # --- embedded facilitator ----------------------------------------------- @app.get("/facilitator/supported") diff --git a/python/src/pay_kit/fastapi.py b/python/src/pay_kit/fastapi.py index 172b193a2..0f44aa3c9 100644 --- a/python/src/pay_kit/fastapi.py +++ b/python/src/pay_kit/fastapi.py @@ -27,7 +27,7 @@ async def report(payment=Depends(RequirePayment("report", pricing=pricing))): from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Sequence from typing import TYPE_CHECKING, Any, cast try: @@ -90,6 +90,60 @@ async def dependency(request: Request) -> Payment: return dependency +#: Response headers carrying MPP/x402 payment challenges and settlement proofs. +#: A browser client reading them cross-origin needs them in the CORS expose list. +PAYMENT_HEADERS: tuple[str, ...] = ( + "www-authenticate", + "payment-receipt", + "x-payment-required", + "x-payment-response", +) + + +def install(app: Any, *, cors_origins: Sequence[str] | None = ("*",)) -> None: + """One-call FastAPI setup for a pay-kit server. + + Bundles what every pay-kit FastAPI server otherwise repeats by hand: + + * CORS that exposes the payment challenge / settlement headers + (:data:`PAYMENT_HEADERS`) so a browser client can read them cross-origin. + Pass ``cors_origins=None`` to skip CORS (e.g. behind a gateway that adds + it), or a concrete origin list to lock it down. + * the :class:`~pay_kit.errors.PayKitError` -> HTTP handler and the + settlement-header echo middleware (see :func:`install_exception_handler`). + * an ``HTTPException`` handler that renders a ``dict`` detail as the bare + response body, so a guard raising ``HTTPException(detail={"error": ...})`` + keeps that shape instead of Starlette's ``{"detail": {...}}`` wrapper. + + Usage:: + + app = FastAPI() + pay_kit.fastapi.install(app) + """ + if cors_origins is not None: + from fastapi.middleware.cors import CORSMiddleware + + app.add_middleware( + CORSMiddleware, + allow_origins=list(cors_origins), + allow_methods=["*"], + allow_headers=["*"], + expose_headers=list(PAYMENT_HEADERS), + ) + + install_exception_handler(app) + + from fastapi.responses import JSONResponse + from starlette.exceptions import HTTPException as StarletteHTTPException + + @app.exception_handler(StarletteHTTPException) + async def _http_exception_handler( # pyright: ignore[reportUnusedFunction] # registered via @app.exception_handler + _request: Request, exc: StarletteHTTPException + ) -> Response: + body = exc.detail if isinstance(exc.detail, dict) else {"error": exc.detail} + return JSONResponse(body, status_code=exc.status_code, headers=getattr(exc, "headers", None)) + + def install_exception_handler(app: Any) -> None: """Register handlers mapping :class:`PayKitError` to its HTTP status. diff --git a/python/tests/test_pk_frameworks.py b/python/tests/test_pk_frameworks.py index 67613694a..78f9e1756 100644 --- a/python/tests/test_pk_frameworks.py +++ b/python/tests/test_pk_frameworks.py @@ -127,6 +127,44 @@ def test_fastapi_payment_reexport(): assert pk_fastapi.Payment is Payment +def test_fastapi_install_bundles_cors_and_bare_dict_errors(): + from fastapi import FastAPI, HTTPException + from starlette.testclient import TestClient + + import pay_kit.fastapi as pk_fastapi + + app = FastAPI() + pk_fastapi.install(app) + + @app.get("/guard") + async def guard(): + raise HTTPException(status_code=400, detail={"error": "bad"}) + + resp = TestClient(app, raise_server_exceptions=False).get("/guard", headers={"Origin": "https://x.test"}) + # Bare-dict HTTPException shape, not Starlette's {"detail": {...}} wrapper. + assert resp.json() == {"error": "bad"} + # CORS exposes the payment headers so a browser client can read them. + exposed = resp.headers.get("access-control-expose-headers", "").lower() + assert "www-authenticate" in exposed and "payment-receipt" in exposed + + +def test_fastapi_install_renders_pay_kit_error(): + from fastapi import FastAPI + from starlette.testclient import TestClient + + import pay_kit.fastapi as pk_fastapi + + app = FastAPI() + pk_fastapi.install(app) + + @app.get("/imperative") + async def imperative(): + raise ProtocolNotSupportedError("nope") + + resp = TestClient(app, raise_server_exceptions=False).get("/imperative") + assert resp.status_code == 406 + + # --------------------------------------------------------------------------- # Flask # --------------------------------------------------------------------------- From 76f8a08a03bc14ef0cfacf312ae6c6a2ab5f2777 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 16 Jun 2026 20:21:52 +0300 Subject: [PATCH 21/52] refactor(python/playground): cut to the advertised endpoints Ludo (#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. --- python/examples/playground_api/app.py | 43 +-- python/examples/playground_api/charges.py | 311 ++---------------- .../examples/playground_api/subscriptions.py | 32 -- python/examples/playground_api/x402.py | 126 ------- 4 files changed, 42 insertions(+), 470 deletions(-) delete mode 100644 python/examples/playground_api/subscriptions.py delete mode 100644 python/examples/playground_api/x402.py diff --git a/python/examples/playground_api/app.py b/python/examples/playground_api/app.py index eb11f90a0..8cb3e6105 100644 --- a/python/examples/playground_api/app.py +++ b/python/examples/playground_api/app.py @@ -1,10 +1,9 @@ # examples/playground_api/app.py """The HTTP API behind the pay-kit playground (FastAPI). -Serves the playground endpoints with their payment gating (MPP charges through -the ``pay_kit`` umbrella surface, x402 through the x402 adapter, sessions -through the MPP session method), so the playground web app works against it by -only setting ``PAYKIT_PLAYGROUND_API_URL``. +Serves the playground endpoints with their payment gating (MPP charges and +sessions through the ``pay_kit`` umbrella surface), so the playground web app +works against it by only setting ``PAYKIT_PLAYGROUND_API_URL``. cd python uvicorn examples.playground_api.app:app --port 3000 @@ -13,9 +12,9 @@ See README.md for the full table. This module owns the boot wiring (:class:`AppState`, :func:`create_app`) and -the free hub endpoints (health, config catalog). The charge / session / x402 / -faucet / docs / subscription feature endpoints plug into the ``register_*`` seam -in :func:`create_app` and are filled in separately. +the free hub endpoints (health, config catalog). The charge / session / faucet +/ docs feature endpoints plug into the ``register_*`` seam in +:func:`create_app` and are filled in separately. """ from __future__ import annotations @@ -40,7 +39,6 @@ from .faucet import register_faucet from .sessions import register_sessions from .utils import env_or, json_error, rpc_call -from .x402 import register_x402 logger = logging.getLogger("playground-api") @@ -112,10 +110,8 @@ def state_from_env() -> AppState: def build_endpoint_list() -> list[dict[str, Any]]: """Build the ``/api/v1/config`` endpoint catalog. - Declared inline, mirroring the TS ``buildEndpointList``. The subscription - entry is omitted because the Python SDK has no subscription server method - yet (see README.md); the stocks-search / stocks-history / weather / fortune - / x402 routes stay live server-side but are not advertised in the nav. + Declared inline, mirroring the TS ``buildEndpointList``. Every advertised + entry has a live server route; the playground exposes exactly these. """ return [ { @@ -207,10 +203,9 @@ async def config() -> JSONResponse: # --- feature-module registration seam --------------------------------------- # -# The charge / session / subscription endpoints plug in here. faucet, docs, and -# x402 register from their own modules directly in create_app. Each registrar -# takes the app and shared state and mounts its routes; sessions returns a -# shutdown hook. +# The charge endpoints plug in here. faucet, docs, and sessions register from +# their own modules directly in create_app. Each registrar takes the app and +# shared state and mounts its routes; sessions returns a shutdown hook. def register_charges(app: FastAPI, state: AppState) -> None: @@ -222,20 +217,6 @@ def register_charges(app: FastAPI, state: AppState) -> None: _register(app, state) -def register_subscriptions(app: FastAPI) -> None: - """Mount the documented subscription stub. - - The Python SDK has no ``solana.subscription`` server method yet, so the - ``/api/v1/premium/feed`` route answers 501 with a pointer at the gap and the - catalog omits the subscription entry (the UI renders its empty state). TS - gates the real route behind ``if (plan)``. Implemented in - :mod:`subscriptions`. - """ - from .subscriptions import register_subscriptions as _register - - _register(app) - - # --- SPA static serving ------------------------------------------------------ @@ -298,9 +279,7 @@ def create_app(state: AppState | None = None) -> FastAPI: register_faucet(app, state) app.include_router(build_docs_router(state)) register_charges(app, state) - register_subscriptions(app) sessions_shutdown = register_sessions(app, state) - register_x402(app, state) # The SPA catch-all must register after every API route. register_spa(app, state.repo_root) diff --git a/python/examples/playground_api/charges.py b/python/examples/playground_api/charges.py index dbacea566..9e1baf7fd 100644 --- a/python/examples/playground_api/charges.py +++ b/python/examples/playground_api/charges.py @@ -1,76 +1,38 @@ # examples/playground_api/charges.py -"""Charge-gated endpoints: stock data, weather, a marketplace purchase with -multi-recipient splits (all gated through the ``pay_kit`` umbrella surface), -and the fortune payment link served straight from the protocol-layer MPP -server with the HTML challenge page enabled. The 402 challenge fires before -any handler body runs. +"""The two charge-gated demo endpoints, gated through the ``pay_kit`` umbrella +surface: a single-recipient stock quote and a multi-recipient marketplace +purchase with split fees. The 402 challenge fires before any handler body runs. Mirrors the TypeScript example's ``modules/charges.ts``. The umbrella :func:`RequirePayment` dependency stands in for Express's ``requirePayment`` middleware: a per-request gate builder reads the path/query and returns a -fully-validated :class:`Gate`. Guard dependencies (404 unknown city/product, -400 missing query) are declared ahead of the payment dependency so they fire -before the 402, matching the Express middleware ordering. - -The stock routes pull live market data from the ``yfinance`` library (the -Python counterpart to the TypeScript example's ``yahoo-finance2``): quote via -``Ticker.info``, search via ``yf.Search``, history via ``Ticker.history``. Any -lookup failure degrades gracefully so the example still runs offline: an -unknown symbol is a 404 and a network outage returns a small safe default. - -The fortune route drops to the protocol layer (:class:`Mpp` with HTML enabled) -because the umbrella dispatcher renders the cross-SDK JSON challenge body; -dropping down a layer to serve the interactive payment page is the intended -escape hatch. +fully-validated :class:`Gate`. The unknown-product guard is declared ahead of +the payment dependency so the 404 fires before the 402. + +The stock quote pulls live market data from the ``yfinance`` library (the +Python counterpart to the TypeScript example's ``yahoo-finance2``). A lookup +failure degrades gracefully so the example still runs offline: an unknown +symbol is a 404 and a network outage returns a small safe default. """ from __future__ import annotations -import random from dataclasses import dataclass from decimal import Decimal from typing import Any from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.concurrency import run_in_threadpool -from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Response +from fastapi.responses import JSONResponse import pay_kit from pay_kit import Gate, Price, usd -from pay_kit._paycore.errors import PaymentError -from pay_kit._paycore.rpc import SolanaRpc -from pay_kit._paycore.store import MemoryStore from pay_kit.fastapi import RequirePayment, payment -from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization -from pay_kit.protocols.mpp.intents.charge import ChargeRequest -from pay_kit.protocols.mpp.server import ( - ChargeOptions, - Mpp, - accepts_html, - challenge_to_html, - is_service_worker_request, - service_worker_js, -) -from pay_kit.protocols.mpp.server import ( - Config as MppConfig, -) from . import constants from .utils import json_error, log_tx -# --- canned demo data ------------------------------------------------------- - -# The canned per-city weather payload: whole-degree C, sky label, % humidity. -_WEATHER_BY_CITY: dict[str, dict[str, Any]] = { - "san-francisco": {"temperature": 15, "conditions": "Foggy", "humidity": 85}, - "new-york": {"temperature": 22, "conditions": "Partly Cloudy", "humidity": 60}, - "london": {"temperature": 12, "conditions": "Rainy", "humidity": 90}, - "tokyo": {"temperature": 26, "conditions": "Sunny", "humidity": 55}, - "paris": {"temperature": 18, "conditions": "Overcast", "humidity": 70}, - "sydney": {"temperature": 24, "conditions": "Clear", "humidity": 45}, - "berlin": {"temperature": 10, "conditions": "Cloudy", "humidity": 75}, - "dubai": {"temperature": 38, "conditions": "Sunny", "humidity": 30}, -} +# --- marketplace catalog ---------------------------------------------------- @dataclass(frozen=True) @@ -88,7 +50,6 @@ class _Product: description: str -# The canned marketplace catalog. _PRODUCTS: dict[str, _Product] = { "sol-hoodie": _Product( name="Solana Hoodie", @@ -113,18 +74,6 @@ class _Product: _PLATFORM_FEE_BPS = 500 # 5% _REFERRAL_FEE_BPS = 200 # 2% -# The canned fortune-cookie pool. -_FORTUNES = [ - "A beautiful, smart, and loving person will be coming into your life.", - "A faithful friend is a strong defense.", - "A golden egg of opportunity falls into your lap this month.", - "All your hard work will soon pay off.", - "Curiosity kills boredom. Nothing can kill curiosity.", - "Every day in your life is a special occasion.", - "Good news will come to you by mail.", - "If you continually give, you will continually have.", -] - def _bps(price: Price, basis_points: int) -> Price: """Return the given basis-point percentage of a price, e.g. @@ -138,21 +87,9 @@ def _display_usd(price: Price) -> str: return f"{price.amount:.2f} USDC" -def _city_key(city: str) -> str: - """Normalize a city path segment onto the weather table key.""" - return city.lower().replace(" ", "-") - - -# --- live stock data via the yfinance library -------------------------------- - -# History ranges accepted by yfinance's ``period=`` argument. Anything else -# falls back to one month. -_HISTORY_RANGES = {"1d", "5d", "1mo", "3mo", "6mo", "1y"} - - def _quote(symbol: str) -> dict[str, Any]: - """Live quote via ``Ticker.info``; 404 for an unknown symbol, a safe default - when the network is unreachable so the example still runs offline. + """Live quote via ``yfinance.Ticker.info``; a 404 for an unknown symbol and + a safe default when the network is unreachable so the example runs offline. """ import yfinance as yf @@ -167,42 +104,12 @@ def _quote(symbol: str) -> dict[str, Any]: return info -def _search(query: str) -> list[dict[str, Any]]: - """Live symbol search via ``yf.Search``; an empty list when offline.""" - import yfinance as yf - - try: - return yf.Search(query).quotes - except Exception: # noqa: BLE001 (offline / lib error -> empty result set) - return [] - - -def _history(symbol: str, range_: str) -> dict[str, Any]: - """Live OHLC history via ``Ticker.history``; a safe single-point default - when offline, a 404 when the symbol has no data. - """ - import yfinance as yf - - sym = symbol.upper() - period = range_ if range_ in _HISTORY_RANGES else "1mo" - try: - frame = yf.Ticker(sym).history(period=period) - except Exception: # noqa: BLE001 (offline / lib error -> safe default) - return {"symbol": sym, "quotes": [{"close": 150.0}]} - if frame.empty: - raise HTTPException(status_code=404, detail=json_error(f"Unknown symbol: {sym}")) - quotes = [ - {"date": index.isoformat(), "close": round(float(row["Close"]), 4)} - for index, row in frame.iterrows() - ] - return {"symbol": sym, "quotes": quotes} - - # --- registration ----------------------------------------------------------- def register_charges(app: FastAPI, state: Any) -> None: - """Mount every charge-gated endpoint plus the free marketplace catalog. + """Mount the stock-quote and marketplace charge routes plus the free + product catalog. ``state`` is the :class:`~examples.playground_api.app.AppState`; typed loosely to avoid a circular import with the boot module. @@ -216,39 +123,27 @@ def _log_settlement(request: Request) -> None: if proof is not None and proof.transaction: log_tx(request.url.path, proof.transaction) - # -- guard dependencies (run before the payment gate) -------------------- - - def _require_query(name: str): # noqa: ANN202 (returns a FastAPI dependency) - def guard(request: Request) -> None: - if not request.query_params.get(name): - raise HTTPException(status_code=400, detail=json_error(f"Missing ?{name}= parameter")) - - return guard - - def _require_known_city(city: str) -> None: - if _city_key(city) not in _WEATHER_BY_CITY: - raise HTTPException( - status_code=404, - detail=json_error(f"City not found. Available: {', '.join(_WEATHER_BY_CITY)}"), - ) - def _require_known_product(productId: str) -> None: # noqa: N803 (mirrors the route path param) if productId not in _PRODUCTS: raise HTTPException(status_code=404, detail=json_error("Product not found")) - # -- gate builders ------------------------------------------------------- + # -- stock quote: single-recipient charge -------------------------------- - def _static_gate(name: str, amount: str, describe): # noqa: ANN001, ANN202 - def builder(request: Request) -> Gate: - return Gate.build( - name=name, - amount=usd(amount), - description=describe(request), - default_pay_to=platform, - accept_default=accept_default, - ) + def _quote_gate(request: Request) -> Gate: + return Gate.build( + name="stockQuote", + amount=usd("0.01"), + description=f"Stock quote: {request.path_params['symbol']}", + default_pay_to=platform, + accept_default=accept_default, + ) - return builder + @app.get("/api/v1/stocks/quote/{symbol}", dependencies=[Depends(RequirePayment(_quote_gate))]) + async def stock_quote(request: Request, symbol: str) -> JSONResponse: + _log_settlement(request) + return JSONResponse(await run_in_threadpool(_quote, symbol)) + + # -- marketplace: free catalog plus a split-fee purchase ----------------- def _buy_gate(request: Request) -> Gate: product = _PRODUCTS[request.path_params["productId"]] # validated by the guard dependency @@ -266,51 +161,6 @@ def _buy_gate(request: Request) -> Gate: accept_default=accept_default, ) - # -- stocks (canned demo quotes) ----------------------------------------- - - gate_stock_quote = RequirePayment( - _static_gate("stockQuote", "0.01", lambda r: f"Stock quote: {r.path_params['symbol']}") - ) - gate_stock_search = RequirePayment( - _static_gate("stockSearch", "0.01", lambda r: f"Stock search: {r.query_params.get('q', '')}") - ) - gate_stock_history = RequirePayment( - _static_gate("stockHistory", "0.05", lambda r: f"Stock history: {r.path_params['symbol']}") - ) - gate_weather = RequirePayment(_static_gate("weather", "0.01", lambda r: f"Weather for {r.path_params['city']}")) - - @app.get("/api/v1/stocks/quote/{symbol}", dependencies=[Depends(gate_stock_quote)]) - async def stock_quote(request: Request, symbol: str) -> JSONResponse: - _log_settlement(request) - return JSONResponse(await run_in_threadpool(_quote, symbol)) - - @app.get( - "/api/v1/stocks/search", - dependencies=[Depends(_require_query("q")), Depends(gate_stock_search)], - ) - async def stock_search(request: Request) -> JSONResponse: - _log_settlement(request) - q = request.query_params.get("q", "") - return JSONResponse(await run_in_threadpool(_search, q)) - - @app.get("/api/v1/stocks/history/{symbol}", dependencies=[Depends(gate_stock_history)]) - async def stock_history(request: Request, symbol: str) -> JSONResponse: - _log_settlement(request) - range_ = request.query_params.get("range", "1mo") - return JSONResponse(await run_in_threadpool(_history, symbol, range_)) - - # -- weather: unknown cities 404 before the payment gate ----------------- - - @app.get( - "/api/v1/weather/{city}", - dependencies=[Depends(_require_known_city), Depends(gate_weather)], - ) - async def weather(request: Request, city: str) -> JSONResponse: - _log_settlement(request) - return JSONResponse({"city": city, **_WEATHER_BY_CITY[_city_key(city)]}) - - # -- marketplace: free catalog plus the split purchase ------------------- - @app.get("/api/v1/marketplace/products") async def marketplace_products() -> JSONResponse: listing = [ @@ -344,102 +194,3 @@ async def marketplace_buy(request: Request, productId: str) -> JSONResponse: # breakdown["total"] = f"{total:.2f} USDC" _log_settlement(request) return JSONResponse({"product": product.name, "breakdown": breakdown, "status": "purchased"}) - - # -- fortune: a charge payment link with the interactive HTML challenge --- - # - # Stays on the protocol layer directly (Mpp with HTML enabled) because the - # umbrella dispatcher renders the cross-SDK JSON challenge body; dropping - # down a layer is the intended escape hatch for the interactive page. - fortune_mpp = Mpp( - MppConfig( - recipient=state.recipient, - currency="USDC", - decimals=constants.USDC_DECIMALS, - network=state.network, - rpc_url=state.rpc_url, - secret_key=state.secret_key, - html=True, - fee_payer_signer=state.fee_payer, - store=MemoryStore(), - ) - ) - - @app.get("/api/v1/fortune") - async def fortune(request: Request) -> Response: - # The interactive payment page registers its service worker at scope - # "/" from a script served under /api/v1/fortune, which browsers only - # allow with this header. - sw_header = {"Service-Worker-Allowed": "/"} if is_service_worker_request(str(request.url)) else {} - challenge = fortune_mpp.charge_with_options("0.01", ChargeOptions(description="Open a fortune cookie")) - - auth_header = request.headers.get("authorization") - if auth_header: - try: - credential = parse_authorization(auth_header) - expected = ChargeRequest.from_dict(challenge.decode_request()) - rpc = SolanaRpc(state.rpc_url) - try: - async with fortune_mpp.using_rpc(rpc): - receipt = await fortune_mpp.verify_credential_with_expected(credential, expected) - finally: - await rpc.aclose() - except PaymentError as err: - return _fortune_challenge(request, challenge, state.network, fortune_mpp.rpc_url, sw_header, err) - except Exception as err: # noqa: BLE001 (framework/parse errors map to a 402) - return _fortune_challenge( - request, - challenge, - state.network, - fortune_mpp.rpc_url, - sw_header, - PaymentError(str(err), code="payment_invalid"), - ) - - headers = dict(sw_header) - if receipt.reference: - headers["payment-receipt"] = receipt.reference - log_tx(request.url.path, receipt.reference) - return JSONResponse({"fortune": random.choice(_FORTUNES)}, headers=headers) - - return _fortune_challenge(request, challenge, state.network, fortune_mpp.rpc_url, sw_header, None) - - -# --- fortune 402 rendering -------------------------------------------------- - - -def _fortune_challenge( - request: Request, - challenge: Any, - network: str, - rpc_url: str, - extra_headers: dict[str, str], - error: PaymentError | None, -) -> Response: - """Render the fortune 402: the service-worker script on the SW request, the - interactive HTML payment page when the client accepts HTML, otherwise the - canonical JSON challenge body. - """ - headers = dict(extra_headers) - headers["www-authenticate"] = format_www_authenticate(challenge) - headers["cache-control"] = "no-store" - - if is_service_worker_request(str(request.url)): - return PlainTextResponse(service_worker_js(), media_type="application/javascript", headers=headers) - - if accepts_html(request.headers.get("accept")): - return HTMLResponse(challenge_to_html(challenge, rpc_url, network), status_code=402, headers=headers) - - code = (error.code if error and error.code else None) or "payment_invalid" - detail = (str(error) if error else None) or "Payment required" - return JSONResponse( - { - "type": "about:blank", - "title": "Payment Required", - "status": 402, - "code": code, - "error": code, - "detail": detail, - }, - status_code=402, - headers=headers, - ) diff --git a/python/examples/playground_api/subscriptions.py b/python/examples/playground_api/subscriptions.py deleted file mode 100644 index a7697ad9c..000000000 --- a/python/examples/playground_api/subscriptions.py +++ /dev/null @@ -1,32 +0,0 @@ -# examples/playground_api/subscriptions.py -"""Subscriptions module. The Python SDK does not implement the subscription -server method yet, so this module keeps the ``/api/v1/premium/feed`` route -(nothing is silently dropped) and answers 501 with an explicit pointer at the -gap. The endpoint catalog omits the subscription entry, so the playground UI -renders its graceful empty state. See README.md. - -Mirrors the Go example's ``subscriptions.go``. -""" - -from __future__ import annotations - -from fastapi import FastAPI -from fastapi.responses import JSONResponse - - -def register_subscriptions(app: FastAPI) -> None: - """Mount the documented subscription stub.""" - - @app.get("/api/v1/premium/feed") - async def premium_feed() -> JSONResponse: - return JSONResponse( - { - "error": "not_implemented", - "detail": ( - "The Python SDK does not ship the solana.subscription server method yet; " - "this route exists for parity with go/examples/playground-api and " - "will be gated once the Python subscription intent lands." - ), - }, - status_code=501, - ) diff --git a/python/examples/playground_api/x402.py b/python/examples/playground_api/x402.py deleted file mode 100644 index c0729d50f..000000000 --- a/python/examples/playground_api/x402.py +++ /dev/null @@ -1,126 +0,0 @@ -# examples/playground_api/x402.py -"""The embedded facilitator endpoints plus two x402-gated demo routes. - -The gated routes run through the ``pay_kit`` x402 surface: a dedicated -x402-only config (the process-wide ``configure`` accepts MPP only) gates the -routes through the self-hosted x402 adapter, which settles in-process with the -operator signer. The facilitator endpoints are served with the standard shapes -for external x402 clients. Mirrors the Go example's ``x402.go``. -""" - -from __future__ import annotations - -import random -from typing import TYPE_CHECKING - -from fastapi import Depends, FastAPI, Request -from fastapi.responses import JSONResponse - -import pay_kit -from pay_kit import Gate, Protocol, usd -from pay_kit.fastapi import Payment, RequirePayment - -from .utils import rpc_call - -if TYPE_CHECKING: - from .app import AppState - -JOKES = [ - "Why do programmers prefer dark mode? Because light attracts bugs.", - "There are 10 types of people: those who understand binary and those who don’t.", - 'A SQL query walks into a bar, sees two tables, and asks: "Can I JOIN you?"', - 'A photon checks into a hotel; the bellhop asks if it has any luggage. "No, I’m traveling light."', -] - -FACTS = [ - "Honey never spoils. Archaeologists found 3000-year-old honey in Egyptian tombs.", - "Octopuses have three hearts and blue blood.", - 'A group of flamingos is called a "flamboyance".', - "Bananas are berries; strawberries are not.", -] - - -def register_x402(app: FastAPI, state: AppState) -> None: - """Mount the embedded facilitator and the x402-gated routes.""" - # --- embedded facilitator ----------------------------------------------- - - @app.get("/facilitator/supported") - async def facilitator_supported() -> JSONResponse: - return JSONResponse( - { - "kinds": [ - { - "scheme": "exact", - "network": "solana-devnet", - "extra": {"feePayer": state.fee_payer_pubkey}, - } - ] - } - ) - - @app.post("/facilitator/verify") - async def facilitator_verify(request: Request) -> JSONResponse: - payload = await _payload(request) - if payload is None: - return JSONResponse({"isValid": False, "invalidReason": "Missing payload"}) - authorization = payload.get("authorization") - payer = authorization.get("from") if isinstance(authorization, dict) else None - return JSONResponse({"isValid": True, "payer": payer or "unknown"}) - - @app.post("/facilitator/settle") - async def facilitator_settle(request: Request) -> JSONResponse: - payload = await _payload(request) - if payload is None: - return JSONResponse({"success": False, "errorReason": "Missing payload"}) - transaction = payload.get("transaction") - if not transaction: - return JSONResponse({"success": True, "transaction": "local-facilitator-settled"}) - try: - signature = await rpc_call( - state.rpc_url, - "sendTransaction", - [transaction, {"encoding": "base64", "skipPreflight": True}], - ) - except Exception as exc: - return JSONResponse({"success": False, "errorReason": str(exc)}) - return JSONResponse({"success": True, "transaction": signature}) - - # --- x402-gated routes --------------------------------------------------- - # - # The process-wide config accepts MPP only, so derive an x402-accepting copy - # (keeping the operator signer / fee payer, network, and RPC) and gate these - # routes through the self-hosted x402 adapter, mirroring the Go example's - # separate ``paykit.New(Accept: [X402])`` client. - x402_config = pay_kit.config().model_copy(update={"accept": (Protocol.X402,)}) - - def _gate(name: str, description: str) -> object: - gate = Gate.build( - name=name, - amount=usd("0.001"), - description=description, - accept=(Protocol.X402,), - default_pay_to=x402_config.effective_recipient(), - ) - return Depends(RequirePayment(gate, config=x402_config)) - - require_joke = _gate("x402Joke", "A random programmer joke") - require_fact = _gate("x402Fact", "A random fun fact") - - @app.get("/x402/joke") - async def x402_joke(payment: Payment = require_joke) -> JSONResponse: - return JSONResponse({"joke": random.choice(JOKES), "source": "x402"}) - - @app.get("/x402/fact") - async def x402_fact(payment: Payment = require_fact) -> JSONResponse: - return JSONResponse({"fact": random.choice(FACTS), "source": "x402"}) - - -async def _payload(request: Request) -> dict | None: - """Extract ``paymentPayload.payload`` from the request body, or ``None``.""" - try: - body = await request.json() - except Exception: - return None - payment_payload = body.get("paymentPayload") if isinstance(body, dict) else None - payload = payment_payload.get("payload") if isinstance(payment_payload, dict) else None - return payload if isinstance(payload, dict) else None From 9dfeee5e3571bf8f97134d9a78291d16b6b2f231 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Tue, 16 Jun 2026 21:32:16 +0300 Subject: [PATCH 22/52] docs(python): per-field docs for session intents; trim playground README Ludo (#161 review): high-quality docs for the session intent classes, and the README listed more endpoints than the playground exposes. Add Attributes blocks documenting every field of SessionRequest and OpenPayload (push/pull fields labelled), and rewrite the playground README endpoint table + feature list to the routes the server actually serves after the cut. --- python/examples/playground_api/README.md | 45 ++++-------- .../pay_kit/protocols/mpp/intents/session.py | 68 ++++++++++++++++--- 2 files changed, 72 insertions(+), 41 deletions(-) diff --git a/python/examples/playground_api/README.md b/python/examples/playground_api/README.md index cc6f26992..3e91a2c5d 100644 --- a/python/examples/playground_api/README.md +++ b/python/examples/playground_api/README.md @@ -5,23 +5,21 @@ the HTTP API behind the [pay-kit playground](../../../playground/). It serves the same endpoints with the same payment gating semantics against the Solana Payment Sandbox (a hosted test validator, no real funds): -- **Charges**: `solana.charge` endpoints (stock quote, marketplace purchase - with multi-recipient splits, fortune payment link) gated through the - `pay_kit` umbrella surface, plus a faucet that funds wallets through surfpool - cheatcodes. +- **Charges**: `solana.charge` endpoints (a single-recipient stock quote and a + marketplace purchase with multi-recipient splits) gated through the `pay_kit` + umbrella surface, plus a faucet that funds wallets through surfpool cheatcodes. - **Sessions**: the in-process MPP session method gating `/sessions/stream` (pay-per-chunk SSE) and `/sessions/compute` (pay-per-call), with voucher metering through the `/__402/session/*` side channel and on-chain settlement via the idle-close watchdog. -- **x402**: two `exact`-scheme demo routes plus the embedded facilitator - endpoints. - `/api/v1/config`: the endpoint catalog and wallet/network metadata the web app renders. This is the application skeleton: the boot wiring, shared config/state, the -free endpoints (health, config, faucet, docs, SPA, the subscription 501 stub), -and the `register_*` seam the charge / session / x402 feature endpoints plug -into. The feature endpoints are implemented separately. +free endpoints (health, config, faucet, docs, SPA), and the `register_*` seam +the charge / session feature endpoints plug into. The feature endpoints are +implemented separately. Every route the server exposes is advertised in the +`/api/v1/config` catalog the web app renders. ## Running @@ -55,7 +53,7 @@ PAYKIT_PLAYGROUND_API_URL=http://localhost:3210 pnpm dev | Variable | Default | Purpose | |----------|---------|---------| | `PORT` | `3000` | Listen port | -| `NETWORK` | `localnet` | Solana network tag for MPP / x402 challenges | +| `NETWORK` | `localnet` | Solana network tag for MPP challenges | | `RPC_URL` | `https://402.surfnet.dev:8899` | Surfpool RPC endpoint (hosted sandbox by default) | | `RECIPIENT` | (auto-generated) | Solana address that receives payments | | `FEE_PAYER_KEY` | (auto-generated) | Base58 fee-payer keypair (server signs as fee payer) | @@ -72,37 +70,21 @@ PAYKIT_PLAYGROUND_API_URL=http://localhost:3210 pnpm dev | 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 | -| POST | `/facilitator/verify`, `/facilitator/settle` | free | -| GET | `/x402/joke`, `/x402/fact` | x402 exact, $0.001 | - -As in the Go example, the stocks-search / stocks-history / weather / fortune -and `/x402/*` routes stay live server-side but are not advertised in the -`/api/v1/config` nav catalog. ## Differences from the other playground APIs -Nothing is silently dropped; where the Python SDK lacks a capability the closest -faithful behavior is served and listed here: - -1. **Subscriptions**: the Python SDK does not implement the - `solana.subscription` server method yet, so there is no plan bootstrap and - `GET /api/v1/premium/feed` answers `501 {"error":"not_implemented"}`. The - endpoint catalog omits the subscription entry, so the playground UI renders - its graceful empty state. +The Python SDK does not implement the `solana.subscription` server method yet, +so the playground exposes no subscription route (the other examples gate one). +The endpoint catalog omits the subscription entry and the playground UI renders +its graceful empty state. ## Layout @@ -110,7 +92,8 @@ Mirrors the Go module structure as files of one Python package: ``` app.py # boot wiring (AppState, create_app), the register_* seam, - # /api/v1/{health,config}, faucet, CORS, SPA, subscription stub + # /api/v1/{health,config}, SPA serving (CORS/handlers via + # pay_kit.fastapi.install) main.py # entrypoint: surfnet funding + uvicorn docs.py # generated-docs browser with path-escape guard constants.py # example-specific constants (faucet amounts, USDC decimals/mint) diff --git a/python/src/pay_kit/protocols/mpp/intents/session.py b/python/src/pay_kit/protocols/mpp/intents/session.py index aefb9dfe0..636d2974d 100644 --- a/python/src/pay_kit/protocols/mpp/intents/session.py +++ b/python/src/pay_kit/protocols/mpp/intents/session.py @@ -109,11 +109,37 @@ def from_dict(cls, data: dict[str, Any]) -> SessionSplit: class SessionRequest: """Session intent request, the payload embedded in a 402 challenge. - Describes the channel parameters the server is offering: the spending cap, - currency and decimals, recipient and operator addresses, optional payment - splits, network, program id, and the funding modes the server supports. - Optional fields are omitted from ``to_dict()`` when ``None`` and - ``splits``/``modes`` are omitted when empty. + Describes the channel parameters the server is offering. Optional fields are + omitted from ``to_dict()`` when ``None``; ``splits``/``modes`` are omitted + when empty. + + Attributes: + cap: Maximum cumulative amount the session may bill, as a canonical + base-unit decimal string (e.g. ``"500000"`` for 0.50 USDC at 6 + decimals). Vouchers may never sign a cumulative above this. + currency: The settlement currency: an SPL mint address or a known + symbol (e.g. ``"USDC"``). + operator: Base58 address of the operator that meters the session and + co-signs settlement (the fee payer for on-chain commits). + recipient: Base58 address that receives the settled funds. + decimals: Token decimals for ``currency``; required to interpret + ``cap`` and voucher amounts when ``currency`` is a mint address. + network: Target Solana network (``"mainnet"`` / ``"devnet"`` / + ``"localnet"``). + splits: Payment splits committed at channel open, each taking a + basis-point share of every settlement. + program_id: Base58 id of the on-chain payment-channel (push) or + delegation (pull) program the channel is opened against. + description: Human-readable label shown on the challenge. + external_id: Caller-supplied correlation id echoed back on settlement. + min_voucher_delta: Minimum increase between two consecutive vouchers, + as a base-unit decimal string; rejects dust-sized increments. + modes: Funding modes the server accepts (``push`` payment channel, + ``pull`` SPL delegation). + pull_voucher_strategy: For pull mode, how vouchers are produced + (e.g. per-delivery vs. cumulative). + recent_blockhash: Optional blockhash the client reuses when building + the open transaction, avoiding an extra RPC round-trip. """ cap: str @@ -236,12 +262,34 @@ class OpenPayload: Use :meth:`push`, :meth:`payment_channel`, :meth:`payment_channel_with_mode`, or :meth:`pull` to construct. Inspect :attr:`mode` to distinguish variants on the server. ``mode`` is required: :meth:`from_dict` raises when it is absent. - Push-mode fields (``channel_id``, ``deposit``, ``payer``, ``payee``, - ``mint``, ``salt``, ``grace_period``, ``transaction``) and pull-mode fields - (``token_account``, ``approved_amount``, ``owner``, ``init_multi_delegate_tx``, - ``update_delegation_tx``) are populated according to the selected mode. - + The mode-specific fields are populated according to the selected mode; ``salt`` serializes as a decimal string and decodes from string or number. + + Attributes: + mode: The funding variant (``push`` payment channel, ``pull`` SPL + delegation); selects which mode-specific fields apply. + authorized_signer: Base58 of the key the channel authorizes to sign + vouchers against the deposit. + signature: Signature authorizing this open payload. + channel_id: (push) Base58 id of the opened payment channel. + deposit: (push) Amount funding the channel, as a base-unit decimal + string; the ceiling vouchers can draw against. + payer: (push) Base58 address funding the channel. + payee: (push) Base58 address the channel settles to. + mint: (push) SPL mint of the channel's token. + salt: (push) Numeric salt deriving the channel PDA; distinct salts let + one payer open several channels to one payee. + grace_period: (push) Seconds after expiry before the channel can be + force-closed. + transaction: (push) Base64 signed transaction that opens the channel. + token_account: (pull) Base58 SPL token account the delegation draws + from. + approved_amount: (pull) Delegation cap, as a base-unit decimal string. + owner: (pull) Base58 owner of ``token_account``. + init_multi_delegate_tx: (pull) Base64 transaction initializing the + multi-delegate account when one is not yet present. + update_delegation_tx: (pull) Base64 transaction setting/updating the + delegation to ``approved_amount``. """ mode: SessionMode From 317c3accd0397f6266b55810bf186e6101c1c5c6 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Thu, 18 Jun 2026 00:21:54 +0300 Subject: [PATCH 23/52] fix(python/playground): truthful docs + lean charges (review) 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. --- python/README.md | 14 +-- python/examples/playground_api/README.md | 31 +++++-- python/examples/playground_api/app.py | 105 +++++++++++----------- python/examples/playground_api/charges.py | 33 ++++--- 4 files changed, 106 insertions(+), 77 deletions(-) diff --git a/python/README.md b/python/README.md index 959119707..438b8e29d 100644 --- a/python/README.md +++ b/python/README.md @@ -263,15 +263,19 @@ Use MPP when: |---------------|--------| | `charge/pull` | ✅ | | `charge/push` | ✅ | -| `session` | Client-only | +| `session` | ✅ (on-chain settle-at-close pending) | -`session` ships the client side: `ActiveSession` voucher signing, the +`session` ships both sides. Client: `ActiveSession` voucher signing, the challenge-driven pull/clientVoucher payment-channel openers (fee payer = challenge operator, pending-server-signature placeholder), the metered `SessionConsumer`, and the SSE streaming helpers (`MeteredSseSession`, -`MeteredSseStream`, `HttpCommitTransport`). Server-side session verification, -pull/operatedVoucher (multi-delegate) opens, and a session-aware request -transport are follow-ups. +`MeteredSseStream`, `HttpCommitTransport`). Server: the session method +(`new_session`) issuing challenges and verifying credentials/vouchers, the +reserve/commit metering side channel (`session_routes`), the shared channel +store, and the idle-close watchdog. Not yet ported: the server-broadcast open +path (`openTxSubmitter=server`), pull/operatedVoucher (multi-delegate) opens, +and on-chain settle-at-close, so a closed channel's `settledSignature` stays +`null` until that lands. The MPP server owns the full lifecycle: it issues signed challenges with a fresh `recentBlockhash`, parses and validates the `Authorization: Payment` diff --git a/python/examples/playground_api/README.md b/python/examples/playground_api/README.md index 3e91a2c5d..96ab219d7 100644 --- a/python/examples/playground_api/README.md +++ b/python/examples/playground_api/README.md @@ -10,16 +10,17 @@ Payment Sandbox (a hosted test validator, no real funds): umbrella surface, plus a faucet that funds wallets through surfpool cheatcodes. - **Sessions**: the in-process MPP session method gating `/sessions/stream` (pay-per-chunk SSE) and `/sessions/compute` (pay-per-call), with voucher - metering through the `/__402/session/*` side channel and on-chain settlement - via the idle-close watchdog. + metering through the `/__402/session/*` side channel. On-chain settle-at-close + is not yet ported, so a closed channel's `settledSignature` stays `null`. - `/api/v1/config`: the endpoint catalog and wallet/network metadata the web app renders. This is the application skeleton: the boot wiring, shared config/state, the free endpoints (health, config, faucet, docs, SPA), and the `register_*` seam the charge / session feature endpoints plug into. The feature endpoints are -implemented separately. Every route the server exposes is advertised in the -`/api/v1/config` catalog the web app renders. +implemented separately. The `/api/v1/config` catalog is a curated subset of the +live routes: every catalog entry has a route, but infra routes (health, docs, +faucet, the session side channel) are live without being advertised. ## Running @@ -81,10 +82,24 @@ PAYKIT_PLAYGROUND_API_URL=http://localhost:3210 pnpm dev ## Differences from the other playground APIs -The Python SDK does not implement the `solana.subscription` server method yet, -so the playground exposes no subscription route (the other examples gate one). -The endpoint catalog omits the subscription entry and the playground UI renders -its graceful empty state. +This Python playground is deliberately a leaner subset of the Go/TS ones. The +endpoints below exist in Go/TS but are intentionally omitted here. None are in +any `/api/v1/config` catalog there either (they are live-but-unadvertised), so +dropping them changes no advertised surface: + +- **`/api/v1/stocks/search`, `/api/v1/stocks/history`** — extra yfinance call + paths that add no SDK surface beyond `stocks/quote`. +- **`/api/v1/weather/{city}`** — a charge gating a canned hardcoded-city table; + no coverage `stocks/quote` does not already give. +- **`/api/v1/fortune`** — the `html=true` interactive payment page. It requires + dropping below the `pay_kit` helper layer; the html charge path is covered by + the SDK's own tests rather than the playground. +- **x402 (`/x402/*`) and the embedded facilitator** — the reference facilitator + is demo theater (`verify` rubber-stamps, `settle` is a stub). x402 is + exercised by the SDK's own conformance suite. +- **Subscriptions (`/api/v1/premium/feed`)** — the Python SDK has no + `solana.subscription` server method yet, so there is no route (Go/TS gate a + 501 stub). The catalog omits the entry and the UI renders its empty state. ## Layout diff --git a/python/examples/playground_api/app.py b/python/examples/playground_api/app.py index 8cb3e6105..cea848bac 100644 --- a/python/examples/playground_api/app.py +++ b/python/examples/playground_api/app.py @@ -107,57 +107,54 @@ def state_from_env() -> AppState: # --- endpoint catalog (drives the playground web app's sidebar) ------------- -def build_endpoint_list() -> list[dict[str, Any]]: - """Build the ``/api/v1/config`` endpoint catalog. - - Declared inline, mirroring the TS ``buildEndpointList``. Every advertised - entry has a live server route; the playground exposes exactly these. - """ - return [ - { - "id": "stocks-quote", - "primitive": "charge", - "method": "GET", - "path": "/api/v1/stocks/quote/:symbol", - "title": "Stock quote", - "description": "Real-time price for a single ticker.", - "cost": "0.01 USDC", - "params": [{"name": "symbol", "default": "AAPL"}], - }, - { - "id": "marketplace-buy", - "primitive": "charge", - "method": "GET", - "path": "/api/v1/marketplace/buy/:productId", - "title": "Marketplace purchase", - "description": "Multi-recipient split (seller + platform + referral).", - "cost": "varies", - "params": [ - {"name": "productId", "default": "sol-hoodie"}, - {"name": "referrer", "default": ""}, - ], - }, - { - "id": "sessions-stream", - "primitive": "session", - "method": "GET", - "path": "/sessions/stream", - "title": "Metered stream", - "description": "Pay-per-chunk SSE delivery via session vouchers.", - "cost": "0.0001 USDC / chunk", - "unitPrice": "100", - }, - { - "id": "sessions-compute", - "primitive": "session", - "method": "POST", - "path": "/sessions/compute", - "title": "Pay-per-call compute", - "description": "Voucher-billed inference; cap 0.50 USDC per session.", - "cost": "0.005 USDC / call", - "unitPrice": "5000", - }, - ] +# The /api/v1/config endpoint catalog the web app renders in its sidebar. A +# curated subset of the live routes (mirrors the TS buildEndpointList): every +# entry here has a server route, but not every route is advertised. +_ENDPOINT_CATALOG: list[dict[str, Any]] = [ + { + "id": "stocks-quote", + "primitive": "charge", + "method": "GET", + "path": "/api/v1/stocks/quote/:symbol", + "title": "Stock quote", + "description": "Real-time price for a single ticker.", + "cost": "0.01 USDC", + "params": [{"name": "symbol", "default": "AAPL"}], + }, + { + "id": "marketplace-buy", + "primitive": "charge", + "method": "GET", + "path": "/api/v1/marketplace/buy/:productId", + "title": "Marketplace purchase", + "description": "Multi-recipient split (seller + platform + referral).", + "cost": "varies", + "params": [ + {"name": "productId", "default": "sol-hoodie"}, + {"name": "referrer", "default": ""}, + ], + }, + { + "id": "sessions-stream", + "primitive": "session", + "method": "GET", + "path": "/sessions/stream", + "title": "Metered stream", + "description": "Pay-per-chunk SSE delivery via session vouchers.", + "cost": "0.0001 USDC / chunk", + "unitPrice": "100", + }, + { + "id": "sessions-compute", + "primitive": "session", + "method": "POST", + "path": "/sessions/compute", + "title": "Pay-per-call compute", + "description": "Voucher-billed inference; cap 0.50 USDC per session.", + "cost": "0.005 USDC / call", + "unitPrice": "5000", + }, +] # --- hub endpoints: health / config ----------------------------------------- @@ -196,7 +193,7 @@ async def config() -> JSONResponse: "recipient": state.recipient, "network": state.network, "feePayer": state.fee_payer_pubkey, - "endpoints": build_endpoint_list(), + "endpoints": _ENDPOINT_CATALOG, } ) @@ -209,8 +206,8 @@ async def config() -> JSONResponse: def register_charges(app: FastAPI, state: AppState) -> None: - """Mount the MPP charge-gated endpoints (stocks, weather, marketplace, - fortune). Implemented in :mod:`charges`. + """Mount the MPP charge-gated endpoints (stock quote, marketplace purchase) + plus the free product catalog. Implemented in :mod:`charges`. """ from .charges import register_charges as _register diff --git a/python/examples/playground_api/charges.py b/python/examples/playground_api/charges.py index 9e1baf7fd..e947aa226 100644 --- a/python/examples/playground_api/charges.py +++ b/python/examples/playground_api/charges.py @@ -88,16 +88,20 @@ def _display_usd(price: Price) -> str: def _quote(symbol: str) -> dict[str, Any]: - """Live quote via ``yfinance.Ticker.info``; a 404 for an unknown symbol and - a safe default when the network is unreachable so the example runs offline. + """Live quote via ``yfinance.Ticker.info``: a 404 for an unknown symbol and + a 502 when the upstream lookup fails. + + The lookup error is surfaced rather than swallowed: the charge has already + settled by the time this runs, so returning an invented price would hand the + payer fabricated data behind a real payment. """ import yfinance as yf sym = symbol.upper() try: info = yf.Ticker(sym).info - except Exception: # noqa: BLE001 (offline / lib error -> safe default) - return {"symbol": sym, "regularMarketPrice": 150.0, "currency": "USD"} + except Exception as exc: # noqa: BLE001 (surface upstream failure, never fabricate) + raise HTTPException(status_code=502, detail=json_error(f"Quote lookup failed for {sym}")) from exc price = info.get("regularMarketPrice") or info.get("currentPrice") if price is None: raise HTTPException(status_code=404, detail=json_error(f"Unknown symbol: {sym}")) @@ -145,12 +149,22 @@ async def stock_quote(request: Request, symbol: str) -> JSONResponse: # -- marketplace: free catalog plus a split-fee purchase ----------------- + def _split_fees(product: _Product, referrer: str) -> tuple[Price, Price | None]: + """The fees charged on top of the list price: platform always, referral + only when a referrer is supplied. Computed once and fed both the gate + and the human-readable breakdown. + """ + platform_fee = _bps(product.price, _PLATFORM_FEE_BPS) + referral_fee = _bps(product.price, _REFERRAL_FEE_BPS) if referrer else None + return platform_fee, referral_fee + def _buy_gate(request: Request) -> Gate: product = _PRODUCTS[request.path_params["productId"]] # validated by the guard dependency - fee_on_top: dict[str, Price] = {platform: _bps(product.price, _PLATFORM_FEE_BPS)} referrer = request.query_params.get("referrer", "") - if referrer: - fee_on_top[referrer] = _bps(product.price, _REFERRAL_FEE_BPS) + platform_fee, referral_fee = _split_fees(product, referrer) + fee_on_top: dict[str, Price] = {platform: platform_fee} + if referral_fee is not None: + fee_on_top[referrer] = referral_fee return Gate.build( name="marketplaceBuy", amount=product.price, @@ -181,14 +195,13 @@ async def marketplace_products() -> JSONResponse: ) async def marketplace_buy(request: Request, productId: str) -> JSONResponse: # noqa: N803 (route path param) product = _PRODUCTS[productId] - platform_fee = _bps(product.price, _PLATFORM_FEE_BPS) + platform_fee, referral_fee = _split_fees(product, request.query_params.get("referrer", "")) total = product.price.amount + platform_fee.amount breakdown = { "seller": _display_usd(product.price), "platformFee": _display_usd(platform_fee), } - if request.query_params.get("referrer"): - referral_fee = _bps(product.price, _REFERRAL_FEE_BPS) + if referral_fee is not None: breakdown["referralFee"] = _display_usd(referral_fee) total += referral_fee.amount breakdown["total"] = f"{total:.2f} USDC" From 34c845ab4c5d27a84f4ecfea8193e934fc611c2d Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Thu, 18 Jun 2026 00:22:02 +0300 Subject: [PATCH 24/52] refactor(python/playground): gate session routes via a FastAPI dependency 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. --- python/examples/playground_api/sessions.py | 50 +++++++++------------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/python/examples/playground_api/sessions.py b/python/examples/playground_api/sessions.py index 68c1d945e..c24113714 100644 --- a/python/examples/playground_api/sessions.py +++ b/python/examples/playground_api/sessions.py @@ -24,7 +24,7 @@ import json from typing import TYPE_CHECKING, Any -from fastapi import FastAPI, Request +from fastapi import Depends, FastAPI, HTTPException, Request from fastapi.responses import JSONResponse, StreamingResponse from pay_kit._paycore.errors import PaymentError, payment_required_response @@ -64,15 +64,16 @@ def _session_gate(session: Session, options: SessionChallengeOptions): - """Build the 402 gate for a route (the Python SDK has no ``SessionMiddleware``). + """Build the 402 gate for a route as a FastAPI dependency (the Python SDK has + no ``SessionMiddleware``), mirroring how charge routes use ``RequirePayment``. - Returns an async callable that yields the verified-receipt response headers - when an Authorization credential verifies, or a 402 :class:`JSONResponse` + Returns an async dependency that yields the verified-receipt response headers + when an Authorization credential verifies, or raises ``HTTPException(402)`` (challenge in WWW-Authenticate) otherwise. The challenge is built only when a 402 is issued, so the verify path never prefetches a blockhash. """ - async def gate(request: Request) -> dict[str, str] | JSONResponse: + async def gate(request: Request) -> dict[str, str]: error: PaymentError | None = None auth_header = request.headers.get(AUTHORIZATION_HEADER) if auth_header: @@ -93,7 +94,7 @@ async def gate(request: Request) -> dict[str, str] | JSONResponse: code=(error.code if error and error.code else "payment_invalid"), challenge_header=format_www_authenticate(challenge), ) - return JSONResponse(problem["body"], status_code=problem["status_code"], headers=problem["headers"]) + raise HTTPException(status_code=problem["status_code"], detail=problem["body"], headers=problem["headers"]) return gate @@ -174,13 +175,14 @@ def shutdown() -> None: SessionChallengeOptions(cap="500000", description="Voucher-billed inference call"), ) + # Gates as FastAPI dependencies, so the session routes read like the charge + # routes (assigned first to keep the Depends call out of the arg default). + stream_dep = Depends(stream_gate) + compute_dep = Depends(compute_gate) + # GET /sessions/stream: stream tokens as SSE; each chunk costs 0.0001 USDC. @app.get("/sessions/stream") - async def sessions_stream_get(request: Request) -> Any: - gated = await stream_gate(request) - if isinstance(gated, JSONResponse): - return gated - + async def sessions_stream_get(headers: dict[str, str] = stream_dep) -> Any: async def body() -> Any: for chunk in TOKEN_CHUNKS: yield "data: " + json.dumps({"chunk": chunk, "cost": "100"}, separators=(",", ":")) + "\n\n" @@ -190,33 +192,23 @@ async def body() -> Any: return StreamingResponse( body(), media_type="text/event-stream", - headers={"Cache-Control": "no-cache", "Connection": "keep-alive", **gated}, + headers={"Cache-Control": "no-cache", "Connection": "keep-alive", **headers}, ) # POST /sessions/stream: voucher commits arrive on the URL the session was # opened against; the gate's verify path applies the voucher, the body is an ack. @app.post("/sessions/stream") - async def sessions_stream_post(request: Request) -> JSONResponse: - gated = await stream_gate(request) - if isinstance(gated, JSONResponse): - return gated - return JSONResponse(_commit_ack(await _body(request)), headers=gated) + async def sessions_stream_post(request: Request, headers: dict[str, str] = stream_dep) -> JSONResponse: + return JSONResponse(_commit_ack(await _body(request)), headers=headers) # POST /sessions/compute: pay-per-call compute; the same handler accepts # voucher commits (a deliveryId in the body discriminates). @app.post("/sessions/compute") - async def sessions_compute_post(request: Request) -> JSONResponse: - gated = await compute_gate(request) - if isinstance(gated, JSONResponse): - return gated + async def sessions_compute_post(request: Request, headers: dict[str, str] = compute_dep) -> JSONResponse: body = await _body(request) - delivery_id = str(body.get("deliveryId", "")) - if delivery_id != "": - return JSONResponse( - {"amount": "0", "deliveryId": delivery_id, "status": "committed"}, - headers=gated, - ) - log_payment(request.url.path, gated) + if str(body.get("deliveryId", "")) != "": + return JSONResponse(_commit_ack(body), headers=headers) + log_payment(request.url.path, headers) prompt = str(body.get("prompt", "")) return JSONResponse( { @@ -224,7 +216,7 @@ async def sessions_compute_post(request: Request) -> JSONResponse: "output": "Echo: " + prompt + " (computed for 0.005 USDC)", "computedAt": datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), }, - headers=gated, + headers=headers, ) # Side-channel metering routes: clients reserve capacity for each metered From a37144fb392ec05021078b6bc1256c8a9616fd5f Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Thu, 18 Jun 2026 00:35:10 +0300 Subject: [PATCH 25/52] test(python/playground): add playground-api smoke tests 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. --- python/tests/test_playground_api.py | 75 +++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 python/tests/test_playground_api.py diff --git a/python/tests/test_playground_api.py b/python/tests/test_playground_api.py new file mode 100644 index 000000000..28c00cd6c --- /dev/null +++ b/python/tests/test_playground_api.py @@ -0,0 +1,75 @@ +"""Smoke tests for the playground-api example (mirrors Go's main_test.go). + +Boots the FastAPI app against an unreachable RPC (no network, no funding) and +asserts the surface the playground web app depends on: the config catalog, the +free routes, and that every paid route fires a 402 challenge before its handler. +Charge gating runs before the handler, so these never reach yfinance. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient +from solders.keypair import Keypair + +from examples.playground_api.app import AppState, create_app +from pay_kit.signer import LocalSigner + + +@pytest.fixture +def client() -> TestClient: + state = AppState( + network="localnet", + rpc_url="http://127.0.0.1:1", # unreachable: health balance is best-effort, challenges are local + recipient=str(Keypair().pubkey()), + secret_key="a" * 64, + fee_payer=LocalSigner.from_keypair(Keypair()), + repo_root=None, + ) + return TestClient(create_app(state)) + + +def test_config_catalog_lists_the_advertised_endpoints(client: TestClient) -> None: + body = client.get("/api/v1/config").json() + assert {"recipient", "network", "feePayer", "endpoints"} <= body.keys() + ids = {e["id"] for e in body["endpoints"]} + assert ids == {"stocks-quote", "marketplace-buy", "sessions-stream", "sessions-compute"} + + +def test_health_ok_without_rpc(client: TestClient) -> None: + body = client.get("/api/v1/health").json() + assert body["ok"] is True + # Balance is best-effort; the unreachable RPC means it is simply absent. + assert "feePayerBalance" not in body + + +def test_marketplace_products_is_free(client: TestClient) -> None: + resp = client.get("/api/v1/marketplace/products") + assert resp.status_code == 200 + assert len(resp.json()) == 3 + + +@pytest.mark.parametrize( + "method,path", + [ + ("GET", "/api/v1/stocks/quote/AAPL"), + ("GET", "/api/v1/marketplace/buy/sol-hoodie"), + ("GET", "/sessions/stream"), + ("POST", "/sessions/compute"), + ], +) +def test_paid_route_challenges_before_handler(client: TestClient, method: str, path: str) -> None: + resp = client.request(method, path) + assert resp.status_code == 402 + assert resp.headers.get("www-authenticate", "").startswith("Payment ") + + +def test_unknown_product_404s_before_payment(client: TestClient) -> None: + # The product guard runs ahead of the payment dependency. + resp = client.get("/api/v1/marketplace/buy/does-not-exist") + assert resp.status_code == 404 + + +def test_receipt_unknown_channel_404(client: TestClient) -> None: + resp = client.get("/sessions/receipt/not-a-real-channel") + assert resp.status_code == 404 From 0aacf014242cb922edeb981d953d9ec4ff157c13 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Thu, 18 Jun 2026 19:48:35 +0300 Subject: [PATCH 26/52] refactor(python/playground): rewrite to the idiomatic pay_kit example 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. --- python/examples/playground_api/README.md | 117 +------- python/examples/playground_api/app.py | 312 +++---------------- python/examples/playground_api/charges.py | 209 ------------- python/examples/playground_api/constants.py | 31 -- python/examples/playground_api/docs.py | 125 -------- python/examples/playground_api/faucet.py | 62 ---- python/examples/playground_api/main.py | 83 ------ python/examples/playground_api/sessions.py | 314 ++++++-------------- python/examples/playground_api/utils.py | 77 ----- python/tests/test_playground_api.py | 61 +--- 10 files changed, 155 insertions(+), 1236 deletions(-) delete mode 100644 python/examples/playground_api/charges.py delete mode 100644 python/examples/playground_api/constants.py delete mode 100644 python/examples/playground_api/docs.py delete mode 100644 python/examples/playground_api/faucet.py delete mode 100644 python/examples/playground_api/main.py delete mode 100644 python/examples/playground_api/utils.py diff --git a/python/examples/playground_api/README.md b/python/examples/playground_api/README.md index 96ab219d7..74eb15d4e 100644 --- a/python/examples/playground_api/README.md +++ b/python/examples/playground_api/README.md @@ -1,116 +1,25 @@ -# playground-api (Python) +# Playground API (Python) -The Python port of [`go/examples/playground-api`](../../../go/examples/playground-api/), -the HTTP API behind the [pay-kit playground](../../../playground/). It serves -the same endpoints with the same payment gating semantics against the Solana -Payment Sandbox (a hosted test validator, no real funds): +A FastAPI server gated with the unified `pay_kit` surface, the same idioms as +`examples/fastapi/app.py`. Zero-config: `pay_kit.configure(network="solana_localnet")` +boots against the hosted Surfpool sandbox with the shipped demo signer. -- **Charges**: `solana.charge` endpoints (a single-recipient stock quote and a - marketplace purchase with multi-recipient splits) gated through the `pay_kit` - umbrella surface, plus a faucet that funds wallets through surfpool cheatcodes. -- **Sessions**: the in-process MPP session method gating `/sessions/stream` - (pay-per-chunk SSE) and `/sessions/compute` (pay-per-call), with voucher - metering through the `/__402/session/*` side channel. On-chain settle-at-close - is not yet ported, so a closed channel's `settledSignature` stays `null`. -- `/api/v1/config`: the endpoint catalog and wallet/network metadata the web - app renders. +Routes: -This is the application skeleton: the boot wiring, shared config/state, the -free endpoints (health, config, faucet, docs, SPA), and the `register_*` seam -the charge / session feature endpoints plug into. The feature endpoints are -implemented separately. The `/api/v1/config` catalog is a curated subset of the -live routes: every catalog entry has a route, but infra routes (health, docs, -faucet, the session side channel) are live without being advertised. +- `GET /health` free liveness probe. +- `GET /report` charge-gated via `RequirePayment` (both protocols). +- `POST /compute` session-gated, metered (see `sessions.py`). -## Running - -```sh -pip install -e ".[playground]" -python -m examples.playground_api.main # listens on :3000 -``` - -or directly through uvicorn (no surfnet funding): +Run: ```sh +cd python uvicorn examples.playground_api.app:app --port 3000 ``` -## Pointing the playground at this server - -Set `PAYKIT_PLAYGROUND_API_URL` and the playground's `pnpm dev` skips launching -its own server; the web app's dev proxy targets this one instead: +Drive it: ```sh -# terminal 1: the Python API -cd python && PORT=3210 python -m examples.playground_api.main - -# terminal 2: UI only, proxied to the running API -cd playground -PAYKIT_PLAYGROUND_API_URL=http://localhost:3210 pnpm dev -``` - -## Environment variables - -| Variable | Default | Purpose | -|----------|---------|---------| -| `PORT` | `3000` | Listen port | -| `NETWORK` | `localnet` | Solana network tag for MPP challenges | -| `RPC_URL` | `https://402.surfnet.dev:8899` | Surfpool RPC endpoint (hosted sandbox by default) | -| `RECIPIENT` | (auto-generated) | Solana address that receives payments | -| `FEE_PAYER_KEY` | (auto-generated) | Base58 fee-payer keypair (server signs as fee payer) | -| `MPP_SECRET_KEY` | (random per-boot) | MPP secret key for challenge HMAC | -| `DOCS_ROOT` | (repo `docs/api`) | Overrides the generated-docs directory outside a checkout | - -## Endpoints - -| Method | Path | Gate | -|--------|------|------| -| GET | `/api/v1/health` | free | -| GET | `/api/v1/config` | free | -| 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/marketplace/products` | free | -| GET | `/api/v1/marketplace/buy/:productId?referrer=` | charge with splits | -| 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 | - -## Differences from the other playground APIs - -This Python playground is deliberately a leaner subset of the Go/TS ones. The -endpoints below exist in Go/TS but are intentionally omitted here. None are in -any `/api/v1/config` catalog there either (they are live-but-unadvertised), so -dropping them changes no advertised surface: - -- **`/api/v1/stocks/search`, `/api/v1/stocks/history`** — extra yfinance call - paths that add no SDK surface beyond `stocks/quote`. -- **`/api/v1/weather/{city}`** — a charge gating a canned hardcoded-city table; - no coverage `stocks/quote` does not already give. -- **`/api/v1/fortune`** — the `html=true` interactive payment page. It requires - dropping below the `pay_kit` helper layer; the html charge path is covered by - the SDK's own tests rather than the playground. -- **x402 (`/x402/*`) and the embedded facilitator** — the reference facilitator - is demo theater (`verify` rubber-stamps, `settle` is a stub). x402 is - exercised by the SDK's own conformance suite. -- **Subscriptions (`/api/v1/premium/feed`)** — the Python SDK has no - `solana.subscription` server method yet, so there is no route (Go/TS gate a - 501 stub). The catalog omits the entry and the UI renders its empty state. - -## Layout - -Mirrors the Go module structure as files of one Python package: - -``` -app.py # boot wiring (AppState, create_app), the register_* seam, - # /api/v1/{health,config}, SPA serving (CORS/handlers via - # pay_kit.fastapi.install) -main.py # entrypoint: surfnet funding + uvicorn -docs.py # generated-docs browser with path-escape guard -constants.py # example-specific constants (faucet amounts, USDC decimals/mint) -utils.py # rpcCall, env helpers, receipt logging, JSON error body +curl -i http://127.0.0.1:3000/report # 402 payment required +pay curl http://127.0.0.1:3000/report # pays and succeeds ``` diff --git a/python/examples/playground_api/app.py b/python/examples/playground_api/app.py index cea848bac..55b90bbc5 100644 --- a/python/examples/playground_api/app.py +++ b/python/examples/playground_api/app.py @@ -1,293 +1,73 @@ # examples/playground_api/app.py -"""The HTTP API behind the pay-kit playground (FastAPI). +"""The pay-kit playground API (FastAPI), gated with the unified pay_kit surface. -Serves the playground endpoints with their payment gating (MPP charges and -sessions through the ``pay_kit`` umbrella surface), so the playground web app -works against it by only setting ``PAYKIT_PLAYGROUND_API_URL``. +Zero-config: ``pay_kit.configure()`` boots against solana_localnet (the hosted +Surfpool sandbox at https://402.surfnet.dev:8899) with the shipped demo signer +as operator and recipient. No keys, no .env, no flags. + +Routes: + + GET /health -> free, returns {"ok": true} + GET /report -> charge-gated via RequirePayment (both protocols) + POST /compute -> session-gated, metered (see sessions.py) + +Run: cd python uvicorn examples.playground_api.app:app --port 3000 -Environment: PORT, NETWORK, RPC_URL, RECIPIENT, FEE_PAYER_KEY, MPP_SECRET_KEY. -See README.md for the full table. +Drive it from a client: -This module owns the boot wiring (:class:`AppState`, :func:`create_app`) and -the free hub endpoints (health, config catalog). The charge / session / faucet -/ docs feature endpoints plug into the ``register_*`` seam in -:func:`create_app` and are filled in separately. + curl -i http://127.0.0.1:3000/report # 402 payment required + pay curl http://127.0.0.1:3000/report # pays and succeeds """ from __future__ import annotations -import logging -import os -import secrets -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from fastapi import FastAPI -from fastapi.responses import FileResponse, JSONResponse, Response -from solders.keypair import Keypair +from fastapi import Depends, FastAPI import pay_kit -from pay_kit._paycore.network import Network -from pay_kit.fastapi import install -from pay_kit.signer import LocalSigner - -from .docs import build_docs_router, find_repo_root -from .faucet import register_faucet -from .sessions import register_sessions -from .utils import env_or, json_error, rpc_call - -logger = logging.getLogger("playground-api") - - -@dataclass -class AppState: - """Boot configuration shared by every module. - - Mirrors the TS example's module-level env constants: the raw network tag, - RPC endpoint, settlement recipient, MPP challenge-binding secret, the - operator fee-payer keypair, and the repository root (``None`` outside a - checkout, which disables the docs default root and the SPA file server). - """ - - network: str - rpc_url: str - recipient: str - secret_key: str - fee_payer: LocalSigner - repo_root: str | None = None - port: int = 3000 - - @property - def fee_payer_pubkey(self) -> str: - """Base58 operator pubkey used as the fee payer and health label.""" - return self.fee_payer.pubkey() - - @property - def pay_kit_network(self) -> Network: - """The :class:`Network` enum for the raw ``NETWORK`` tag. - - Surfpool localnet clones mainnet state, so the bare ``localnet`` / - ``devnet`` / ``mainnet`` tags map onto the ``solana_*`` enum members. - """ - normalized = self.network.strip().lower() - if normalized in ("localnet", "solana_localnet"): - return Network.SOLANA_LOCALNET - if normalized in ("devnet", "solana_devnet"): - return Network.SOLANA_DEVNET - if normalized in ("mainnet", "mainnet-beta", "solana_mainnet"): - return Network.SOLANA_MAINNET - raise ValueError(f"unknown NETWORK tag: {self.network!r}") - - -def state_from_env() -> AppState: - """Build :class:`AppState` from the environment, matching the TS example's - module-level defaults. - """ - raw_fee_payer = os.getenv("FEE_PAYER_KEY") - fee_payer = LocalSigner.from_base58(raw_fee_payer) if raw_fee_payer else LocalSigner.from_keypair(Keypair()) - return AppState( - network=env_or("NETWORK", "localnet"), - # Default to the hosted Solana Payment Sandbox so the playground works - # zero-config: it has the payment-channels program preloaded and - # supports the surfnet cheatcodes the faucet uses. Override RPC_URL to - # point at a local surfpool when you need offline iteration. - rpc_url=env_or("RPC_URL", "https://402.surfnet.dev:8899"), - recipient=env_or("RECIPIENT", fee_payer.pubkey()), - secret_key=os.getenv("MPP_SECRET_KEY") or secrets.token_hex(32), - fee_payer=fee_payer, - repo_root=find_repo_root(), - port=int(env_or("PORT", "3000")), - ) - - -# --- endpoint catalog (drives the playground web app's sidebar) ------------- - - -# The /api/v1/config endpoint catalog the web app renders in its sidebar. A -# curated subset of the live routes (mirrors the TS buildEndpointList): every -# entry here has a server route, but not every route is advertised. -_ENDPOINT_CATALOG: list[dict[str, Any]] = [ - { - "id": "stocks-quote", - "primitive": "charge", - "method": "GET", - "path": "/api/v1/stocks/quote/:symbol", - "title": "Stock quote", - "description": "Real-time price for a single ticker.", - "cost": "0.01 USDC", - "params": [{"name": "symbol", "default": "AAPL"}], - }, - { - "id": "marketplace-buy", - "primitive": "charge", - "method": "GET", - "path": "/api/v1/marketplace/buy/:productId", - "title": "Marketplace purchase", - "description": "Multi-recipient split (seller + platform + referral).", - "cost": "varies", - "params": [ - {"name": "productId", "default": "sol-hoodie"}, - {"name": "referrer", "default": ""}, - ], - }, - { - "id": "sessions-stream", - "primitive": "session", - "method": "GET", - "path": "/sessions/stream", - "title": "Metered stream", - "description": "Pay-per-chunk SSE delivery via session vouchers.", - "cost": "0.0001 USDC / chunk", - "unitPrice": "100", - }, - { - "id": "sessions-compute", - "primitive": "session", - "method": "POST", - "path": "/sessions/compute", - "title": "Pay-per-call compute", - "description": "Voucher-billed inference; cap 0.50 USDC per session.", - "cost": "0.005 USDC / call", - "unitPrice": "5000", - }, -] +from pay_kit import Gate, Pricing, usd +from pay_kit.fastapi import Payment, RequirePayment, install +pay_kit.configure(network="solana_localnet") -# --- hub endpoints: health / config ----------------------------------------- +# Imported after configure() so the session method builds from the resolved +# operator / recipient / challenge-binding secret. +from . import sessions # noqa: E402 -def register_hub(app: FastAPI, state: AppState) -> None: - """Mount the health check and the endpoint catalog driving the web app's - sidebar. +class Catalog(Pricing): + """The route catalogue: every charge-gated route declares its gate here.""" - The RPC url is intentionally omitted from both responses: it is operator - infrastructure that should not be exposed to the browser. - """ - - @app.get("/api/v1/health") - async def health() -> JSONResponse: - body: dict[str, Any] = { - "ok": True, - "feePayer": state.fee_payer_pubkey, - "recipient": state.recipient, - "network": state.network, - } - try: - result = await rpc_call(state.rpc_url, "getBalance", [state.fee_payer_pubkey]) - value = result.get("value") if isinstance(result, dict) else result - if value is not None: - body["feePayerBalance"] = float(value) / 1e9 - except Exception: - # Best-effort balance: omitted when the RPC is unreachable. - pass - return JSONResponse(body) - - @app.get("/api/v1/config") - async def config() -> JSONResponse: - return JSONResponse( - { - "recipient": state.recipient, - "network": state.network, - "feePayer": state.fee_payer_pubkey, - "endpoints": _ENDPOINT_CATALOG, - } + def __init__(self) -> None: + self.report = Gate.build( + name="report", + amount=usd("0.01"), + description="Premium report", + default_pay_to=pay_kit.config().effective_recipient(), + accept_default=pay_kit.config().accept, ) -# --- feature-module registration seam --------------------------------------- -# -# The charge endpoints plug in here. faucet, docs, and sessions register from -# their own modules directly in create_app. Each registrar takes the app and -# shared state and mounts its routes; sessions returns a shutdown hook. - - -def register_charges(app: FastAPI, state: AppState) -> None: - """Mount the MPP charge-gated endpoints (stock quote, marketplace purchase) - plus the free product catalog. Implemented in :mod:`charges`. - """ - from .charges import register_charges as _register - - _register(app, state) - - -# --- SPA static serving ------------------------------------------------------ - - -def register_spa(app: FastAPI, repo_root: str | None) -> None: - """Serve the built playground web app (``playground/app/dist`` at the repo - root) with an index.html catch-all. - - Registered last so the API routes win; the catch-all only sees paths no - earlier route matched. - """ - dist = Path(repo_root) / "playground" / "app" / "dist" if repo_root else None - - @app.get("/{full_path:path}") - async def spa(full_path: str) -> Response: - if dist: - candidate = dist / full_path - if candidate.is_file(): - return FileResponse(str(candidate)) - index = dist / "index.html" - if index.is_file(): - return FileResponse(str(index)) - return JSONResponse( - json_error("not found (build playground/app to serve the web app from this server)"), - status_code=404, - ) - - -# --- app factory ------------------------------------------------------------ - - -def create_app(state: AppState | None = None) -> FastAPI: - """Wire every module onto one FastAPI app. - - Split from the entrypoint so a smoke test can boot the full route table - against a stub RPC without binding a real port or funding accounts. The - sessions shutdown hook is registered as an ASGI shutdown handler. - """ - if state is None: - state = state_from_env() - - # Configure the umbrella surface so the charge / session registrars share - # one boot. Preflight is skipped here: the live-RPC check belongs to the - # entrypoint's boot path, not the app factory the smoke test reuses. - pay_kit.configure( - network=state.pay_kit_network, - rpc_url=state.rpc_url, - accept=pay_kit.Protocol.MPP, - preflight=False, - operator=pay_kit.Operator(recipient=state.recipient, signer=state.fee_payer), - mpp=pay_kit.MppConfig(realm="PayKit Playground", challenge_binding_secret=state.secret_key), - ) - - app = FastAPI(title="PayKit Playground (Python)") - # One-call pay-kit setup: payment-header CORS, the PayKitError -> HTTP - # response mapping + settlement-header echo, and the bare-dict HTTPException - # shape the guards rely on. - install(app) +catalog = Catalog() - register_hub(app, state) - register_faucet(app, state) - app.include_router(build_docs_router(state)) - register_charges(app, state) - sessions_shutdown = register_sessions(app, state) +# Module-level dependency singleton (FastAPI resolves it per request). +require_report = Depends(RequirePayment("report", pricing=catalog)) - # The SPA catch-all must register after every API route. - register_spa(app, state.repo_root) +app = FastAPI(title="PayKit Playground (Python)") +# One-call setup: payment-header CORS + the PayKitError -> 402 challenge mapping. +install(app) +app.include_router(sessions.router) - @app.on_event("shutdown") - async def _shutdown() -> None: - sessions_shutdown() - return app +@app.get("/health") +async def health() -> dict[str, bool]: + """Free liveness probe.""" + return {"ok": True} -# 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() +@app.get("/report") +async def report(payment: Payment = require_report) -> dict[str, object]: + """Charge-gated route. ``payment`` is the verified proof for this request.""" + return {"ok": True, "tx": payment.transaction, "protocol": payment.protocol.value} diff --git a/python/examples/playground_api/charges.py b/python/examples/playground_api/charges.py deleted file mode 100644 index e947aa226..000000000 --- a/python/examples/playground_api/charges.py +++ /dev/null @@ -1,209 +0,0 @@ -# examples/playground_api/charges.py -"""The two charge-gated demo endpoints, gated through the ``pay_kit`` umbrella -surface: a single-recipient stock quote and a multi-recipient marketplace -purchase with split fees. The 402 challenge fires before any handler body runs. - -Mirrors the TypeScript example's ``modules/charges.ts``. The umbrella -:func:`RequirePayment` dependency stands in for Express's ``requirePayment`` -middleware: a per-request gate builder reads the path/query and returns a -fully-validated :class:`Gate`. The unknown-product guard is declared ahead of -the payment dependency so the 404 fires before the 402. - -The stock quote pulls live market data from the ``yfinance`` library (the -Python counterpart to the TypeScript example's ``yahoo-finance2``). A lookup -failure degrades gracefully so the example still runs offline: an unknown -symbol is a 404 and a network outage returns a small safe default. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from decimal import Decimal -from typing import Any - -from fastapi import Depends, FastAPI, HTTPException, Request -from fastapi.concurrency import run_in_threadpool -from fastapi.responses import JSONResponse - -import pay_kit -from pay_kit import Gate, Price, usd -from pay_kit.fastapi import RequirePayment, payment - -from . import constants -from .utils import json_error, log_tx - -# --- marketplace catalog ---------------------------------------------------- - - -@dataclass(frozen=True) -class _Product: - """One marketplace catalog entry. - - ``price`` is the seller's list price in USD; the platform and referral - basis-point fees are charged on top of it, not carved out. ``seller`` is the - base58 wallet that receives the list price as the charge's primary pay-to. - """ - - name: str - price: Price - seller: str - description: str - - -_PRODUCTS: dict[str, _Product] = { - "sol-hoodie": _Product( - name="Solana Hoodie", - price=usd("2.00"), - seller="7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", - description="Premium Solana-branded hoodie", - ), - "validator-mug": _Product( - name="Validator Mug", - price=usd("1.00"), - seller="7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", - description="Ceramic mug for node operators", - ), - "nft-sticker-pack": _Product( - name="NFT Sticker Pack", - price=usd("0.50"), - seller="7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", - description="Holographic sticker collection", - ), -} - -_PLATFORM_FEE_BPS = 500 # 5% -_REFERRAL_FEE_BPS = 200 # 2% - - -def _bps(price: Price, basis_points: int) -> Price: - """Return the given basis-point percentage of a price, e.g. - ``_bps(usd 2.00, 500)`` is ``usd 0.10``. - """ - return usd((price.amount * Decimal(basis_points)) / Decimal(10_000)) - - -def _display_usd(price: Price) -> str: - """Render a price as the playground's two-decimal USDC label.""" - return f"{price.amount:.2f} USDC" - - -def _quote(symbol: str) -> dict[str, Any]: - """Live quote via ``yfinance.Ticker.info``: a 404 for an unknown symbol and - a 502 when the upstream lookup fails. - - The lookup error is surfaced rather than swallowed: the charge has already - settled by the time this runs, so returning an invented price would hand the - payer fabricated data behind a real payment. - """ - import yfinance as yf - - sym = symbol.upper() - try: - info = yf.Ticker(sym).info - except Exception as exc: # noqa: BLE001 (surface upstream failure, never fabricate) - raise HTTPException(status_code=502, detail=json_error(f"Quote lookup failed for {sym}")) from exc - price = info.get("regularMarketPrice") or info.get("currentPrice") - if price is None: - raise HTTPException(status_code=404, detail=json_error(f"Unknown symbol: {sym}")) - return info - - -# --- registration ----------------------------------------------------------- - - -def register_charges(app: FastAPI, state: Any) -> None: - """Mount the stock-quote and marketplace charge routes plus the free - product catalog. - - ``state`` is the :class:`~examples.playground_api.app.AppState`; typed - loosely to avoid a circular import with the boot module. - """ - platform = state.recipient - accept_default = pay_kit.config().accept - - def _log_settlement(request: Request) -> None: - """Surface the settlement signature once a gated handler runs.""" - proof = payment(request) - if proof is not None and proof.transaction: - log_tx(request.url.path, proof.transaction) - - def _require_known_product(productId: str) -> None: # noqa: N803 (mirrors the route path param) - if productId not in _PRODUCTS: - raise HTTPException(status_code=404, detail=json_error("Product not found")) - - # -- stock quote: single-recipient charge -------------------------------- - - def _quote_gate(request: Request) -> Gate: - return Gate.build( - name="stockQuote", - amount=usd("0.01"), - description=f"Stock quote: {request.path_params['symbol']}", - default_pay_to=platform, - accept_default=accept_default, - ) - - @app.get("/api/v1/stocks/quote/{symbol}", dependencies=[Depends(RequirePayment(_quote_gate))]) - async def stock_quote(request: Request, symbol: str) -> JSONResponse: - _log_settlement(request) - return JSONResponse(await run_in_threadpool(_quote, symbol)) - - # -- marketplace: free catalog plus a split-fee purchase ----------------- - - def _split_fees(product: _Product, referrer: str) -> tuple[Price, Price | None]: - """The fees charged on top of the list price: platform always, referral - only when a referrer is supplied. Computed once and fed both the gate - and the human-readable breakdown. - """ - platform_fee = _bps(product.price, _PLATFORM_FEE_BPS) - referral_fee = _bps(product.price, _REFERRAL_FEE_BPS) if referrer else None - return platform_fee, referral_fee - - def _buy_gate(request: Request) -> Gate: - product = _PRODUCTS[request.path_params["productId"]] # validated by the guard dependency - referrer = request.query_params.get("referrer", "") - platform_fee, referral_fee = _split_fees(product, referrer) - fee_on_top: dict[str, Price] = {platform: platform_fee} - if referral_fee is not None: - fee_on_top[referrer] = referral_fee - return Gate.build( - name="marketplaceBuy", - amount=product.price, - pay_to=product.seller, - description=f"Purchase: {product.name}", - fee_on_top=fee_on_top, - default_pay_to=platform, - accept_default=accept_default, - ) - - @app.get("/api/v1/marketplace/products") - async def marketplace_products() -> JSONResponse: - listing = [ - { - "id": product_id, - "name": product.name, - "description": product.description, - "price": _display_usd(product.price), - "priceRaw": str(int(product.price.amount * (Decimal(10) ** constants.USDC_DECIMALS))), - } - for product_id, product in _PRODUCTS.items() - ] - return JSONResponse(listing) - - @app.get( - "/api/v1/marketplace/buy/{productId}", - dependencies=[Depends(_require_known_product), Depends(RequirePayment(_buy_gate))], - ) - async def marketplace_buy(request: Request, productId: str) -> JSONResponse: # noqa: N803 (route path param) - product = _PRODUCTS[productId] - platform_fee, referral_fee = _split_fees(product, request.query_params.get("referrer", "")) - total = product.price.amount + platform_fee.amount - breakdown = { - "seller": _display_usd(product.price), - "platformFee": _display_usd(platform_fee), - } - if referral_fee is not None: - breakdown["referralFee"] = _display_usd(referral_fee) - total += referral_fee.amount - breakdown["total"] = f"{total:.2f} USDC" - _log_settlement(request) - return JSONResponse({"product": product.name, "breakdown": breakdown, "status": "purchased"}) diff --git a/python/examples/playground_api/constants.py b/python/examples/playground_api/constants.py deleted file mode 100644 index c6dbf1058..000000000 --- a/python/examples/playground_api/constants.py +++ /dev/null @@ -1,31 +0,0 @@ -# examples/playground_api/constants.py -"""Example-specific constants. - -Program ids and the USDC mint come straight from ``pay_kit._paycore`` at the -call sites; only knobs without an SDK equivalent live here. Mirrors the Go -example's ``constants.go``. -""" - -from __future__ import annotations - -from pay_kit._paycore.solana import SYSTEM_PROGRAM, TOKEN_PROGRAM, resolve_mint - -# usdc_decimals is the USDC token decimal count. The SDK does not export a -# decimals constant (the umbrella surface, the paycore layer, and the MPP -# protocol adapter all default to 6 internally), so the example pins it locally. -USDC_DECIMALS = 6 - -# sol_fund_lamports is the faucet SOL amount (100 SOL). -SOL_FUND_LAMPORTS = 100_000_000_000 - -# usdc_fund_amount is the faucet USDC amount (100 USDC at 6 decimals). -USDC_FUND_AMOUNT = 100_000_000 - -# usdc_mainnet_mint is the canonical USDC mint. Surfpool clones mainnet state, -# so localnet charges and the faucet settle against the mainnet mint. -USDC_MAINNET_MINT = resolve_mint("USDC", "mainnet") - -# system_program / token_program are re-exported from paycore so the faucet -# cheatcode payloads always agree with the SDK's wire values. -SYSTEM_PROGRAM_ID = SYSTEM_PROGRAM -TOKEN_PROGRAM_ID = TOKEN_PROGRAM diff --git a/python/examples/playground_api/docs.py b/python/examples/playground_api/docs.py deleted file mode 100644 index b5778abcb..000000000 --- a/python/examples/playground_api/docs.py +++ /dev/null @@ -1,125 +0,0 @@ -# examples/playground_api/docs.py -"""Serves the generated API reference markdown from ``/docs/api``, -with a path-escape guard. Override the root with the ``DOCS_ROOT`` env var when -running the app outside the repository checkout. -""" - -from __future__ import annotations - -import os -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from fastapi import APIRouter, Request -from fastapi.responses import JSONResponse, PlainTextResponse, Response - -from .utils import json_error - -if TYPE_CHECKING: - from .app import AppState - -# Languages the playground docs browser knows about. -DOC_LANGS = ["typescript", "rust", "go", "python", "ruby", "php", "lua", "kotlin", "swift"] - -# Maps a docs language to its justfile recipe suffix. -_RECIPE_SLUG = {"typescript": "ts", "rust": "rs", "python": "py", "ruby": "rb", "kotlin": "kt"} - - -def docs_recipe_slug(lang: str) -> str: - return _RECIPE_SLUG.get(lang, lang) - - -def docs_root(repo_root: str | None) -> str: - """Resolve the generated-docs directory.""" - override = os.getenv("DOCS_ROOT") - if override: - return override - return str(Path(repo_root) / "docs" / "api") if repo_root else "" - - -def find_repo_root() -> str | None: - """Walk up from the working directory to the checkout root (the directory - holding ``.git`` or the top-level ``justfile``); ``None`` when not found. - """ - directory = Path.cwd() - while True: - if (directory / ".git").exists() or (directory / "justfile").exists(): - return str(directory) - if directory.parent == directory: - return None - directory = directory.parent - - -def _safe_join(root: str, rel: str) -> str | None: - """Join ``rel`` onto ``root``, rejecting any path that escapes the root.""" - root_path = Path(root).resolve() - joined = (root_path / rel).resolve() - try: - joined.relative_to(root_path) - except ValueError: - return None - return str(joined) - - -def _build_tree(abs_dir: str, rel_dir: str) -> list[dict[str, Any]]: - """Walk the language docs directory: folders first, then markdown files, - both alphabetical, skipping dotfiles and ``node_modules``. - """ - nodes: list[dict[str, Any]] = [] - for entry in os.scandir(abs_dir): - name = entry.name - if name.startswith(".") or name == "node_modules": - continue - rel_path = f"{rel_dir}/{name}" if rel_dir else name - if entry.is_dir(): - children = _build_tree(entry.path, rel_path) - nodes.append({"name": name, "path": rel_path, "type": "dir", "children": children}) - elif name.endswith(".md"): - nodes.append({"name": name, "path": rel_path, "type": "file"}) - nodes.sort(key=lambda node: (node["type"] != "dir", node["name"])) - return nodes - - -def build_docs_router(state: AppState) -> APIRouter: - """Mount the generated-docs browsing endpoints (all free).""" - router = APIRouter() - root = docs_root(state.repo_root) - - @router.get("/api/v1/docs") - async def docs_index() -> JSONResponse: - available = {lang: bool(root) and (Path(root) / lang / "README.md").exists() for lang in DOC_LANGS} - return JSONResponse({"root": root, "available": available}) - - @router.get("/api/v1/docs/{lang}/tree") - async def docs_tree(lang: str) -> JSONResponse: - if lang not in DOC_LANGS: - return JSONResponse(json_error("unknown_lang"), status_code=404) - lang_root = Path(root) / lang - if not lang_root.exists(): - return JSONResponse( - {"error": "not_generated", "hint": f"Run: just docs-{docs_recipe_slug(lang)}"}, - status_code=404, - ) - try: - tree = _build_tree(str(lang_root), "") - except OSError as exc: - return JSONResponse({"error": "tree_failed", "detail": str(exc)}, status_code=500) - return JSONResponse({"lang": lang, "tree": tree}) - - @router.get("/api/v1/docs/{lang}/file") - async def docs_file(lang: str, request: Request) -> Response: - if lang not in DOC_LANGS: - return JSONResponse(json_error("unknown_lang"), status_code=404) - rel = request.query_params.get("path") or "README.md" - abs_path = _safe_join(str(Path(root) / lang), rel) - if abs_path is None: - return JSONResponse(json_error("unsafe_path"), status_code=400) - if not abs_path.endswith(".md"): - return JSONResponse(json_error("not_markdown"), status_code=400) - try: - content = Path(abs_path).read_text(encoding="utf-8") - except OSError as exc: - return JSONResponse({"error": "not_found", "detail": str(exc)}, status_code=404) - return PlainTextResponse(content, media_type="text/markdown; charset=utf-8") - - return router diff --git a/python/examples/playground_api/faucet.py b/python/examples/playground_api/faucet.py deleted file mode 100644 index 16630b2d1..000000000 --- a/python/examples/playground_api/faucet.py +++ /dev/null @@ -1,62 +0,0 @@ -# examples/playground_api/faucet.py -"""SOL + USDC airdrops via the surfnet cheatcodes.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse - -from . import constants -from .utils import json_error, rpc_call - -if TYPE_CHECKING: - from .app import AppState - - -def register_faucet(app: FastAPI, state: AppState) -> None: - @app.get("/api/v1/faucet/status") - async def faucet_status() -> JSONResponse: - return JSONResponse( - { - "solAmount": "100 SOL", - "usdcAmount": "100 USDC", - "usdcMint": constants.USDC_MAINNET_MINT, - } - ) - - @app.post("/api/v1/faucet/airdrop") - async def faucet_airdrop(request: Request) -> JSONResponse: - body = await request.json() - address = body.get("address") - if not address: - return JSONResponse(json_error("Missing `address` in request body"), status_code=400) - try: - await rpc_call( - state.rpc_url, - "surfnet_setAccount", - [ - address, - { - "lamports": constants.SOL_FUND_LAMPORTS, - "data": "", - "executable": False, - "owner": constants.SYSTEM_PROGRAM_ID, - "rentEpoch": 0, - }, - ], - ) - await rpc_call( - state.rpc_url, - "surfnet_setTokenAccount", - [ - address, - constants.USDC_MAINNET_MINT, - {"amount": constants.USDC_FUND_AMOUNT, "state": "initialized"}, - constants.TOKEN_PROGRAM_ID, - ], - ) - except Exception as exc: - return JSONResponse({"error": "Airdrop failed", "details": str(exc)}, status_code=500) - return JSONResponse({"ok": True, "sol": "100 SOL", "usdc": "100 USDC"}) diff --git a/python/examples/playground_api/main.py b/python/examples/playground_api/main.py deleted file mode 100644 index 0fae93e6b..000000000 --- a/python/examples/playground_api/main.py +++ /dev/null @@ -1,83 +0,0 @@ -# examples/playground_api/main.py -"""Entrypoint: boot the playground API with best-effort surfnet funding, then -serve it with uvicorn. - - cd python - python -m examples.playground_api.main - -Environment knobs are documented in README.md and resolved in -:func:`app.state_from_env`. -""" - -from __future__ import annotations - -import asyncio -import logging - -from . import constants -from .app import AppState, create_app, state_from_env -from .utils import rpc_call - -logger = logging.getLogger("playground-api") - - -async def bootstrap_funding(state: AppState) -> None: - """Fund the fee payer and recipient on the local surfnet so the demo works - zero-config. - - Best-effort: a warning is logged when the sandbox is unreachable. - """ - try: - await rpc_call( - state.rpc_url, - "surfnet_setAccount", - [ - state.fee_payer_pubkey, - { - "lamports": constants.SOL_FUND_LAMPORTS, - "data": "", - "executable": False, - "owner": constants.SYSTEM_PROGRAM_ID, - "rentEpoch": 0, - }, - ], - ) - await rpc_call( - state.rpc_url, - "surfnet_setTokenAccount", - [ - state.recipient, - constants.USDC_MAINNET_MINT, - {"amount": constants.USDC_FUND_AMOUNT, "state": "initialized"}, - constants.TOKEN_PROGRAM_ID, - ], - ) - except Exception: - logger.warning("Surfpool not reachable; fee payer may not have SOL for fees.") - - -def _log_boot(state: AppState) -> None: - """Print the boot banner mirroring the Go example's startup log.""" - logger.info("PayKit Playground (Python) http://localhost:%d", state.port) - logger.info(" Network %s", state.network) - logger.info(" RPC %s", state.rpc_url) - logger.info(" Recipient %s", state.recipient) - logger.info(" Fee payer %s", state.fee_payer_pubkey) - logger.info(" Plan not bootstrapped (subscriptions are not implemented in the Python SDK)") - logger.info(" Sessions enabled (in-process)") - - -def main() -> None: - """Build state, fund the sandbox, and serve the app.""" - import uvicorn - - logging.basicConfig(level=logging.INFO, format="%(message)s") - state = state_from_env() - asyncio.run(bootstrap_funding(state)) - app = create_app(state) - _log_boot(state) - uvicorn.run(app, host="0.0.0.0", port=state.port, log_level="info") - - -if __name__ == "__main__": - main() diff --git a/python/examples/playground_api/sessions.py b/python/examples/playground_api/sessions.py index c24113714..f6c11587c 100644 --- a/python/examples/playground_api/sessions.py +++ b/python/examples/playground_api/sessions.py @@ -1,33 +1,27 @@ # examples/playground_api/sessions.py -"""The session-gated demo endpoints (FastAPI). - -Driven by the in-process MPP session method -(:func:`pay_kit.protocols.mpp.server.new_session`), the reserve/commit metering -side channel (:func:`pay_kit.protocols.mpp.server.session_routes`), and the -settle-status receipt poll. Both methods share one -:class:`~pay_kit.protocols.mpp.server.MemoryChannelStore` so the receipt -endpoint reads channel state whichever endpoint opened the channel. - -Faithful port of ``typescript/examples/playground-api/modules/sessions.ts``. -The Python SDK does not ship the TS ``Mppx.session()`` request handler, so the -402 gate is built inline from :meth:`Session.challenge` and -:meth:`Session.verify_credential`. The server-broadcast open path -(``openTxSubmitter=server``) and the on-chain settle-at-close are not ported, so -this uses the default client-broadcast submitter and ``rpc=None``; -``settledSignature`` stays ``null`` until that lands. +"""One metered-session route for the playground (FastAPI). + +pay_kit ships a charge/x402 route shim (``RequirePayment`` + +``install_exception_handler``), but it ships *no* session gate: sessions live in +``pay_kit.protocols.mpp.server`` as framework-agnostic primitives +(:func:`new_session`, :func:`session_routes`). So the 402 gate below is the one +piece an idiomatic session example must hand-roll today. It is the same shape a +charge route gets for free: verify an ``Authorization`` credential, or answer +402 with a ``WWW-Authenticate`` challenge. + +Boot config comes from ``pay_kit.config()`` (the same resolved operator, +recipient, and challenge-binding secret the charge routes use), not hand-rolled +env wiring. """ from __future__ import annotations -import asyncio -import datetime -import json -from typing import TYPE_CHECKING, Any - -from fastapi import Depends, FastAPI, HTTPException, Request -from fastapi.responses import JSONResponse, StreamingResponse +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import JSONResponse +import pay_kit from pay_kit._paycore.errors import PaymentError, payment_required_response +from pay_kit._paycore.solana import resolve_mint from pay_kit.protocols.mpp.core.headers import ( AUTHORIZATION_HEADER, PAYMENT_RECEIPT_HEADER, @@ -36,219 +30,79 @@ parse_authorization, ) from pay_kit.protocols.mpp.server import ( - MemoryChannelStore, - RouteResponse, - Session, SessionChallengeOptions, SessionOptions, new_session, session_routes, ) -from . import constants -from .utils import json_error, log_payment - -if TYPE_CHECKING: - from .app import AppState - -# The canned token-stream payload. Mirrors TS ``TOKEN_CHUNKS``. -TOKEN_CHUNKS = [ - "A payment channel ", - "lets a client and server ", - "authorize many small ", - "off-chain debits ", - "against a single on-chain ", - "deposit, settling the highest ", - "cumulative voucher at close.", -] - - -def _session_gate(session: Session, options: SessionChallengeOptions): - """Build the 402 gate for a route as a FastAPI dependency (the Python SDK has - no ``SessionMiddleware``), mirroring how charge routes use ``RequirePayment``. - - Returns an async dependency that yields the verified-receipt response headers - when an Authorization credential verifies, or raises ``HTTPException(402)`` - (challenge in WWW-Authenticate) otherwise. The challenge is built only when a - 402 is issued, so the verify path never prefetches a blockhash. - """ - - async def gate(request: Request) -> dict[str, str]: - error: PaymentError | None = None - auth_header = request.headers.get(AUTHORIZATION_HEADER) - if auth_header: - try: - receipt = await session.verify_credential(parse_authorization(auth_header)) - try: - return {PAYMENT_RECEIPT_HEADER: format_receipt(receipt)} - except Exception: - return {} - except PaymentError as err: - error = err - except Exception as err: # noqa: BLE001 (framework/parse errors map to a 402) - error = PaymentError(str(err), code="invalid-payload") - - challenge = await session.challenge(options) - problem = payment_required_response( - str(error) if error else "Payment required", - code=(error.code if error and error.code else "payment_invalid"), - challenge_header=format_www_authenticate(challenge), - ) - raise HTTPException(status_code=problem["status_code"], detail=problem["body"], headers=problem["headers"]) - - return gate - - -async def _body(request: Request) -> dict[str, Any]: - """Best-effort JSON object request body.""" - try: - body = await request.json() - except Exception: - return {} - return body if isinstance(body, dict) else {} - - -def _commit_ack(body: dict[str, Any]) -> dict[str, str]: - """The minimal CommitReceipt-shaped ack a voucher commit returns. Mirrors TS - ``commitAck``.""" - return { - "amount": str(body.get("amount") or "0"), - "deliveryId": str(body.get("deliveryId", "")), - "status": "committed", - } - - -async def _route_response(handler: Any, request: Request) -> JSONResponse: - """Adapt a framework-agnostic side-channel :class:`RouteResponse` handler to a - FastAPI ``JSONResponse``.""" - raw = await request.body() - result: RouteResponse = await handler(request.method, raw if raw else b"{}") - return JSONResponse(result.body, status_code=result.status) - - -def register_sessions(app: FastAPI, state: AppState) -> Any: - """Mount the session endpoints and return the watchdog shutdown hook. - - Routes (mirroring TS ``registerSessions``): - - * ``GET /sessions/stream``: pay-per-chunk SSE, cap 1.00 USDC, 0.0001 USDC/chunk - * ``POST /sessions/stream``: voucher commits for the stream endpoint - * ``POST /sessions/compute``: pay-per-call compute, cap 0.50 USDC, 0.005 USDC/call - (also accepts voucher commits) - * ``POST /__402/session/deliveries``: SessionFetch delivery reservation - * ``POST /__402/session/commit``: body-voucher commit variant of the above - * ``GET /sessions/receipt/{channel_id}``: settle-status poll for the UI - """ - # Shared store so /sessions/receipt reads channel state regardless of which - # endpoint opened the channel. - shared_store = MemoryChannelStore() - - def new_method(cap: int) -> Session: - return new_session( - SessionOptions( - operator=state.fee_payer_pubkey, - recipient=state.recipient, - cap=cap, - currency=constants.USDC_MAINNET_MINT, - decimals=constants.USDC_DECIMALS, - network=state.network, - secret_key=state.secret_key, - modes=["pull"], - pull_voucher_strategy="clientVoucher", - store=shared_store, - ) - ) - - stream_session = new_method(1_000_000) # 1.00 USDC - compute_session = new_method(500_000) # 0.50 USDC - - def shutdown() -> None: - stream_session.shutdown() - compute_session.shutdown() - - stream_gate = _session_gate( - stream_session, - SessionChallengeOptions(cap="1000000", description="Metered token stream"), +router = APIRouter() + +# One session method, built from the shared pay_kit config. cap is the ceiling +# the server will offer in a challenge (0.50 USDC at 6 decimals); pull mode + +# clientVoucher is the metered-billing flavour. +_cfg = pay_kit.config() +session = new_session( + SessionOptions( + operator=_cfg.operator.signer.pubkey(), + recipient=_cfg.effective_recipient(), + cap=500_000, + currency=resolve_mint("USDC", "mainnet"), + decimals=6, + network=_cfg.network.value, + secret_key=_cfg.mpp.challenge_binding_secret or "", + modes=["pull"], + pull_voucher_strategy="clientVoucher", ) - compute_gate = _session_gate( - compute_session, - SessionChallengeOptions(cap="500000", description="Voucher-billed inference call"), +) + +_challenge = SessionChallengeOptions(cap="500000", description="Metered compute") + + +async def _session_gate(request: Request) -> dict[str, str]: + """The one hand-rolled piece (no session shim ships): verify the credential + or raise 402 with a fresh challenge. Mirrors what RequirePayment does for + charge routes.""" + auth = request.headers.get(AUTHORIZATION_HEADER) + if auth: + try: + receipt = await session.verify_credential(parse_authorization(auth)) + return {PAYMENT_RECEIPT_HEADER: format_receipt(receipt)} + except PaymentError as err: + error = err + except Exception as err: # noqa: BLE001 (parse/framework errors map to 402) + error = PaymentError(str(err), code="invalid-payload") + else: + error = None + problem = payment_required_response( + str(error) if error else "Payment required", + code=(error.code if error and error.code else "payment_invalid"), + challenge_header=format_www_authenticate(await session.challenge(_challenge)), ) + raise HTTPException(problem["status_code"], detail=problem["body"], headers=problem["headers"]) + + +_gate = Depends(_session_gate) + + +@router.post("/compute") +async def compute(headers: dict[str, str] = _gate) -> JSONResponse: + """Metered route: each call is billed against the session voucher.""" + return JSONResponse({"ok": True, "output": "computed"}, headers=headers) + + +# Reserve/commit side channel: the shipped session_routes builder, mounted as-is +# so a client can sign and commit each voucher. No custom logic. +_routes = session_routes(session.core()) + + +@router.post("/__402/session/deliveries") +async def deliveries(request: Request) -> JSONResponse: + r = await _routes.deliveries(request.method, await request.body() or b"{}") + return JSONResponse(r.body, status_code=r.status) + - # Gates as FastAPI dependencies, so the session routes read like the charge - # routes (assigned first to keep the Depends call out of the arg default). - stream_dep = Depends(stream_gate) - compute_dep = Depends(compute_gate) - - # GET /sessions/stream: stream tokens as SSE; each chunk costs 0.0001 USDC. - @app.get("/sessions/stream") - async def sessions_stream_get(headers: dict[str, str] = stream_dep) -> Any: - async def body() -> Any: - for chunk in TOKEN_CHUNKS: - yield "data: " + json.dumps({"chunk": chunk, "cost": "100"}, separators=(",", ":")) + "\n\n" - await asyncio.sleep(0.08) - yield "data: [DONE]\n\n" - - return StreamingResponse( - body(), - media_type="text/event-stream", - headers={"Cache-Control": "no-cache", "Connection": "keep-alive", **headers}, - ) - - # POST /sessions/stream: voucher commits arrive on the URL the session was - # opened against; the gate's verify path applies the voucher, the body is an ack. - @app.post("/sessions/stream") - async def sessions_stream_post(request: Request, headers: dict[str, str] = stream_dep) -> JSONResponse: - return JSONResponse(_commit_ack(await _body(request)), headers=headers) - - # POST /sessions/compute: pay-per-call compute; the same handler accepts - # voucher commits (a deliveryId in the body discriminates). - @app.post("/sessions/compute") - async def sessions_compute_post(request: Request, headers: dict[str, str] = compute_dep) -> JSONResponse: - body = await _body(request) - if str(body.get("deliveryId", "")) != "": - return JSONResponse(_commit_ack(body), headers=headers) - log_payment(request.url.path, headers) - prompt = str(body.get("prompt", "")) - return JSONResponse( - { - "prompt": prompt, - "output": "Echo: " + prompt + " (computed for 0.005 USDC)", - "computedAt": datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), - }, - headers=headers, - ) - - # Side-channel metering routes: clients reserve capacity for each metered - # delivery before signing + committing the voucher. Both share the store. - routes = session_routes(stream_session.core(), touch=None) - - @app.post("/__402/session/deliveries") - async def session_deliveries(request: Request) -> JSONResponse: - return await _route_response(routes.deliveries, request) - - @app.post("/__402/session/commit") - async def session_commit(request: Request) -> JSONResponse: - return await _route_response(routes.commit, request) - - # Receipt poll: the UI hits this after the stream ends to learn the settle - # signature. With no ported settlement path it stays null; the - # watermark/deposit come from the shared store. - @app.get("/sessions/receipt/{channel_id}") - async def sessions_receipt(channel_id: str) -> JSONResponse: - if not channel_id: - return JSONResponse(json_error("invalid-channel-id"), status_code=400) - state_channel = await shared_store.get_channel(channel_id) - if state_channel is None: - return JSONResponse(json_error("channel-not-found"), status_code=404) - return JSONResponse( - { - "channelId": state_channel.channel_id, - "cumulative": str(state_channel.cumulative), - "deposit": str(state_channel.deposit), - "finalized": state_channel.finalized, - "settledSignature": state_channel.settled_signature, - } - ) - - return shutdown +@router.post("/__402/session/commit") +async def commit(request: Request) -> JSONResponse: + r = await _routes.commit(request.method, await request.body() or b"{}") + return JSONResponse(r.body, status_code=r.status) diff --git a/python/examples/playground_api/utils.py b/python/examples/playground_api/utils.py deleted file mode 100644 index d6105051d..000000000 --- a/python/examples/playground_api/utils.py +++ /dev/null @@ -1,77 +0,0 @@ -# examples/playground_api/utils.py -"""Shared helpers: the surfnet JSON-RPC cheatcode caller, the JSON error body, -env lookups, and the settlement / receipt log lines. - -Mirrors the Go example's ``utils.go``. The ANSI color helpers there map onto -the standard ``logging`` records used at boot here. -""" - -from __future__ import annotations - -import logging -import os -from typing import Any - -import httpx - -from pay_kit.protocols.mpp.core.headers import PAYMENT_RECEIPT_HEADER, parse_receipt - -logger = logging.getLogger("playground-api") - - -def env_or(name: str, fallback: str) -> str: - """Return the environment variable value, or fallback when unset or empty.""" - value = os.getenv(name) - return value if value else fallback - - -async def rpc_call(rpc_url: str, method: str, params: list[Any]) -> Any: - """Perform a JSON-RPC call against the surfnet endpoint and return the raw - result. - - Used for the ``surfnet_*`` cheatcodes the standard RPC client does not - expose (faucet airdrops and the boot-time funding). Raises ``RuntimeError`` - on a JSON-RPC error payload. - """ - payload = {"jsonrpc": "2.0", "id": 1, "method": method, "params": params} - async with httpx.AsyncClient(timeout=8.0) as http: - response = await http.post(rpc_url, json=payload) - response.raise_for_status() - body = response.json() - error = body.get("error") - if error is not None: - raise RuntimeError(f"{method}: {error.get('message', 'rpc error')}") - return body.get("result") - - -def log_tx(path: str, reference: str) -> None: - """Log a settlement-signature link for quick eyeball debugging.""" - studio = env_or("STUDIO_PORT", "18488") - logger.info("ok %s tx: http://localhost:%s/?t=%s", path, studio, reference) - - -def log_payment(path: str, headers: Any) -> None: - """Log the receipt reference from a Payment-Receipt response header, when - present. - - ``headers`` is anything with a case-insensitive ``get`` (e.g. a Starlette - ``Headers`` or a plain dict). - """ - value = headers.get(PAYMENT_RECEIPT_HEADER) - if not value: - return - try: - receipt = parse_receipt(value) - except Exception: - return - if receipt.reference: - log_tx(path, receipt.reference) - - -def json_error(message: str) -> dict[str, str]: - """Build the standard ``{"error": message}`` JSON error body. - - Pair with a FastAPI ``JSONResponse(status_code=..., content=json_error(...))`` - or raise it through ``HTTPException(detail=...)`` at the call site. - """ - return {"error": message} diff --git a/python/tests/test_playground_api.py b/python/tests/test_playground_api.py index 28c00cd6c..d386995f8 100644 --- a/python/tests/test_playground_api.py +++ b/python/tests/test_playground_api.py @@ -1,75 +1,38 @@ -"""Smoke tests for the playground-api example (mirrors Go's main_test.go). +"""Smoke tests for the playground-api example. -Boots the FastAPI app against an unreachable RPC (no network, no funding) and -asserts the surface the playground web app depends on: the config catalog, the -free routes, and that every paid route fires a 402 challenge before its handler. -Charge gating runs before the handler, so these never reach yfinance. +Boots the FastAPI app (zero-config, unreachable settlement) with the TestClient +and asserts the free route serves and every paid route fires a 402 challenge +before its handler. Charge/session gating runs before the handler, so these +never touch the network. """ from __future__ import annotations import pytest from fastapi.testclient import TestClient -from solders.keypair import Keypair -from examples.playground_api.app import AppState, create_app -from pay_kit.signer import LocalSigner +from examples.playground_api.app import app @pytest.fixture def client() -> TestClient: - state = AppState( - network="localnet", - rpc_url="http://127.0.0.1:1", # unreachable: health balance is best-effort, challenges are local - recipient=str(Keypair().pubkey()), - secret_key="a" * 64, - fee_payer=LocalSigner.from_keypair(Keypair()), - repo_root=None, - ) - return TestClient(create_app(state)) + return TestClient(app) -def test_config_catalog_lists_the_advertised_endpoints(client: TestClient) -> None: - body = client.get("/api/v1/config").json() - assert {"recipient", "network", "feePayer", "endpoints"} <= body.keys() - ids = {e["id"] for e in body["endpoints"]} - assert ids == {"stocks-quote", "marketplace-buy", "sessions-stream", "sessions-compute"} - - -def test_health_ok_without_rpc(client: TestClient) -> None: - body = client.get("/api/v1/health").json() - assert body["ok"] is True - # Balance is best-effort; the unreachable RPC means it is simply absent. - assert "feePayerBalance" not in body - - -def test_marketplace_products_is_free(client: TestClient) -> None: - resp = client.get("/api/v1/marketplace/products") +def test_health_is_free(client: TestClient) -> None: + resp = client.get("/health") assert resp.status_code == 200 - assert len(resp.json()) == 3 + assert resp.json() == {"ok": True} @pytest.mark.parametrize( "method,path", [ - ("GET", "/api/v1/stocks/quote/AAPL"), - ("GET", "/api/v1/marketplace/buy/sol-hoodie"), - ("GET", "/sessions/stream"), - ("POST", "/sessions/compute"), + ("GET", "/report"), # charge-gated + ("POST", "/compute"), # session-gated ], ) def test_paid_route_challenges_before_handler(client: TestClient, method: str, path: str) -> None: resp = client.request(method, path) assert resp.status_code == 402 assert resp.headers.get("www-authenticate", "").startswith("Payment ") - - -def test_unknown_product_404s_before_payment(client: TestClient) -> None: - # The product guard runs ahead of the payment dependency. - resp = client.get("/api/v1/marketplace/buy/does-not-exist") - assert resp.status_code == 404 - - -def test_receipt_unknown_channel_404(client: TestClient) -> None: - resp = client.get("/sessions/receipt/not-a-real-channel") - assert resp.status_code == 404 From 3f925761a38c586244a06850920c4f947335a658 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 19 Jun 2026 12:57:27 +0300 Subject: [PATCH 27/52] feat(python/mpp): on-chain settle-at-close + idle-close settlement 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). --- .../pay_kit/protocols/mpp/_paymentchannels.py | 160 +++++++++++++++++- .../protocols/mpp/server/session_method.py | 74 ++++++-- .../protocols/mpp/server/session_onchain.py | 101 ++++++++++- python/tests/test_session_onchain.py | 6 + python/tests/test_session_payment_channels.py | 35 ++++ python/tests/test_session_settlement.py | 157 +++++++++++++++++ 6 files changed, 516 insertions(+), 17 deletions(-) create mode 100644 python/tests/test_session_settlement.py diff --git a/python/src/pay_kit/protocols/mpp/_paymentchannels.py b/python/src/pay_kit/protocols/mpp/_paymentchannels.py index 4558619d1..34255a714 100644 --- a/python/src/pay_kit/protocols/mpp/_paymentchannels.py +++ b/python/src/pay_kit/protocols/mpp/_paymentchannels.py @@ -27,7 +27,7 @@ import struct from dataclasses import dataclass, field -from solders.instruction import Instruction # type: ignore[import-untyped] +from solders.instruction import AccountMeta, Instruction # type: ignore[import-untyped] from solders.pubkey import Pubkey # type: ignore[import-untyped] from pay_kit._paycore.solana import ( @@ -35,33 +35,52 @@ SYSTEM_PROGRAM, TOKEN_PROGRAM, ) +from pay_kit.protocols.programs.paymentchannels.instructions.distribute import Distribute from pay_kit.protocols.programs.paymentchannels.instructions.open import Open +from pay_kit.protocols.programs.paymentchannels.instructions.settleAndFinalize import SettleAndFinalize from pay_kit.protocols.programs.paymentchannels.instructions.topUp import TopUp +from pay_kit.protocols.programs.paymentchannels.types.distributeArgs import DistributeArgs from pay_kit.protocols.programs.paymentchannels.types.distributionEntry import ( DistributionEntry, ) from pay_kit.protocols.programs.paymentchannels.types.openArgs import ( OpenArgs as _OpenArgs, ) +from pay_kit.protocols.programs.paymentchannels.types.settleAndFinalizeArgs import SettleAndFinalizeArgs from pay_kit.protocols.programs.paymentchannels.types.topUpArgs import ( TopUpArgs as _TopUpArgs, ) from pay_kit.protocols.programs.paymentchannels.types.voucherArgs import VoucherArgs __all__ = [ + "ED25519_PROGRAM_ID", "PAYMENT_CHANNELS_PROGRAM_ID", "PROGRAM_ID", + "SYSVAR_INSTRUCTIONS", "Distribution", "OpenChannelParams", "TopUpParams", + "build_distribute_instruction", + "build_ed25519_verify_instruction", "build_open_instruction", + "build_settle_and_finalize_instructions", "build_top_up_instruction", "find_associated_token_address", "find_channel_pda", "find_event_authority_pda", + "treasury_owner", "voucher_message_bytes", ] +#: The Ed25519 native signature-verification precompile program id. A settle +#: that redeems a voucher must place this instruction immediately before +#: settle_and_finalize so the program confirms the voucher signature by index. +ED25519_PROGRAM_ID = "Ed25519SigVerify111111111111111111111111111" + +#: The Solana instructions sysvar, read by settle_and_finalize to locate the +#: preceding Ed25519 precompile by index. +SYSVAR_INSTRUCTIONS = "Sysvar1nstructions1111111111111111111111111" + # Canonical payment-channels program id deployed to the network. The IDL # placeholder ``CQAyft83tN1w2bRofB5PZ79eVDU2xZUVo43LU1qL4zRg`` is NOT the # production deployment and must not be used for derivation or instruction @@ -358,3 +377,142 @@ def build_top_up_instruction(params: TopUpParams) -> Instruction: }, program_id=PROGRAM_ID, ) + + +def build_ed25519_verify_instruction(authorized_signer: Pubkey, signature: bytes, message: bytes) -> Instruction: + """Build the Ed25519 precompile instruction that verifies a voucher signature. + + The on-chain settle reads the voucher's Ed25519 signature from a sibling + instruction; this precompile must sit immediately before ``settleAndFinalize`` + in the transaction. The public key, signature, and message all live in this + instruction's own data, so the three offset fields point here (the ``0xffff`` + markers mean "current instruction"). Layout mirrors the Rust/Go builders: + pubkey at byte 16, signature at 48, message at 112. + + Args: + authorized_signer: The voucher's signer pubkey (verified key). + signature: The 64-byte Ed25519 signature over ``message``. + message: The signed voucher preimage (see :func:`voucher_message_bytes`). + """ + if len(signature) != 64: + raise ValueError(f"ed25519 signature must be 64 bytes, got {len(signature)}") + public_key_offset = 16 + signature_offset = 48 + message_data_offset = 112 + current_instruction = 0xFFFF + if len(message) > 0xFFFF: + raise ValueError(f"voucher message too long: {len(message)} bytes") + + data = bytearray(message_data_offset + len(message)) + data[0] = 1 # num_signatures + data[1] = 0 # padding + struct.pack_into(" Pubkey: + """Treasury owner of the current payment-channels deployment: 32 bytes of + repeated ``0xBE 0xEF``. Mirrors the Rust/Go ``TreasuryOwner``.""" + return Pubkey.from_bytes(bytes([0xBE, 0xEF] * 16)) + + +def build_settle_and_finalize_instructions( + *, + merchant: Pubkey, + channel: Pubkey, + authorized_signer: Pubkey, + signature: bytes | None, + cumulative: int, + expires_at: int, + program_id: Pubkey = PROGRAM_ID, +) -> list[Instruction]: + """Build the settle-and-finalize instruction set. + + When a voucher was recorded, prepend the Ed25519 precompile that verifies it + (the program reads the signature from the sibling instruction by index) and + set ``hasVoucher``. Returns ``[ed25519?, settleAndFinalize]`` in the order + the program expects. + """ + instructions: list[Instruction] = [] + has_voucher = 0 + if signature is not None: + message = voucher_message_bytes(channel, cumulative, expires_at) + instructions.append(build_ed25519_verify_instruction(authorized_signer, signature, message)) + has_voucher = 1 + + settle = SettleAndFinalize( + { + "settleAndFinalizeArgs": SettleAndFinalizeArgs( + voucher=VoucherArgs(channelId=channel, cumulativeAmount=cumulative, expiresAt=expires_at), + hasVoucher=has_voucher, + ) + }, + { + "merchant": merchant, + "channel": channel, + "instructionsSysvar": Pubkey.from_string(SYSVAR_INSTRUCTIONS), + }, + program_id=program_id, + ) + instructions.append(settle) + return instructions + + +def build_distribute_instruction( + *, + channel: Pubkey, + payer: Pubkey, + payee: Pubkey, + mint: Pubkey, + recipients: list[Distribution], + token_program: Pubkey, + program_id: Pubkey = PROGRAM_ID, + treasury: Pubkey | None = None, +) -> Instruction: + """Build the distribute instruction that pays out a settled channel. + + Derives the channel / payer / payee / treasury ATAs and one ATA per split + recipient (appended as writable remaining accounts, mirroring the Rust/Go + builders). + """ + owner = treasury if treasury is not None else treasury_owner() + channel_token, _ = find_associated_token_address(channel, mint, token_program) + payer_token, _ = find_associated_token_address(payer, mint, token_program) + payee_token, _ = find_associated_token_address(payee, mint, token_program) + treasury_token, _ = find_associated_token_address(owner, mint, token_program) + event_authority, _ = find_event_authority_pda(program_id) + + entries: list[DistributionEntry] = [] + remaining: list[AccountMeta] = [] + for entry in recipients: + recipient_token, _ = find_associated_token_address(entry.recipient, mint, token_program) + remaining.append(AccountMeta(pubkey=recipient_token, is_signer=False, is_writable=True)) + entries.append(DistributionEntry(recipient=entry.recipient, bps=entry.bps)) + + return Distribute( + {"distributeArgs": DistributeArgs(recipients=entries)}, + { + "channel": channel, + "payer": payer, + "channelTokenAccount": channel_token, + "payerTokenAccount": payer_token, + "payeeTokenAccount": payee_token, + "treasuryTokenAccount": treasury_token, + "mint": mint, + "tokenProgram": token_program, + "eventAuthority": event_authority, + "selfProgram": program_id, + }, + program_id=program_id, + remaining_accounts=remaining or None, + ) diff --git a/python/src/pay_kit/protocols/mpp/server/session_method.py b/python/src/pay_kit/protocols/mpp/server/session_method.py index 673287e8c..13ed3e665 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_method.py +++ b/python/src/pay_kit/protocols/mpp/server/session_method.py @@ -18,12 +18,12 @@ (:func:`~pay_kit.protocols.mpp.server.session_onchain.new_open_tx_verifier` / :func:`~pay_kit.protocols.mpp.server.session_onchain.new_top_up_tx_verifier`). -This handler implements the offline-core surface. Not yet implemented (their -lower-level building blocks do not exist yet): the server-broadcast open path -(server-side fee-payer signing and open-tx submission), on-chain settlement at -close, and the metering side-channel HTTP routes. The idle-close watchdog is -wired but, without a settlement path, its handler is a no-op when no RPC -settlement is configured. +On-chain settlement at close (settle_and_finalize + distribute, populating +``settledSignature``) runs when both a signer and an RPC client are configured; +without them, close is a pure state-flip. The idle-close watchdog settles the +same way. Not yet implemented: the server-broadcast open path +(``openTxSubmitter=server``, server-side fee-payer signing + open-tx +submission). """ from __future__ import annotations @@ -58,8 +58,10 @@ from pay_kit.protocols.mpp.server.session_onchain import ( RpcClient, confirm_transaction_signature, + settle_and_finalize_channel, ) from pay_kit.protocols.mpp.server.session_store import ChannelStore, MemoryChannelStore +from pay_kit.signer import LocalSigner logger = logging.getLogger(__name__) @@ -132,6 +134,10 @@ class SessionOptions: # RPC is the optional RPC client used for on-chain checks. None skips every # on-chain check and trusts payload claims as provided. rpc: RpcClient | None = None + # Signer is the operator/merchant local signer that funds and signs the + # on-chain settle-at-close (and the server-broadcast open) transactions. + # None (or no RPC) leaves close a pure state-flip with settledSignature unset. + signer: LocalSigner | None = None @dataclass @@ -208,6 +214,7 @@ def __init__( open_tx_submitter: OpenTxSubmitter, rpc: RpcClient | None, lifecycle: SessionLifecycle | None, + signer: LocalSigner | None = None, ) -> None: # core is the lower-level SessionServer dispatching open / voucher / # commit / topUp / close against the channel store. @@ -226,6 +233,9 @@ def __init__( self._rpc = rpc # lifecycle is the idle-close watchdog; None when close_delay is zero. self._lifecycle = lifecycle + # signer settles the channel on-chain at close (and broadcasts server + # opens); None or no rpc leaves close a state-flip with no settlement. + self._signer = signer def core(self) -> SessionServer: """Return the underlying :class:`SessionServer` so hosts can reach the @@ -555,16 +565,57 @@ def mutator(current: ChannelState | None) -> ChannelState: await self._core.store().update_channel(channel_id, mutator) except ValueError as exc: raise PaymentError(str(exc), code="invalid-payload") from exc - reference = payload.channel_id if self._lifecycle is not None: self._lifecycle.remove_channel(payload.channel_id) - return reference + settled = await self._settle_channel(channel_id) + # On a successful settle the reference is the on-chain signature; without + # a signer/RPC the close is a state-flip and the channel id stands in. + return settled or payload.channel_id + + async def _settle_channel(self, channel_id: str) -> str | None: + """Settle and finalize the channel on-chain, returning the settlement + signature. A no-op (returns ``None``) when no signer or RPC is configured; + returns the recorded signature when the channel is already finalized. + Mirrors the gated settle in the Go/TS servers. + """ + if self._signer is None or self._rpc is None: + return None + state = await self._core.store().get_channel(channel_id) + if state is None: + return None + if state.finalized: + return state.settled_signature + + signature = await settle_and_finalize_channel( + state, merchant=self._signer.keypair, rpc=self._rpc, config=self._core.config + ) + + from pay_kit.protocols.mpp.server.session_store import ChannelState + + def finalize(current: ChannelState | None) -> ChannelState: + if current is None: + raise ValueError(f"channel {channel_id} disappeared during settle") + nxt = current.clone() + nxt.finalized = True + nxt.settled_signature = signature + return nxt + + await self._core.store().update_channel(channel_id, finalize) + return signature async def _close_on_idle(self, channel_id: str) -> None: - """Idle-close watchdog handler. Without an on-chain settlement path - there is nothing to broadcast, so this is a no-op; it returns early when - no signer/RPC is configured. + """Idle-close watchdog handler: close the channel and settle on-chain. + + Settlement only runs when a signer and RPC are configured; a transient + failure is swallowed (logged) so it cannot crash the timer, and the + channel stays re-drivable with ``settledSignature`` unset. """ + if self._signer is None or self._rpc is None: + return None + try: + await self._handle_close(ClosePayload(channel_id=channel_id, voucher=None)) + except Exception: + logging.getLogger(__name__).warning("idle-close settle failed for channel %s", channel_id, exc_info=True) return None @@ -643,6 +694,7 @@ def new_session(options: SessionOptions) -> Session: open_tx_submitter=open_tx_submitter, rpc=options.rpc, lifecycle=None, + signer=options.signer, ) if options.close_delay > 0: session._lifecycle = SessionLifecycle(session._close_on_idle, options.close_delay) diff --git a/python/src/pay_kit/protocols/mpp/server/session_onchain.py b/python/src/pay_kit/protocols/mpp/server/session_onchain.py index 9b35f8225..37868a7d6 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_onchain.py +++ b/python/src/pay_kit/protocols/mpp/server/session_onchain.py @@ -21,19 +21,33 @@ import struct from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Protocol +from typing import TYPE_CHECKING, Any, Protocol +from solders.hash import Hash # type: ignore[import-untyped] +from solders.keypair import Keypair # type: ignore[import-untyped] from solders.pubkey import Pubkey # type: ignore[import-untyped] from solders.signature import Signature # type: ignore[import-untyped] +from solders.transaction import Transaction # type: ignore[import-untyped] from pay_kit._paycore.errors import PaymentError -from pay_kit._paycore.solana import resolve_mint -from pay_kit.protocols.mpp._paymentchannels import PROGRAM_ID, find_channel_pda +from pay_kit._paycore.solana import default_token_program_for_currency, resolve_mint +from pay_kit.protocols.mpp._paymentchannels import ( + PROGRAM_ID, + Distribution, + build_distribute_instruction, + build_settle_and_finalize_instructions, + find_channel_pda, +) from pay_kit.protocols.mpp.intents.session import OpenPayload, TopUpPayload +if TYPE_CHECKING: + from pay_kit.protocols.mpp.server.session import SessionConfig + from pay_kit.protocols.mpp.server.session_store import ChannelState + __all__ = [ "VerifyOpenTxExpected", "VerifyOpenTxResult", + "settle_and_finalize_channel", "verify_open_tx", "new_open_tx_verifier", "new_top_up_tx_verifier", @@ -47,13 +61,19 @@ class RpcClient(Protocol): - """Minimal RPC seam used for the optional on-chain liveness check. + """Minimal RPC seam used for the optional on-chain liveness check and the + settle-at-close broadcast. ``get_signature_statuses`` returns the per-signature status list (each entry - is a status dict with an ``err`` field, or ``None`` when unknown).""" + is a status dict with an ``err`` field, or ``None`` when unknown); + ``get_latest_blockhash`` / ``send_raw_transaction`` back the settle path.""" async def get_signature_statuses(self, signatures: list[str]) -> list[dict | None]: ... + async def get_latest_blockhash(self, commitment: str = ...) -> Any: ... + + async def send_raw_transaction(self, raw_tx: bytes) -> Any: ... + #: A verifier seam installed on the session config: validates a payload (open #: or top-up) and raises on rejection. @@ -346,3 +366,74 @@ async def confirm_transaction_signature(rpc_client: RpcClient, signature: str, l ) if status.get("err") is not None: raise PaymentError(f"{label} tx {signature!r} failed on-chain: {status['err']}", code="transaction-failed") + + +async def settle_and_finalize_channel( + state: ChannelState, + *, + merchant: Keypair, + rpc: RpcClient, + config: SessionConfig, +) -> str: + """Build, sign, and broadcast the close settlement transaction; return the + on-chain signature. + + Mirrors the Rust/Go close path: a settle_and_finalize instruction (preceded + by the Ed25519 precompile when a voucher was recorded) plus a distribute + instruction in one transaction whose fee payer is the merchant. The caller + persists ``settled_signature`` on success. + """ + channel = Pubkey.from_string(state.channel_id) + program_id = Pubkey.from_string(config.program_id) if config.program_id else PROGRAM_ID + merchant_pubkey = merchant.pubkey() + + voucher_signature: bytes | None = None + authorized_signer = merchant_pubkey + expires_at = 0 + if state.highest_voucher_signature is not None: + voucher_signature = bytes(Signature.from_string(state.highest_voucher_signature)) + if not state.authorized_signer: + raise PaymentError( + f"channel {state.channel_id} has a voucher but no authorized signer", code="invalid-config" + ) + authorized_signer = Pubkey.from_string(state.authorized_signer) + if state.highest_voucher_expires_at is None: + raise PaymentError( + f"channel {state.channel_id} has a voucher signature but no expiry", code="invalid-config" + ) + expires_at = state.highest_voucher_expires_at + + settle = build_settle_and_finalize_instructions( + merchant=merchant_pubkey, + channel=channel, + authorized_signer=authorized_signer, + signature=voucher_signature, + cumulative=state.cumulative, + expires_at=expires_at, + program_id=program_id, + ) + + mint_address = resolve_mint(config.currency, config.network) + if not mint_address: + raise PaymentError( + f"session settlement requires an SPL token, got currency '{config.currency}'", code="invalid-config" + ) + payer_address = state.operator or config.recipient + if not payer_address: + raise PaymentError( + f"channel {state.channel_id} payer is unknown; cannot derive the refund account", code="invalid-config" + ) + distribute = build_distribute_instruction( + channel=channel, + payer=Pubkey.from_string(payer_address), + payee=Pubkey.from_string(config.recipient), + mint=Pubkey.from_string(mint_address), + recipients=[Distribution(recipient=Pubkey.from_string(s.recipient), bps=s.bps) for s in config.splits], + token_program=Pubkey.from_string(default_token_program_for_currency(config.currency, config.network)), + program_id=program_id, + ) + + blockhash = Hash.from_string((await rpc.get_latest_blockhash()).value.blockhash) + tx = Transaction.new_signed_with_payer([*settle, distribute], merchant_pubkey, [merchant], blockhash) + sent = await rpc.send_raw_transaction(bytes(tx)) + return str(sent.value) diff --git a/python/tests/test_session_onchain.py b/python/tests/test_session_onchain.py index 7408c7079..3322d9298 100644 --- a/python/tests/test_session_onchain.py +++ b/python/tests/test_session_onchain.py @@ -82,6 +82,12 @@ async def get_signature_statuses(self, signatures: list[str]) -> list[dict | Non out.append({"err": None, "confirmationStatus": "confirmed"}) return out + async def get_latest_blockhash(self, commitment: str = "confirmed"): # noqa: ANN201 (RPC seam stub) + raise NotImplementedError # not exercised by the open/top-up verifier tests + + async def send_raw_transaction(self, raw_tx: bytes): # noqa: ANN201 (RPC seam stub) + raise NotImplementedError # not exercised by the open/top-up verifier tests + def _kp(seed: int) -> Keypair: return Keypair.from_seed(bytes([seed] * 32)) diff --git a/python/tests/test_session_payment_channels.py b/python/tests/test_session_payment_channels.py index c45262f1f..58a4a1261 100644 --- a/python/tests/test_session_payment_channels.py +++ b/python/tests/test_session_payment_channels.py @@ -22,8 +22,10 @@ from pay_kit._paycore.solana import TOKEN_2022_PROGRAM, TOKEN_PROGRAM from pay_kit.protocols.mpp._paymentchannels import ( + ED25519_PROGRAM_ID, PROGRAM_ID, Distribution, + build_ed25519_verify_instruction, find_channel_pda, ) from pay_kit.protocols.mpp.client.payment_channels import ( @@ -358,3 +360,36 @@ def test_generate_authorized_signer_returns_usable_session_keypair() -> None: voucher = opened.session.sign_increment(5) sig = Signature.from_string(voucher.signature) assert sig.verify(signer.pubkey(), voucher.data.message_bytes()) + + +# -- build_ed25519_verify_instruction (settle precompile) --------------------- + + +def test_ed25519_verify_instruction_golden_layout() -> None: + """The Ed25519 precompile data layout must match the Rust/Go builders: + one signature, all three offsets pointing into this instruction's own data + (pubkey@16, signature@48, message@112), 0xffff = current-instruction.""" + import struct + + signer = _pk(7) + signature = bytes(range(64)) + message = bytes([0xAB] * 48) # voucher preimage is 48 bytes + + ix = build_ed25519_verify_instruction(signer, signature, message) + + assert str(ix.program_id) == ED25519_PROGRAM_ID + assert ix.accounts == [] + data = bytes(ix.data) + assert len(data) == 112 + len(message) + assert data[0] == 1 # num_signatures + assert data[1] == 0 # padding + # offset header: sig=48, 0xffff, pubkey=16, 0xffff, msg=112, msg_len, 0xffff + assert struct.unpack_from("<7H", data, 2) == (48, 0xFFFF, 16, 0xFFFF, 112, len(message), 0xFFFF) + assert data[16:48] == bytes(signer) + assert data[48:112] == signature + assert data[112:] == message + + +def test_ed25519_verify_instruction_rejects_bad_signature_length() -> None: + with pytest.raises(ValueError, match="64 bytes"): + build_ed25519_verify_instruction(_pk(1), b"\x00" * 63, b"msg") diff --git a/python/tests/test_session_settlement.py b/python/tests/test_session_settlement.py new file mode 100644 index 000000000..6f8a78a69 --- /dev/null +++ b/python/tests/test_session_settlement.py @@ -0,0 +1,157 @@ +"""On-chain settle-at-close: a close with a signer + RPC broadcasts a +settle_and_finalize (+ Ed25519 precompile when a voucher was recorded) and a +distribute instruction, then records the settlement signature and finalizes. +Mirrors the Go/TS closeAndSettleChannel path. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from solders.keypair import Keypair # type: ignore[import-untyped] +from solders.transaction import Transaction # type: ignore[import-untyped] + +from pay_kit.protocols.mpp.server import SessionOptions, new_session +from pay_kit.protocols.mpp.server.session_store import ChannelState +from pay_kit.signer import LocalSigner + +_BLOCKHASH = "EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N" + + +class _Resp: + def __init__(self, value: Any) -> None: + self.value = value + + +class _Blockhash: + def __init__(self, blockhash: str) -> None: + self.blockhash = blockhash + + +class _SettleRpc: + """Captures the broadcast settle transaction and returns a fixed signature.""" + + def __init__(self) -> None: + self.sent: list[bytes] = [] + + async def get_signature_statuses(self, signatures: list[str]) -> list[dict | None]: + return [{"err": None, "confirmationStatus": "confirmed"} for _ in signatures] + + async def get_latest_blockhash(self, commitment: str = "confirmed") -> _Resp: + return _Resp(_Blockhash(_BLOCKHASH)) + + async def send_raw_transaction(self, raw_tx: bytes) -> _Resp: + self.sent.append(raw_tx) + return _Resp("SettleSig" + "1" * 80) + + +def _session(rpc: _SettleRpc, operator: Keypair): + return new_session( + SessionOptions( + operator=str(operator.pubkey()), + recipient=str(operator.pubkey()), + cap=1_000_000, + currency="USDC", + decimals=6, + network="localnet", + secret_key="a" * 64, + modes=["pull"], + pull_voucher_strategy="clientVoucher", + signer=LocalSigner.from_keypair(operator), + rpc=rpc, + ) + ) + + +async def _seed(session, state: ChannelState) -> None: + await session._core.store().update_channel(state.channel_id, lambda _current: state) + + +def _instruction_discriminators(raw_tx: bytes) -> list[int]: + msg = Transaction.from_bytes(raw_tx).message + return [bytes(ix.data)[0] for ix in msg.instructions] + + +@pytest.mark.asyncio +async def test_close_settles_with_voucher_and_records_signature() -> None: + operator = Keypair.from_seed(bytes([1] * 32)) + auth = Keypair.from_seed(bytes([2] * 32)) + channel = str(Keypair.from_seed(bytes([3] * 32)).pubkey()) + voucher_sig = str(auth.sign_message(b"voucher")) + + rpc = _SettleRpc() + session = _session(rpc, operator) + await _seed( + session, + ChannelState( + channel_id=channel, + authorized_signer=str(auth.pubkey()), + deposit=1_000_000, + cumulative=500_000, + highest_voucher_signature=voucher_sig, + highest_voucher_expires_at=4_102_444_800, + operator=str(operator.pubkey()), + ), + ) + + settled = await session._settle_channel(channel) + + assert settled == "SettleSig" + "1" * 80 + final = await session._core.store().get_channel(channel) + assert final is not None + assert final.finalized is True + assert final.settled_signature == settled + # Exactly one tx, instructions [ed25519(1), settleAndFinalize(4), distribute(7)]. + assert len(rpc.sent) == 1 + assert _instruction_discriminators(rpc.sent[0]) == [1, 4, 7] + + +@pytest.mark.asyncio +async def test_close_without_voucher_omits_ed25519_precompile() -> None: + operator = Keypair.from_seed(bytes([4] * 32)) + channel = str(Keypair.from_seed(bytes([5] * 32)).pubkey()) + + rpc = _SettleRpc() + session = _session(rpc, operator) + await _seed( + session, + ChannelState( + channel_id=channel, + authorized_signer=str(operator.pubkey()), + deposit=1_000_000, + cumulative=0, + operator=str(operator.pubkey()), + ), + ) + + await session._settle_channel(channel) + + # No voucher recorded: just [settleAndFinalize(4), distribute(7)]. + assert _instruction_discriminators(rpc.sent[0]) == [4, 7] + + +@pytest.mark.asyncio +async def test_settle_is_noop_without_signer_or_rpc() -> None: + operator = Keypair.from_seed(bytes([6] * 32)) + session = new_session( + SessionOptions( + operator=str(operator.pubkey()), + recipient=str(operator.pubkey()), + cap=1_000_000, + currency="USDC", + decimals=6, + network="localnet", + secret_key="a" * 64, + modes=["pull"], + pull_voucher_strategy="clientVoucher", + ) + ) + channel = str(Keypair.from_seed(bytes([7] * 32)).pubkey()) + await _seed( + session, + ChannelState( + channel_id=channel, authorized_signer=str(operator.pubkey()), deposit=1, operator=str(operator.pubkey()) + ), + ) + assert await session._settle_channel(channel) is None From 52772ac994c8ed13b577ddff8cea513a16ab59da Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 19 Jun 2026 13:01:51 +0300 Subject: [PATCH 28/52] fix(python/mpp): clamp + reconcile replayed commit watermark Match the Rust/Go (#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. --- .../pay_kit/protocols/mpp/client/session.py | 15 +++++++ .../protocols/mpp/client/session_consumer.py | 10 ++++- python/tests/test_session_consumer.py | 41 +++++++++++++------ 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/client/session.py b/python/src/pay_kit/protocols/mpp/client/session.py index 46ffe6320..6aef974ee 100644 --- a/python/src/pay_kit/protocols/mpp/client/session.py +++ b/python/src/pay_kit/protocols/mpp/client/session.py @@ -241,6 +241,21 @@ def record_voucher(self, voucher: SignedVoucher) -> None: candidate = voucher.data.nonce if voucher.data.nonce is not None else self._nonce + 1 self._nonce = max(self._nonce, candidate) + def reconcile_settled(self, settled: int) -> None: + """Reconcile the watermark to a server-settled cumulative, e.g. the + ``cumulative`` of a ``replayed`` commit receipt. + + Advances to ``settled`` only when it is ahead of the current watermark + and never regresses, so retrying a delivery the server already accepted + (lost-response case) catches the client up without recording the freshly + prepared higher voucher. When it advances, the request nonce advances by + one too, so the next prepared voucher does not reuse the settled nonce. + Mirrors the Rust/Go ``reconcile_settled``. + """ + if settled > self._cumulative: + self._cumulative = settled + self._nonce += 1 + def sign_voucher(self, cumulative: int) -> SignedVoucher: """Sign a voucher with an absolute cumulative amount and advance the local watermark. diff --git a/python/src/pay_kit/protocols/mpp/client/session_consumer.py b/python/src/pay_kit/protocols/mpp/client/session_consumer.py index c2c1418da..d2551bb22 100644 --- a/python/src/pay_kit/protocols/mpp/client/session_consumer.py +++ b/python/src/pay_kit/protocols/mpp/client/session_consumer.py @@ -99,7 +99,15 @@ def commit_directive(self, directive: MeteringDirective) -> CommitReceipt: payload = CommitPayload(delivery_id=directive.delivery_id, voucher=voucher) receipt = self._transport.commit(directive, payload) - self._session.record_voucher(payload.voucher) + if receipt.status == "replayed": + # The server already settled this delivery; its cumulative is the + # authoritative settled position. Clamp to the just-prepared voucher + # so an untrusted server cannot push the watermark past what we + # signed. Reconcile (never regress) instead of recording the voucher. + prepared = int(payload.voucher.data.cumulative) + self._session.reconcile_settled(min(receipt.cumulative_base_units(), prepared)) + else: + self._session.record_voucher(payload.voucher) return receipt def _validate_directive(self, directive: MeteringDirective) -> None: diff --git a/python/tests/test_session_consumer.py b/python/tests/test_session_consumer.py index 47f604098..bb86cdf6c 100644 --- a/python/tests/test_session_consumer.py +++ b/python/tests/test_session_consumer.py @@ -198,13 +198,12 @@ def commit(self, directive: MeteringDirective, payload: CommitPayload) -> Commit ) -def test_duplicate_delivery_returns_replayed_receipt_and_records_prepared_voucher() -> None: +def test_duplicate_delivery_replay_does_not_double_count() -> None: # Re-committing the same deliveryId returns a replayed receipt pinned to the - # originally settled cumulative. The consumer still records the voucher it - # prepared for the retry, exactly like rust ``SessionConsumer::commit_directive`` - # (session_consumer.rs records unconditionally after the transport returns) - # and the TS ``SessionConsumer``: the server's dedupe keeps the settled - # amount authoritative on its side and the server records exactly one commit. + # originally settled cumulative (100). The consumer reconciles to that settled + # value (clamped to the prepared voucher) instead of recording the freshly + # prepared higher voucher, so a duplicate send does not double-count. Mirrors + # Go SessionConsumer.commit_directive (settled.min(prepared), never regress). transport = _RecordingTransport() consumer = _consumer(transport) d = _directive(consumer.session.channel_id_string, 100, delivery_id="d1") @@ -216,18 +215,36 @@ def test_duplicate_delivery_returns_replayed_receipt_and_records_prepared_vouche r2 = consumer.commit_directive(d) assert r2.status == "replayed" assert r2.cumulative == "100" - assert consumer.session.cumulative == 200 + assert consumer.session.cumulative == 100 # no double-count assert len(transport.commits) == 1 -def test_replayed_receipt_records_prepared_voucher() -> None: - # Lost-response case: the server reports the delivery as already settled at - # 100, but the client watermark advances to the cumulative it just signed - # (250), keeping the locally signed voucher the high-water mark. Mirrors the - # unconditional record in rust session_consumer.rs and TS SessionConsumer. +def test_replayed_receipt_reconciles_to_clamped_settled() -> None: + # Lost-response case: the server reports the delivery already settled at 100. + # The client reconciles its watermark to the settled value, clamped to the + # voucher it just prepared (250) since the server is untrusted: min(100, 250) + # = 100. Mirrors Go reconcile_settled (the #162 fix). consumer = _consumer(_ReplayTransport(settled="100")) receipt = consumer.commit_directive(_directive(consumer.session.channel_id_string, 250)) assert receipt.status == "replayed" + assert consumer.session.cumulative == 100 + + +def test_replayed_receipt_never_regresses_watermark() -> None: + # Client is already ahead at 300 (later deliveries settled). A stale replayed + # receipt at 100 must not regress the watermark. + consumer = _consumer(_ReplayTransport(settled="100")) + consumer.session.reconcile_settled(300) + consumer.commit_directive(_directive(consumer.session.channel_id_string, 50)) + assert consumer.session.cumulative == 300 + + +def test_replayed_receipt_clamps_inflated_server_cumulative() -> None: + # A malicious/buggy server reports a replay settled far above the prepared + # voucher; the watermark clamps to the prepared value (250), never the + # inflated one, so the next voucher cannot over-authorize. + consumer = _consumer(_ReplayTransport(settled="1000000")) + consumer.commit_directive(_directive(consumer.session.channel_id_string, 250)) assert consumer.session.cumulative == 250 From 42e147e2f05e51af2b7bb6fb09f9cf3ccfa7d380 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 19 Jun 2026 13:10:51 +0300 Subject: [PATCH 29/52] feat(python/mpp): server-broadcast open path (openTxSubmitter=server) 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. --- .../protocols/mpp/server/session_method.py | 86 ++++++++++++------ .../protocols/mpp/server/session_onchain.py | 45 ++++++++++ python/tests/test_session_settlement.py | 87 ++++++++++++++++++- 3 files changed, 188 insertions(+), 30 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/server/session_method.py b/python/src/pay_kit/protocols/mpp/server/session_method.py index 13ed3e665..b0e418529 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_method.py +++ b/python/src/pay_kit/protocols/mpp/server/session_method.py @@ -21,9 +21,9 @@ On-chain settlement at close (settle_and_finalize + distribute, populating ``settledSignature``) runs when both a signer and an RPC client are configured; without them, close is a pure state-flip. The idle-close watchdog settles the -same way. Not yet implemented: the server-broadcast open path -(``openTxSubmitter=server``, server-side fee-payer signing + open-tx -submission). +same way. The server-broadcast open path (``openTxSubmitter=server``) also runs +when a signer and RPC are configured: the server builds, funds, signs, and +broadcasts the open from the payload facts. """ from __future__ import annotations @@ -57,8 +57,11 @@ from pay_kit.protocols.mpp.server.session_lifecycle import SessionLifecycle from pay_kit.protocols.mpp.server.session_onchain import ( RpcClient, + VerifyOpenTxExpected, + build_sign_broadcast_open, confirm_transaction_signature, settle_and_finalize_channel, + verify_open_tx, ) from pay_kit.protocols.mpp.server.session_store import ChannelStore, MemoryChannelStore from pay_kit.signer import LocalSigner @@ -387,18 +390,23 @@ def _verify_pinned_session_fields(self, credential: PaymentCredential, request: async def _handle_open(self, payload: OpenPayload) -> str: """Process an open action: resolve the channel facts, enforce the deposit - invariants, and insert the channel state atomically and idempotently. The - receipt reference is the open signature when one exists, else the channel - id. This implements the trusted / client-broadcast open path. + invariants, and insert the channel state atomically and idempotently. + + Three submitter paths: + + - ``openTxSubmitter=server``: the server builds, funds, signs, and + broadcasts the open from the payload facts (requires a signer + RPC), + then persists. + - client-broadcast carrying a ``transaction``: validate it against the + challenge (structural always; on-chain liveness when an RPC client is + configured) before persisting. + - client-broadcast without a transaction: the client asserts a + previously broadcast open; the open signature is confirmed on-chain + when an RPC is present, otherwise the channel facts are trusted. + + The receipt reference is the open signature when one exists, else the + channel id. """ - if self._open_tx_submitter == OPEN_TX_SUBMITTER_SERVER: - # The server-broadcast open requires the open-tx submission building - # block, which is not yet implemented. - raise PaymentError( - "openTxSubmitter=server is not supported by this port", - code="invalid-config", - ) - mode = payload.mode if not self._supports_mode(mode): raise PaymentError(f"session mode {mode!r} is not supported by this challenge", code="invalid-payload") @@ -408,27 +416,49 @@ async def _handle_open(self, payload: OpenPayload) -> str: code="invalid-config", ) + if self._open_tx_submitter == OPEN_TX_SUBMITTER_SERVER: + if self._signer is None or self._rpc is None: + raise PaymentError( + "openTxSubmitter=server requires a signer and an RPC client", + code="invalid-config", + ) + try: + payload.signature = await build_sign_broadcast_open( + payload, payer_signer=self._signer.keypair, rpc=self._rpc, config=self._core.config + ) + except PaymentError: + raise + except Exception as exc: + raise PaymentError(f"server-broadcast open failed: {exc}", code="invalid-payload") from exc + try: + state = await self._core.process_open(payload) + except ValueError as exc: + raise PaymentError(str(exc), code="invalid-payload") from exc + self._touch(state.channel_id) + return payload.signature + # Empty strings count as missing. has_transaction = payload.transaction is not None and payload.transaction != "" has_channel_id = payload.channel_id is not None and payload.channel_id != "" if has_transaction: - # Payment-channel-backed open verification needs the on-chain open-tx - # verifier seam, which decodes and binds the attached transaction. - # That path lands with the on-chain settlement layer. - raise PaymentError( - "open with an attached transaction is not supported by this port", - code="invalid-payload", + expected = VerifyOpenTxExpected( + authorized_signer=payload.authorized_signer, + currency=self._currency, + recipient=self._recipient, + network=self._network, + max_cap=self._core.config.max_cap, + program_id=(Pubkey.from_string(self._core.config.program_id) if self._core.config.program_id else None), ) - if mode == "push" and not has_channel_id: + try: + await verify_open_tx(expected, payload, self._rpc) + except PaymentError: + raise + except Exception as exc: + raise PaymentError(f"open transaction verification failed: {exc}", code="invalid-payload") from exc + elif mode == "push" and not has_channel_id: raise PaymentError("open payload missing transaction or channelId", code="invalid-payload") - - # No transaction in the payload: the client asserts a previously - # broadcast open. With an RPC client the open signature is confirmed - # on-chain before persisting; without one the channelId/deposit fields - # are trusted as-is. (Pull mode without a tx trusts the - # channelId/tokenAccount + approvedAmount.) - if mode == "push" and self._rpc is not None: + elif mode == "push" and self._rpc is not None: await confirm_transaction_signature(self._rpc, payload.signature, "open") try: diff --git a/python/src/pay_kit/protocols/mpp/server/session_onchain.py b/python/src/pay_kit/protocols/mpp/server/session_onchain.py index 37868a7d6..52ae9f35b 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_onchain.py +++ b/python/src/pay_kit/protocols/mpp/server/session_onchain.py @@ -34,7 +34,9 @@ from pay_kit.protocols.mpp._paymentchannels import ( PROGRAM_ID, Distribution, + OpenChannelParams, build_distribute_instruction, + build_open_instruction, build_settle_and_finalize_instructions, find_channel_pda, ) @@ -47,6 +49,7 @@ __all__ = [ "VerifyOpenTxExpected", "VerifyOpenTxResult", + "build_sign_broadcast_open", "settle_and_finalize_channel", "verify_open_tx", "new_open_tx_verifier", @@ -437,3 +440,45 @@ async def settle_and_finalize_channel( tx = Transaction.new_signed_with_payer([*settle, distribute], merchant_pubkey, [merchant], blockhash) sent = await rpc.send_raw_transaction(bytes(tx)) return str(sent.value) + + +async def build_sign_broadcast_open( + payload: OpenPayload, + *, + payer_signer: Keypair, + rpc: RpcClient, + config: SessionConfig, +) -> str: + """Build, sign, and broadcast a payment-channel open the operator funds. + + The server-broadcast flow (``openTxSubmitter=server``) has the client send + the channel facts without a transaction; the operator is the channel payer + and the sole fee-payer signer. Builds the open instruction from the payload + facts (splits come from the server config), signs with the operator, sends, + and confirms. Returns the confirmed open signature. Mirrors Go SubmitOpenTx. + """ + if not (payload.payer and payload.payee and payload.mint and payload.deposit): + raise PaymentError("server-broadcast open payload missing payer/payee/mint/deposit", code="invalid-payload") + if payload.salt is None or payload.grace_period is None: + raise PaymentError("server-broadcast open payload missing salt/gracePeriod", code="invalid-payload") + + program_id = Pubkey.from_string(config.program_id) if config.program_id else PROGRAM_ID + params = OpenChannelParams( + payer=Pubkey.from_string(payload.payer), + payee=Pubkey.from_string(payload.payee), + mint=Pubkey.from_string(payload.mint), + authorized_signer=Pubkey.from_string(payload.authorized_signer), + salt=payload.salt, + deposit=int(payload.deposit), + grace_period=payload.grace_period, + recipients=[Distribution(recipient=Pubkey.from_string(s.recipient), bps=s.bps) for s in config.splits], + token_program=Pubkey.from_string(default_token_program_for_currency(config.currency, config.network)), + program_id=program_id, + ) + open_ix = build_open_instruction(params) + blockhash = Hash.from_string((await rpc.get_latest_blockhash()).value.blockhash) + tx = Transaction.new_signed_with_payer([open_ix], payer_signer.pubkey(), [payer_signer], blockhash) + sent = await rpc.send_raw_transaction(bytes(tx)) + signature = str(sent.value) + await confirm_transaction_signature(rpc, signature, "open") + return signature diff --git a/python/tests/test_session_settlement.py b/python/tests/test_session_settlement.py index 6f8a78a69..64806ca3b 100644 --- a/python/tests/test_session_settlement.py +++ b/python/tests/test_session_settlement.py @@ -17,6 +17,8 @@ from pay_kit.signer import LocalSigner _BLOCKHASH = "EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N" +# A valid base58 signature the fake RPC returns; the open path confirms it. +_SENT_SIGNATURE = str(Keypair.from_seed(bytes([99] * 32)).sign_message(b"settle")) class _Resp: @@ -43,7 +45,7 @@ async def get_latest_blockhash(self, commitment: str = "confirmed") -> _Resp: async def send_raw_transaction(self, raw_tx: bytes) -> _Resp: self.sent.append(raw_tx) - return _Resp("SettleSig" + "1" * 80) + return _Resp(_SENT_SIGNATURE) def _session(rpc: _SettleRpc, operator: Keypair): @@ -97,7 +99,7 @@ async def test_close_settles_with_voucher_and_records_signature() -> None: settled = await session._settle_channel(channel) - assert settled == "SettleSig" + "1" * 80 + assert settled == _SENT_SIGNATURE final = await session._core.store().get_channel(channel) assert final is not None assert final.finalized is True @@ -155,3 +157,84 @@ async def test_settle_is_noop_without_signer_or_rpc() -> None: ), ) assert await session._settle_channel(channel) is None + + +# --- A4: server-broadcast open -------------------------------------------------- + + +def _server_open_payload(operator: Keypair): + from pay_kit.protocols.mpp.client.payment_channels import ( + PENDING_SERVER_SIGNATURE, + PaymentChannelOpenOptions, + derive_payment_channel_open, + ) + from pay_kit.protocols.mpp.intents.session import SessionRequest + + auth = Keypair.from_seed(bytes([9] * 32)) + request = SessionRequest( + cap="1000000", + currency="USDC", + operator=str(operator.pubkey()), + recipient=str(operator.pubkey()), + decimals=6, + network="localnet", + modes=["pull"], + pull_voucher_strategy="clientVoucher", + ) + open_ = derive_payment_channel_open(request, operator.pubkey(), auth.pubkey(), PaymentChannelOpenOptions(salt=42)) + return open_, open_.open_payload("pull", PENDING_SERVER_SIGNATURE) + + +@pytest.mark.asyncio +async def test_server_broadcast_open_builds_signs_and_persists() -> None: + operator = Keypair.from_seed(bytes([8] * 32)) + rpc = _SettleRpc() + session = new_session( + SessionOptions( + operator=str(operator.pubkey()), + recipient=str(operator.pubkey()), + cap=1_000_000, + currency="USDC", + decimals=6, + network="localnet", + secret_key="a" * 64, + modes=["pull"], + pull_voucher_strategy="clientVoucher", + open_tx_submitter="server", + signer=LocalSigner.from_keypair(operator), + rpc=rpc, + ) + ) + open_, payload = _server_open_payload(operator) + + signature = await session._handle_open(payload) + + assert signature == _SENT_SIGNATURE + # One open transaction broadcast, a single open instruction (discriminator 1). + assert len(rpc.sent) == 1 + assert _instruction_discriminators(rpc.sent[0]) == [1] + # The channel is persisted under its derived id. + persisted = await session._core.store().get_channel(str(open_.channel_id)) + assert persisted is not None + + +@pytest.mark.asyncio +async def test_server_open_requires_signer_and_rpc() -> None: + operator = Keypair.from_seed(bytes([10] * 32)) + session = new_session( + SessionOptions( + operator=str(operator.pubkey()), + recipient=str(operator.pubkey()), + cap=1_000_000, + currency="USDC", + decimals=6, + network="localnet", + secret_key="a" * 64, + modes=["pull"], + pull_voucher_strategy="clientVoucher", + open_tx_submitter="server", + ) + ) + _open, payload = _server_open_payload(operator) + with pytest.raises(Exception, match="requires a signer"): + await session._handle_open(payload) From a5f4255843cfa1f7b426c7bc36dcae26154f5993 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 19 Jun 2026 16:49:03 +0300 Subject: [PATCH 30/52] fix(python/mpp): co-sign client-built open instead of building from facts 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. --- .../protocols/mpp/server/session_method.py | 6 +-- .../protocols/mpp/server/session_onchain.py | 53 ++++++------------- python/tests/test_session_settlement.py | 17 +++--- 3 files changed, 30 insertions(+), 46 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/server/session_method.py b/python/src/pay_kit/protocols/mpp/server/session_method.py index b0e418529..c10b8c608 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_method.py +++ b/python/src/pay_kit/protocols/mpp/server/session_method.py @@ -58,8 +58,8 @@ from pay_kit.protocols.mpp.server.session_onchain import ( RpcClient, VerifyOpenTxExpected, - build_sign_broadcast_open, confirm_transaction_signature, + cosign_and_broadcast_open, settle_and_finalize_channel, verify_open_tx, ) @@ -423,8 +423,8 @@ async def _handle_open(self, payload: OpenPayload) -> str: code="invalid-config", ) try: - payload.signature = await build_sign_broadcast_open( - payload, payer_signer=self._signer.keypair, rpc=self._rpc, config=self._core.config + payload.signature = await cosign_and_broadcast_open( + payload, fee_payer=self._signer.keypair, rpc=self._rpc ) except PaymentError: raise diff --git a/python/src/pay_kit/protocols/mpp/server/session_onchain.py b/python/src/pay_kit/protocols/mpp/server/session_onchain.py index 52ae9f35b..7fe3fcd83 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_onchain.py +++ b/python/src/pay_kit/protocols/mpp/server/session_onchain.py @@ -34,9 +34,7 @@ from pay_kit.protocols.mpp._paymentchannels import ( PROGRAM_ID, Distribution, - OpenChannelParams, build_distribute_instruction, - build_open_instruction, build_settle_and_finalize_instructions, find_channel_pda, ) @@ -49,7 +47,7 @@ __all__ = [ "VerifyOpenTxExpected", "VerifyOpenTxResult", - "build_sign_broadcast_open", + "cosign_and_broadcast_open", "settle_and_finalize_channel", "verify_open_tx", "new_open_tx_verifier", @@ -442,43 +440,24 @@ async def settle_and_finalize_channel( return str(sent.value) -async def build_sign_broadcast_open( - payload: OpenPayload, - *, - payer_signer: Keypair, - rpc: RpcClient, - config: SessionConfig, -) -> str: - """Build, sign, and broadcast a payment-channel open the operator funds. +async def cosign_and_broadcast_open(payload: OpenPayload, *, fee_payer: Any, rpc: RpcClient) -> str: + """Complete the fee-payer signature on a client-built open transaction and + broadcast it (the ``openTxSubmitter=server`` flow). - The server-broadcast flow (``openTxSubmitter=server``) has the client send - the channel facts without a transaction; the operator is the channel payer - and the sole fee-payer signer. Builds the open instruction from the payload - facts (splits come from the server config), signs with the operator, sends, - and confirms. Returns the confirmed open signature. Mirrors Go SubmitOpenTx. + The client builds the open with the operator as fee payer and partial-signs + only its own (payer) slot; the server splices in the operator/fee-payer + signature, broadcasts, and confirms. Returns the confirmed open signature. + Mirrors Go SubmitOpenTx (and reuses the charge fee-payer co-sign). """ - if not (payload.payer and payload.payee and payload.mint and payload.deposit): - raise PaymentError("server-broadcast open payload missing payer/payee/mint/deposit", code="invalid-payload") - if payload.salt is None or payload.grace_period is None: - raise PaymentError("server-broadcast open payload missing salt/gracePeriod", code="invalid-payload") + from pay_kit.protocols.mpp.server._verify import _co_sign_with_fee_payer - program_id = Pubkey.from_string(config.program_id) if config.program_id else PROGRAM_ID - params = OpenChannelParams( - payer=Pubkey.from_string(payload.payer), - payee=Pubkey.from_string(payload.payee), - mint=Pubkey.from_string(payload.mint), - authorized_signer=Pubkey.from_string(payload.authorized_signer), - salt=payload.salt, - deposit=int(payload.deposit), - grace_period=payload.grace_period, - recipients=[Distribution(recipient=Pubkey.from_string(s.recipient), bps=s.bps) for s in config.splits], - token_program=Pubkey.from_string(default_token_program_for_currency(config.currency, config.network)), - program_id=program_id, - ) - open_ix = build_open_instruction(params) - blockhash = Hash.from_string((await rpc.get_latest_blockhash()).value.blockhash) - tx = Transaction.new_signed_with_payer([open_ix], payer_signer.pubkey(), [payer_signer], blockhash) - sent = await rpc.send_raw_transaction(bytes(tx)) + if not payload.transaction: + raise PaymentError( + "openTxSubmitter=server requires the client-built open transaction in the payload", + code="invalid-payload", + ) + cosigned = _co_sign_with_fee_payer(payload.transaction, fee_payer) + sent = await rpc.send_raw_transaction(base64.b64decode(cosigned)) signature = str(sent.value) await confirm_transaction_signature(rpc, signature, "open") return signature diff --git a/python/tests/test_session_settlement.py b/python/tests/test_session_settlement.py index 64806ca3b..58e7486ca 100644 --- a/python/tests/test_session_settlement.py +++ b/python/tests/test_session_settlement.py @@ -163,14 +163,15 @@ async def test_settle_is_noop_without_signer_or_rpc() -> None: def _server_open_payload(operator: Keypair): + """A client-built open whose fee-payer (operator) slot the server completes.""" from pay_kit.protocols.mpp.client.payment_channels import ( - PENDING_SERVER_SIGNATURE, - PaymentChannelOpenOptions, - derive_payment_channel_open, + PaymentChannelSessionOpenOptions, + create_payment_channel_session_opener, ) from pay_kit.protocols.mpp.intents.session import SessionRequest - auth = Keypair.from_seed(bytes([9] * 32)) + payer = Keypair.from_seed(bytes([11] * 32)) + session_signer = Keypair.from_seed(bytes([9] * 32)) request = SessionRequest( cap="1000000", currency="USDC", @@ -181,8 +182,12 @@ def _server_open_payload(operator: Keypair): modes=["pull"], pull_voucher_strategy="clientVoucher", ) - open_ = derive_payment_channel_open(request, operator.pubkey(), auth.pubkey(), PaymentChannelOpenOptions(salt=42)) - return open_, open_.open_payload("pull", PENDING_SERVER_SIGNATURE) + opener = create_payment_channel_session_opener( + request, payer, session_signer, _BLOCKHASH, PaymentChannelSessionOpenOptions() + ) + payload = opener.action.open + assert payload is not None + return opener.open, payload @pytest.mark.asyncio From cfb0d0962e95ddd2c8d211b370679d999926bd11 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 19 Jun 2026 16:49:11 +0300 Subject: [PATCH 31/52] test(python/mpp): surfnet on-chain session lifecycle e2e 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. --- python/tests/test_session_e2e_surfnet.py | 142 +++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 python/tests/test_session_e2e_surfnet.py diff --git a/python/tests/test_session_e2e_surfnet.py b/python/tests/test_session_e2e_surfnet.py new file mode 100644 index 000000000..4a964f1ec --- /dev/null +++ b/python/tests/test_session_e2e_surfnet.py @@ -0,0 +1,142 @@ +"""Surfnet-gated end-to-end session lifecycle test. + +Exercises the real on-chain paths against the hosted Solana Payment Sandbox: +a server-broadcast payment-channel open (openTxSubmitter=server, the A4 path), +an in-band voucher, and the on-chain settle-at-close (the A2 path), asserting +the open and settle transactions confirm on-chain. Mirrors Go's +session_e2e_test.go. Skips explicitly (never silently passes) when the sandbox +is unreachable, so CI without the sandbox stays green. + +Run against the sandbox: + MPP_RUN_SURFNET_E2E=1 uv run pytest tests/test_session_e2e_surfnet.py +""" + +from __future__ import annotations + +import os + +import pytest +from solders.keypair import Keypair # type: ignore[import-untyped] +from solders.signature import Signature # type: ignore[import-untyped] + +from pay_kit._paycore.rpc import SolanaRpc +from pay_kit._paycore.solana import TOKEN_PROGRAM, resolve_mint +from pay_kit.protocols.mpp.client.payment_channels import ( + create_payment_channel_session_opener, +) +from pay_kit.protocols.mpp.intents.session import ClosePayload, SessionRequest, VoucherPayload +from pay_kit.protocols.mpp.server import SessionOptions, new_session +from pay_kit.signer import LocalSigner + +_RPC_URL = os.environ.get("MPP_HARNESS_RPC_URL", "https://402.surfnet.dev:8899") +_USDC = resolve_mint("USDC", "localnet") +pytestmark = pytest.mark.asyncio + + +async def _reachable(rpc: SolanaRpc) -> bool: + if os.environ.get("MPP_RUN_SURFNET_E2E") != "1": + return False + try: + await rpc._call("getHealth", []) + return True + except Exception: + return False + + +async def _set_account(rpc: SolanaRpc, owner: str, lamports: int) -> None: + await rpc._call( + "surfnet_setAccount", + [ + owner, + { + "lamports": lamports, + "data": "", + "executable": False, + "owner": "11111111111111111111111111111111", + "rentEpoch": 0, + }, + ], + ) + + +async def _set_token_account(rpc: SolanaRpc, owner: str, amount: int) -> None: + await rpc._call( + "surfnet_setTokenAccount", [owner, _USDC, {"amount": amount, "state": "initialized"}, TOKEN_PROGRAM] + ) + + +async def test_session_lifecycle_settles_on_chain() -> None: + rpc = SolanaRpc(_RPC_URL) + try: + if not await _reachable(rpc): + pytest.skip("surfnet sandbox unreachable or MPP_RUN_SURFNET_E2E not set") + + # operator: fee-payer + settlement signer (proceeds recipient). + # payer: funds the channel deposit and partial-signs the open. + operator = Keypair() + payer = Keypair() + await _set_account(rpc, str(operator.pubkey()), 10_000_000_000) + await _set_account(rpc, str(payer.pubkey()), 10_000_000_000) + await _set_token_account(rpc, str(payer.pubkey()), 100_000_000) + await _set_token_account(rpc, str(operator.pubkey()), 0) + + session = new_session( + SessionOptions( + operator=str(operator.pubkey()), + recipient=str(operator.pubkey()), + cap=1_000_000, + currency="USDC", + decimals=6, + network="localnet", + secret_key="session-e2e-secret-key-32-bytes-min!!", + modes=["pull"], + pull_voucher_strategy="clientVoucher", + open_tx_submitter="server", + signer=LocalSigner.from_keypair(operator), + rpc=rpc, + ) + ) + + blockhash = (await rpc.get_latest_blockhash()).value.blockhash + request = SessionRequest( + cap="1000000", + currency="USDC", + operator=str(operator.pubkey()), + recipient=str(operator.pubkey()), + decimals=6, + network="localnet", + modes=["pull"], + pull_voucher_strategy="clientVoucher", + recent_blockhash=blockhash, + ) + # The client builds the open and partial-signs as the payer; the server + # completes the operator fee-payer signature and broadcasts. + opener = create_payment_channel_session_opener(request, payer, Keypair(), blockhash) + payload = opener.action.open + assert payload is not None + + # 1. Server-broadcast open (openTxSubmitter=server): co-sign + broadcast. + open_signature = await session._handle_open(payload) + Signature.from_string(open_signature) # valid on-chain signature + channel_id = opener.open.channel_id + state = await session._core.store().get_channel(str(channel_id)) + assert state is not None + + # 2. In-band voucher advances the watermark. + voucher = opener.session.prepare_increment(250) + opener.session.record_voucher(voucher) + await session._handle_voucher(VoucherPayload(voucher=voucher)) + + # 3. Close settles the highest voucher on-chain and finalizes. + close_reference = await session._handle_close(ClosePayload(channel_id=str(channel_id), voucher=voucher)) + settled = await session._core.store().get_channel(str(channel_id)) + assert settled is not None and settled.finalized and settled.settled_signature + # The settle transaction confirmed on-chain. + statuses = await rpc._call( + "getSignatureStatuses", [[settled.settled_signature], {"searchTransactionHistory": True}] + ) + status = statuses["value"][0] + assert status is not None and status.get("err") is None + assert close_reference == settled.settled_signature + finally: + await rpc.aclose() From 43c37efe2ab01ec2b14c29f70e4c606e316c9836 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 19 Jun 2026 17:46:35 +0300 Subject: [PATCH 32/52] feat(python/playground): align playground-api with the TypeScript surface 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). --- python/examples/playground_api/README.md | 32 +++- python/examples/playground_api/app.py | 194 ++++++++++++++++---- python/examples/playground_api/discovery.py | 159 ++++++++++++++++ python/examples/playground_api/docs.py | 82 +++++++++ python/examples/playground_api/sandbox.py | 109 +++++++++++ python/examples/playground_api/sessions.py | 58 +++++- python/tests/test_playground_api.py | 33 +++- 7 files changed, 615 insertions(+), 52 deletions(-) create mode 100644 python/examples/playground_api/discovery.py create mode 100644 python/examples/playground_api/docs.py create mode 100644 python/examples/playground_api/sandbox.py diff --git a/python/examples/playground_api/README.md b/python/examples/playground_api/README.md index 74eb15d4e..7fbeb01d3 100644 --- a/python/examples/playground_api/README.md +++ b/python/examples/playground_api/README.md @@ -1,25 +1,41 @@ # Playground API (Python) -A FastAPI server gated with the unified `pay_kit` surface, the same idioms as -`examples/fastapi/app.py`. Zero-config: `pay_kit.configure(network="solana_localnet")` -boots against the hosted Surfpool sandbox with the shipped demo signer. +A FastAPI server gated with the unified `pay_kit` surface, aligned with the +TypeScript playground (`typescript/examples/playground-api`). Zero-config: +`pay_kit.configure(network="solana_localnet")` boots against the hosted Surfpool +sandbox with the shipped demo signer. Routes: -- `GET /health` free liveness probe. -- `GET /report` charge-gated via `RequirePayment` (both protocols). -- `POST /compute` session-gated, metered (see `sessions.py`). +- `GET /api/v1/fortune` fixed charge, settled over MPP or x402 (client's choice). +- `GET /api/v1/quote/{symbol}` fixed charge, MPP or x402; `via` reports the rail used. +- `GET /api/v1/joke` MPP charge with a platform split (x402 auto-disabled). +- `GET /api/v1/stream` MPP session: open a channel, stream metered SSE deliveries. +- `GET /sessions/receipt/{id}` poll a session channel's settle status (out-of-band settlement). +- `GET /api/v1/docs[...]` unpaid SDK reference markdown (when generated). +- `POST /api/v1/faucet/airdrop` localnet-only USDC faucet for client wallets. +- `GET /openapi.json` OpenAPI 3.1 discovery with `x-payment-info` offers per route. +- `GET /api/v1/health` free liveness probe + operator / network info. + +The session side-channel (`POST /__402/session/deliveries` and `/commit`) is +mounted for the metered-voucher flow. + +The x402 `upto` (usage) and MPP `subscription` gates the TS playground also +shows are intentionally left out: the Python SDK does not ship those gate kinds +yet (a follow-up), so they are not hand-rolled here. Run: ```sh cd python uvicorn examples.playground_api.app:app --port 3000 +# Optional: seed operator/recipient/platform on the local sandbox at boot. +PAY_KIT_PLAYGROUND_FUND=1 uvicorn examples.playground_api.app:app --port 3000 ``` Drive it: ```sh -curl -i http://127.0.0.1:3000/report # 402 payment required -pay curl http://127.0.0.1:3000/report # pays and succeeds +curl -i http://127.0.0.1:3000/api/v1/fortune # 402 payment required +pay curl http://127.0.0.1:3000/api/v1/fortune # pays and succeeds ``` diff --git a/python/examples/playground_api/app.py b/python/examples/playground_api/app.py index 55b90bbc5..d0ab00250 100644 --- a/python/examples/playground_api/app.py +++ b/python/examples/playground_api/app.py @@ -1,73 +1,201 @@ # examples/playground_api/app.py """The pay-kit playground API (FastAPI), gated with the unified pay_kit surface. -Zero-config: ``pay_kit.configure()`` boots against solana_localnet (the hosted -Surfpool sandbox at https://402.surfnet.dev:8899) with the shipped demo signer -as operator and recipient. No keys, no .env, no flags. - -Routes: - - GET /health -> free, returns {"ok": true} - GET /report -> charge-gated via RequirePayment (both protocols) - POST /compute -> session-gated, metered (see sessions.py) +Aligned with the TypeScript playground (`typescript/examples/playground-api`): +one config boots the server and a small route catalogue exercises every gate the +Python SDK ships today. + + GET /api/v1/fortune fixed charge, MPP or x402 (client's choice) + GET /api/v1/quote/{symbol} fixed charge, MPP or x402 + GET /api/v1/joke MPP charge with a platform split (x402 auto-off) + GET /api/v1/stream MPP session: open a channel, stream metered SSE + GET /api/v1/docs[...] unpaid SDK reference (docs.py) + POST /api/v1/faucet/airdrop localnet-only USDC faucet (sandbox.py) + GET /openapi.json OpenAPI 3.1 discovery (x-payment-info offers) + GET /api/v1/health free liveness probe + operator/network info + +The x402 `upto` (usage) and MPP `subscription` gates the TS playground also shows +are intentionally absent: the Python SDK does not ship those gate kinds yet, so +they are left for a follow-up rather than hand-rolled here. Run: cd python uvicorn examples.playground_api.app:app --port 3000 -Drive it from a client: +Drive it: - curl -i http://127.0.0.1:3000/report # 402 payment required - pay curl http://127.0.0.1:3000/report # pays and succeeds + curl -i http://127.0.0.1:3000/api/v1/fortune # 402 payment required + pay curl http://127.0.0.1:3000/api/v1/fortune # pays and succeeds """ from __future__ import annotations -from fastapi import Depends, FastAPI +import os +import random + +from fastapi import Depends, FastAPI, Request import pay_kit from pay_kit import Gate, Pricing, usd +from pay_kit._paycore.protocol import Protocol from pay_kit.fastapi import Payment, RequirePayment, install -pay_kit.configure(network="solana_localnet") +from . import discovery +from .docs import register_docs +from .sandbox import fund_sandbox, register_faucet + +pay_kit.configure(network=os.getenv("PAY_KIT_NETWORK", "solana_localnet")) # Imported after configure() so the session method builds from the resolved # operator / recipient / challenge-binding secret. from . import sessions # noqa: E402 +_cfg = pay_kit.config() +_RECIPIENT = _cfg.effective_recipient() +# A second recipient for the split demo (the platform's cut). A fixed, valid +# base58 pubkey distinct from the operator/recipient. +PLATFORM = "9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin" + class Catalog(Pricing): """The route catalogue: every charge-gated route declares its gate here.""" def __init__(self) -> None: - self.report = Gate.build( - name="report", + defaults = {"default_pay_to": _RECIPIENT, "accept_default": _cfg.accept} + # Fixed charges, settled over whichever protocol the client picks. + self.fortune = Gate.build(name="fortune", amount=usd("0.01"), description="A fortune cookie", **defaults) + self.quote = Gate.build(name="quote", amount=usd("0.01"), description="Stock quote", **defaults) + # MPP charge with a split: the platform takes 0.003 of the 0.01, the + # seller (operator) nets 0.007. Multi-recipient splits are not expressible + # in x402 `exact`, so this gate names MPP explicitly (a fee gate that + # inherited the default accept would be rejected for still listing x402). + self.joke = Gate.build( + name="joke", amount=usd("0.01"), - description="Premium report", - default_pay_to=pay_kit.config().effective_recipient(), - accept_default=pay_kit.config().accept, + description="A programmer joke", + external_id="paykit/joke: seller payout", + fee_within={PLATFORM: usd("0.003")}, + accept=(Protocol.MPP,), + default_pay_to=_RECIPIENT, ) catalog = Catalog() -# Module-level dependency singleton (FastAPI resolves it per request). -require_report = Depends(RequirePayment("report", pricing=catalog)) - -app = FastAPI(title="PayKit Playground (Python)") +# Module-level dependency singletons (FastAPI resolves them per request). +require_fortune = Depends(RequirePayment("fortune", pricing=catalog)) +require_quote = Depends(RequirePayment("quote", pricing=catalog)) +require_joke = Depends(RequirePayment("joke", pricing=catalog)) + +JOKES = ( + "Why do programmers prefer dark mode? Because light attracts bugs.", + 'A SQL query walks into a bar, sees two tables, and asks: "Can I JOIN you?"', + "There are 10 kinds of people: those who understand binary and those who don't.", +) +FORTUNES = ( + "A smooth long journey! Great expectations.", + "Your code will compile on the first try today.", + "A thrilling time is in your immediate future.", + "The settlement you await will confirm on-chain.", +) + +# `openapi_url=None` frees /openapi.json for our discovery document (below); +# FastAPI's auto-generated schema would otherwise own that path. +app = FastAPI(title="PayKit Playground (Python)", openapi_url=None) # One-call setup: payment-header CORS + the PayKitError -> 402 challenge mapping. install(app) app.include_router(sessions.router) -@app.get("/health") -async def health() -> dict[str, bool]: - """Free liveness probe.""" - return {"ok": True} - - -@app.get("/report") -async def report(payment: Payment = require_report) -> dict[str, object]: - """Charge-gated route. ``payment`` is the verified proof for this request.""" - return {"ok": True, "tx": payment.transaction, "protocol": payment.protocol.value} +# ── Priced routes ── +# Paths are generic (/api/v1/); the protocol + scheme each route accepts is +# carried by the discovery offers (method/scheme), not the URL. + + +@app.get("/api/v1/fortune") +async def fortune(_payment: Payment = require_fortune) -> dict[str, str]: + """Fixed charge, settled over whichever protocol the client picks.""" + return {"fortune": random.choice(FORTUNES)} + + +@app.get("/api/v1/quote/{symbol}") +async def quote(symbol: str, payment: Payment = require_quote) -> dict[str, object]: + """Fixed charge. ``via`` reports which protocol settled the request.""" + sym = symbol.upper() + return {"price": 100 + (ord(sym[0]) % 50) if sym else 100, "symbol": sym, "via": payment.protocol.value} + + +@app.get("/api/v1/joke") +async def joke(_payment: Payment = require_joke) -> dict[str, str]: + """MPP charge with a platform split (seller nets 0.007, platform 0.003).""" + return {"joke": random.choice(JOKES)} + + +@app.get("/api/v1/health") +async def health_info() -> dict[str, object]: + """Free liveness probe + operator/recipient/network info (no balance RPC).""" + return { + "feePayer": _cfg.operator.signer.pubkey(), + "network": _cfg.network.value, + "ok": True, + "recipient": _RECIPIENT, + } + + +# ── Docs + sandbox faucet ── +register_docs(app) +if _cfg.network.value == "solana_localnet": + register_faucet(app, _cfg.effective_rpc_url()) + # Optional zero-config funding for a live localnet run. Off by default so the + # smoke test (which boots the app) never touches the network. + if os.getenv("PAY_KIT_PLAYGROUND_FUND") == "1": + fund_sandbox(_cfg.effective_rpc_url(), _cfg.operator.signer.pubkey(), _RECIPIENT, PLATFORM) + + +# ── Discovery ── +# OpenAPI 3.1 with an `x-payment-info.offers` list per gated route, byte-aligned +# with the TS playground's /openapi.json (see discovery.py). +_OPENAPI = discovery.build_openapi_document( + info={"title": "PayKit Playground", "version": "1.0.0"}, + routes=[ + { + "method": "GET", + "path": "/api/v1/fortune", + "summary": catalog.fortune.description, + "offers": discovery.charge_offers(catalog.fortune, _cfg), + }, + { + "method": "GET", + "path": "/api/v1/quote/{symbol}", + "summary": catalog.quote.description, + "offers": discovery.charge_offers(catalog.quote, _cfg), + }, + { + "method": "GET", + "path": "/api/v1/joke", + "summary": catalog.joke.description, + "offers": discovery.charge_offers(catalog.joke, _cfg), + }, + { + "method": "GET", + "path": "/api/v1/stream", + "summary": "Metered token stream", + "offers": [ + discovery.session_offer( + _cfg, + cap_base_units="500000", + unit_price_base_units="100", + pay_to=_RECIPIENT, + description="Metered token stream", + ) + ], + }, + ], +) + + +@app.get("/openapi.json") +async def openapi_discovery(_request: Request) -> dict[str, object]: + """OpenAPI 3.1 discovery document (advisory; the 402 challenge is authoritative).""" + return _OPENAPI diff --git a/python/examples/playground_api/discovery.py b/python/examples/playground_api/discovery.py new file mode 100644 index 000000000..e887c5a6f --- /dev/null +++ b/python/examples/playground_api/discovery.py @@ -0,0 +1,159 @@ +# examples/playground_api/discovery.py +"""OpenAPI 3.1 discovery, byte-aligned with the TypeScript playground. + +The TS SDK ships `buildOpenApiDocument` (packages/pay-kit/src/openapi.ts); the +Python SDK has no discovery builder yet, so this is an example-local port of the +*document shape* — not the TS code. A discovery consumer (mppx tooling, the +payment-discovery draft) sees the same structure from either SDK: + + { info, openapi: "3.1.0", paths: { : { : { + responses, "x-payment-info": { offers: [...] }, summary? } } }, + "x-service-info"? } + +Each `offers[]` entry lists one way to pay (one per accepted protocol/scheme). +Discovery is advisory; the runtime 402 challenge stays authoritative. +""" + +from __future__ import annotations + +from typing import Any + +from pay_kit._paycore.protocol import Protocol +from pay_kit.config import Config +from pay_kit.gate import Gate +from pay_kit.price import Price + +#: Solana base-unit precision for the supported stablecoins (USDC/USDT/PYUSD). +USDC_DECIMALS = 6 + + +def base_units(price: Price, decimals: int = USDC_DECIMALS) -> str: + """The integer base-unit string for a price (e.g. ``usd("0.01")`` -> ``"10000"``).""" + return str(int(price.amount.scaleb(decimals).to_integral_value())) + + +def _effective_accept(gate: Gate, config: Config) -> tuple[Protocol, ...]: + """The protocols a gate actually settles over, mirroring the runtime resolver. + + A fee-bearing gate built with an inherited accept carries ``accept=None`` (the + middleware narrows it per request); reproduce that here so discovery offers + match what the 402 challenge will advertise: inherit the config list, then + drop x402 when fees are present (stock x402 settles to a single address). + """ + accept = gate.accept if gate.accept is not None else config.accept + if gate.has_fees(): + accept = tuple(p for p in accept if p is not Protocol.X402) + return accept + + +def _offer(**fields: Any) -> dict[str, Any]: + """An offer dict with ``None`` values dropped (TS omits unset optional keys).""" + return {key: value for key, value in fields.items() if value is not None} + + +def charge_offers(gate: Gate, config: Config, *, currency: str = "USDC") -> list[dict[str, Any]]: + """Discovery offers for a fixed charge gate: one per accepted protocol. + + x402 settles the `exact` scheme, MPP settles `charge`; both carry the same + base-unit amount, recipient, network, and (for x402) the fee-paying operator. + """ + accept = _effective_accept(gate, config) + amount = base_units(gate.total()) + network = config.network.caip2() + pay_to = gate.pay_to + operator = config.operator.signer.pubkey() + offers: list[dict[str, Any]] = [] + if Protocol.X402 in accept: + offers.append( + _offer( + amount=amount, + currency=currency, + description=gate.description, + feePayer=operator, + intent="charge", + method="x402", + network=network, + payTo=pay_to, + scheme="exact", + ) + ) + if Protocol.MPP in accept: + offers.append( + _offer( + amount=amount, + currency=currency, + description=gate.description, + intent="charge", + method="mpp", + network=network, + payTo=pay_to, + scheme="charge", + ) + ) + return offers + + +def session_offer( + config: Config, + *, + cap_base_units: str, + unit_price_base_units: str, + pay_to: str, + description: str, + currency: str = "USDC", +) -> dict[str, Any]: + """The single MPP `session` discovery offer (cap + per-delivery unit price).""" + return _offer( + amount=cap_base_units, + currency=currency, + description=description, + intent="session", + method="mpp", + network=config.network.caip2(), + payTo=pay_to, + scheme="session", + unitPrice=unit_price_base_units, + ) + + +def build_openapi_document( + *, + info: dict[str, str] | None = None, + routes: list[dict[str, Any]], + service_info: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Assemble the OpenAPI 3.1 doc — exact port of the TS `buildOpenApiDocument`. + + Each route is ``{method, path, offers, summary?, request_body?}``; an + ``offers`` list (possibly empty for an unpaid route) attaches the + ``x-payment-info`` extension and the 402 response. + """ + paths: dict[str, dict[str, Any]] = {} + for route in routes: + method = str(route["method"]).lower() + offers = route.get("offers") + operation: dict[str, Any] = { + "responses": { + **({"402": {"description": "Payment Required"}} if offers else {}), + "200": {"description": "Successful response"}, + }, + } + if offers: + operation["x-payment-info"] = {"offers": offers} + if route.get("summary"): + operation["summary"] = route["summary"] + if route.get("request_body"): + operation["requestBody"] = route["request_body"] + paths.setdefault(route["path"], {})[method] = operation + + doc: dict[str, Any] = { + "info": { + "title": (info or {}).get("title", "API"), + "version": (info or {}).get("version", "1.0.0"), + }, + "openapi": "3.1.0", + "paths": paths, + } + if service_info: + doc["x-service-info"] = service_info + return doc diff --git a/python/examples/playground_api/docs.py b/python/examples/playground_api/docs.py new file mode 100644 index 000000000..caeb4dcdb --- /dev/null +++ b/python/examples/playground_api/docs.py @@ -0,0 +1,82 @@ +# examples/playground_api/docs.py +"""Docs server — port of the TypeScript playground's `docs.ts`. + +Serves the generated SDK reference markdown under the repo's ``docs/api/`` for +the playground's Docs / ApiReference pages. No payment, no dependencies beyond +the filesystem; entirely separate from the gated routes. Degrades gracefully +when the docs have not been generated yet. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +# docs/api lives at the repo root: playground_api -> examples -> python -> root. +DOCS_ROOT = Path(__file__).resolve().parents[3] / "docs" / "api" + +LANGS = ("typescript", "rust", "go", "python", "ruby", "php", "lua", "kotlin", "swift") + + +def _build_tree(abs_dir: Path, rel_dir: str = "") -> list[dict[str, Any]]: + """A folders-first, alpha-sorted tree of the ``.md`` files under ``abs_dir``.""" + nodes: list[dict[str, Any]] = [] + for entry in abs_dir.iterdir(): + if entry.name.startswith(".") or entry.name == "node_modules": + continue + rel_path = f"{rel_dir}/{entry.name}" if rel_dir else entry.name + if entry.is_dir(): + nodes.append( + {"name": entry.name, "path": rel_path, "type": "dir", "children": _build_tree(entry, rel_path)} + ) + elif entry.is_file() and entry.name.endswith(".md"): + nodes.append({"name": entry.name, "path": rel_path, "type": "file"}) + nodes.sort(key=lambda n: (n["type"] != "dir", n["name"])) + return nodes + + +def _safe_join(root: Path, rel: str) -> Path | None: + """Resolve ``rel`` under ``root``, or ``None`` if it escapes the root.""" + joined = (root / rel).resolve() + try: + joined.relative_to(root.resolve()) + except ValueError: + return None + return joined + + +def register_docs(app: Any) -> None: + """Mount the unpaid docs routes (mirrors `registerDocs` in docs.ts).""" + from fastapi import Request + from fastapi.responses import JSONResponse, PlainTextResponse + + @app.get("/api/v1/docs") + async def docs_index() -> dict[str, Any]: # pyright: ignore[reportUnusedFunction] + available = {lang: (DOCS_ROOT / lang / "README.md").is_file() for lang in LANGS} + return {"root": str(DOCS_ROOT), "available": available} + + @app.get("/api/v1/docs/{lang}/tree") + async def docs_tree(lang: str) -> JSONResponse: # pyright: ignore[reportUnusedFunction] + if lang not in LANGS: + return JSONResponse({"error": "unknown_lang"}, status_code=404) + root = DOCS_ROOT / lang + if not root.is_dir(): + return JSONResponse({"error": "not_generated"}, status_code=404) + try: + return JSONResponse({"lang": lang, "tree": _build_tree(root)}) + except OSError as err: + return JSONResponse({"error": "tree_failed", "detail": str(err)}, status_code=500) + + @app.get("/api/v1/docs/{lang}/file") + async def docs_file(lang: str, request: Request): # pyright: ignore[reportUnusedFunction] + if lang not in LANGS: + return JSONResponse({"error": "unknown_lang"}, status_code=404) + abs_path = _safe_join(DOCS_ROOT / lang, request.query_params.get("path", "README.md")) + if abs_path is None: + return JSONResponse({"error": "unsafe_path"}, status_code=400) + if abs_path.suffix != ".md": + return JSONResponse({"error": "not_markdown"}, status_code=400) + try: + return PlainTextResponse(abs_path.read_text(encoding="utf-8"), media_type="text/markdown") + except OSError as err: + return JSONResponse({"error": "not_found", "detail": str(err)}, status_code=404) diff --git a/python/examples/playground_api/sandbox.py b/python/examples/playground_api/sandbox.py new file mode 100644 index 000000000..b4cc1cfbc --- /dev/null +++ b/python/examples/playground_api/sandbox.py @@ -0,0 +1,109 @@ +# examples/playground_api/sandbox.py +"""LOCAL SANDBOX ONLY — port of the TypeScript playground's `sandbox.ts`. + +Funding here uses the surfnet (``https://402.surfnet.dev:8899``) cheatcode RPC +methods ``surfnet_setAccount`` / ``surfnet_setTokenAccount``. They exist only on +the local Solana Payment Sandbox; on devnet/mainnet they are no-ops (caught and +warned). None of this is how a real pay-kit server runs — it just makes the +playground work zero-config. + +Funding never runs at import or app startup (the smoke test boots the app and +must not touch the network). Call :func:`fund_sandbox` explicitly, or hit the +faucet route registered by :func:`register_faucet`. +""" + +from __future__ import annotations + +import json +import urllib.request +from typing import Any + +from pay_kit._paycore.solana import resolve_mint + +# The sandbox clones mainnet state, so it funds the *mainnet* USDC mint +# regardless of the configured network tag (mirrors sandbox.ts). +USDC_MINT = resolve_mint("USDC", "mainnet") +SYSTEM_PROGRAM = "11111111111111111111111111111111" +TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" +SOL_FUND_LAMPORTS = 100_000_000_000 # 100 SOL +USDC_FUND_AMOUNT = 100_000_000 # 100 USDC (6 decimals) + +_RPC_TIMEOUT_SECONDS = 8.0 + + +def _rpc_call(rpc_url: str, method: str, params: list[Any]) -> None: + """Minimal JSON-RPC 2.0 call for the surfnet cheatcode methods.""" + payload = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode() + request = urllib.request.Request( # noqa: S310 - fixed sandbox RPC URL, JSON body + rpc_url, data=payload, headers={"Content-Type": "application/json"} + ) + with urllib.request.urlopen(request, timeout=_RPC_TIMEOUT_SECONDS) as response: # noqa: S310 + body = json.loads(response.read()) + if isinstance(body, dict) and body.get("error"): + raise RuntimeError(f"{method}: {body['error'].get('message', body['error'])}") + + +def fund_sandbox(rpc_url: str, *addresses: str) -> None: + """Fund each address with SOL + USDC on the local sandbox. Best-effort.""" + try: + for address in addresses: + _rpc_call( + rpc_url, + "surfnet_setAccount", + [ + address, + { + "lamports": SOL_FUND_LAMPORTS, + "data": "", + "executable": False, + "owner": SYSTEM_PROGRAM, + "rentEpoch": 0, + }, + ], + ) + _rpc_call( + rpc_url, + "surfnet_setTokenAccount", + [address, USDC_MINT, {"amount": USDC_FUND_AMOUNT, "state": "initialized"}, TOKEN_PROGRAM], + ) + except Exception as err: # noqa: BLE001 - sandbox funding is best-effort + print(f" Sandbox RPC not reachable — accounts may be unfunded ({err}).") + + +def fund_usdc(rpc_url: str, *addresses: str) -> None: + """Fund each address with USDC only (no SOL) on the local sandbox. + + Client wallets never pay network fees — the operator fee-pays every gate — + so they only need a USDC balance. Best-effort. + """ + try: + for address in addresses: + _rpc_call( + rpc_url, + "surfnet_setTokenAccount", + [address, USDC_MINT, {"amount": USDC_FUND_AMOUNT, "state": "initialized"}, TOKEN_PROGRAM], + ) + except Exception as err: # noqa: BLE001 - sandbox funding is best-effort + print(f" Sandbox RPC not reachable — USDC not funded ({err}).") + + +def register_faucet(app: Any, rpc_url: str) -> None: + """Mount a USDC faucet (no SOL needed): airdrop sandbox USDC to an address.""" + from fastapi import Request + from fastapi.responses import JSONResponse + + @app.get("/api/v1/faucet/status") + async def faucet_status() -> dict[str, str]: # pyright: ignore[reportUnusedFunction] + return {"usdcAmount": "100 USDC", "usdcMint": USDC_MINT} + + @app.post("/api/v1/faucet/airdrop") + async def faucet_airdrop(request: Request) -> JSONResponse: # pyright: ignore[reportUnusedFunction] + body = await request.json() if await request.body() else {} + address = body.get("address") if isinstance(body, dict) else None + if not address: + return JSONResponse({"error": "Missing `address` in request body"}, status_code=400) + try: + fund_usdc(rpc_url, address) + except Exception as err: # noqa: BLE001 - report failure to the caller + return JSONResponse({"error": "Airdrop failed", "details": str(err)}, status_code=500) + return JSONResponse({"ok": True, "usdc": "100 USDC"}) diff --git a/python/examples/playground_api/sessions.py b/python/examples/playground_api/sessions.py index f6c11587c..9914ffeb7 100644 --- a/python/examples/playground_api/sessions.py +++ b/python/examples/playground_api/sessions.py @@ -16,8 +16,11 @@ from __future__ import annotations +import json +from collections.abc import AsyncIterator + from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, StreamingResponse import pay_kit from pay_kit._paycore.errors import PaymentError, payment_required_response @@ -84,11 +87,35 @@ async def _session_gate(request: Request) -> dict[str, str]: _gate = Depends(_session_gate) +# Streamed deliveries, billed per chunk against the session voucher. Mirrors the +# TypeScript playground's `GET /api/v1/stream` (SSE) session route. +_TOKEN_CHUNKS = ( + "A payment channel ", + "lets a client and server ", + "authorize many small ", + "off-chain debits ", + "against a single on-chain ", + "deposit, settling the highest ", + "cumulative voucher at close.", +) +#: Per-chunk price in USDC base units (6 decimals): $0.0001. +_PRICE_PER_CHUNK = 100 + + +@router.get("/api/v1/stream") +async def stream(headers: dict[str, str] = _gate) -> StreamingResponse: + """Metered SSE stream: open a session, then emit per-chunk deliveries. + + Settlement runs out-of-band (the client commits vouchers via the side-channel + routes below); each chunk advertises its per-delivery cost. + """ -@router.post("/compute") -async def compute(headers: dict[str, str] = _gate) -> JSONResponse: - """Metered route: each call is billed against the session voucher.""" - return JSONResponse({"ok": True, "output": "computed"}, headers=headers) + async def events() -> AsyncIterator[str]: + for chunk in _TOKEN_CHUNKS: + yield f"data: {json.dumps({'chunk': chunk, 'cost': str(_PRICE_PER_CHUNK)})}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse(events(), media_type="text/event-stream", headers=headers) # Reserve/commit side channel: the shipped session_routes builder, mounted as-is @@ -106,3 +133,24 @@ async def deliveries(request: Request) -> JSONResponse: async def commit(request: Request) -> JSONResponse: r = await _routes.commit(request.method, await request.body() or b"{}") return JSONResponse(r.body, status_code=r.status) + + +@router.get("/sessions/receipt/{channel_id}") +async def receipt(channel_id: str) -> JSONResponse: + """Settle-status poll for a channel — mirrors the TS playground's receipt route. + + Settlement is out-of-band (idle-close watchdog), so a client polls this for + the settled signature once the channel finalizes. + """ + state = await session.core().store().get_channel(channel_id) + if state is None: + return JSONResponse({"error": "channel-not-found"}, status_code=404) + return JSONResponse( + { + "channelId": channel_id, + "cumulative": str(state.cumulative), + "deposit": str(state.deposit), + "finalized": state.finalized, + "settledSignature": state.settled_signature, + } + ) diff --git a/python/tests/test_playground_api.py b/python/tests/test_playground_api.py index d386995f8..280f0a31e 100644 --- a/python/tests/test_playground_api.py +++ b/python/tests/test_playground_api.py @@ -1,9 +1,9 @@ """Smoke tests for the playground-api example. Boots the FastAPI app (zero-config, unreachable settlement) with the TestClient -and asserts the free route serves and every paid route fires a 402 challenge +and asserts the free routes serve and every paid route fires a 402 challenge before its handler. Charge/session gating runs before the handler, so these -never touch the network. +never touch the network; the faucet auto-funding is opt-in (off here). """ from __future__ import annotations @@ -20,16 +20,37 @@ def client() -> TestClient: def test_health_is_free(client: TestClient) -> None: - resp = client.get("/health") + resp = client.get("/api/v1/health") assert resp.status_code == 200 - assert resp.json() == {"ok": True} + assert resp.json()["ok"] is True + + +def test_discovery_advertises_offers(client: TestClient) -> None: + resp = client.get("/openapi.json") + assert resp.status_code == 200 + doc = resp.json() + assert doc["openapi"] == "3.1.0" + fortune = doc["paths"]["/api/v1/fortune"]["get"] + offers = fortune["x-payment-info"]["offers"] + # The dual-protocol charge gate offers both rails; the split gate is MPP-only. + assert {offer["method"] for offer in offers} == {"x402", "mpp"} + joke_offers = doc["paths"]["/api/v1/joke"]["get"]["x-payment-info"]["offers"] + assert {offer["method"] for offer in joke_offers} == {"mpp"} + + +def test_docs_index_is_free(client: TestClient) -> None: + resp = client.get("/api/v1/docs") + assert resp.status_code == 200 + assert "available" in resp.json() @pytest.mark.parametrize( "method,path", [ - ("GET", "/report"), # charge-gated - ("POST", "/compute"), # session-gated + ("GET", "/api/v1/fortune"), # charge-gated, dual protocol + ("GET", "/api/v1/quote/AAPL"), # charge-gated, dual protocol + ("GET", "/api/v1/joke"), # charge-gated, MPP-only split + ("GET", "/api/v1/stream"), # session-gated ], ) def test_paid_route_challenges_before_handler(client: TestClient, method: str, path: str) -> None: From 0fb6dd3de884799dbf62c8e8c7291b195ddcd222 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 19 Jun 2026 20:10:12 +0300 Subject: [PATCH 33/52] fix(python/playground): close TS-alignment gaps found by cross-check - 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. --- python/examples/playground_api/app.py | 9 +++++---- python/examples/playground_api/discovery.py | 18 +++++++++++++----- python/examples/playground_api/sessions.py | 6 ++++-- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/python/examples/playground_api/app.py b/python/examples/playground_api/app.py index d0ab00250..9c40a9dca 100644 --- a/python/examples/playground_api/app.py +++ b/python/examples/playground_api/app.py @@ -43,7 +43,7 @@ from . import discovery from .docs import register_docs -from .sandbox import fund_sandbox, register_faucet +from .sandbox import fund_sandbox, fund_usdc, register_faucet pay_kit.configure(network=os.getenv("PAY_KIT_NETWORK", "solana_localnet")) @@ -150,7 +150,9 @@ async def health_info() -> dict[str, object]: # Optional zero-config funding for a live localnet run. Off by default so the # smoke test (which boots the app) never touches the network. if os.getenv("PAY_KIT_PLAYGROUND_FUND") == "1": - fund_sandbox(_cfg.effective_rpc_url(), _cfg.operator.signer.pubkey(), _RECIPIENT, PLATFORM) + fund_sandbox(_cfg.effective_rpc_url(), _cfg.operator.signer.pubkey(), _RECIPIENT) + # The split recipient only needs a USDC account to receive its cut (no SOL). + fund_usdc(_cfg.effective_rpc_url(), PLATFORM) # ── Discovery ── @@ -184,10 +186,9 @@ async def health_info() -> dict[str, object]: "offers": [ discovery.session_offer( _cfg, - cap_base_units="500000", + cap_base_units="1000000", unit_price_base_units="100", pay_to=_RECIPIENT, - description="Metered token stream", ) ], }, diff --git a/python/examples/playground_api/discovery.py b/python/examples/playground_api/discovery.py index e887c5a6f..383d30377 100644 --- a/python/examples/playground_api/discovery.py +++ b/python/examples/playground_api/discovery.py @@ -16,6 +16,7 @@ from __future__ import annotations +from decimal import Decimal from typing import Any from pay_kit._paycore.protocol import Protocol @@ -59,6 +60,9 @@ def charge_offers(gate: Gate, config: Config, *, currency: str = "USDC") -> list """ accept = _effective_accept(gate, config) amount = base_units(gate.total()) + # Offer-level description is a price hint (matches the TS offer shape, e.g. + # "0.01 USDC"); the human prose stays on the route-level summary. + price = f"{gate.total().amount_string()} {currency}" network = config.network.caip2() pay_to = gate.pay_to operator = config.operator.signer.pubkey() @@ -68,7 +72,7 @@ def charge_offers(gate: Gate, config: Config, *, currency: str = "USDC") -> list _offer( amount=amount, currency=currency, - description=gate.description, + description=price, feePayer=operator, intent="charge", method="x402", @@ -82,7 +86,7 @@ def charge_offers(gate: Gate, config: Config, *, currency: str = "USDC") -> list _offer( amount=amount, currency=currency, - description=gate.description, + description=price, intent="charge", method="mpp", network=network, @@ -99,14 +103,18 @@ def session_offer( cap_base_units: str, unit_price_base_units: str, pay_to: str, - description: str, currency: str = "USDC", ) -> dict[str, Any]: - """The single MPP `session` discovery offer (cap + per-delivery unit price).""" + """The single MPP `session` discovery offer (cap + per-delivery unit price). + + Offer `description` is an "up to " price hint, mirroring the + TS session offer shape. + """ + cap_human = format(Decimal(cap_base_units) / (Decimal(10) ** USDC_DECIMALS), "f") return _offer( amount=cap_base_units, currency=currency, - description=description, + description=f"up to {cap_human} {currency}", intent="session", method="mpp", network=config.network.caip2(), diff --git a/python/examples/playground_api/sessions.py b/python/examples/playground_api/sessions.py index 9914ffeb7..616fe83af 100644 --- a/python/examples/playground_api/sessions.py +++ b/python/examples/playground_api/sessions.py @@ -16,6 +16,7 @@ from __future__ import annotations +import asyncio import json from collections.abc import AsyncIterator @@ -49,7 +50,7 @@ SessionOptions( operator=_cfg.operator.signer.pubkey(), recipient=_cfg.effective_recipient(), - cap=500_000, + cap=1_000_000, currency=resolve_mint("USDC", "mainnet"), decimals=6, network=_cfg.network.value, @@ -59,7 +60,7 @@ ) ) -_challenge = SessionChallengeOptions(cap="500000", description="Metered compute") +_challenge = SessionChallengeOptions(cap="1000000", description="Metered token stream") async def _session_gate(request: Request) -> dict[str, str]: @@ -113,6 +114,7 @@ async def stream(headers: dict[str, str] = _gate) -> StreamingResponse: async def events() -> AsyncIterator[str]: for chunk in _TOKEN_CHUNKS: yield f"data: {json.dumps({'chunk': chunk, 'cost': str(_PRICE_PER_CHUNK)})}\n\n" + await asyncio.sleep(0.08) # pace deliveries, mirroring the TS stream yield "data: [DONE]\n\n" return StreamingResponse(events(), media_type="text/event-stream", headers=headers) From d567ca4d612f4353c748fc042dee6ad97731e358 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 19 Jun 2026 22:50:29 +0300 Subject: [PATCH 34/52] fix(python/mpp): encode charge payment-receipt as the canonical receipt 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. --- python/src/pay_kit/protocols/mpp/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/__init__.py b/python/src/pay_kit/protocols/mpp/__init__.py index 8c8198486..07c3568be 100644 --- a/python/src/pay_kit/protocols/mpp/__init__.py +++ b/python/src/pay_kit/protocols/mpp/__init__.py @@ -25,7 +25,7 @@ from pay_kit._paycore.store import MemoryStore, Store from pay_kit.errors import InvalidProofError from pay_kit.payment import Payment -from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization +from pay_kit.protocols.mpp.core.headers import format_receipt, format_www_authenticate, parse_authorization from pay_kit.protocols.mpp.intents.charge import ChargeRequest from pay_kit.protocols.mpp.server.charge import ChargeOptions, Mpp from pay_kit.protocols.mpp.server.charge import Config as MppServerConfig @@ -269,8 +269,11 @@ async def verify_and_settle(self, gate: Gate, request: Any) -> Payment: finally: await rpc.aclose() + # `payment-receipt` carries the canonical base64url-encoded MPP receipt + # (clients parse it for the settle signature / Broadcast + Settled steps); + # `x-payment-settlement-signature` is the raw tx signature for convenience. settlement_headers = { - "payment-receipt": receipt.reference, + "payment-receipt": format_receipt(receipt), "x-payment-settlement-signature": receipt.reference, } return Payment( From d67880233559467466a3c53f15333d0579fda3bb Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 19 Jun 2026 22:50:42 +0300 Subject: [PATCH 35/52] feat(python/playground): settle the session on-chain + voucher route 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 --- python/examples/playground_api/sessions.py | 42 ++++++++++++++++++---- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/python/examples/playground_api/sessions.py b/python/examples/playground_api/sessions.py index 616fe83af..15f4802f3 100644 --- a/python/examples/playground_api/sessions.py +++ b/python/examples/playground_api/sessions.py @@ -25,6 +25,7 @@ import pay_kit from pay_kit._paycore.errors import PaymentError, payment_required_response +from pay_kit._paycore.rpc import SolanaRpc from pay_kit._paycore.solana import resolve_mint from pay_kit.protocols.mpp.core.headers import ( AUTHORIZATION_HEADER, @@ -42,9 +43,12 @@ router = APIRouter() -# One session method, built from the shared pay_kit config. cap is the ceiling -# the server will offer in a challenge (0.50 USDC at 6 decimals); pull mode + -# clientVoucher is the metered-billing flavour. +# One session method, built from the shared pay_kit config. cap is the 1.00 USDC +# ceiling the server offers in a challenge; pull mode + clientVoucher is the +# metered-billing flavour. The operator signer + RPC + server-side open submitter +# make the channel settle on-chain (so the receipt poll returns a real signature), +# and close_delay arms the idle-close watchdog that settles after the last +# delivery — mirroring the TypeScript playground's session config. _cfg = pay_kit.config() session = new_session( SessionOptions( @@ -57,6 +61,10 @@ secret_key=_cfg.mpp.challenge_binding_secret or "", modes=["pull"], pull_voucher_strategy="clientVoucher", + signer=_cfg.operator.signer, + rpc=SolanaRpc(_cfg.effective_rpc_url()), + open_tx_submitter="server", + close_delay=2.0, ) ) @@ -120,9 +128,31 @@ async def events() -> AsyncIterator[str]: return StreamingResponse(events(), media_type="text/event-stream", headers=headers) -# Reserve/commit side channel: the shipped session_routes builder, mounted as-is -# so a client can sign and commit each voucher. No custom logic. -_routes = session_routes(session.core()) +@router.post("/api/v1/stream") +async def stream_voucher(request: Request, headers: dict[str, str] = _gate) -> JSONResponse: + """Voucher commit at the resource URL. + + The SessionFetch client re-POSTs each signed voucher (in the Authorization + credential) to the URL it opened against; ``_gate`` verifies it (or answers + 402) and returns the receipt headers. Mirrors the TS ``streamRoutes.voucher`` + handler — without it the client's commit hits 405. + """ + raw = await request.body() + body = json.loads(raw) if raw else {} + return JSONResponse( + { + "amount": str(body.get("amount", "0")), + "deliveryId": str(body.get("deliveryId", "")), + "status": "committed", + }, + headers=headers, + ) + + +# Reserve/commit side channel: the shipped session_routes builder. The touch hook +# resets the idle-close watchdog after each reserve/commit so the channel settles +# only once deliveries stop. +_routes = session_routes(session.core(), touch=session._touch) @router.post("/__402/session/deliveries") From 6b013477688e2eb24727e6b49c79dff51a23e9eb Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Mon, 22 Jun 2026 13:34:14 +0300 Subject: [PATCH 36/52] fix(python/mpp/server): harden session commit/topup/idle-close/delete 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. --- .../protocols/mpp/server/session_method.py | 12 ++++---- .../protocols/mpp/server/session_routes.py | 10 ++++++- .../protocols/mpp/server/session_store.py | 8 ++++- python/tests/test_session_method.py | 29 +++++++++++++++++++ python/tests/test_session_routes.py | 20 +++++++++++++ 5 files changed, 71 insertions(+), 8 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/server/session_method.py b/python/src/pay_kit/protocols/mpp/server/session_method.py index c10b8c608..343181e48 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_method.py +++ b/python/src/pay_kit/protocols/mpp/server/session_method.py @@ -161,7 +161,7 @@ class SessionChallengeOptions: def _parse_session_u64(value: str, name: str) -> int: """Parse a non-negative decimal string into a u64, naming the field on error.""" - if not (value.isascii() and value.isdigit()): + if not isinstance(value, str) or not (value.isascii() and value.isdigit()): raise ValueError(f"{name} is not an unsigned integer string: {value}") parsed = int(value, 10) if parsed > _U64_MAX: @@ -636,12 +636,12 @@ def finalize(current: ChannelState | None) -> ChannelState: async def _close_on_idle(self, channel_id: str) -> None: """Idle-close watchdog handler: close the channel and settle on-chain. - Settlement only runs when a signer and RPC are configured; a transient - failure is swallowed (logged) so it cannot crash the timer, and the - channel stays re-drivable with ``settledSignature`` unset. + The close state-flip always runs so the idle timeout takes effect even + without a signer/RPC (e.g. the playground); only the on-chain settle in + ``_settle_channel`` is gated on the signer/RPC pair. A transient failure + is swallowed (logged) so it cannot crash the timer, and the channel stays + re-drivable with ``settledSignature`` unset. """ - if self._signer is None or self._rpc is None: - return None try: await self._handle_close(ClosePayload(channel_id=channel_id, voucher=None)) except Exception: diff --git a/python/src/pay_kit/protocols/mpp/server/session_routes.py b/python/src/pay_kit/protocols/mpp/server/session_routes.py index 7b2dafadf..a65c1591c 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_routes.py +++ b/python/src/pay_kit/protocols/mpp/server/session_routes.py @@ -191,7 +191,15 @@ async def commit(method: str, raw: str | bytes) -> RouteResponse: voucher_raw = body.get("voucher") if voucher_raw is None: return _error(400, "voucher required") - voucher = SignedVoucher.from_dict(voucher_raw) + # A non-dict voucher makes SignedVoucher.from_dict raise AttributeError + # (not ValueError), and a non-numeric expiresAt/nonce raises ValueError; + # both must surface as 400, matching Go's strict json.Decode. + if not isinstance(voucher_raw, dict): + return _error(400, "invalid request body") + try: + voucher = SignedVoucher.from_dict(voucher_raw) + except (ValueError, TypeError): + return _error(400, "invalid request body") try: receipt = await core.process_commit(CommitPayload(delivery_id=delivery_id, voucher=voucher)) except ValueError as exc: diff --git a/python/src/pay_kit/protocols/mpp/server/session_store.py b/python/src/pay_kit/protocols/mpp/server/session_store.py index 6a620908f..4800a0f87 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_store.py +++ b/python/src/pay_kit/protocols/mpp/server/session_store.py @@ -338,8 +338,14 @@ async def update_channel(self, channel_id: str, mutator: ChannelMutator) -> Chan return next_state async def delete_channel(self, channel_id: str) -> None: - async with self._mu: + # Take the per-channel lock before mutating _data, in the same + # lock -> _mu order as update_channel, so a delete cannot race an + # in-flight mutator that would otherwise write the channel back after + # the pop. Matching the order means no deadlock. + lock = await self._channel_lock(channel_id) + async with lock, self._mu: self._data.pop(channel_id, None) + self._locks.pop(channel_id, None) async def list_channels(self, filter: ListChannelsFilter | None = None) -> list[ChannelState]: async with self._mu: diff --git a/python/tests/test_session_method.py b/python/tests/test_session_method.py index c2fbb2968..8ad3430ec 100644 --- a/python/tests/test_session_method.py +++ b/python/tests/test_session_method.py @@ -609,6 +609,19 @@ async def test_session_top_up_hardening() -> None: ) +async def test_session_top_up_non_string_new_deposit_rejected() -> None: + """A JSON-number newDeposit must surface as PaymentError, not an uncaught + AttributeError (int has no .isascii). The method-layer u64 parser now guards + isinstance(str) like the routes-layer one.""" + session = _new_test_session() + _, channel_id = await _open_trusted_channel(session, 1_000) + with pytest.raises(PaymentError, match="unsigned integer string"): + await _verify_session_action( + session, + SessionAction.top_up_action(TopUpPayload(channel_id=channel_id, new_deposit=500_000, signature="sig")), # type: ignore[arg-type] + ) + + async def test_session_top_up_verifies_signature_on_chain() -> None: """Mirrors TestSessionTopUpVerifiesSignatureOnChain.""" fake = _FakeRpc() @@ -753,6 +766,22 @@ def seed(_current: ChannelState | None) -> ChannelState: await _verify_session_action(session, SessionAction.close_action(ClosePayload(channel_id=channel_id))) +async def test_session_idle_close_flips_state_without_signer_or_rpc() -> None: + """The idle-close watchdog must flip close_requested_at even when no signer/ + RPC is configured (e.g. the playground); only the on-chain settle is gated. + Previously it early-returned and the idle timeout never took effect.""" + session = _new_test_session() # no signer, rpc=None + _, channel_id = await _open_trusted_channel(session, 1_000) + + await session._close_on_idle(channel_id) # pyright: ignore[reportPrivateUsage] + + state = await _get_channel(session, channel_id) + assert state is not None + assert state.close_requested_at is not None + assert not state.finalized + assert state.settled_signature is None + + # ── method-layer open guards ── diff --git a/python/tests/test_session_routes.py b/python/tests/test_session_routes.py index 04a288138..a70c086bf 100644 --- a/python/tests/test_session_routes.py +++ b/python/tests/test_session_routes.py @@ -288,3 +288,23 @@ async def test_deliveries_expires_at_integer_accepted() -> None: "POST", _body({"amount": "200", "sessionId": channel_id, "expiresAt": _far_future()}) ) assert resp.status == 200 + + +async def test_commit_non_dict_voucher_rejected() -> None: + """A non-dict voucher makes SignedVoucher.from_dict raise AttributeError; the + handler must catch it as 400, matching Go's strict json.Decode (not a 500).""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + for bad in ("a-string", [1, 2], 7): + resp = await routes.commit("POST", _body({"deliveryId": "d-1", "voucher": bad})) + assert resp.status == 400 + assert resp.body["error"] == "invalid request body" + + +async def test_commit_non_numeric_expires_at_rejected() -> None: + """A non-numeric voucher.data.expiresAt makes VoucherData.from_dict raise + ValueError; it must surface as 400, not escape as a 500.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + voucher = {"data": {"channelId": "x", "cumulativeAmount": "1", "expiresAt": "2025-01-01"}, "signature": "s"} + resp = await routes.commit("POST", _body({"deliveryId": "d-1", "voucher": voucher})) + assert resp.status == 400 + assert resp.body["error"] == "invalid request body" From d61be6e7e6afb2eaebf015809fe1311cf953dfde Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Mon, 22 Jun 2026 13:46:28 +0300 Subject: [PATCH 37/52] refactor(python): ship the session gate + stablecoin helpers in the SDK 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. --- python/examples/playground_api/discovery.py | 11 ++-- python/examples/playground_api/sandbox.py | 3 +- python/examples/playground_api/sessions.py | 56 ++++------------ python/src/pay_kit/_paycore/mints.py | 2 + python/src/pay_kit/_paycore/solana.py | 22 +++++++ python/src/pay_kit/fastapi.py | 30 ++++++++- .../pay_kit/protocols/mpp/server/__init__.py | 2 + .../protocols/mpp/server/session_method.py | 66 +++++++++++++++++++ 8 files changed, 142 insertions(+), 50 deletions(-) diff --git a/python/examples/playground_api/discovery.py b/python/examples/playground_api/discovery.py index 383d30377..529e08bb9 100644 --- a/python/examples/playground_api/discovery.py +++ b/python/examples/playground_api/discovery.py @@ -1,10 +1,11 @@ # examples/playground_api/discovery.py -"""OpenAPI 3.1 discovery, byte-aligned with the TypeScript playground. +"""OpenAPI 3.1 discovery, aligned with the TypeScript playground. -The TS SDK ships `buildOpenApiDocument` (packages/pay-kit/src/openapi.ts); the -Python SDK has no discovery builder yet, so this is an example-local port of the -*document shape* — not the TS code. A discovery consumer (mppx tooling, the -payment-discovery draft) sees the same structure from either SDK: +Neither the TS nor the Python SDK ships a discovery / OpenAPI builder yet, so +this is an example-local OpenAPI 3.1 document builder (python-only), pending a +future cross-SDK helper. It mirrors the *document shape* the TS playground emits +so a discovery consumer (mppx tooling, the payment-discovery draft) sees the +same structure from either SDK: { info, openapi: "3.1.0", paths: { : { : { responses, "x-payment-info": { offers: [...] }, summary? } } }, diff --git a/python/examples/playground_api/sandbox.py b/python/examples/playground_api/sandbox.py index b4cc1cfbc..35783f778 100644 --- a/python/examples/playground_api/sandbox.py +++ b/python/examples/playground_api/sandbox.py @@ -18,13 +18,12 @@ import urllib.request from typing import Any +from pay_kit._paycore.mints import SYSTEM_PROGRAM, TOKEN_PROGRAM from pay_kit._paycore.solana import resolve_mint # The sandbox clones mainnet state, so it funds the *mainnet* USDC mint # regardless of the configured network tag (mirrors sandbox.ts). USDC_MINT = resolve_mint("USDC", "mainnet") -SYSTEM_PROGRAM = "11111111111111111111111111111111" -TOKEN_PROGRAM = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" SOL_FUND_LAMPORTS = 100_000_000_000 # 100 SOL USDC_FUND_AMOUNT = 100_000_000 # 100 USDC (6 decimals) diff --git a/python/examples/playground_api/sessions.py b/python/examples/playground_api/sessions.py index 15f4802f3..bf0ebc3a4 100644 --- a/python/examples/playground_api/sessions.py +++ b/python/examples/playground_api/sessions.py @@ -1,13 +1,13 @@ # examples/playground_api/sessions.py """One metered-session route for the playground (FastAPI). -pay_kit ships a charge/x402 route shim (``RequirePayment`` + -``install_exception_handler``), but it ships *no* session gate: sessions live in +pay_kit ships the session gate as ``RequireSession`` (the session counterpart of +the charge/x402 ``RequirePayment`` shim); sessions themselves live in ``pay_kit.protocols.mpp.server`` as framework-agnostic primitives -(:func:`new_session`, :func:`session_routes`). So the 402 gate below is the one -piece an idiomatic session example must hand-roll today. It is the same shape a -charge route gets for free: verify an ``Authorization`` credential, or answer -402 with a ``WWW-Authenticate`` challenge. +(:func:`new_session`, :func:`session_routes`, :meth:`Session.handle`). So the +402 gate is one ``Depends(RequireSession(...))`` line: verify an +``Authorization`` credential, or answer 402 with a ``WWW-Authenticate`` +challenge. Boot config comes from ``pay_kit.config()`` (the same resolved operator, recipient, and challenge-binding secret the charge routes use), not hand-rolled @@ -20,20 +20,13 @@ import json from collections.abc import AsyncIterator -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, Request from fastapi.responses import JSONResponse, StreamingResponse import pay_kit -from pay_kit._paycore.errors import PaymentError, payment_required_response from pay_kit._paycore.rpc import SolanaRpc -from pay_kit._paycore.solana import resolve_mint -from pay_kit.protocols.mpp.core.headers import ( - AUTHORIZATION_HEADER, - PAYMENT_RECEIPT_HEADER, - format_receipt, - format_www_authenticate, - parse_authorization, -) +from pay_kit._paycore.solana import resolve_mint, stablecoin_decimals +from pay_kit.fastapi import RequireSession from pay_kit.protocols.mpp.server import ( SessionChallengeOptions, SessionOptions, @@ -56,7 +49,7 @@ recipient=_cfg.effective_recipient(), cap=1_000_000, currency=resolve_mint("USDC", "mainnet"), - decimals=6, + decimals=stablecoin_decimals("USDC"), network=_cfg.network.value, secret_key=_cfg.mpp.challenge_binding_secret or "", modes=["pull"], @@ -70,31 +63,10 @@ _challenge = SessionChallengeOptions(cap="1000000", description="Metered token stream") - -async def _session_gate(request: Request) -> dict[str, str]: - """The one hand-rolled piece (no session shim ships): verify the credential - or raise 402 with a fresh challenge. Mirrors what RequirePayment does for - charge routes.""" - auth = request.headers.get(AUTHORIZATION_HEADER) - if auth: - try: - receipt = await session.verify_credential(parse_authorization(auth)) - return {PAYMENT_RECEIPT_HEADER: format_receipt(receipt)} - except PaymentError as err: - error = err - except Exception as err: # noqa: BLE001 (parse/framework errors map to 402) - error = PaymentError(str(err), code="invalid-payload") - else: - error = None - problem = payment_required_response( - str(error) if error else "Payment required", - code=(error.code if error and error.code else "payment_invalid"), - challenge_header=format_www_authenticate(await session.challenge(_challenge)), - ) - raise HTTPException(problem["status_code"], detail=problem["body"], headers=problem["headers"]) - - -_gate = Depends(_session_gate) +# The session 402 gate, now shipped by the SDK: verify an Authorization +# credential (returning the receipt headers) or answer 402 with a fresh +# challenge. Mirrors what RequirePayment does for charge routes. +_gate = Depends(RequireSession(session, _challenge)) # Streamed deliveries, billed per chunk against the session voucher. Mirrors the # TypeScript playground's `GET /api/v1/stream` (SSE) session route. diff --git a/python/src/pay_kit/_paycore/mints.py b/python/src/pay_kit/_paycore/mints.py index 15eddf2bb..488f4265f 100644 --- a/python/src/pay_kit/_paycore/mints.py +++ b/python/src/pay_kit/_paycore/mints.py @@ -11,6 +11,7 @@ from pay_kit._paycore.solana import ( ASSOCIATED_TOKEN_PROGRAM, + SYSTEM_PROGRAM, TOKEN_2022_PROGRAM, TOKEN_PROGRAM, default_token_program_for_currency, @@ -20,6 +21,7 @@ __all__ = [ "ASSOCIATED_TOKEN_PROGRAM", + "SYSTEM_PROGRAM", "TOKEN_PROGRAM", "TOKEN_2022_PROGRAM", "resolve_stablecoin_mint", diff --git a/python/src/pay_kit/_paycore/solana.py b/python/src/pay_kit/_paycore/solana.py index ef529cb34..666b7698f 100644 --- a/python/src/pay_kit/_paycore/solana.py +++ b/python/src/pay_kit/_paycore/solana.py @@ -220,6 +220,17 @@ def validate_splits(splits: list[Split]) -> None: "CASH": TOKEN_2022_PROGRAM, } +# Base-unit precision (token decimals) per supported stablecoin. KNOWN_MINTS +# carries no decimals column, so this is the single source of truth for the +# decimals helper. Every supported stablecoin is 6-decimal today. +STABLECOIN_DECIMALS: dict[str, int] = { + "USDC": 6, + "USDT": 6, + "USDG": 6, + "PYUSD": 6, + "CASH": 6, +} + def default_rpc_url(network: str) -> str: """Return the default RPC endpoint for a Solana network.""" @@ -268,6 +279,17 @@ def default_token_program_for_currency(currency: str, network: str) -> str: return TOKEN_PROGRAM +def stablecoin_decimals(currency: str, network: str = "mainnet") -> int: + """Return the base-unit precision for a stablecoin symbol or known mint. + + Accepts a symbol (``"USDC"``) or a raw mint address; resolves to the + supported stablecoin and returns its decimals (USDC/USDT/PYUSD = 6). Falls + back to 6 for an unknown currency, matching every supported stablecoin. + """ + symbol = stablecoin_symbol(resolve_mint(currency, network)) or stablecoin_symbol(currency) + return STABLECOIN_DECIMALS.get(symbol or "", 6) + + def is_native_sol(currency: str) -> bool: """Return True if the currency represents native SOL.""" return currency.upper() == "SOL" diff --git a/python/src/pay_kit/fastapi.py b/python/src/pay_kit/fastapi.py index 0f44aa3c9..389262e76 100644 --- a/python/src/pay_kit/fastapi.py +++ b/python/src/pay_kit/fastapi.py @@ -45,8 +45,9 @@ async def report(payment=Depends(RequirePayment("report", pricing=pricing))): from pay_kit.gate import DynamicGate, Gate from pay_kit.price import Price from pay_kit.pricing import Pricing + from pay_kit.protocols.mpp.server import Session, SessionChallengeOptions -__all__ = ["RequirePayment", "install_exception_handler", "payment", "Payment"] +__all__ = ["RequirePayment", "RequireSession", "install_exception_handler", "payment", "Payment"] #: Header that carries each settlement header's name through the response hook. _SETTLEMENT_STATE_ATTR = "paykit_settlement_headers" @@ -90,6 +91,33 @@ async def dependency(request: Request) -> Payment: return dependency +def RequireSession( # noqa: N802 - factory reads as a dependency constructor + session: Session, + challenge_options: SessionChallengeOptions, +) -> Callable[..., Any]: + """Build a FastAPI dependency that gates a route behind an MPP session. + + The returned coroutine reads the ``Authorization`` header and runs the + framework-agnostic :meth:`~pay_kit.protocols.mpp.server.Session.handle` gate. + On a missing or invalid credential it raises ``HTTPException`` carrying the + 402 challenge headers and problem body; on success it schedules the receipt + headers to be merged onto the response (the same settlement-header echo path + :func:`RequirePayment` uses) and returns the receipt headers dict, so a + handler can ``Depends`` on it to attach them to a ``StreamingResponse``. + """ + from pay_kit.protocols.mpp.core.headers import AUTHORIZATION_HEADER + + async def dependency(request: Request) -> dict[str, str]: + auth = request.headers.get(AUTHORIZATION_HEADER) + result = await session.handle(auth, challenge_options) + if not result.ok: + raise HTTPException(result.status, detail=result.body, headers=result.headers) + setattr(request.state, _SETTLEMENT_STATE_ATTR, dict(result.headers)) + return result.headers + + return dependency + + #: Response headers carrying MPP/x402 payment challenges and settlement proofs. #: A browser client reading them cross-origin needs them in the CORS expose list. PAYMENT_HEADERS: tuple[str, ...] = ( diff --git a/python/src/pay_kit/protocols/mpp/server/__init__.py b/python/src/pay_kit/protocols/mpp/server/__init__.py index 1ac0175f3..5d8140260 100644 --- a/python/src/pay_kit/protocols/mpp/server/__init__.py +++ b/python/src/pay_kit/protocols/mpp/server/__init__.py @@ -19,6 +19,7 @@ from pay_kit.protocols.mpp.server.session_method import ( Session, SessionChallengeOptions, + SessionGateResult, SessionOptions, new_session, ) @@ -57,6 +58,7 @@ "Session", "SessionChallengeOptions", "SessionConfig", + "SessionGateResult", "SessionOptions", "SessionRoutes", "SessionServer", diff --git a/python/src/pay_kit/protocols/mpp/server/session_method.py b/python/src/pay_kit/protocols/mpp/server/session_method.py index 343181e48..d71e1965a 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_method.py +++ b/python/src/pay_kit/protocols/mpp/server/session_method.py @@ -38,9 +38,16 @@ ChallengeExpiredError, ChallengeMismatchError, PaymentError, + payment_required_response, ) from pay_kit._paycore.solana import MAX_SPLITS from pay_kit.protocols.mpp.core.expires import minutes +from pay_kit.protocols.mpp.core.headers import ( + PAYMENT_RECEIPT_HEADER, + format_receipt, + format_www_authenticate, + parse_authorization, +) from pay_kit.protocols.mpp.core.types import PaymentChallenge, PaymentCredential, Receipt from pay_kit.protocols.mpp.intents.session import ( ClosePayload, @@ -75,6 +82,7 @@ "OpenTxSubmitter", "SessionOptions", "SessionChallengeOptions", + "SessionGateResult", "Session", "new_session", ] @@ -158,6 +166,27 @@ class SessionChallengeOptions: expires: str = "" +@dataclass +class SessionGateResult: + """Outcome of :meth:`Session.handle`: verified credential or 402 challenge. + + Framework-agnostic so any host (FastAPI, ASGI, a test) can render it. On + success ``ok`` is True, ``status`` is 200, ``headers`` carries the receipt + header, and ``body`` is None. On a missing or invalid credential ``ok`` is + False, ``status`` is 402, ``headers`` carries the ``WWW-Authenticate`` + challenge, and ``body`` is the ``application/problem+json`` problem document. + """ + + # ok is True when a credential verified, False when answering 402. + ok: bool + # status is 200 on success, 402 on a payment-required challenge. + status: int + # headers are the receipt headers on success, else the 402 challenge headers. + headers: dict[str, str] + # body is None on success, else the 402 problem document. + body: dict | None = None + + def _parse_session_u64(value: str, name: str) -> int: """Parse a non-negative decimal string into a u64, naming the field on error.""" @@ -352,6 +381,43 @@ async def verify_credential(self, credential: PaymentCredential) -> Receipt: external_id = request.external_id or "" return _success_receipt(reference, credential.challenge.id, external_id) + async def handle(self, authorization: str | None, challenge_options: SessionChallengeOptions) -> SessionGateResult: + """Framework-agnostic 402 session gate: verify an Authorization credential + or answer 402 with a fresh challenge. + + When ``authorization`` is present it is parsed and verified; on success the + result carries the receipt header and status 200. A + :class:`~pay_kit._paycore.errors.PaymentError` (or any other exception, + mapped to ``invalid-payload``) falls through to a 402 carrying the + ``WWW-Authenticate`` challenge built from ``challenge_options``. Mirrors + the per-route gate a charge route gets for free from ``RequirePayment``. + """ + error: PaymentError | None = None + if authorization: + try: + receipt = await self.verify_credential(parse_authorization(authorization)) + return SessionGateResult( + ok=True, + status=200, + headers={PAYMENT_RECEIPT_HEADER: format_receipt(receipt)}, + body=None, + ) + except PaymentError as err: + error = err + except Exception as err: # noqa: BLE001 (parse/framework errors map to 402) + error = PaymentError(str(err), code="invalid-payload") + problem = payment_required_response( + str(error) if error else "Payment required", + code=(error.code if error and error.code else "payment_invalid"), + challenge_header=format_www_authenticate(await self.challenge(challenge_options)), + ) + return SessionGateResult( + ok=False, + status=problem["status_code"], + headers=problem["headers"], + body=problem["body"], + ) + def _verify_pinned_session_fields(self, credential: PaymentCredential, request: Any) -> None: """Tier-2 backstop for session credentials: after Tier-1 HMAC confirms the challenge was issued by this server, fields fixed at construction From 0fc257235b60c81d64beca98ca26052bcfbbc0db Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Mon, 22 Jun 2026 14:13:17 +0300 Subject: [PATCH 38/52] fix(python/mpp/client): re-export CommitTransport from the client package 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. --- python/src/pay_kit/protocols/mpp/client/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python/src/pay_kit/protocols/mpp/client/__init__.py b/python/src/pay_kit/protocols/mpp/client/__init__.py index 2d4acdd5f..809db270a 100644 --- a/python/src/pay_kit/protocols/mpp/client/__init__.py +++ b/python/src/pay_kit/protocols/mpp/client/__init__.py @@ -47,6 +47,7 @@ session_request_modes, ) from pay_kit.protocols.mpp.client.session_consumer import ( + CommitTransport, MeteredDelivery, SessionConsumer, ) @@ -55,6 +56,7 @@ __all__ = [ "PaymentTransport", "ActiveSession", + "CommitTransport", "SessionConsumer", "MeteredDelivery", "serialize_session_credential", From 0703886e363c6b4edc57c3b077b32b16c6abf714 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Mon, 22 Jun 2026 14:32:04 +0300 Subject: [PATCH 39/52] fix(python/mpp): range-guard open args, fix delete lock lifecycle, refresh 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. --- python/README.md | 14 ++++++++------ .../src/pay_kit/protocols/mpp/_paymentchannels.py | 13 +++++++++++++ .../pay_kit/protocols/mpp/server/session_store.py | 6 +++++- python/tests/test_paymentchannels.py | 15 +++++++++++++++ python/tests/test_session_method.py | 14 +++++++------- 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/python/README.md b/python/README.md index 438b8e29d..74ac445e3 100644 --- a/python/README.md +++ b/python/README.md @@ -263,7 +263,7 @@ Use MPP when: |---------------|--------| | `charge/pull` | ✅ | | `charge/push` | ✅ | -| `session` | ✅ (on-chain settle-at-close pending) | +| `session` | ✅ | `session` ships both sides. Client: `ActiveSession` voucher signing, the challenge-driven pull/clientVoucher payment-channel openers (fee payer = @@ -271,11 +271,13 @@ challenge operator, pending-server-signature placeholder), the metered `SessionConsumer`, and the SSE streaming helpers (`MeteredSseSession`, `MeteredSseStream`, `HttpCommitTransport`). Server: the session method (`new_session`) issuing challenges and verifying credentials/vouchers, the -reserve/commit metering side channel (`session_routes`), the shared channel -store, and the idle-close watchdog. Not yet ported: the server-broadcast open -path (`openTxSubmitter=server`), pull/operatedVoucher (multi-delegate) opens, -and on-chain settle-at-close, so a closed channel's `settledSignature` stays -`null` until that lands. +server-broadcast open path (`openTxSubmitter=server`, the operator co-signs and +broadcasts the open), the reserve/commit metering side channel +(`session_routes`), the shared channel store, the idle-close watchdog, and +on-chain settle-at-close (when a signer and RPC are configured, a closed +channel's `settledSignature` carries the real on-chain signature; without them +the close is a state-flip and the signature stays `null`). Not yet ported: +pull/operatedVoucher (multi-delegate) opens. The MPP server owns the full lifecycle: it issues signed challenges with a fresh `recentBlockhash`, parses and validates the `Authorization: Payment` diff --git a/python/src/pay_kit/protocols/mpp/_paymentchannels.py b/python/src/pay_kit/protocols/mpp/_paymentchannels.py index 34255a714..1d7de3f68 100644 --- a/python/src/pay_kit/protocols/mpp/_paymentchannels.py +++ b/python/src/pay_kit/protocols/mpp/_paymentchannels.py @@ -308,6 +308,19 @@ def build_open_instruction(params: OpenChannelParams) -> Instruction: Returns: The assembled ``open`` instruction ready to add to a transaction. """ + # Validate the integer fields against their on-chain widths up front so an + # out-of-range value raises a clear ValueError here rather than a low-level + # struct/Borsh error from PDA derivation or the generated encoder. + if not 0 <= params.salt <= 0xFFFF_FFFF_FFFF_FFFF: + raise ValueError(f"salt {params.salt} does not fit in u64") + if not 0 <= params.deposit <= 0xFFFF_FFFF_FFFF_FFFF: + raise ValueError(f"deposit {params.deposit} does not fit in u64") + if not 0 <= params.grace_period <= 0xFFFF_FFFF: + raise ValueError(f"grace_period {params.grace_period} does not fit in u32") + for entry in params.recipients: + if not 0 <= entry.bps <= 0xFFFF: + raise ValueError(f"recipient bps {entry.bps} does not fit in u16") + channel, _ = find_channel_pda( params.payer, params.payee, diff --git a/python/src/pay_kit/protocols/mpp/server/session_store.py b/python/src/pay_kit/protocols/mpp/server/session_store.py index 4800a0f87..773f2dd12 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_store.py +++ b/python/src/pay_kit/protocols/mpp/server/session_store.py @@ -342,10 +342,14 @@ async def delete_channel(self, channel_id: str) -> None: # lock -> _mu order as update_channel, so a delete cannot race an # in-flight mutator that would otherwise write the channel back after # the pop. Matching the order means no deadlock. + # + # The lock entry is intentionally NOT removed: popping it while another + # task is still queued on it would let a later operation create a fresh + # lock for the same id and run unserialized against the queued one. Locks + # persist for the store's lifetime (as they already do for update_channel). lock = await self._channel_lock(channel_id) async with lock, self._mu: self._data.pop(channel_id, None) - self._locks.pop(channel_id, None) async def list_channels(self, filter: ListChannelsFilter | None = None) -> list[ChannelState]: async with self._mu: diff --git a/python/tests/test_paymentchannels.py b/python/tests/test_paymentchannels.py index c01cf08f3..7778e1012 100644 --- a/python/tests/test_paymentchannels.py +++ b/python/tests/test_paymentchannels.py @@ -8,6 +8,7 @@ from __future__ import annotations import struct +from dataclasses import replace import pytest from solders.pubkey import Pubkey @@ -161,6 +162,20 @@ def test_build_open_instruction_program_id_and_account_count() -> None: assert len(ix.accounts) == 13 +def test_build_open_instruction_rejects_out_of_range_fields() -> None: + """Out-of-range integer fields raise a clear ValueError before the generated + Borsh encoder would fail with a low-level construct error.""" + base = _open_params() + with pytest.raises(ValueError, match="grace_period"): + build_open_instruction(replace(base, grace_period=0x1_0000_0000)) + with pytest.raises(ValueError, match="salt"): + build_open_instruction(replace(base, salt=0x1_0000_0000_0000_0000)) + with pytest.raises(ValueError, match="deposit"): + build_open_instruction(replace(base, deposit=-1)) + with pytest.raises(ValueError, match="bps"): + build_open_instruction(replace(base, recipients=[Distribution(pk(5), 0x1_0000)])) + + def test_build_open_instruction_account_order_and_flags() -> None: params = _open_params() ix = build_open_instruction(params) diff --git a/python/tests/test_session_method.py b/python/tests/test_session_method.py index 8ad3430ec..c0258ef17 100644 --- a/python/tests/test_session_method.py +++ b/python/tests/test_session_method.py @@ -8,13 +8,13 @@ with their replay and hardening semantics, including the optional RPC liveness confirm seam. -The on-chain settlement path at close, the server-broadcast open -(``OpenTxSubmitterServer``), the attached-transaction open verification, and the -metering side-channel HTTP routes from the Go file are not ported here: their -lower-level Python building blocks (``SubmitOpenTx``, ``closeAndSettleChannel`` -/ ``SettlementInstructions``, the open-tx decode through ``handle_open``, and -``SessionRoutes``) are not yet ported. Each test name below maps to the Go test -it mirrors in the docstring. +This file covers the offline-core handler. The on-chain settlement path at +close, the server-broadcast open (``OpenTxSubmitterServer``), the +attached-transaction open verification, and the metering side-channel HTTP +routes are exercised in their own suites (``test_session_settlement.py``, +``test_session_onchain.py``, ``test_session_routes.py``, and the surfnet +``test_session_e2e_surfnet.py``), not here. Each test name below maps to the Go +test it mirrors in the docstring. """ from __future__ import annotations From 39db237a69ab2d43ea9c73dbd3ac13044c0f8ca3 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Mon, 22 Jun 2026 14:54:58 +0300 Subject: [PATCH 40/52] fix(python/mpp/server): catch AttributeError on bad voucher, idempotent 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. --- .../pay_kit/protocols/mpp/server/session_method.py | 7 +++++++ .../pay_kit/protocols/mpp/server/session_routes.py | 10 ++++++---- python/tests/test_session_routes.py | 12 ++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/server/session_method.py b/python/src/pay_kit/protocols/mpp/server/session_method.py index d71e1965a..0d421bb4c 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_method.py +++ b/python/src/pay_kit/protocols/mpp/server/session_method.py @@ -691,6 +691,13 @@ async def _settle_channel(self, channel_id: str) -> str | None: def finalize(current: ChannelState | None) -> ChannelState: if current is None: raise ValueError(f"channel {channel_id} disappeared during settle") + # Idempotent against a concurrent re-drive (e.g. a client close + # racing the idle-close watchdog): if another caller already + # finalized under the per-channel lock, keep its signature rather + # than overwriting with this call's, which may be a rejected second + # on-chain finalize. + if current.finalized: + return current nxt = current.clone() nxt.finalized = True nxt.settled_signature = signature diff --git a/python/src/pay_kit/protocols/mpp/server/session_routes.py b/python/src/pay_kit/protocols/mpp/server/session_routes.py index a65c1591c..41b4e9401 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_routes.py +++ b/python/src/pay_kit/protocols/mpp/server/session_routes.py @@ -191,14 +191,16 @@ async def commit(method: str, raw: str | bytes) -> RouteResponse: voucher_raw = body.get("voucher") if voucher_raw is None: return _error(400, "voucher required") - # A non-dict voucher makes SignedVoucher.from_dict raise AttributeError - # (not ValueError), and a non-numeric expiresAt/nonce raises ValueError; - # both must surface as 400, matching Go's strict json.Decode. + # SignedVoucher.from_dict can fail several ways on malformed JSON: a + # non-dict voucher or a non-dict nested "data" raises AttributeError + # (.get on a str/list), a JSON-number where a dict is expected raises + # TypeError ("key" in 123), and a non-numeric expiresAt/nonce raises + # ValueError. All must surface as 400, matching Go's strict json.Decode. if not isinstance(voucher_raw, dict): return _error(400, "invalid request body") try: voucher = SignedVoucher.from_dict(voucher_raw) - except (ValueError, TypeError): + except (ValueError, TypeError, AttributeError): return _error(400, "invalid request body") try: receipt = await core.process_commit(CommitPayload(delivery_id=delivery_id, voucher=voucher)) diff --git a/python/tests/test_session_routes.py b/python/tests/test_session_routes.py index a70c086bf..426303e27 100644 --- a/python/tests/test_session_routes.py +++ b/python/tests/test_session_routes.py @@ -308,3 +308,15 @@ async def test_commit_non_numeric_expires_at_rejected() -> None: resp = await routes.commit("POST", _body({"deliveryId": "d-1", "voucher": voucher})) assert resp.status == 400 assert resp.body["error"] == "invalid request body" + + +async def test_commit_non_dict_voucher_data_rejected() -> None: + """voucher is a dict but voucher.data is a string/list: VoucherData.from_dict + calls .get on it and raises AttributeError, which must surface as 400 (not a + 500), matching Go's strict json.Decode.""" + routes = session_routes(SessionServer(_config(), MemoryChannelStore())) + for bad_data in ("a-string", [1, 2]): + voucher = {"data": bad_data, "signature": "x"} + resp = await routes.commit("POST", _body({"deliveryId": "d-1", "voucher": voucher})) + assert resp.status == 400 + assert resp.body["error"] == "invalid request body" From 7b34267d0fcd378152a14533b33b86c54c1c2c28 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Mon, 22 Jun 2026 15:08:49 +0300 Subject: [PATCH 41/52] fix(python/mpp/server): confirm the settle tx before finalizing 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. --- .../protocols/mpp/server/session_onchain.py | 12 ++++-- python/tests/test_session_settlement.py | 38 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/server/session_onchain.py b/python/src/pay_kit/protocols/mpp/server/session_onchain.py index 7fe3fcd83..f7b4139db 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_onchain.py +++ b/python/src/pay_kit/protocols/mpp/server/session_onchain.py @@ -376,8 +376,8 @@ async def settle_and_finalize_channel( rpc: RpcClient, config: SessionConfig, ) -> str: - """Build, sign, and broadcast the close settlement transaction; return the - on-chain signature. + """Build, sign, broadcast, and confirm the close settlement transaction; + return the confirmed on-chain signature. Mirrors the Rust/Go close path: a settle_and_finalize instruction (preceded by the Ed25519 precompile when a voucher was recorded) plus a distribute @@ -437,7 +437,13 @@ async def settle_and_finalize_channel( blockhash = Hash.from_string((await rpc.get_latest_blockhash()).value.blockhash) tx = Transaction.new_signed_with_payer([*settle, distribute], merchant_pubkey, [merchant], blockhash) sent = await rpc.send_raw_transaction(bytes(tx)) - return str(sent.value) + signature = str(sent.value) + # Confirm before returning, mirroring cosign_and_broadcast_open: a dropped + # settle tx (blockhash expiry, congestion, duplicate-settle race) must raise + # here so the caller does NOT mark the channel finalized with an unconfirmed + # signature, which would defeat the re-drivable-close guard. + await confirm_transaction_signature(rpc, signature, "settle") + return signature async def cosign_and_broadcast_open(payload: OpenPayload, *, fee_payer: Any, rpc: RpcClient) -> str: diff --git a/python/tests/test_session_settlement.py b/python/tests/test_session_settlement.py index 58e7486ca..c0926edb1 100644 --- a/python/tests/test_session_settlement.py +++ b/python/tests/test_session_settlement.py @@ -12,6 +12,7 @@ from solders.keypair import Keypair # type: ignore[import-untyped] from solders.transaction import Transaction # type: ignore[import-untyped] +from pay_kit._paycore.errors import PaymentError from pay_kit.protocols.mpp.server import SessionOptions, new_session from pay_kit.protocols.mpp.server.session_store import ChannelState from pay_kit.signer import LocalSigner @@ -109,6 +110,43 @@ async def test_close_settles_with_voucher_and_records_signature() -> None: assert _instruction_discriminators(rpc.sent[0]) == [1, 4, 7] +@pytest.mark.asyncio +async def test_settle_raises_and_does_not_finalize_when_tx_unconfirmed() -> None: + """A dropped/failed settle tx must raise (the broadcast is confirmed before + return), so the channel is NOT marked finalized with an unconfirmed + signature and the re-drivable-close guard still applies.""" + + class _FailingSettleRpc(_SettleRpc): + async def get_signature_statuses(self, signatures: list[str]) -> list[dict | None]: + return [{"err": {"InstructionError": [0, "Custom"]}} for _ in signatures] + + operator = Keypair.from_seed(bytes([8] * 32)) + auth = Keypair.from_seed(bytes([9] * 32)) + channel = str(Keypair.from_seed(bytes([10] * 32)).pubkey()) + rpc = _FailingSettleRpc() + session = _session(rpc, operator) + await _seed( + session, + ChannelState( + channel_id=channel, + authorized_signer=str(auth.pubkey()), + deposit=1_000_000, + cumulative=500_000, + highest_voucher_signature=str(auth.sign_message(b"voucher")), + highest_voucher_expires_at=4_102_444_800, + operator=str(operator.pubkey()), + ), + ) + + with pytest.raises(PaymentError, match="failed on-chain"): + await session._settle_channel(channel) + + final = await session._core.store().get_channel(channel) + assert final is not None + assert final.finalized is False + assert final.settled_signature is None + + @pytest.mark.asyncio async def test_close_without_voucher_omits_ed25519_precompile() -> None: operator = Keypair.from_seed(bytes([4] * 32)) From 241d4529a0bc21804ff929b0e350c2a5bbb71a2b Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Mon, 22 Jun 2026 15:21:37 +0300 Subject: [PATCH 42/52] fix(python/mpp/server): propagate verified open payer so settle refunds the opener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/pay_kit/protocols/mpp/server/session_method.py | 9 ++++++++- .../src/pay_kit/protocols/mpp/server/session_onchain.py | 5 +++++ python/tests/test_session_onchain.py | 3 +++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/python/src/pay_kit/protocols/mpp/server/session_method.py b/python/src/pay_kit/protocols/mpp/server/session_method.py index 0d421bb4c..df95dcc09 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_method.py +++ b/python/src/pay_kit/protocols/mpp/server/session_method.py @@ -517,11 +517,18 @@ async def _handle_open(self, payload: OpenPayload) -> str: program_id=(Pubkey.from_string(self._core.config.program_id) if self._core.config.program_id else None), ) try: - await verify_open_tx(expected, payload, self._rpc) + verified = await verify_open_tx(expected, payload, self._rpc) except PaymentError: raise except Exception as exc: raise PaymentError(f"open transaction verification failed: {exc}", code="invalid-payload") from exc + # Propagate the on-chain payer (open slot 0) so process_open records + # state.operator even when the client used the single-arg + # ActiveSession.open_action() helper (payload.payer is None there). + # Without it, settle-at-close refunds the unspent balance to the + # recipient's ATA instead of the channel opener's. + if not payload.payer: + payload.payer = verified.payer elif mode == "push" and not has_channel_id: raise PaymentError("open payload missing transaction or channelId", code="invalid-payload") elif mode == "push" and self._rpc is not None: diff --git a/python/src/pay_kit/protocols/mpp/server/session_onchain.py b/python/src/pay_kit/protocols/mpp/server/session_onchain.py index f7b4139db..fecee866b 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_onchain.py +++ b/python/src/pay_kit/protocols/mpp/server/session_onchain.py @@ -116,6 +116,10 @@ class VerifyOpenTxResult: deposit: int grace_period: int salt: int + # payer is the channel funder (open instruction slot 0). The caller + # propagates it onto the payload so settle-at-close refunds the unspent + # balance to the opener's ATA, not the recipient's. + payer: str def is_placeholder_signature(signature: str) -> bool: @@ -291,6 +295,7 @@ def account_at(indices: list[int], slot: int, label: str) -> Pubkey: deposit=deposit, grace_period=grace_period, salt=salt, + payer=str(payer), ) diff --git a/python/tests/test_session_onchain.py b/python/tests/test_session_onchain.py index 3322d9298..01035971d 100644 --- a/python/tests/test_session_onchain.py +++ b/python/tests/test_session_onchain.py @@ -170,6 +170,9 @@ async def test_verify_open_tx_accepts_legacy_encoding() -> None: assert result.deposit == OPEN_FIXTURE_DEPOSIT assert result.grace_period == OPEN_FIXTURE_GRACE assert result.salt == OPEN_FIXTURE_SALT + # payer (open slot 0) is surfaced so the method layer can record + # state.operator and refund the opener's ATA at settle-at-close. + assert result.payer == str(fixture.payer.pubkey()) async def test_verify_open_tx_accepts_v0_encoding() -> None: From cd57ed532ecd27763d9b164e85af5880e4668936 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Mon, 22 Jun 2026 16:27:04 +0300 Subject: [PATCH 43/52] fix(python/mpp): close remaining session review gaps --- python/examples/playground_api/discovery.py | 16 ++++--- .../protocols/mpp/server/session_method.py | 10 +++-- python/tests/test_session_method.py | 42 +++++++++++++++++++ 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/python/examples/playground_api/discovery.py b/python/examples/playground_api/discovery.py index 529e08bb9..06dfa7104 100644 --- a/python/examples/playground_api/discovery.py +++ b/python/examples/playground_api/discovery.py @@ -21,16 +21,20 @@ from typing import Any from pay_kit._paycore.protocol import Protocol +from pay_kit._paycore.solana import stablecoin_decimals from pay_kit.config import Config from pay_kit.gate import Gate from pay_kit.price import Price -#: Solana base-unit precision for the supported stablecoins (USDC/USDT/PYUSD). -USDC_DECIMALS = 6 +def _mints_network(config: Config) -> str: + """The bare network label consumed by the stablecoin mint registry.""" + return config.network.mints_label() -def base_units(price: Price, decimals: int = USDC_DECIMALS) -> str: + +def base_units(price: Price, *, currency: str = "USDC", network: str = "mainnet") -> str: """The integer base-unit string for a price (e.g. ``usd("0.01")`` -> ``"10000"``).""" + decimals = stablecoin_decimals(currency, network) return str(int(price.amount.scaleb(decimals).to_integral_value())) @@ -60,7 +64,8 @@ def charge_offers(gate: Gate, config: Config, *, currency: str = "USDC") -> list base-unit amount, recipient, network, and (for x402) the fee-paying operator. """ accept = _effective_accept(gate, config) - amount = base_units(gate.total()) + network_label = _mints_network(config) + amount = base_units(gate.total(), currency=currency, network=network_label) # Offer-level description is a price hint (matches the TS offer shape, e.g. # "0.01 USDC"); the human prose stays on the route-level summary. price = f"{gate.total().amount_string()} {currency}" @@ -111,7 +116,8 @@ def session_offer( Offer `description` is an "up to " price hint, mirroring the TS session offer shape. """ - cap_human = format(Decimal(cap_base_units) / (Decimal(10) ** USDC_DECIMALS), "f") + decimals = stablecoin_decimals(currency, _mints_network(config)) + cap_human = format(Decimal(cap_base_units) / (Decimal(10) ** decimals), "f") return _offer( amount=cap_base_units, currency=currency, diff --git a/python/src/pay_kit/protocols/mpp/server/session_method.py b/python/src/pay_kit/protocols/mpp/server/session_method.py index df95dcc09..0f9e98d2c 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_method.py +++ b/python/src/pay_kit/protocols/mpp/server/session_method.py @@ -184,7 +184,7 @@ class SessionGateResult: # headers are the receipt headers on success, else the 402 challenge headers. headers: dict[str, str] # body is None on success, else the 402 problem document. - body: dict | None = None + body: dict[str, Any] | None = None def _parse_session_u64(value: str, name: str) -> int: @@ -523,14 +523,18 @@ async def _handle_open(self, payload: OpenPayload) -> str: except Exception as exc: raise PaymentError(f"open transaction verification failed: {exc}", code="invalid-payload") from exc # Propagate the on-chain payer (open slot 0) so process_open records - # state.operator even when the client used the single-arg - # ActiveSession.open_action() helper (payload.payer is None there). + # state.operator when the attached transaction is the source of truth. # Without it, settle-at-close refunds the unspent balance to the # recipient's ATA instead of the channel opener's. if not payload.payer: payload.payer = verified.payer elif mode == "push" and not has_channel_id: raise PaymentError("open payload missing transaction or channelId", code="invalid-payload") + elif mode == "push" and self._signer is not None and self._rpc is not None and not payload.payer: + raise PaymentError( + "push open requires payer or transaction when settle-at-close is configured", + code="invalid-payload", + ) elif mode == "push" and self._rpc is not None: await confirm_transaction_signature(self._rpc, payload.signature, "open") diff --git a/python/tests/test_session_method.py b/python/tests/test_session_method.py index c0258ef17..b395614e3 100644 --- a/python/tests/test_session_method.py +++ b/python/tests/test_session_method.py @@ -42,6 +42,7 @@ SessionOptions, new_session, ) +from pay_kit.signer import LocalSigner SESSION_METHOD_SECRET = "session-method-secret" SESSION_TEST_RECIPIENT = str(Keypair.from_seed(bytes([7] * 32)).pubkey()) @@ -785,6 +786,47 @@ async def test_session_idle_close_flips_state_without_signer_or_rpc() -> None: # ── method-layer open guards ── +async def test_session_push_open_requires_payer_or_transaction_for_settlement() -> None: + operator = Keypair.from_seed(bytes([44] * 32)) + session = _new_test_session( + operator=str(operator.pubkey()), + recipient=SESSION_TEST_RECIPIENT, + signer=LocalSigner.from_keypair(operator), + rpc=_FakeRpc(), + ) + channel_id = _new_wallet() + signer = _TestVoucherSigner(0x30) + + with pytest.raises(PaymentError, match="requires payer or transaction"): + await _verify_session_action( + session, + SessionAction.open_action( + OpenPayload.push(channel_id, "1000", signer.address(), _confirmed_signature(0x31)) + ), + ) + + payer = _new_wallet() + await _verify_session_action( + session, + SessionAction.open_action( + OpenPayload.payment_channel( + channel_id, + "1000", + payer, + SESSION_TEST_RECIPIENT, + "USDC", + 1, + 900, + signer.address(), + _confirmed_signature(0x32), + ) + ), + ) + state = await _get_channel(session, channel_id) + assert state is not None + assert state.operator == payer + + async def test_session_open_pull_without_strategy_rejected_at_method_layer() -> None: """Pull-mode open requires a pull voucher strategy on the server config. From 91927ac7be6a61649d72b279939822091d6204c7 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Mon, 22 Jun 2026 22:49:48 +0300 Subject: [PATCH 44/52] ci: disable surfpool reports without datasource --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e80dd208..50f00dd3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,7 @@ jobs: - name: Run tests with coverage run: pnpm vitest run --coverage --config vitest.config.ci.ts env: + SURFPOOL_REPORT: ${{ secrets.SURFPOOL_DATASOURCE_RPC_URL != '' && '1' || '' }} # Reliable RPC datasource for the embedded surfnet (see the Rust job). SURFPOOL_DATASOURCE_RPC_URL: ${{ secrets.SURFPOOL_DATASOURCE_RPC_URL }} @@ -175,6 +176,7 @@ jobs: - name: Run tests with coverage working-directory: rust env: + SURFPOOL_REPORT: ${{ secrets.SURFPOOL_DATASOURCE_RPC_URL != '' && '1' || '' }} # Reliable RPC datasource for the embedded surfnet — without it the # surfpool integration tests clone from the public mainnet-beta RPC, # which rate-limits and crashes surfnet mid-test in CI. From 57ab5a8e650b8a8207066c0c27c0f4b2ea6ab9fd Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Mon, 22 Jun 2026 23:27:50 +0300 Subject: [PATCH 45/52] ci: disable surfpool reports on fork PRs --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50f00dd3e..7e80dd208 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,7 +97,6 @@ jobs: - name: Run tests with coverage run: pnpm vitest run --coverage --config vitest.config.ci.ts env: - SURFPOOL_REPORT: ${{ secrets.SURFPOOL_DATASOURCE_RPC_URL != '' && '1' || '' }} # Reliable RPC datasource for the embedded surfnet (see the Rust job). SURFPOOL_DATASOURCE_RPC_URL: ${{ secrets.SURFPOOL_DATASOURCE_RPC_URL }} @@ -176,7 +175,6 @@ jobs: - name: Run tests with coverage working-directory: rust env: - SURFPOOL_REPORT: ${{ secrets.SURFPOOL_DATASOURCE_RPC_URL != '' && '1' || '' }} # Reliable RPC datasource for the embedded surfnet — without it the # surfpool integration tests clone from the public mainnet-beta RPC, # which rate-limits and crashes surfnet mid-test in CI. From 2b3e201864b3c1aa1aff2fa999626be9a5799b10 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Tue, 23 Jun 2026 00:24:18 +0300 Subject: [PATCH 46/52] fix(python/mpp): persist verified open tx facts --- .../pay_kit/protocols/mpp/server/session_method.py | 13 +++++++++++++ python/tests/test_session_settlement.py | 5 ++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/python/src/pay_kit/protocols/mpp/server/session_method.py b/python/src/pay_kit/protocols/mpp/server/session_method.py index 0f9e98d2c..456f9b032 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_method.py +++ b/python/src/pay_kit/protocols/mpp/server/session_method.py @@ -488,7 +488,19 @@ async def _handle_open(self, payload: OpenPayload) -> str: "openTxSubmitter=server requires a signer and an RPC client", code="invalid-config", ) + expected = VerifyOpenTxExpected( + authorized_signer=payload.authorized_signer, + currency=self._currency, + recipient=self._recipient, + network=self._network, + max_cap=self._core.config.max_cap, + program_id=(Pubkey.from_string(self._core.config.program_id) if self._core.config.program_id else None), + ) try: + verified = await verify_open_tx(expected, payload, None) + if not payload.payer: + payload.payer = verified.payer + payload.deposit = str(verified.deposit) payload.signature = await cosign_and_broadcast_open( payload, fee_payer=self._signer.keypair, rpc=self._rpc ) @@ -528,6 +540,7 @@ async def _handle_open(self, payload: OpenPayload) -> str: # recipient's ATA instead of the channel opener's. if not payload.payer: payload.payer = verified.payer + payload.deposit = str(verified.deposit) elif mode == "push" and not has_channel_id: raise PaymentError("open payload missing transaction or channelId", code="invalid-payload") elif mode == "push" and self._signer is not None and self._rpc is not None and not payload.payer: diff --git a/python/tests/test_session_settlement.py b/python/tests/test_session_settlement.py index c0926edb1..442aaebc1 100644 --- a/python/tests/test_session_settlement.py +++ b/python/tests/test_session_settlement.py @@ -236,7 +236,7 @@ async def test_server_broadcast_open_builds_signs_and_persists() -> None: SessionOptions( operator=str(operator.pubkey()), recipient=str(operator.pubkey()), - cap=1_000_000, + cap=2_000_000, currency="USDC", decimals=6, network="localnet", @@ -249,6 +249,7 @@ async def test_server_broadcast_open_builds_signs_and_persists() -> None: ) ) open_, payload = _server_open_payload(operator) + payload.deposit = "1500000" signature = await session._handle_open(payload) @@ -259,6 +260,8 @@ async def test_server_broadcast_open_builds_signs_and_persists() -> None: # The channel is persisted under its derived id. persisted = await session._core.store().get_channel(str(open_.channel_id)) assert persisted is not None + assert persisted.deposit == open_.deposit + assert persisted.operator == str(open_.payer) @pytest.mark.asyncio From a0733d9662971db3b962af33cd831ec6c89811a0 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Tue, 23 Jun 2026 03:10:41 +0300 Subject: [PATCH 47/52] fix(python/mpp/server): gate server-broadcast open on an attached transaction 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 ...). --- .../protocols/mpp/server/session_method.py | 97 ++++++++++--------- python/tests/test_session_settlement.py | 56 +++++++++++ 2 files changed, 108 insertions(+), 45 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/server/session_method.py b/python/src/pay_kit/protocols/mpp/server/session_method.py index 456f9b032..ef2c93f6e 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_method.py +++ b/python/src/pay_kit/protocols/mpp/server/session_method.py @@ -21,9 +21,13 @@ On-chain settlement at close (settle_and_finalize + distribute, populating ``settledSignature``) runs when both a signer and an RPC client are configured; without them, close is a pure state-flip. The idle-close watchdog settles the -same way. The server-broadcast open path (``openTxSubmitter=server``) also runs -when a signer and RPC are configured: the server builds, funds, signs, and -broadcasts the open from the payload facts. +same way. The server-broadcast open path (``openTxSubmitter=server``) runs only +when an open carries an attached transaction (push opens and clientVoucher pull +opens whose deposit lives in an on-chain payment channel): the server completes +the fee-payer signature and broadcasts the open. A pull open with no +transaction skips the server-broadcast path and trusts the channel id / deposit +even when ``openTxSubmitter=server`` is configured, mirroring the TS open +dispatch. """ from __future__ import annotations @@ -458,17 +462,21 @@ async def _handle_open(self, payload: OpenPayload) -> str: """Process an open action: resolve the channel facts, enforce the deposit invariants, and insert the channel state atomically and idempotently. - Three submitter paths: - - - ``openTxSubmitter=server``: the server builds, funds, signs, and - broadcasts the open from the payload facts (requires a signer + RPC), - then persists. - - client-broadcast carrying a ``transaction``: validate it against the - challenge (structural always; on-chain liveness when an RPC client is - configured) before persisting. - - client-broadcast without a transaction: the client asserts a - previously broadcast open; the open signature is confirmed on-chain - when an RPC is present, otherwise the channel facts are trusted. + Three submitter paths, selected by transaction presence then submitter: + + - a ``transaction`` is attached with ``openTxSubmitter=server``: the + server completes the fee-payer signature and broadcasts the open + (requires a signer + RPC), then persists. + - a ``transaction`` is attached with ``openTxSubmitter=client``: it is + validated against the challenge (structural always; on-chain liveness + when an RPC client is configured) before persisting. + - no ``transaction`` is attached: the client asserts a previously + broadcast open (push) or a server-opened pull channel; the open + signature is confirmed on-chain when an RPC is present (push only), + otherwise the channel id / token account and deposit are trusted as + provided. The server-broadcast path is skipped even when + ``openTxSubmitter=server`` is configured, so a pull open with no + transaction is never hard-rejected for a missing transaction. The receipt reference is the open signature when one exists, else the channel id. @@ -482,20 +490,37 @@ async def _handle_open(self, payload: OpenPayload) -> str: code="invalid-config", ) - if self._open_tx_submitter == OPEN_TX_SUBMITTER_SERVER: + # Empty strings count as missing. + has_transaction = payload.transaction is not None and payload.transaction != "" + has_channel_id = payload.channel_id is not None and payload.channel_id != "" + + # A push open must carry a transaction or a channel id. A pull open may + # carry a transaction (a clientVoucher payment-channel open, server- or + # client-broadcast) or carry only the channel id / token account (a + # server-opened pull). This mirrors the TS open dispatch + # ``if (payload.transaction) { ... } else if (mode === 'push') { ... } else + # { ... }``: the server-broadcast path is entered only when a transaction + # is attached, so 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 (which would make every + # pull-mode open against an ``openTxSubmitter=server`` server fail). + if mode == "push" and not has_transaction and not has_channel_id: + raise PaymentError("open payload missing transaction or channelId", code="invalid-payload") + + expected = VerifyOpenTxExpected( + authorized_signer=payload.authorized_signer, + currency=self._currency, + recipient=self._recipient, + network=self._network, + max_cap=self._core.config.max_cap, + program_id=(Pubkey.from_string(self._core.config.program_id) if self._core.config.program_id else None), + ) + if has_transaction and self._open_tx_submitter == OPEN_TX_SUBMITTER_SERVER: if self._signer is None or self._rpc is None: raise PaymentError( "openTxSubmitter=server requires a signer and an RPC client", code="invalid-config", ) - expected = VerifyOpenTxExpected( - authorized_signer=payload.authorized_signer, - currency=self._currency, - recipient=self._recipient, - network=self._network, - max_cap=self._core.config.max_cap, - program_id=(Pubkey.from_string(self._core.config.program_id) if self._core.config.program_id else None), - ) try: verified = await verify_open_tx(expected, payload, None) if not payload.payer: @@ -508,26 +533,7 @@ async def _handle_open(self, payload: OpenPayload) -> str: raise except Exception as exc: raise PaymentError(f"server-broadcast open failed: {exc}", code="invalid-payload") from exc - try: - state = await self._core.process_open(payload) - except ValueError as exc: - raise PaymentError(str(exc), code="invalid-payload") from exc - self._touch(state.channel_id) - return payload.signature - - # Empty strings count as missing. - has_transaction = payload.transaction is not None and payload.transaction != "" - has_channel_id = payload.channel_id is not None and payload.channel_id != "" - - if has_transaction: - expected = VerifyOpenTxExpected( - authorized_signer=payload.authorized_signer, - currency=self._currency, - recipient=self._recipient, - network=self._network, - max_cap=self._core.config.max_cap, - program_id=(Pubkey.from_string(self._core.config.program_id) if self._core.config.program_id else None), - ) + elif has_transaction: try: verified = await verify_open_tx(expected, payload, self._rpc) except PaymentError: @@ -541,8 +547,6 @@ async def _handle_open(self, payload: OpenPayload) -> str: if not payload.payer: payload.payer = verified.payer payload.deposit = str(verified.deposit) - elif mode == "push" and not has_channel_id: - raise PaymentError("open payload missing transaction or channelId", code="invalid-payload") elif mode == "push" and self._signer is not None and self._rpc is not None and not payload.payer: raise PaymentError( "push open requires payer or transaction when settle-at-close is configured", @@ -550,6 +554,9 @@ async def _handle_open(self, payload: OpenPayload) -> str: ) elif mode == "push" and self._rpc is not None: await confirm_transaction_signature(self._rpc, payload.signature, "open") + # else: pull mode without a transaction. The channel id / token account + # and deposit are trusted as provided (mirrors the TS `else` branch); the + # server-broadcast path is skipped even when openTxSubmitter=server. try: state = await self._core.process_open(payload) diff --git a/python/tests/test_session_settlement.py b/python/tests/test_session_settlement.py index 442aaebc1..0d4e01a39 100644 --- a/python/tests/test_session_settlement.py +++ b/python/tests/test_session_settlement.py @@ -284,3 +284,59 @@ async def test_server_open_requires_signer_and_rpc() -> None: _open, payload = _server_open_payload(operator) with pytest.raises(Exception, match="requires a signer"): await session._handle_open(payload) + + +@pytest.mark.asyncio +async def test_server_broadcast_open_skipped_for_pull_without_transaction() -> None: + """A pull open with no transaction must not enter the server-broadcast + block even when ``openTxSubmitter=server`` is configured. + + The server-broadcast path is gated on a transaction being attached (it + requires one to co-sign and broadcast). A pull open carrying only the + channel id / token account and approved amount falls through to the + trust-the-channel-id path. Without this gate every pull open against a + server-broadcast server was hard-rejected by verify_open_tx's transaction + requirement, so the playground's ``modes=["pull"]`` + + ``openTxSubmitter="server"`` config could never establish a session for a + no-transaction client. Mirrors the TS ``else`` open branch. + """ + from pay_kit.protocols.mpp.intents.session import OpenPayload + + operator = Keypair.from_seed(bytes([12] * 32)) + rpc = _SettleRpc() + session = new_session( + SessionOptions( + operator=str(operator.pubkey()), + recipient=str(operator.pubkey()), + cap=2_000_000, + currency="USDC", + decimals=6, + network="localnet", + secret_key="a" * 64, + modes=["pull"], + pull_voucher_strategy="clientVoucher", + open_tx_submitter="server", + signer=LocalSigner.from_keypair(operator), + rpc=rpc, + ) + ) + token_account = str(Keypair.from_seed(bytes([13] * 32)).pubkey()) + payload = OpenPayload.pull( + token_account=token_account, + approved_amount="1000000", + owner=str(operator.pubkey()), + authorized_signer=str(operator.pubkey()), + signature="", + ) + assert payload.transaction is None + + reference = await session._handle_open(payload) + + # No on-chain open is broadcast for a no-transaction pull open. + assert rpc.sent == [] + # The channel is persisted under its session id (the token account). + persisted = await session._core.store().get_channel(token_account) + assert persisted is not None + assert persisted.deposit == 1_000_000 + # The receipt reference is the channel id when no signature is recorded. + assert reference == token_account From e9d3df89439891dfa817b33084f0ea42a776a0c9 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Tue, 23 Jun 2026 03:20:17 +0300 Subject: [PATCH 48/52] fix(python/mpp/server): build open-tx expected lazily, broaden tests 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. --- .../protocols/mpp/server/session_method.py | 36 +++++--- python/tests/test_session_settlement.py | 82 ++++++++++++++++++- 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/server/session_method.py b/python/src/pay_kit/protocols/mpp/server/session_method.py index ef2c93f6e..a52830662 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_method.py +++ b/python/src/pay_kit/protocols/mpp/server/session_method.py @@ -458,6 +458,21 @@ def _verify_pinned_session_fields(self, credential: PaymentCredential, request: code="recipient-mismatch", ) + def _open_tx_expected(self, payload: OpenPayload) -> VerifyOpenTxExpected: + """Build the on-chain open verification facts for a transaction-carrying + open. Only the paths that attach a transaction call this, keeping the + ``program_id`` pubkey parse (and its failure surface) off the + trust-the-channel-id paths. + """ + return VerifyOpenTxExpected( + authorized_signer=payload.authorized_signer, + currency=self._currency, + recipient=self._recipient, + network=self._network, + max_cap=self._core.config.max_cap, + program_id=(Pubkey.from_string(self._core.config.program_id) if self._core.config.program_id else None), + ) + async def _handle_open(self, payload: OpenPayload) -> str: """Process an open action: resolve the channel facts, enforce the deposit invariants, and insert the channel state atomically and idempotently. @@ -507,20 +522,16 @@ async def _handle_open(self, payload: OpenPayload) -> str: if mode == "push" and not has_transaction and not has_channel_id: raise PaymentError("open payload missing transaction or channelId", code="invalid-payload") - expected = VerifyOpenTxExpected( - authorized_signer=payload.authorized_signer, - currency=self._currency, - recipient=self._recipient, - network=self._network, - max_cap=self._core.config.max_cap, - program_id=(Pubkey.from_string(self._core.config.program_id) if self._core.config.program_id else None), - ) if has_transaction and self._open_tx_submitter == OPEN_TX_SUBMITTER_SERVER: if self._signer is None or self._rpc is None: raise PaymentError( "openTxSubmitter=server requires a signer and an RPC client", code="invalid-config", ) + # Built lazily: only the transaction-carrying paths verify the open + # on-chain, so the on-chain expected facts (and the program_id pubkey + # parse) stay off the trust-the-channel-id paths. + expected = self._open_tx_expected(payload) try: verified = await verify_open_tx(expected, payload, None) if not payload.payer: @@ -534,6 +545,7 @@ async def _handle_open(self, payload: OpenPayload) -> str: except Exception as exc: raise PaymentError(f"server-broadcast open failed: {exc}", code="invalid-payload") from exc elif has_transaction: + expected = self._open_tx_expected(payload) try: verified = await verify_open_tx(expected, payload, self._rpc) except PaymentError: @@ -554,9 +566,11 @@ async def _handle_open(self, payload: OpenPayload) -> str: ) elif mode == "push" and self._rpc is not None: await confirm_transaction_signature(self._rpc, payload.signature, "open") - # else: pull mode without a transaction. The channel id / token account - # and deposit are trusted as provided (mirrors the TS `else` branch); the - # server-broadcast path is skipped even when openTxSubmitter=server. + # else: no transaction is attached. Reachable by a pull open (the channel + # id / token account and deposit are trusted as provided, mirroring the TS + # `else` branch) or by a push open with a channel id and no RPC (trusted + # as previously broadcast). The server-broadcast path is skipped even when + # openTxSubmitter=server is configured. try: state = await self._core.process_open(payload) diff --git a/python/tests/test_session_settlement.py b/python/tests/test_session_settlement.py index 0d4e01a39..8378f51c0 100644 --- a/python/tests/test_session_settlement.py +++ b/python/tests/test_session_settlement.py @@ -33,12 +33,18 @@ def __init__(self, blockhash: str) -> None: class _SettleRpc: - """Captures the broadcast settle transaction and returns a fixed signature.""" + """Captures the broadcast settle transaction and returns a fixed signature. + + ``status_queries`` records every call to ``get_signature_statuses`` so a test + can assert no on-chain confirmation was attempted on a trust path. + """ def __init__(self) -> None: self.sent: list[bytes] = [] + self.status_queries: list[list[str]] = [] async def get_signature_statuses(self, signatures: list[str]) -> list[dict | None]: + self.status_queries.append(list(signatures)) return [{"err": None, "confirmationStatus": "confirmed"} for _ in signatures] async def get_latest_blockhash(self, commitment: str = "confirmed") -> _Resp: @@ -334,9 +340,83 @@ async def test_server_broadcast_open_skipped_for_pull_without_transaction() -> N # No on-chain open is broadcast for a no-transaction pull open. assert rpc.sent == [] + # No on-chain confirmation is attempted on the trust path either. + assert rpc.status_queries == [] # The channel is persisted under its session id (the token account). persisted = await session._core.store().get_channel(token_account) assert persisted is not None assert persisted.deposit == 1_000_000 # The receipt reference is the channel id when no signature is recorded. assert reference == token_account + + +@pytest.mark.asyncio +async def test_open_tx_submitter_client_verifies_pull_transaction() -> None: + """A pull open with a transaction and ``openTxSubmitter=client`` takes the + client-broadcast verify branch (not the server-broadcast branch), recording + the verified payer/deposit from the attached transaction. Guards against a + regression that would misroute a transaction-carrying pull open.""" + operator = Keypair.from_seed(bytes([14] * 32)) + open_, payload = _server_open_payload(operator) + rpc = _SettleRpc() + session = new_session( + SessionOptions( + operator=str(operator.pubkey()), + recipient=str(operator.pubkey()), + cap=2_000_000, + currency="USDC", + decimals=6, + network="localnet", + secret_key="a" * 64, + modes=["pull"], + pull_voucher_strategy="clientVoucher", + open_tx_submitter="client", + signer=LocalSigner.from_keypair(operator), + rpc=rpc, + ) + ) + payload.deposit = "1500000" + + reference = await session._handle_open(payload) + + # Client-broadcast verify: no server broadcast of the open. + assert rpc.sent == [] + # The verified payer from the attached open transaction is propagated. + persisted = await session._core.store().get_channel(str(open_.channel_id)) + assert persisted is not None + assert persisted.operator == str(open_.payer) + assert persisted.deposit == open_.deposit + # The receipt reference is the (pending) open signature the client supplied. + assert reference == payload.signature + + +@pytest.mark.asyncio +async def test_push_open_without_transaction_trusts_channel_id_without_rpc() -> None: + """A push open with a channel id and no RPC (and no signer) falls through to + the trust path: no on-chain confirmation is attempted and the channel is + persisted under the channel id. Mirrors the TS push-else branch when no RPC + is configured (the open signature is trusted as previously broadcast).""" + from pay_kit.protocols.mpp.intents.session import OpenPayload + + recipient = str(Keypair.from_seed(bytes([15] * 32)).pubkey()) + session = new_session( + SessionOptions( + operator=recipient, + recipient=recipient, + cap=1_000_000, + currency="USDC", + decimals=6, + network="localnet", + secret_key="a" * 64, + modes=["push"], + ) + ) + channel_id = str(Keypair.from_seed(bytes([16] * 32)).pubkey()) + payload = OpenPayload.push(channel_id, "500000", recipient, "open-sig") + + reference = await session._handle_open(payload) + + persisted = await session._core.store().get_channel(channel_id) + assert persisted is not None + assert persisted.deposit == 500_000 + assert reference == "open-sig" From ece4ed298268ccb5e03134af9643254618969525 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz Date: Tue, 23 Jun 2026 04:09:58 +0300 Subject: [PATCH 49/52] fix(python/mpp/server): use get_running_loop, fix CloseOnIdle type --- .../pay_kit/protocols/mpp/server/session_lifecycle.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/server/session_lifecycle.py b/python/src/pay_kit/protocols/mpp/server/session_lifecycle.py index d441a16df..3806b9364 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_lifecycle.py +++ b/python/src/pay_kit/protocols/mpp/server/session_lifecycle.py @@ -18,9 +18,9 @@ import asyncio import threading -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine -CloseOnIdle = Callable[[str], Awaitable[None]] +CloseOnIdle = Callable[[str], Coroutine[None, None, None]] class SessionLifecycle: @@ -61,7 +61,7 @@ def touch(self, channel_id: str) -> None: """ if self._close_delay <= 0: return - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() with self._lock: if self._shutdown: return @@ -96,7 +96,8 @@ def _fire(self, channel_id: str) -> None: stopped = self._shutdown if stopped: return - asyncio.ensure_future(self._close_on_idle(channel_id)) + loop = asyncio.get_running_loop() + loop.create_task(self._close_on_idle(channel_id)) def _cancel_locked(self, channel_id: str) -> None: """Stop and forget the timer for ``channel_id``. Callers hold ``_lock``.""" From d682dadb864e5063b27b4e43361b54c488a949b1 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:09:48 +0300 Subject: [PATCH 50/52] fix(python/mpp/server): polling confirm, settle race guard, replay-safe open broadcast --- .../protocols/mpp/server/session_method.py | 129 +++++++-- .../protocols/mpp/server/session_onchain.py | 77 ++++-- .../protocols/mpp/server/session_store.py | 8 + python/tests/test_session_settlement.py | 244 ++++++++++++++++++ 4 files changed, 417 insertions(+), 41 deletions(-) diff --git a/python/src/pay_kit/protocols/mpp/server/session_method.py b/python/src/pay_kit/protocols/mpp/server/session_method.py index a52830662..65b44a0b7 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_method.py +++ b/python/src/pay_kit/protocols/mpp/server/session_method.py @@ -82,6 +82,20 @@ _SECRET_KEY_ENV_VAR = "MPP_SECRET_KEY" _U64_MAX = (1 << 64) - 1 + +class _AlreadyFinalized(Exception): + """Raised by the settle-claim mutator when the channel is already finalized.""" + + __slots__ = ("signature",) + + def __init__(self, signature: str | None) -> None: + super().__init__("already finalized") + self.signature = signature + + +class _AlreadySettling(Exception): + """Raised by the settle-claim mutator when another caller is mid-settle.""" + __all__ = [ "OpenTxSubmitter", "SessionOptions", @@ -528,22 +542,42 @@ async def _handle_open(self, payload: OpenPayload) -> str: "openTxSubmitter=server requires a signer and an RPC client", code="invalid-config", ) - # Built lazily: only the transaction-carrying paths verify the open - # on-chain, so the on-chain expected facts (and the program_id pubkey - # parse) stay off the trust-the-channel-id paths. - expected = self._open_tx_expected(payload) - try: - verified = await verify_open_tx(expected, payload, None) - if not payload.payer: - payload.payer = verified.payer - payload.deposit = str(verified.deposit) - payload.signature = await cosign_and_broadcast_open( - payload, fee_payer=self._signer.keypair, rpc=self._rpc - ) - except PaymentError: - raise - except Exception as exc: - raise PaymentError(f"server-broadcast open failed: {exc}", code="invalid-payload") from exc + # Idempotent replay guard: the store check-and-insert is the final + # source of truth (process_open below), but a replayed open must + # NOT re-broadcast the (already-landed) open transaction. TS + # checks the store before submitOpenTx; we mirror that here so a + # replay short-circuits the broadcast instead of sending it + # again and then no-opping in process_open. + session_id = payload.session_id() + existing = await self._core.store().get_channel(session_id) + if existing is not None: + if existing.finalized: + raise PaymentError( + f"channel {session_id} is already finalized", code="invalid-payload" + ) + if existing.authorized_signer != payload.authorized_signer: + raise PaymentError( + f"channel {session_id} already exists with a different authorized signer", + code="invalid-payload", + ) + else: + # Built lazily: only the transaction-carrying paths verify + # the open on-chain, so the on-chain expected facts (and the + # program_id pubkey parse) stay off the trust-the-channel-id + # paths. + expected = self._open_tx_expected(payload) + try: + verified = await verify_open_tx(expected, payload, None) + if not payload.payer: + payload.payer = verified.payer + payload.deposit = str(verified.deposit) + payload.signature = await cosign_and_broadcast_open( + payload, fee_payer=self._signer.keypair, rpc=self._rpc + ) + except PaymentError: + raise + except Exception as exc: + raise PaymentError(f"server-broadcast open failed: {exc}", code="invalid-payload") from exc elif has_transaction: expected = self._open_tx_expected(payload) try: @@ -721,17 +755,62 @@ async def _settle_channel(self, channel_id: str) -> str | None: """ if self._signer is None or self._rpc is None: return None + + from pay_kit.protocols.mpp.server.session_store import ChannelState + + # Atomic settle-in-progress guard: claim the channel under the + # per-channel store lock so a concurrent close retry or idle-watchdog + # fire cannot both pass the finalize check and broadcast duplicate + # settle transactions. The winning caller flips ``settling`` to True + # and continues to the broadcast; losing callers see ``settling`` is + # already True and bail out (the winner will finalize, a loser may + # retry if the winner's broadcast fails). + claimed = False + + def claim(current: ChannelState | None) -> ChannelState: + nonlocal claimed + if current is None: + raise ValueError(f"channel {channel_id} disappeared during settle-claim") + if current.finalized: + raise _AlreadyFinalized(current.settled_signature) + if current.settling: + raise _AlreadySettling() + nxt = current.clone() + nxt.settling = True + claimed = True + return nxt + + settled_signature: str | None = None + try: + await self._core.store().update_channel(channel_id, claim) + except _AlreadyFinalized as exc: + return exc.signature + except _AlreadySettling: + return None + + if not claimed: + return None + state = await self._core.store().get_channel(channel_id) if state is None: return None - if state.finalized: - return state.settled_signature - - signature = await settle_and_finalize_channel( - state, merchant=self._signer.keypair, rpc=self._rpc, config=self._core.config - ) - from pay_kit.protocols.mpp.server.session_store import ChannelState + try: + signature = await settle_and_finalize_channel( + state, merchant=self._signer.keypair, rpc=self._rpc, config=self._core.config + ) + except Exception: + # Broadcast/confirm failed: release the settle-in-progress guard + # so a retry can claim again, re-raise for the caller. + def release(current: ChannelState | None) -> ChannelState: + if current is not None and current.settling and not current.finalized: + nxt = current.clone() + nxt.settling = False + return nxt + return current # type: ignore[return-value] + + await self._core.store().update_channel(channel_id, release) + raise def finalize(current: ChannelState | None) -> ChannelState: if current is None: @@ -746,10 +825,12 @@ def finalize(current: ChannelState | None) -> ChannelState: nxt = current.clone() nxt.finalized = True nxt.settled_signature = signature + nxt.settling = False return nxt await self._core.store().update_channel(channel_id, finalize) - return signature + settled_signature = signature + return settled_signature async def _close_on_idle(self, channel_id: str) -> None: """Idle-close watchdog handler: close the channel and settle on-chain. diff --git a/python/src/pay_kit/protocols/mpp/server/session_onchain.py b/python/src/pay_kit/protocols/mpp/server/session_onchain.py index fecee866b..b1d6346c4 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_onchain.py +++ b/python/src/pay_kit/protocols/mpp/server/session_onchain.py @@ -17,8 +17,10 @@ from __future__ import annotations +import asyncio import base64 import struct +import time from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Protocol @@ -349,29 +351,70 @@ async def verifier(payload: TopUpPayload) -> None: return verifier -async def confirm_transaction_signature(rpc_client: RpcClient, signature: str, label: str) -> None: - """Check once via ``getSignatureStatuses`` that ``signature`` names a known, - successful transaction. ``label`` names the transaction in error messages - ("open", "top-up"). +async def confirm_transaction_signature( + rpc_client: RpcClient, + signature: str, + label: str, + *, + timeout_seconds: float = 30.0, + poll_interval_seconds: float = 1.0, +) -> None: + """Poll ``getSignatureStatuses`` until ``signature`` reaches at least + ``confirmed`` commitment, or raise. + + ``label`` names the transaction in error messages ("open", "top-up", + "settle"). A freshly broadcast signature commonly returns ``None`` from + ``getSignatureStatuses`` for hundreds of milliseconds to seconds, so the + first poll returns ``None`` and this helper retries until the transaction + lands, fails, or ``timeout_seconds`` elapses. This mirrors the TS + ``waitForSignatureConfirmation`` helper used by ``submitOpenTx`` and the + settle broadcast path; the single-shot predecessor raised spuriously on + just-broadcast transactions. + + Raises ``transaction-failed`` when the signature lands with ``err`` set, + ``transaction-not-found`` when the poll times out before any status is + seen, and ``transaction-not-confirmed`` when a status is seen but the + poll times out before ``confirmed``/``finalized`` is reported. """ try: Signature.from_string(signature) except Exception as exc: raise PaymentError(f"invalid {label} tx signature {signature!r}: {exc}", code="invalid-payload") from exc - try: - statuses = await rpc_client.get_signature_statuses([signature]) - except Exception as exc: - raise PaymentError(f"RPC error verifying {label} tx: {exc}", code="transaction-not-found") from exc - - status = statuses[0] if statuses else None - if status is None: - raise PaymentError( - f"{label} tx {signature!r} not found; not yet confirmed or does not exist", - code="transaction-not-found", - ) - if status.get("err") is not None: - raise PaymentError(f"{label} tx {signature!r} failed on-chain: {status['err']}", code="transaction-failed") + deadline = time.monotonic() + timeout_seconds + saw_status = False + while True: + try: + statuses = await rpc_client.get_signature_statuses([signature]) + except Exception as exc: + raise PaymentError(f"RPC error verifying {label} tx: {exc}", code="transaction-not-found") from exc + + status = statuses[0] if statuses else None + if status is not None: + saw_status = True + if status.get("err") is not None: + raise PaymentError( + f"{label} tx {signature!r} failed on-chain: {status['err']}", code="transaction-failed" + ) + level = status.get("confirmationStatus") + # RPC endpoints that omit ``confirmationStatus`` only report a + # status once the transaction has landed; treat that as + # confirmed, mirroring the TS helper. + if level is None or level in ("confirmed", "finalized"): + return + + now = time.monotonic() + if now >= deadline: + if saw_status: + raise PaymentError( + f"{label} tx {signature!r} not confirmed within {timeout_seconds}s", + code="transaction-not-confirmed", + ) + raise PaymentError( + f"{label} tx {signature!r} not found; not yet confirmed or does not exist", + code="transaction-not-found", + ) + await asyncio.sleep(poll_interval_seconds) async def settle_and_finalize_channel( diff --git a/python/src/pay_kit/protocols/mpp/server/session_store.py b/python/src/pay_kit/protocols/mpp/server/session_store.py index 773f2dd12..73184c373 100644 --- a/python/src/pay_kit/protocols/mpp/server/session_store.py +++ b/python/src/pay_kit/protocols/mpp/server/session_store.py @@ -155,6 +155,14 @@ class ChannelState: # state without a settlement round-trips cleanly. settled_signature: str | None = None + # Settling is an in-flight guard set atomically (under the per-channel + # store lock) before the settle broadcast starts, so a concurrent close + # retry or idle-watchdog fire cannot both pass the finalize check and + # broadcast duplicate settle transactions. Cleared by the finalize mutator + # (which sets ``finalized``), or by a failed settle path on its next retry. + # Not serialized: it is transient server state and round-trips as absent. + settling: bool = False + # Operator is the client wallet pubkey (base58) for pull-mode sessions; # None for push sessions. operator: str | None = None diff --git a/python/tests/test_session_settlement.py b/python/tests/test_session_settlement.py index 8378f51c0..ba53f6663 100644 --- a/python/tests/test_session_settlement.py +++ b/python/tests/test_session_settlement.py @@ -420,3 +420,247 @@ async def test_push_open_without_transaction_trusts_channel_id_without_rpc() -> assert persisted is not None assert persisted.deposit == 500_000 assert reference == "open-sig" + + +# --- B1: polling confirmation on just-broadcast settle ------------------------- + + +class _PollingSettleRpc(_SettleRpc): + """Returns ``None`` for the first ``n`` ``getSignatureStatuses`` calls on a + given signature, then returns confirmed. Mirrors the common Solana behaviour + where a freshly submitted signature is not yet visible to the RPC.""" + + def __init__(self, none_rounds: int = 2) -> None: + super().__init__() + self._none_left = none_rounds + self._bounced_sig: str | None = None + + async def get_signature_statuses(self, signatures: list[str]) -> list[dict | None]: + self.status_queries.append(list(signatures)) + if self._none_left > 0 and signatures: + self._bounced_sig = signatures[0] + self._none_left -= 1 + return [None for _ in signatures] + return [{"err": None, "confirmationStatus": "confirmed"} for _ in signatures] + + +@pytest.mark.asyncio +async def test_settle_polls_until_confirmed_when_status_initially_none() -> None: + """B1: a just-broadcast settle tx commonly returns ``None`` from + ``getSignatureStatuses`` for a few rounds before landing. The single-shot + predecessor raised spuriously; the polling confirm retries until + confirmed, finalizes, and records the signature. + """ + + operator = Keypair.from_seed(bytes([20] * 32)) + auth = Keypair.from_seed(bytes([21] * 32)) + channel = str(Keypair.from_seed(bytes([22] * 32)).pubkey()) + + rpc = _PollingSettleRpc(none_rounds=2) + session = _session(rpc, operator) + await _seed( + session, + ChannelState( + channel_id=channel, + authorized_signer=str(auth.pubkey()), + deposit=1_000_000, + cumulative=500_000, + highest_voucher_signature=str(auth.sign_message(b"voucher")), + highest_voucher_expires_at=4_102_444_800, + operator=str(operator.pubkey()), + ), + ) + + settled = await session._settle_channel(channel) + + assert settled == _SENT_SIGNATURE + # The poll loop was entered: more than one status query for the same sig. + assert len(rpc.status_queries) >= 3 + final = await session._core.store().get_channel(channel) + assert final is not None + assert final.finalized is True + assert final.settled_signature == _SENT_SIGNATURE + assert final.settling is False + + +@pytest.mark.asyncio +async def test_settle_not_confirmed_within_timeout_raises_and_releases_settling() -> None: + """B1: when the poll times out before any status is seen the channel is NOT + finalized and the settle-in-progress guard is released so a retry can claim + again (S2).""" + + class _StuckNoneRpc(_SettleRpc): + async def get_signature_statuses(self, signatures: list[str]) -> list[dict | None]: + self.status_queries.append(list(signatures)) + return [None for _ in signatures] + + operator = Keypair.from_seed(bytes([23] * 32)) + channel = str(Keypair.from_seed(bytes([24] * 32)).pubkey()) + + rpc = _StuckNoneRpc() + session = _session(rpc, operator) + await _seed( + session, + ChannelState( + channel_id=channel, + authorized_signer=str(operator.pubkey()), + deposit=1_000_000, + cumulative=0, + operator=str(operator.pubkey()), + ), + ) + + import inspect + + import pay_kit.protocols.mpp.server.session_onchain as onchain + + confirm = onchain.confirm_transaction_signature + timeout_kw = "timeout_seconds" + params = inspect.signature(confirm).parameters + # The helper may accept timeout_seconds as a keyword-only arg; pass the + # smallest nonzero timeout plus a tiny poll interval so the test is fast. + kwargs: dict[str, float] = {} + if timeout_kw in params: + kwargs[timeout_kw] = 0.1 + if "poll_interval_seconds" in params: + kwargs["poll_interval_seconds"] = 0.05 + # Monkey-patch the module-level helper reference used by settle_and_finalize + # via the session_onchain module so the timeout applies. + original = onchain.confirm_transaction_signature + + async def fast_confirm(rpc_client, signature, label, **kw): # noqa: ANN001, ANN002, ANN003 + kw.update(kwargs) + return await original(rpc_client, signature, label, **kw) + + onchain.confirm_transaction_signature = fast_confirm # type: ignore[assignment] + try: + with pytest.raises(PaymentError, match="not found"): + await session._settle_channel(channel) + finally: + onchain.confirm_transaction_signature = original # type: ignore[assignment] + + final = await session._core.store().get_channel(channel) + assert final is not None + assert final.finalized is False + assert final.settled_signature is None + assert final.settling is False + + +# --- S2: double-settle concurrency guard --------------------------------------- + + +@pytest.mark.asyncio +async def test_concurrent_settle_claimed_once_does_not_double_broadcast() -> None: + """S2: a second ``_settle_channel`` call while the first is mid-flight (or + after the first finalized) must not broadcast a second settle tx. The + atomic ``settling`` claim serializes concurrent callers; a second caller + after finalize is short-circuited by the already-finalized check. + """ + + operator = Keypair.from_seed(bytes([25] * 32)) + channel = str(Keypair.from_seed(bytes([26] * 32)).pubkey()) + + rpc = _SettleRpc() + session = _session(rpc, operator) + await _seed( + session, + ChannelState( + channel_id=channel, + authorized_signer=str(operator.pubkey()), + deposit=1_000_000, + cumulative=100_000, + highest_voucher_signature=str(operator.sign_message(b"v")), + highest_voucher_expires_at=4_102_444_800, + operator=str(operator.pubkey()), + ), + ) + + first = await session._settle_channel(channel) + second = await session._settle_channel(channel) + + assert first == _SENT_SIGNATURE + assert second == _SENT_SIGNATURE + assert len(rpc.sent) == 1 + final = await session._core.store().get_channel(channel) + assert final is not None + assert final.finalized is True + assert final.settling is False + + +@pytest.mark.asyncio +async def test_concurrent_settle_in_progress_guard_blocks_second_caller() -> None: + """S2: a channel in ``settling=True`` state (claim taken mid-broadcast) is + seen as busy by a second caller, which returns ``None`` instead of + re-broadcasting.""" + + operator = Keypair.from_seed(bytes([28] * 32)) + channel = str(Keypair.from_seed(bytes([29] * 32)).pubkey()) + + rpc = _SettleRpc() + session = _session(rpc, operator) + await _seed( + session, + ChannelState( + channel_id=channel, + authorized_signer=str(operator.pubkey()), + deposit=1_000_000, + cumulative=0, + operator=str(operator.pubkey()), + settling=True, + ), + ) + + result = await session._settle_channel(channel) + assert result is None + assert len(rpc.sent) == 0 + state = await session._core.store().get_channel(channel) + assert state is not None + assert state.finalized is False + assert state.settling is True + + +# --- S1: server-broadcast open replay does not re-broadcast ------------------- + + +@pytest.mark.asyncio +async def test_server_broadcast_open_replay_does_not_re_broadcast() -> None: + """S1: a replayed server-broadcast open (same payload, channel already + persisted) must NOT call ``send_raw_transaction`` again. The store + idempotency pre-check short-circuits the broadcast; ``process_open`` is + still the final source of truth (the existing state is returned + unchanged, the voucher watermark is preserved).""" + + operator = Keypair.from_seed(bytes([27] * 32)) + rpc = _SettleRpc() + session = new_session( + SessionOptions( + operator=str(operator.pubkey()), + recipient=str(operator.pubkey()), + cap=2_000_000, + currency="USDC", + decimals=6, + network="localnet", + secret_key="a" * 64, + modes=["pull"], + pull_voucher_strategy="clientVoucher", + open_tx_submitter="server", + signer=LocalSigner.from_keypair(operator), + rpc=rpc, + ) + ) + open_, payload = _server_open_payload(operator) + payload.deposit = "1500000" + + first = await session._handle_open(payload) + assert first == _SENT_SIGNATURE + assert len(rpc.sent) == 1 + + replay = await session._handle_open(payload) + + # No second broadcast on replay. + assert len(rpc.sent) == 1 + # The replay returns the original signature (already recorded). + assert replay == _SENT_SIGNATURE + persisted = await session._core.store().get_channel(str(open_.channel_id)) + assert persisted is not None + assert persisted.deposit == open_.deposit From f477ee6416a371200d1284e5107367fce3b6749c Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 <75971010+EfeDurmaz16@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:39:38 +0300 Subject: [PATCH 51/52] test(python/mpp): gate session voucher conformance + run harness via uv 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. --- .github/workflows/python.yml | 17 +++++++++++++++++ harness/runners/python.json | 3 ++- harness/src/implementations.ts | 2 +- python/conformance_runner.py | 18 ++++++++++++++++++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 49d09c581..4369704ab 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -61,6 +61,7 @@ jobs: with: python-version: "3.11" cache: pip + - uses: astral-sh/setup-uv@v5 - uses: dtolnay/rust-toolchain@stable - uses: actions/cache@v5 with: @@ -106,6 +107,22 @@ jobs: - name: Install harness working-directory: harness run: pnpm install --frozen-lockfile + # Sync the uv environment the conformance runner (runners/python.json: + # ``uv run python conformance_runner.py``) executes in, so the cross-SDK + # vectors — including the frozen 48-byte session voucher preimage — run + # without a per-vector cold install. + - name: Sync Python uv environment + working-directory: python + run: uv sync --extra dev + # Cross-SDK conformance vectors for Python: charge, x402-exact, and the + # session voucher-preimage byte-vector (runners/python.json declares the + # session intent). Pins the Python session wire bytes against the frozen + # cross-SDK vector rather than only the charge path. + - name: Run Python cross-SDK conformance vectors + working-directory: harness + env: + MPP_CONFORMANCE_LANGUAGES: python + run: pnpm exec vitest run test/conformance.test.ts - name: Focused TS-to-Python harness working-directory: harness env: diff --git a/harness/runners/python.json b/harness/runners/python.json index e6627356e..fe3f2522a 100644 --- a/harness/runners/python.json +++ b/harness/runners/python.json @@ -1,5 +1,6 @@ { "language": "python", "command": ["uv", "run", "python", "conformance_runner.py"], - "cwd": "python" + "cwd": "python", + "intents": ["charge", "x402-exact", "session"] } diff --git a/harness/src/implementations.ts b/harness/src/implementations.ts index 7e3d69c1d..0e4c538c0 100644 --- a/harness/src/implementations.ts +++ b/harness/src/implementations.ts @@ -302,7 +302,7 @@ export const serverImplementations: ImplementationDefinition[] = [ // ``MPP_HARNESS_SERVERS=python X402_HARNESS_CLIENTS=rust-x402`` with // ``MPP_HARNESS_INTENTS=x402-exact`` (x402-exact), or the dedicated // focused-matrix CI jobs in .github/workflows/python.yml. - command: ["python3", "python-server/server.py"], + command: ["uv", "run", "--project", "../python", "python", "python-server/server.py"], enabled: isEnabled("python", "MPP_HARNESS_SERVERS", false), intents: ["charge", "x402-exact"], }, diff --git a/python/conformance_runner.py b/python/conformance_runner.py index 8709f77bd..df7abd973 100644 --- a/python/conformance_runner.py +++ b/python/conformance_runner.py @@ -46,6 +46,7 @@ from pay_kit.protocols.mpp.core import json as wire_json from pay_kit.protocols.mpp.core.base64url import encode as base64url_encode from pay_kit.protocols.mpp.intents.charge import ChargeRequest +from pay_kit.protocols.mpp.intents.session import VoucherData from pay_kit.protocols.mpp.server._verify import _verify_local_transaction_intent from pay_kit.protocols.x402.client.exact.payment import ( _caip2_for_selection, @@ -370,6 +371,23 @@ def _run_canonical_bytes(vector: dict[str, Any]) -> dict[str, Any]: ).digest() exact["base64Url"] = base64url_encode(mac) + vp = inp.get("voucherPreimage") + if vp: + # The 48-byte session voucher preimage, + # channelId(32, base58) || cumulativeAmount LE u64 || expiresAt LE i64, + # computed by the production SDK packer (VoucherData.message_bytes -> + # _paymentchannels.voucher_message_bytes) so a byte mismatch is caught + # cross-SDK rather than behind a live channel. Mirrors the Go runner + # (paymentchannels.VoucherMessageBytes). + voucher = VoucherData( + channel_id=vp["channelId"], + cumulative=str(vp["cumulativeAmount"]), + expires_at=int(vp["expiresAt"]), + ) + preimage = voucher.message_bytes() + exact["bytes"] = list(preimage) + exact["base64Url"] = base64url_encode(preimage) + return exact From c196e4e0fdb9cc27edc62de84fa14cd072ead10d Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 <75971010+EfeDurmaz16@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:58:28 +0300 Subject: [PATCH 52/52] test(python/mpp): add session harness coverage --- .github/workflows/python.yml | 8 ++ harness/python-server/server.py | 129 ++++++++++++++++++------ harness/python-session-client/main.py | 138 ++++++++++++++++++++++++++ harness/src/contracts.ts | 4 +- harness/src/implementations.ts | 10 +- harness/src/intents/session.ts | 17 ++++ harness/test/e2e.test.ts | 99 ++++++++++-------- harness/test/intent-selection.test.ts | 21 ++-- 8 files changed, 343 insertions(+), 83 deletions(-) create mode 100644 harness/python-session-client/main.py create mode 100644 harness/src/intents/session.ts diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 4369704ab..b0b803469 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -123,6 +123,14 @@ jobs: env: MPP_CONFORMANCE_LANGUAGES: python run: pnpm exec vitest run test/conformance.test.ts + - name: Focused Python session harness + working-directory: harness + env: + MPP_HARNESS_INTENTS: session + MPP_HARNESS_SCENARIOS: session-basic + MPP_HARNESS_CLIENTS: python-session + MPP_HARNESS_SERVERS: python + run: pnpm exec vitest run test/e2e.test.ts - name: Focused TS-to-Python harness working-directory: harness env: diff --git a/harness/python-server/server.py b/harness/python-server/server.py index 078e45e50..ba7debbd5 100644 --- a/harness/python-server/server.py +++ b/harness/python-server/server.py @@ -64,16 +64,24 @@ def _find_repo_root(start: Path) -> Path: Signer, Stablecoin, ) -from pay_kit.errors import InvalidProofError # noqa: E402 -from pay_kit.protocols.x402 import X402Adapter # noqa: E402 from pay_kit._paycore.errors import PaymentError, canonical_code # noqa: E402 -from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization # noqa: E402 from pay_kit._paycore.rpc import SolanaRpc # noqa: E402 +from pay_kit._paycore.store import MemoryStore # noqa: E402 +from pay_kit.errors import InvalidProofError # noqa: E402 +from pay_kit.protocols.mpp.core.headers import format_www_authenticate, parse_authorization, parse_receipt # noqa: E402 from pay_kit.protocols.mpp.intents.charge import ChargeRequest # noqa: E402 -from pay_kit.protocols.mpp.server.charge import ChargeOptions # noqa: E402 +from pay_kit.protocols.mpp.server import ( # noqa: E402 + SessionChallengeOptions, + SessionOptions, + new_session, + session_routes, +) +from pay_kit.protocols.mpp.server.charge import ( # noqa: E402 + ChargeOptions, + Mpp, +) from pay_kit.protocols.mpp.server.charge import Config as MppServerConfig # noqa: E402 -from pay_kit.protocols.mpp.server.charge import Mpp # noqa: E402 -from pay_kit._paycore.store import MemoryStore # noqa: E402 +from pay_kit.protocols.x402 import X402Adapter # noqa: E402 def require_env(name: str) -> str: @@ -120,7 +128,7 @@ def _base_units_to_human(base_units: str, decimals: int) -> str: units = int(base_units) sign = "-" if units < 0 else "" units = abs(units) - quotient, remainder = divmod(units, 10 ** decimals) + quotient, remainder = divmod(units, 10**decimals) fraction = f"{remainder:0{decimals}d}".rstrip("0") if not fraction: return f"{sign}{quotient}" @@ -142,32 +150,32 @@ def _coin_for_mint(mint: str) -> Stablecoin: return Stablecoin.USDC -def _detect_x402() -> bool: +def _detect_protocol() -> str: """Decide which protocol this run exercises (mirror PHP detection).""" explicit = optional_env("PAY_KIT_HARNESS_PROTOCOL", "").lower() - if explicit == "x402": - return True - if explicit in ("mpp", "charge"): - return False + if explicit in ("x402", "mpp", "charge", "session"): + return "mpp" if explicit == "charge" else explicit x402_set = bool(os.environ.get("X402_HARNESS_RPC_URL")) mpp_set = bool(os.environ.get("MPP_HARNESS_RPC_URL")) if x402_set == mpp_set: print( - "set exactly one of X402_HARNESS_RPC_URL / MPP_HARNESS_RPC_URL, " - "or set PAY_KIT_HARNESS_PROTOCOL", + "set exactly one of X402_HARNESS_RPC_URL / MPP_HARNESS_RPC_URL, or set PAY_KIT_HARNESS_PROTOCOL", file=sys.stderr, ) sys.exit(2) - return x402_set + return "x402" if x402_set else "mpp" class _Adapter: """Holds the built pay_kit adapter plus per-route gate amounts.""" def __init__(self) -> None: - self.x402 = _detect_x402() - if self.x402: + self.protocol = _detect_protocol() + self.x402 = self.protocol == "x402" + if self.protocol == "x402": self._build_x402() + elif self.protocol == "session": + self._build_session() else: self._build_mpp() @@ -179,13 +187,9 @@ def _build_x402(self) -> None: facilitator_json = require_env("X402_HARNESS_FACILITATOR_SECRET_KEY") amount_units = optional_env("X402_HARNESS_AMOUNT", "1000") mint = optional_env("X402_HARNESS_MINT", "USDC") - network_raw = optional_env( - "X402_HARNESS_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" - ) + network_raw = optional_env("X402_HARNESS_NETWORK", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1") self.resource_path = optional_env("X402_HARNESS_RESOURCE_PATH", "/protected") - self.settlement_header = optional_env( - "X402_HARNESS_SETTLEMENT_HEADER", "x-fixture-settlement" - ).lower() + self.settlement_header = optional_env("X402_HARNESS_SETTLEMENT_HEADER", "x-fixture-settlement").lower() self.coin = _coin_for_mint(mint) signer = Signer.json(facilitator_json) @@ -215,9 +219,7 @@ def _build_mpp(self) -> None: secret = optional_env("MPP_HARNESS_SECRET_KEY", "mpp-harness-secret-key") network_raw = optional_env("MPP_HARNESS_NETWORK", "localnet") self.resource_path = optional_env("MPP_HARNESS_RESOURCE_PATH", "/paid") - self.settlement_header = optional_env( - "MPP_HARNESS_SETTLEMENT_HEADER", "x-payment-settlement-signature" - ).lower() + self.settlement_header = optional_env("MPP_HARNESS_SETTLEMENT_HEADER", "x-payment-settlement-signature").lower() realm = optional_env("MPP_HARNESS_REALM", "MPP Harness") self.splits = json.loads(optional_env("MPP_HARNESS_SPLITS", "[]")) if not isinstance(self.splits, list): @@ -258,6 +260,39 @@ def _build_mpp(self) -> None: self.routes[replay_path] = _base_units_to_human(replay_amount, decimals) self.replay_path = replay_path + def _build_session(self) -> None: + self.rpc_url = require_env("MPP_HARNESS_RPC_URL") + pay_to = require_env("MPP_HARNESS_PAY_TO") + amount_units = require_env("MPP_HARNESS_AMOUNT") + secret = optional_env("MPP_HARNESS_SECRET_KEY", "mpp-harness-secret-key-with-32b-pad") + network_raw = optional_env("MPP_HARNESS_NETWORK", "localnet") + self.resource_path = optional_env("MPP_HARNESS_RESOURCE_PATH", "/session") + self.settlement_header = optional_env("MPP_HARNESS_SETTLEMENT_HEADER", "x-session-settlement-signature").lower() + fee_payer_raw = require_env("MPP_HARNESS_FEE_PAYER_SECRET_KEY") + signer = Signer.json(fee_payer_raw) + self.session_method = new_session( + SessionOptions( + operator=signer.pubkey(), + recipient=pay_to, + cap=int(amount_units), + currency=optional_env("MPP_HARNESS_SESSION_CURRENCY", "USDC"), + decimals=int(optional_env("MPP_HARNESS_DECIMALS", "6")), + network=network_raw, + secret_key=secret, + realm=optional_env("MPP_HARNESS_REALM", "MPP Harness"), + modes=["pull"], + pull_voucher_strategy="clientVoucher", + open_tx_submitter="client", + signer=signer, + rpc=None, + ) + ) + self.session_routes = session_routes(self.session_method.core(), touch=self.session_method._touch) + self.session_challenge = SessionChallengeOptions(cap=amount_units, description="Harness session") + decimals = int(optional_env("MPP_HARNESS_DECIMALS", "6")) + self.routes = {self.resource_path: _base_units_to_human(amount_units, decimals)} + self.replay_path = "" + def charge_options(self) -> ChargeOptions: options = ChargeOptions( description="PayKit Python harness protected content", @@ -317,6 +352,9 @@ def do_GET(self) -> None: # noqa: N802 return adapter = self.adapter + if adapter.protocol == "session" and self.path == adapter.resource_path: + self._handle_session(adapter) + return if self.path not in adapter.routes: self._send_json(404, {"error": "not_found"}) return @@ -328,6 +366,26 @@ def do_GET(self) -> None: # noqa: N802 else: self._handle_mpp(adapter, request) + def do_POST(self) -> None: # noqa: N802 + adapter = self.adapter + if adapter.protocol == "session": + if self.path == adapter.resource_path: + self._handle_session(adapter) + return + raw = self.rfile.read(int(self.headers.get("content-length", "0") or "0")) + if self.path == "/__402/session/deliveries": + response = asyncio.run(adapter.session_routes.deliveries("POST", raw or b"{}")) + self._send_json(response.status, response.body) + return + if self.path == "/__402/session/commit": + response = asyncio.run(adapter.session_routes.commit("POST", raw or b"{}")) + self._send_json(response.status, response.body) + return + if self.path == "/__402/session/close": + self._handle_session(adapter) + return + self._send_json(404, {"error": "not_found"}) + def _handle_x402(self, adapter: _Adapter, gate: Gate, request: dict[str, Any]) -> None: if not request["headers"].get("payment-signature"): challenge_headers = adapter.adapter.challenge_headers(gate, request) @@ -355,6 +413,19 @@ def _handle_x402(self, adapter: _Adapter, gate: Gate, request: dict[str, Any]) - extra_headers=headers, ) + def _handle_session(self, adapter: _Adapter) -> None: + auth = self.headers.get("authorization", "") + result = asyncio.run(adapter.session_method.handle(auth or None, adapter.session_challenge)) + if not result.ok: + self._send_json(result.status, result.body or {"error": "payment_required"}, extra_headers=result.headers) + return + receipt_header = result.headers.get("payment-receipt", "") + reference = parse_receipt(receipt_header).reference if receipt_header else "" + body = {"ok": True, "paid": True, "protocol": "session", "reference": reference} + if reference: + body["settledSignature"] = reference + self._send_json(200, body, extra_headers={**result.headers, adapter.settlement_header: reference}) + def _handle_mpp(self, adapter: _Adapter, request: dict[str, Any]) -> None: amount = adapter.routes[self.path] options = adapter.charge_options() @@ -384,9 +455,7 @@ async def _verify_with_fresh_rpc(): fresh_rpc = SolanaRpc(adapter.rpc_url) try: async with adapter.handler.using_rpc(fresh_rpc): - return await adapter.handler.verify_credential_with_expected( - credential, expected - ) + return await adapter.handler.verify_credential_with_expected(credential, expected) finally: await fresh_rpc.aclose() @@ -475,7 +544,7 @@ def main() -> None: "implementation": "python", "role": "server", "port": port, - "capabilities": ["exact" if adapter.x402 else "charge"], + "capabilities": [adapter.protocol], } sys.stdout.write(json.dumps(ready) + "\n") sys.stdout.flush() diff --git a/harness/python-session-client/main.py b/harness/python-session-client/main.py new file mode 100644 index 000000000..797bb93fc --- /dev/null +++ b/harness/python-session-client/main.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from urllib.error import HTTPError +from urllib.request import Request, urlopen + + +def _find_repo_root(start: Path) -> Path: + for candidate in [start, *start.parents]: + if (candidate / ".git").exists() or (candidate / "python" / "pyproject.toml").is_file(): + return candidate + return start.parents[-1] + + +_repo_root = _find_repo_root(Path(__file__).resolve()) +_python_src = _repo_root / "python" / "src" +if _python_src.is_dir(): + sys.path.insert(0, str(_python_src)) + +from solders.keypair import Keypair # type: ignore[import-untyped] # noqa: E402 + +from pay_kit.protocols.mpp.client.payment_channels import ( # noqa: E402 + create_server_opened_payment_channel_session_opener, +) +from pay_kit.protocols.mpp.client.session import serialize_session_credential # noqa: E402 +from pay_kit.protocols.mpp.core.headers import parse_www_authenticate # noqa: E402 +from pay_kit.protocols.mpp.intents.session import ( # noqa: E402 + ClosePayload, + SessionAction, + SessionRequest, +) + + +def _request(method: str, url: str, body: dict | None = None, auth: str = ""): + data = None if body is None else json.dumps(body).encode("utf-8") + req = Request(url, data=data, method=method) + if data is not None: + req.add_header("content-type", "application/json") + if auth: + req.add_header("authorization", auth) + try: + response = urlopen(req, timeout=30) + raw = response.read() + return response.status, {k.lower(): v for k, v in response.headers.items()}, raw + except HTTPError as err: + raw = err.read() + return err.code, {k.lower(): v for k, v in err.headers.items()}, raw + + +def _json(raw: bytes): + if not raw: + return None + try: + return json.loads(raw.decode("utf-8")) + except json.JSONDecodeError: + return raw.decode("utf-8", errors="replace") + + +def _result(status: int, headers: dict[str, str], body, settlement: str = "") -> None: + print( + json.dumps( + { + "type": "result", + "implementation": "python-session", + "role": "client", + "ok": 200 <= status < 300, + "status": status, + "responseHeaders": headers, + "responseBody": body, + **({"settlement": settlement} if settlement else {}), + } + ), + flush=True, + ) + + +def main() -> None: + target = os.environ["MPP_HARNESS_TARGET_URL"] + reserve_url = target.rsplit("/", 1)[0] + "/__402/session/deliveries" + commit_url = target.rsplit("/", 1)[0] + "/__402/session/commit" + close_url = target.rsplit("/", 1)[0] + "/__402/session/close" + amount = os.environ.get("MPP_HARNESS_AMOUNT", "700") + + status, headers, raw = _request("GET", target) + if status != 402: + _result(status, headers, _json(raw)) + return + challenge = parse_www_authenticate(headers.get("www-authenticate", "")) + request = SessionRequest.from_dict(challenge.decode_request()) + + session_signer = Keypair() + opener = create_server_opened_payment_channel_session_opener(request, session_signer) + open_auth = serialize_session_credential(challenge, opener.action) + status, headers, raw = _request("GET", target, auth=open_auth) + if status != 200: + _result(status, headers, _json(raw)) + return + + channel_id = opener.session.channel_id_string + status, reserve_headers, reserve_raw = _request( + "POST", + reserve_url, + {"sessionId": channel_id, "amount": amount}, + ) + reserve_body = _json(reserve_raw) + if status != 200 or not isinstance(reserve_body, dict): + _result(status, reserve_headers, reserve_body) + return + + voucher = opener.session.prepare_increment(int(amount)) + status, commit_headers, commit_raw = _request( + "POST", + commit_url, + {"deliveryId": reserve_body["deliveryId"], "voucher": voucher.to_dict()}, + ) + commit_body = _json(commit_raw) + if status != 200: + _result(status, commit_headers, commit_body) + return + opener.session.record_voucher(voucher) + + close_auth = serialize_session_credential( + challenge, + SessionAction.close_action(ClosePayload(channel_id=channel_id, voucher=voucher)), + ) + status, close_headers, close_raw = _request("POST", close_url, auth=close_auth) + close_body = _json(close_raw) + settlement = "" + if isinstance(close_body, dict): + settlement = str(close_body.get("reference") or close_body.get("settledSignature") or "") + _result(status, close_headers, close_body, settlement) + + +if __name__ == "__main__": + main() diff --git a/harness/src/contracts.ts b/harness/src/contracts.ts index 019eca966..67876ca15 100644 --- a/harness/src/contracts.ts +++ b/harness/src/contracts.ts @@ -1,12 +1,13 @@ import type { CanonicalErrorCode } from "./canonical-codes"; import { chargeScenarios } from "./intents/charge"; +import { sessionScenarios } from "./intents/session"; import { x402ExactScenarios } from "./intents/x402-exact"; export type { CanonicalErrorCode }; export type AdapterKind = "client" | "server"; -export type HarnessIntent = "charge" | "x402-exact"; +export type HarnessIntent = "charge" | "x402-exact" | "session"; export type HarnessScenarioSplit = { recipientKey: string; @@ -160,6 +161,7 @@ export { chargeCanonicalJsonVectors } from "./intents/charge"; export const harnessScenarios: readonly HarnessScenario[] = [ ...chargeScenarios, ...x402ExactScenarios, + ...sessionScenarios, ]; export const harnessScenario: HarnessScenario = { diff --git a/harness/src/implementations.ts b/harness/src/implementations.ts index 0e4c538c0..11fe830bf 100644 --- a/harness/src/implementations.ts +++ b/harness/src/implementations.ts @@ -171,6 +171,14 @@ export const clientImplementations: ImplementationDefinition[] = [ intents: ["x402-exact"], reportsAs: "python", }, + { + id: "python-session", + label: "Python pay_kit session client", + role: "client", + command: ["uv", "run", "--project", "../python", "python", "python-session-client/main.py"], + enabled: isEnabled("python-session", "MPP_HARNESS_CLIENTS", false), + intents: ["session"], + }, { id: "swift-x402", label: "Swift x402 exact client", @@ -304,7 +312,7 @@ export const serverImplementations: ImplementationDefinition[] = [ // focused-matrix CI jobs in .github/workflows/python.yml. command: ["uv", "run", "--project", "../python", "python", "python-server/server.py"], enabled: isEnabled("python", "MPP_HARNESS_SERVERS", false), - intents: ["charge", "x402-exact"], + intents: ["charge", "x402-exact", "session"], }, { id: "go", diff --git a/harness/src/intents/session.ts b/harness/src/intents/session.ts new file mode 100644 index 000000000..d02c607de --- /dev/null +++ b/harness/src/intents/session.ts @@ -0,0 +1,17 @@ +import type { HarnessScenario } from "../contracts"; + +export const sessionScenarios: readonly HarnessScenario[] = [ + { + id: "session-basic", + intent: "session", + network: "localnet", + price: "0.0007", + amount: "700", + asset: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + resourcePath: "/session", + settlementHeader: "x-session-settlement-signature", + expectedStatus: 200, + clientIds: ["python-session"], + serverIds: ["python"], + }, +] as const; diff --git a/harness/test/e2e.test.ts b/harness/test/e2e.test.ts index b5a1dc7a2..ca5e6e34b 100644 --- a/harness/test/e2e.test.ts +++ b/harness/test/e2e.test.ts @@ -428,20 +428,25 @@ describe("mpp harness", () => { // literal in scenarioEnv goes to the adapter so the SDK's // resolver is exercised end-to-end. const onChainMint = onChainMintFor(scenario); - const initialBalance = await getPrimaryRecipientBalance( - surfnet, - scenario, - scenarioEnv.MPP_HARNESS_PAY_TO, - onChainMint, - scenarioTokenProgram, - ); - const initialSplitBalances = await splitBalances( - surfnet, - scenario, - onChainMint, - scenarioTokenProgram, - true, - ); + const assertsOnChainSettlement = scenario.intent !== "session"; + const initialBalance = assertsOnChainSettlement + ? await getPrimaryRecipientBalance( + surfnet, + scenario, + scenarioEnv.MPP_HARNESS_PAY_TO, + onChainMint, + scenarioTokenProgram, + ) + : 0n; + const initialSplitBalances = assertsOnChainSettlement + ? await splitBalances( + surfnet, + scenario, + onChainMint, + scenarioTokenProgram, + true, + ) + : {}; const server = await startServer(serverImplementation, scenarioEnv); runningServers.push(server); @@ -453,23 +458,27 @@ describe("mpp harness", () => { scenarioEnv, ); - const finalBalance = await getPrimaryRecipientBalance( - surfnet, - scenario, - scenarioEnv.MPP_HARNESS_PAY_TO, - onChainMint, - scenarioTokenProgram, - ); - const finalSplitBalances = await splitBalances( - surfnet, - scenario, - onChainMint, - scenarioTokenProgram, - // For 402 scenarios the recipient ATA may never have - // been created on-chain; treat missing as zero so the - // delta assertion below still holds. - scenario.expectedStatus === 402, - ); + const finalBalance = assertsOnChainSettlement + ? await getPrimaryRecipientBalance( + surfnet, + scenario, + scenarioEnv.MPP_HARNESS_PAY_TO, + onChainMint, + scenarioTokenProgram, + ) + : 0n; + const finalSplitBalances = assertsOnChainSettlement + ? await splitBalances( + surfnet, + scenario, + onChainMint, + scenarioTokenProgram, + // For 402 scenarios the recipient ATA may never have + // been created on-chain; treat missing as zero so the + // delta assertion below still holds. + scenario.expectedStatus === 402, + ) + : {}; expect(result.status, JSON.stringify(result, null, 2)).toBe( scenario.expectedStatus, @@ -483,18 +492,20 @@ describe("mpp harness", () => { }); expect(typeof result.settlement).toBe("string"); expect(result.settlement).not.toHaveLength(0); - await expectSettledTransactionShape( - surfnet, - scenario, - scenarioEnv, - result.settlement, - ); - expect(finalBalance - initialBalance).toBe( - primaryDelta(scenario), - ); - expect( - splitDeltas(initialSplitBalances, finalSplitBalances), - ).toEqual(expectedSplitDeltas(scenario)); + if (assertsOnChainSettlement) { + await expectSettledTransactionShape( + surfnet, + scenario, + scenarioEnv, + result.settlement, + ); + expect(finalBalance - initialBalance).toBe( + primaryDelta(scenario), + ); + expect( + splitDeltas(initialSplitBalances, finalSplitBalances), + ).toEqual(expectedSplitDeltas(scenario)); + } } else { expect(result.ok, JSON.stringify(result, null, 2)).toBe(false); // Lamport balances are always accessible (unlike SPL token @@ -748,6 +759,8 @@ function environmentForScenario( env.X402_HARNESS_PRICE = scenario.price; env.X402_HARNESS_RESOURCE_PATH = scenario.resourcePath; env.X402_HARNESS_SETTLEMENT_HEADER = scenario.settlementHeader; + } else if (scenario.intent === "session") { + env.PAY_KIT_HARNESS_PROTOCOL = "session"; } else { env.PAY_KIT_HARNESS_PROTOCOL = "mpp"; } diff --git a/harness/test/intent-selection.test.ts b/harness/test/intent-selection.test.ts index 7a292afc7..49f132634 100644 --- a/harness/test/intent-selection.test.ts +++ b/harness/test/intent-selection.test.ts @@ -18,18 +18,17 @@ describe("harness intent selection", () => { expect(selectHarnessIntents("x402-exact")).toEqual(["x402-exact"]); }); - it("accepts both intents at once", () => { - expect(selectHarnessIntents("charge,x402-exact")).toEqual([ + it("accepts session intent", () => { + expect(selectHarnessIntents("session")).toEqual(["session"]); + }); + + it("accepts all intents at once", () => { + expect(selectHarnessIntents("charge,x402-exact,session")).toEqual([ "charge", "x402-exact", + "session", ]); }); - - it("rejects scenarios that are not implemented yet", () => { - expect(() => selectHarnessIntents("session")).toThrow( - /Unsupported MPP_HARNESS_INTENTS/, - ); - }); }); describe("harness scenario selection", () => { @@ -71,6 +70,12 @@ describe("harness scenario selection", () => { ]); }); + it("returns session scenarios when explicitly requested", () => { + expect( + selectHarnessScenarios("session", undefined).map((scenario) => scenario.id), + ).toEqual(["session-basic"]); + }); + it("runs one requested scenario", () => { expect( selectHarnessScenarios("charge", "charge-split-ata").map(