Skip to content

feat(go): mpp/session client and server + playground-api example#160

Merged
lgalabru merged 45 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/go-mpp-session
Jun 12, 2026
Merged

feat(go): mpp/session client and server + playground-api example#160
lgalabru merged 45 commits into
solana-foundation:mainfrom
EfeDurmaz16:feat/go-mpp-session

Conversation

@EfeDurmaz16

@EfeDurmaz16 EfeDurmaz16 commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

feat(go): mpp/session client and server

Comprehensive mpp/session support for the Go SDK, rebased onto main after #165 and aligned with the rewritten skills/pay-sdk-implementation/references/intents/mpp-session.md. The Rust crate (rust/crates/mpp/src) is the wire truth throughout; the TypeScript implementation merged in #165 is the second reference. This PR ships both halves of the intent: the client surface and the full server-side session method, plus a Go port of the playground API example.

What this adds

Wire and program layer:

  • Session wire types (SessionRequest, the tagged SessionAction union, OpenPayload, VoucherData/SignedVoucher, CommitPayload/CommitReceipt, TopUpPayload, ClosePayload, MeteringDirective, MeteringUsage) in go/protocols/mpp/intents/session.go, mirroring rust/crates/mpp/src/protocol/intents/session.rs field for field: salt as a u64 string accepting string or number on decode (with a raw-bytes re-parse to avoid float precision loss past 2^53), the cumulative decode alias with cumulativeAmount the only serialized name, SessionID() keyed channelId first with tokenAccount fallback, topUp.newDeposit as the new total, and DefaultSessionExpiresAt = 4_102_444_800.
  • A codama-go generated payment-channels client under go/protocols/programs/paymentchannels/ (recipe in skills/pay-sdk-implementation/codegen/, reproducible via generate-payment-channels-client-go.ts), covering every instruction (open, topUp, settle, settleAndFinalize, distribute, finalize, requestClose, withdrawPayer, emitEvent) plus account and type decoders, with the production program id overriding the IDL placeholder and the single-byte discriminators (open = 1, topUp = 3). A frozen-hex parity suite (go/protocols/programs/paymentchannels_parity_test/) pins the borsh byte layouts against the Rust spine.
  • The 48-byte voucher preimage (channel_id || cumulative u64 LE || expires_at i64 LE) has a single packer that VoucherData.MessageBytes delegates to, and is pinned by cross-SDK conformance vectors (harness/vectors/session-voucher.json, including a near-u64-max cumulative case) implemented in go/cmd/conformance.

Client layer, keyed to the skill's component inventory:

  • Challenge parsing and mode selection: ParseSessionChallenge plus challenge selection with mode gating in client/challenge_selection.go, encoding modes empty or omitted as push-only (TS sessionRequestModes semantics, Rust membership checks).
  • Challenge-driven open layer in client/payment_channels.go, ported line by line from rust/crates/mpp/src/client/payment_channels.rs: open derivation (mint from the challenge currency with the localnet to mainnet fallback, deposit defaulting to the cap, grace defaulting to 900 seconds, token program resolved from the currency so Token-2022 currencies open correctly, splits, random u64 salt), transaction assembly with fee payer = challenge operator and payer partial-signing only its own slot, the challenge recentBlockhash, the PENDING_SERVER_SIGNATURE placeholder (64 ones), and standard base64 with padding for the wire transaction. A per-call program id can be threaded through for non-mainnet clusters.
  • Session state with the prepare/record split: ActiveSession signs vouchers without advancing the watermark and records them only after server acceptance, with channel-match checks, strict monotonicity, u64 overflow guards, and the Rust nonce rule.
  • Metered consumer: SessionConsumer validates the directive session, prepares the voucher for watermark + amount, and commits with the directive's deliveryId. On a replayed receipt it reconciles the watermark to min(settled, prepared) instead of recording blindly, matching the hardened semantics proposed for the Rust client in fix(rust): do not advance session watermark on a replayed commit #162; the clamp treats the server as untrusted.
  • Streaming consumption in client/http_stream.go, porting rust/crates/mpp/src/client/http_stream.rs: incremental SSE decoding, metered event classification (mpp.metering/metering, mpp.usage/usage, done, [DONE]), enforcement that a usage event's deliveryId matches the live directive and that usage overrides only the amount, and a net/http-backed commit transport.

Server layer, mirroring the TS server that landed in #165 (typescript/packages/mpp/src/server/Session.ts and server/session/*) and rust/crates/mpp/src/server/session.rs:

  • go/protocols/mpp/server/session_store.go: per-channel state store with an atomic UpdateChannel read-modify-write mutator (per-channel mutex; a failing mutator leaves state unchanged), pluggable like the existing replay stores.
  • session_voucher.go: the ordered voucher verifier as a pure function with the exact reference check sequence (parse, finalized, close pending, idempotent replay on same cumulative and same re-verified signature, strictly above watermark, within deposit, minVoucherDelta, Ed25519 against the stored authorizedSigner, expiry), preflighted outside the store lock and re-checked inside.
  • session.go: the off-chain SessionServer core (challenge request building with cap clamping, open/voucher/commit/topUp/close handlers, reservation accounting cumulative + pendingTotal + amount <= deposit, sequence assignment with the default <sessionId>:<sequence> delivery id, replay returning the cached receipt).
  • session_onchain.go: open-transaction verification (decode, fee-payer signature binding, deposit and recipient checks) and settle/finalize composition (settleAndFinalize with the ed25519 precompile instruction immediately preceding it, distribute bundled in the same transaction), gated behind an injectable verifier seam so the no-RPC trust model matches Rust rpc_url = None. Server-broadcast open submission completes the fee-payer signature and broadcasts, recording the settled signature on channel state.
  • session_method.go and session_routes.go: the HTTP-facing session method (HMAC-bound 402 challenges, credential verification dispatching across the five actions) and the reserve/commit metering side channel (POST /__402/session/deliveries, POST /__402/session/commit), flagged as the TypeScript-server extension it is.
  • session_lifecycle.go: the idle-close watchdog (single-shot timer per channel, reset on voucher/commit/topUp, close-and-settle on fire), mirroring the TS-only lifecycle extension.
  • session_stream.go: a server-side metered SSE stream writer emitting the directive/usage/done event shapes the client consumer parses.

Example:

  • go/examples/playground-api: a Go port of typescript/examples/playground-api with endpoint parity for the playground web app. Charges (stock quote, marketplace splits, fortune payment link, faucet), sessions (/sessions/stream pay-per-chunk SSE and /sessions/compute pay-per-call with real opens, side-channel metering, and idle-close on-chain settlement), the x402 exact demo routes with the embedded facilitator, and the config catalog endpoint. Setting PAYKIT_PLAYGROUND_API_URL points the playground's pnpm dev at this server instead of launching the TypeScript one.

Encoding boundaries follow the repo rule: canonical JSON (RFC 8785) to base64url without padding for the credential envelope and request field, standard base64 with padding for transactions, base58 for signatures, pubkeys, and blockhashes.

Notes for review

  • The replayed-receipt reconcile in SessionConsumer intentionally tracks the fix(rust): do not advance session watermark on a replayed commit #162 Rust semantics rather than the current TS client, which still records the prepared voucher unconditionally. If fix(rust): do not advance session watermark on a replayed commit #162 lands first this is exact parity; if not, this is the same divergence flagged there.
  • The generated client under go/protocols/programs/paymentchannels/ is codegen output and is carved out of the coverage gate alongside the examples; the curated layer in go/paycore/paymentchannels is fully covered.
  • The README scheme matrix now marks mpp/session shipped on both client and server, with the scope notes inline.

Testing

  • go test ./...: all packages pass, including the session intent serde suite, client session/consumer/stream suites, the server method, store, voucher, on-chain, concurrency, and lifecycle suites, and the playground example smoke tests.
  • Surfpool-gated e2e: session_e2e_test.go and playground_e2e_test.go drive the full lifecycle (real open completed and broadcast by the server, metered SSE with per-chunk vouchers, side-channel reserve/commit, on-chain settle at idle close) against the hosted Solana Payment Sandbox; they skip explicitly when the sandbox is unreachable or under -short, never silently pass.
  • Frozen-hex borsh parity tests pin the generated instruction encodings against the Rust spine; harness/vectors/session-voucher.json pins the voucher preimage cross-SDK.
  • CI: the Go workflow gains a playground job that builds and boots go/examples/playground-api and runs the payment-link Playwright suite against it; the harness matrix job honors PAY_KIT_HARNESS_PROTOCOL in the Go client adapter.
  • gofmt clean; lint configured to skip the generated client only.

@greptile-apps

greptile-apps Bot commented Jun 8, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds the MPP session intent to the Go SDK, client-side only. A client can now open a payment channel, sign cumulative Ed25519 vouchers off-chain, top up, close, and drive metered delivery via a SessionConsumer, all byte-identical with the Rust spine. The codama-generated payment-channels program client is committed under protocols/programs/paymentchannels/, with a thin hand-written glue layer for PDA derivation, the voucher preimage, and the open/topUp instruction builders.

  • protocols/mpp/intents/session.go implements the full wire-type tagged union (SessionAction, OpenPayload, VoucherData, CommitPayload, MeteringDirective, MeteredEnvelope[T]) with custom JSON marshaling for the cumulativeAmount/cumulative alias and the salt decimal-string encoding.
  • protocols/mpp/client/session.go and session_consumer.go implement ActiveSession (monotonic watermark guard, voucher signing, action builders) and SessionConsumer/MeteredDelivery[T] with a correct replay-reconciliation path that clamps the server-reported cumulative to the client's prepared voucher.
  • paycore/paymentchannels/paymentchannels.go provides PDA derivation, VoucherMessageBytes (the canonical 48-byte preimage), and the BuildOpenInstruction/BuildTopUpInstruction helpers; the duplicate preimage previously in VoucherData.MessageBytes now delegates to this single source.

Confidence Score: 5/5

Safe to merge. All three issues from the previous review round are addressed: replay watermark double-counting replaced by clamped ReconcileSettled, duplicate voucher preimage eliminated by delegation, and missing cumulative field now errors at deserialization.

The session consumer replay logic is correct and covered by six targeted tests (lost-response reconcile, stale-replay no-regression, malicious-server clamp, unknown status rejection). Borsh discriminators and account ordering are pinned by frozen cross-language parity vectors. No logic defects were found.

No files require special attention. Generated files under protocols/programs/paymentchannels/ are byte-reproducible from the IDL and correctly excluded from lint gates.

Important Files Changed

Filename Overview
go/protocols/mpp/client/session_consumer.go Correct replay-reconciliation logic: clamps settled to the prepared voucher; ReconcileSettled never regresses the watermark; unknown receipt status rejected before advancing state.
go/protocols/mpp/client/session.go ActiveSession voucher signing and watermark management is well-implemented; PrepareVoucher/RecordVoucher separation is consistent with the ack/commit retry contract.
go/protocols/mpp/intents/session.go Wire types, custom marshal/unmarshal (salt, cumulative alias, action tag union) all correct; missing cumulative now returns an error; VoucherData.MessageBytes delegates to the canonical packer.
go/paycore/paymentchannels/paymentchannels.go PDA derivation, VoucherMessageBytes, BuildOpenInstruction/BuildTopUpInstruction all correct; init() pins the production program ID; SetProgramID follows the same global-var pattern as the generated package.
go/protocols/programs/paymentchannels_parity_test/parity_test.go Frozen Borsh vectors for OpenArgs, TopUpArgs, VoucherArgs, and the single-byte discriminator guard against silent IDL-layout drift.
go/protocols/mpp/client/session_consumer_test.go Comprehensive replay-handling tests now assert the watermark after replay, closing the gap identified in the previous review round.
harness/src/conformance/runners.ts Intent-capability opt-in for runners (DEFAULT_INTENTS fallback) lets the session intent land without breaking existing language runners that haven't implemented it yet.

Sequence Diagram

sequenceDiagram
    participant C as Client (ActiveSession)
    participant SC as SessionConsumer
    participant T as CommitTransport
    participant S as Server

    C->>C: NewActiveSession(channelID, signer)
    C->>C: OpenAction(deposit, txSig)
    C->>S: POST Authorization: Payment open action
    S-->>C: 200 OK (session established)

    loop Metered deliveries
        S-->>SC: "MeteredEnvelope{payload, MeteringDirective}"
        SC->>SC: "Accept(envelope) -> MeteredDelivery"
        Note over SC: Application processes payload
        SC->>C: "PrepareIncrement(amount) -> SignedVoucher"
        Note over C: Watermark NOT yet advanced
        SC->>T: "Commit(directive, CommitPayload{voucher})"
        T->>S: POST commit endpoint
        alt CommitStatusCommitted
            S-->>T: "CommitReceipt{status:committed, cumulative}"
            T-->>SC: receipt
            SC->>C: RecordVoucher(voucher)
            Note over C: Watermark advanced to cumulative
        else CommitStatusReplayed
            S-->>T: "CommitReceipt{status:replayed, cumulative=settled}"
            T-->>SC: receipt
            SC->>C: ReconcileSettled(min(settled, prepared))
            Note over C: Watermark reconciled, never regresses
        else Transport error
            T-->>SC: error
            Note over C: Watermark unchanged - safe to retry
        end
    end

    C->>C: CloseAction(finalIncrement)
    C->>S: POST Authorization: Payment close action
Loading

Reviews (8): Last reviewed commit: "refactor(go): delegate VoucherData.Messa..." | Re-trigger Greptile

Comment on lines +72 to +85
voucher, err := c.session.PrepareIncrement(amount)
if err != nil {
return intents.CommitReceipt{}, err
}
payload := intents.CommitPayload{DeliveryID: directive.DeliveryID, Voucher: voucher}

receipt, err := c.transport.Commit(ctx, directive, payload)
if err != nil {
return intents.CommitReceipt{}, err
}
if err := c.session.RecordVoucher(voucher); err != nil {
return intents.CommitReceipt{}, err
}
return receipt, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Replayed receipt still advances the local cumulative watermark

RecordVoucher(voucher) is called unconditionally on any successful transport response, including CommitStatusReplayed. In the intended retry-after-failure path the watermark is still at 0, so advancing it is correct. But if the first call already succeeded (watermark at amount) and the caller commits the same directive again, PrepareIncrement(amount) creates a voucher at 2×amount. The server returns Replayed with the original cumulative=amount, and RecordVoucher then advances the local watermark to 2×amount — the client has now signed and locally recorded a voucher the server never accepted as a new settlement. A subsequent channel close or settlement instruction could use that 2×amount voucher to claim more funds than were authorized.

TestConsumerDuplicateDeliveryReplayedNotDoubleCounted checks only that the server records one commit; it never asserts consumer.Session().Cumulative() after the replay, so this drift is not caught. The fix is to skip RecordVoucher when receipt.Status == CommitStatusReplayed and the current cumulative already equals or exceeds the replayed amount.

Comment on lines +900 to +923
// MessageBytes serializes the voucher to the payment-channels VoucherArgs bytes
// signed by Ed25519: channelId(32) || cumulativeAmount(LE u64) ||
// expiresAt(LE i64), for a total of exactly 48 bytes.
//
// Mirrors rust VoucherData::message_bytes (which delegates to
// payment_channels::voucher_message_bytes).
func (v VoucherData) MessageBytes() ([]byte, error) {
channelID, err := base58.Decode(v.ChannelID)
if err != nil {
return nil, fmt.Errorf("invalid channelId %q: %w", v.ChannelID, err)
}
if len(channelID) != 32 {
return nil, fmt.Errorf("channelId must be 32 bytes, got %d", len(channelID))
}
cumulative, err := strconv.ParseUint(v.Cumulative, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid voucher cumulative")
}
out := make([]byte, 48)
copy(out[:32], channelID)
binary.LittleEndian.PutUint64(out[32:40], cumulative)
binary.LittleEndian.PutUint64(out[40:48], uint64(v.ExpiresAt))
return out, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Duplicate voucher-preimage implementation risks silent divergence

VoucherData.MessageBytes() re-implements the same 48-byte layout (channelId || cumulative LE u64 || expiresAt LE i64) as paymentchannels.VoucherMessageBytes(). The signing path in client/session.go's PrepareVoucher already calls paymentchannels.VoucherMessageBytes, while VoucherData.MessageBytes is a separate reimplementation. Both currently agree, but any future change to one that misses the other would silently produce mismatched signatures that only surface at on-chain settlement. Consider having VoucherData.MessageBytes decode the channel ID and delegate directly to paymentchannels.VoucherMessageBytes rather than carrying a parallel implementation.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +197 to +201

if _, err := builder.ValidateAndBuild(); err != nil {
return nil, fmt.Errorf("build open instruction: %w", err)
}
return materialize(builder, builder.GetAccounts())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 ValidateAndBuild() result discarded; materialize encodes the builder directly

ValidateAndBuild() is called only for its error-checking side-effect; the returned *Instruction is discarded, and materialize(builder, builder.GetAccounts()) then Borsh-encodes the builder directly. The comment explains why (the generated Instruction stores the impl by value, causing a pointer-receiver panic on Accounts()). This workaround is correct today, but if the codegen ever changes how ValidateAndBuild() mutates builder state (e.g., setting default accounts or computing derived fields), the discarded result may diverge from what materialize encodes. A code comment at the call site calling this out explicitly would make the coupling clearer for future maintainers regenerating the client.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +879 to +898
// UnmarshalJSON decodes VoucherData, accepting "cumulative" as an alias for
// "cumulativeAmount", mirroring rust's serde(alias="cumulative").
func (v *VoucherData) UnmarshalJSON(data []byte) error {
var wire voucherDataJSON
if err := json.Unmarshal(data, &wire); err != nil {
return fmt.Errorf("decode voucher data: %w", err)
}
*v = VoucherData{
ChannelID: wire.ChannelID,
ExpiresAt: wire.ExpiresAt,
Nonce: wire.Nonce,
}
switch {
case wire.CumulativeAmount != nil:
v.Cumulative = *wire.CumulativeAmount
case wire.CumulativeAlias != nil:
v.Cumulative = *wire.CumulativeAlias
}
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Missing cumulative field accepted silently

UnmarshalJSON returns nil when neither cumulativeAmount nor cumulative appears in the JSON, leaving VoucherData.Cumulative as "". Any subsequent call — RecordVoucher, MessageBytes, parseCumulative — then fails with the cryptic message "invalid voucher cumulative \"\"" instead of a deserialization error pointing to the missing field. The Rust VoucherData treats cumulative_amount as a required field (non-Option), so the Go implementation is more permissive than the spec allows. A missing default case that returns an error would align Go with Rust and fail fast at the protocol boundary rather than deep inside the watermark logic.

Suggested change
// UnmarshalJSON decodes VoucherData, accepting "cumulative" as an alias for
// "cumulativeAmount", mirroring rust's serde(alias="cumulative").
func (v *VoucherData) UnmarshalJSON(data []byte) error {
var wire voucherDataJSON
if err := json.Unmarshal(data, &wire); err != nil {
return fmt.Errorf("decode voucher data: %w", err)
}
*v = VoucherData{
ChannelID: wire.ChannelID,
ExpiresAt: wire.ExpiresAt,
Nonce: wire.Nonce,
}
switch {
case wire.CumulativeAmount != nil:
v.Cumulative = *wire.CumulativeAmount
case wire.CumulativeAlias != nil:
v.Cumulative = *wire.CumulativeAlias
}
return nil
}
switch {
case wire.CumulativeAmount != nil:
v.Cumulative = *wire.CumulativeAmount
case wire.CumulativeAlias != nil:
v.Cumulative = *wire.CumulativeAlias
default:
return fmt.Errorf("voucher data: missing required cumulativeAmount field")
}
return nil
}

EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 8, 2026
…ign vouchers

On a replayed CommitReceipt the server has already settled the delivery, so
its Cumulative is authoritative. CommitDirective now reconciles the local
watermark to that cumulative (advancing when behind, e.g. a lost response, and
never regressing) instead of recording the freshly prepared higher voucher,
which would let a later close sign for more than was settled. RecordVoucher
also rejects a voucher whose channel does not match the active session.

Surfaced by Greptile and Codex on solana-foundation#160. Mirrors the rust spine fix (solana-foundation#162).
@EfeDurmaz16 EfeDurmaz16 force-pushed the feat/go-mpp-session branch from 1ea204e to 473f854 Compare June 8, 2026 22:58
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 8, 2026
…reign vouchers

On a replayed CommitReceipt the server has already settled the delivery, so
its cumulative is authoritative. SessionConsumer::commit_directive now
reconciles the local watermark to that cumulative (advancing when behind, e.g.
a lost response, and never regressing) instead of recording the freshly
prepared higher voucher, which would let a later channel close sign for more
than was settled. ActiveSession::record_voucher also rejects a voucher whose
channel does not match the active session. Adds reconcile_settled plus
regression tests for reconcile, no-regress, and the foreign-channel guard.

Surfaced by Greptile/Codex on the Go and Python session ports (solana-foundation#160, solana-foundation#161).
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 8, 2026
Addresses Greptile + Codex review of solana-foundation#160:
- 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.
- RecordVoucher rejects a voucher whose channel does not match the session.
- CommitDirective records only on an explicit committed receipt and rejects
  unknown statuses, so a malformed/misrouted receipt never advances local state.

Mirrors the rust spine fix (solana-foundation#162).
@EfeDurmaz16 EfeDurmaz16 force-pushed the feat/go-mpp-session branch from 473f854 to d334a30 Compare June 8, 2026 23:09
Wire @codama/renderers-go (^2.0.0) into the codegen tooling and generate
the Go client for the payment-channels program, mirroring the existing
Rust codegen path.

- Add generate-payment-channels-client-go.ts + a `payment-channels:go`
  npm script that renders idl/payment-channels.json into
  go/protocols/programs/paymentchannels/ via @codama/renderers-go. Output
  uses github.com/gagliardetto/{solana-go,binary} (already Go deps) and is
  byte-for-byte reproducible across runs.
- Exclude the generated programs dir from golangci-lint and the justfile
  vet/staticcheck gates: the output carries a DO-NOT-EDIT header and
  follows gagliardetto/solana-go's generated-client idioms (unkeyed
  VariantType literals, QF1008 embedded-field selectors) that can't be
  fixed in place. golangci-lint (the CI gate) runs govet with composites
  off, so the local justfile vet is aligned with -composites=false.
- Add an out-of-tree byte-parity guard
  (protocols/programs/paymentchannels_parity_test) pinning the Open
  instruction's single-byte discriminator (rust OPEN_DISCRIMINATOR: u8 = 1,
  not the 8-byte Anchor form), the OpenArgs Borsh layout
  {salt u64, deposit u64, grace_period u32, recipients Vec<{recipient, bps u16}>},
  and the 48-byte voucher preimage {channel_id||cumulative_le||expires_le}
  exposed by the generated VoucherArgs. Vectors frozen from borsh::to_vec
  over the identical Rust spine struct layouts.

go build, gofmt -l, go vet -composites=false, and golangci-lint are clean
on both the generated dir and the parity guard; the parity tests pass with
-race.
Implement the client-only MPP session intent on the Go SDK, mirroring the
rust spine (client/session.rs, client/session_consumer.rs,
protocol/intents/session.rs, program/payment_channels.rs) for byte-exact
wire and voucher parity.

- intents/session.go: SessionRequest/SessionAction tagged union (open,
  voucher, commit, topUp, close), OpenPayload (push + pull), SignedVoucher
  with the 48-byte Borsh preimage (channelId || cumulative LE u64 ||
  expiresAt LE i64), metering types. Salt marshals as a decimal string and
  decodes from string-or-number; cumulativeAmount accepts the cumulative
  alias.
- client/session.go: ActiveSession voucher state machine (monotonic
  watermark, nonce, off-chain Ed25519 signing over the preimage), action
  builders, credential serialization, and session challenge parsing.
- client/session_consumer.go: metered-delivery consumer (Accept/Ack/Commit)
  that advances the local watermark only on a successful commit.
- paycore/paymentchannels: hand-written glue over the generated
  payment-channels client (production program id pinned, channel/event PDAs,
  voucher preimage, open + top_up instruction builders).
- wire: add IntentName.IsSession; broaden the intents package doc.
- CI: cover paycore/paymentchannels in the Go coverage gate.

Scope is client-only PUSH plus pull/clientVoucher; pull/operatedVoucher and
the server path are deferred. Voucher preimage and Ed25519 signature are
cross-checked byte-for-byte against the rust pk(9)/42/1234 vector and the
[42;32]-seed signer.
Addresses Greptile + Codex review of solana-foundation#160:
- 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.
- RecordVoucher rejects a voucher whose channel does not match the session.
- CommitDirective records only on an explicit committed receipt and rejects
  unknown statuses, so a malformed/misrouted receipt never advances local state.

Mirrors the rust spine fix (solana-foundation#162).
Pin the 48-byte session voucher preimage
(channelId(32) || cumulativeAmount LE u64 || expiresAt LE i64) as
canonical-bytes conformance vectors, driven through the Go SDK's
paymentchannels.VoucherMessageBytes so a byte mismatch is caught cross-SDK
rather than behind a live channel. Includes a near-u64-max cumulative to
assert little-endian packing has no precision loss.

Adds a per-runner intent capability gate: a runner manifest may declare the
intents it supports (default: charge, x402-exact), and the driver skips
vectors for intents a runner does not declare. This lets the new session
intent land with only the SDKs that implement it (here: go) without editing
every other language's runner. go.json declares session support.
…mulative

Greptile solana-foundation#162 follow-up: reconcile_settled advanced cumulative but left the
nonce unchanged, so the first delivery after a lost-response replay reused the
nonce the server already settled. Bump the nonce by one whenever reconcile
advances (mirroring record_voucher's accounting for that delivery). The nonce
is client request-counter metadata (not in the signed 48-byte preimage), so
this is a consistency fix, not a fund-safety one. Adds a delivery-after-replay
regression test.
…innet clusters

The glue hardcoded the mainnet program id, so PDA derivation and instruction
emission could not target a devnet/localnet deployment (which lives at a
different address). Add SetProgramID, mirroring the generated package's API, so
a consumer can point the glue at a non-mainnet program. Validated end to end
against a local validator: a real open + top_up built by the glue are accepted
by the deployed program, and the channel account + escrow balance verify
on-chain.
…umulative

Review follow-ups on the session client:
- CommitDirective 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.
- VoucherData.UnmarshalJSON now rejects a voucher carrying neither
  cumulativeAmount nor cumulative (rust models it as a required field) instead
  of leaving it empty and failing cryptically later.
- Fix a now-stale materialize doc comment (program id follows SetProgramID) and
  rename v1/v2 test locals.
The wire type hand-rolled the 48-byte voucher preimage, duplicating
paycore/paymentchannels.VoucherMessageBytes. The rust spine does not duplicate
it (VoucherData::message_bytes delegates to payment_channels::voucher_message_bytes),
and the hand-rolled copy was exercised only by its own tests, so it could drift
from the packer the signing and conformance paths actually use. Delegate to the
canonical packer for a single source of truth (the doc comment already claimed
this). No behavior change.
Mirror rust OpenChannelParams.program_id and the program_id argument of
build_top_up_instruction: OpenChannelParams and TopUpParams gain an
optional ProgramID resolved to the package default when unset, with
FindChannelPDAForProgram and FindEventAuthorityPDAForProgram exposing
explicit-program PDA derivation for challenge-driven opens.
Mirror rust/crates/mpp/src/client/payment_channels.rs: derive every open
parameter from the session challenge (deposit defaults to the cap, grace
period 900s, random u64 salt, token program resolved from the currency
so Token-2022 mints work, programId honored per challenge), assemble the
legacy open transaction with the operator as fee payer and a payer
partial-sign, echo the challenge recentBlockhash, encode the wire
transaction as standard base64 with padding, and expose the
pull/clientVoucher session openers with the PendingServerSignature
placeholder. Adds NewActiveSessionWithWatermark for resumed sessions and
NewEphemeralSessionSigner for the production ephemeral voucher key.
Mirror rust/crates/mpp/src/client/http_stream.rs: an incremental
SseDecoder, ParseMeteredSseEvent handling the mpp.metering/metering,
mpp.usage/usage, done, and [DONE] event names, a transport-neutral
MeteredSseSession over SessionConsumer that pairs usage events with the
live directive (mismatched deliveryId rejected, usage overrides only
the amount), an HTTPCommitTransport posting commit payloads to the
directive commitUrl, and a MeteredSseStream that drains an SSE response
body and commits the final amount on Ack.
Mirror selectSolanaSessionChallenge from the TS reference: filter 402
challenges by the session intent, network (mainnet/mainnet-beta folded),
and currency via mint resolution, then prefer the client's funding
modes. SessionRequestModes encodes the omitted-or-empty modes means
push-only rule, and SelectSessionChallengeFromHeaders selects straight
from WWW-Authenticate header values.
Update the README matrix (mpp/session client ships, server does not),
spell out the in-scope and out-of-scope session surface including the
missing SessionFetch-style wrapper and its per-channel watermark-reset
semantics, align the session.go scope header with the payment-channel
openers, and stop framing signer.Generate as test-only now that
ephemeral session signers are a production path.
Replace dead-store zero-value initializations with var declarations in
the payment-channel builders (SA4006) and drop the unused rpcSendErr
test type (U1000) so the justfile lint recipe runs clean.
Exercise invalid operator, native-SOL currency, missing and malformed
blockhash rejection in the openers and open-tx builder, plus invalid
UTF-8, malformed metering JSON, and missing-directive Ack propagation
through MeteredSseStream.
Pluggable ChannelStore interface plus an in-memory implementation with
per-channel locking, mirroring rust ChannelStore/MemoryChannelStore in
rust/crates/mpp/src/store.rs and the TypeScript createMemorySessionStore
(typescript/packages/mpp/src/server/session/store.ts). ChannelState is a
field-for-field rust mirror with matching serde wire names; UpdateChannel
is the only mutation path so voucher verification can re-check state
atomically. Tests cover insert/update visibility, 50-way concurrent
serialization, failed-mutator rollback, list filters, and clone safety.
Pure VerifyVoucherForChannel mirroring SessionServer::verify_voucher in
rust/crates/mpp/src/server/session.rs and the TypeScript
server/session/voucher.ts, with the harness-tested check order: parse u64,
finalized, close pending, idempotent replay (signature re-verified),
strict monotonicity, deposit cap, minVoucherDelta, Ed25519 against the
stored authorizedSigner, expiry. Rejections carry the eight stable reason
tags from the TypeScript reference. Tests mirror the TS verifier matrix
plus adversarial ordering checks (every earlier step beats later
failures), forged-replay re-verification, and expired-replay rejection.
SessionServer with the off-chain session core, mirroring
rust/crates/mpp/src/server/session.rs and the off-chain half of the
TypeScript server/Session.ts:

- BuildChallengeRequest: cap clamped to MaxCap, minVoucherDelta only when
  positive, modes omitted when push-only, pullVoucherStrategy only when
  pull is offered.
- ProcessOpen: mode-advertised gate, deposit > 0 and at most cap, session
  keyed by channelId first then tokenAccount, atomic check-and-insert with
  idempotent replay that never resets the watermark; finalized or
  different-signer replays rejected.
- VerifyVoucher: full ordered preflight outside the lock, state-dependent
  re-checks inside the atomic mutator (finalized, close-pending, replay,
  concurrent watermark advance).
- ProcessTopUp: newDeposit must exceed current and stay within cap,
  rejected when finalized or close-pending.
- BeginDelivery: overflow-safe cumulative + pendingTotal + amount <=
  deposit reservation, sequence assignment, default deliveryId
  <sessionId>:<sequence>, duplicate-id rejection, directive expiry.
- ProcessCommit: reserved-amount enforcement and idempotent replay
  returning the cached receipt after re-verifying the voucher signature.
- ProcessClose: close-pending blocks vouchers/deliveries/commits/top-ups,
  double close rejected, non-monotonic final voucher is a hard error that
  leaves state untouched, idempotent replay of the highest voucher
  accepted.

On-chain verification is a seam: SessionConfig.VerifyOpenTx and
VerifyTopUpTx run before state is persisted when set (rust rpc_url
equivalent); nil trusts the payload as provided, for unit tests or
out-of-band verification. Tests mirror the rust unit suite and the
off-chain session-server.test.ts coverage, including legacy cumulative
alias acceptance end to end and racing-store interleavings that exercise
every in-mutator re-check.
Per-channel single-shot idle timers mirroring the TypeScript-only
extension in typescript/packages/mpp/src/server/session/lifecycle.ts:
Touch re-arms, RemoveChannel cancels, Shutdown cancels everything and
disables future touches, and a non-positive delay turns the watchdog into
a no-op. The rust SessionServer has no equivalent; hosts there drive close
explicitly.
Add the server-side settlement builders to paycore/paymentchannels,
mirroring rust/crates/mpp/src/program/payment_channels.rs and the
builders in typescript/packages/mpp/src/server/session/on-chain.ts:

- BuildEd25519VerifyInstruction: the Ed25519 precompile encoding with
  public key at offset 16, signature at 48, message at 112, and 0xFFFF
  current-instruction markers, locked by a byte-layout test ported from
  session-on-chain.test.ts.
- BuildSettleAndFinalizeInstructions: settle_and_finalize over the
  voucher watermark, with the precompile instruction placed immediately
  before it and hasVoucher=1 when a voucher signature is provided.
- BuildDistributeInstruction: the 10 fixed accounts in rust order plus
  one writable recipient ATA per split, deriving channel, payer, payee,
  and treasury token accounts for the given token program.
- TreasuryOwner and the Ed25519 precompile program id constants.

Tests pin the instruction bytes against the TypeScript golden vectors,
including the pre-Codama open-instruction golden from
payment-channels-open-ix.test.ts and Token-2022 ATA derivation.
Flip the mpp/session server column, split the session scope notes into
client and server halves describing the challenge issuance, credential
dispatch, on-chain open handling, metering side channel, SSE writer,
idle-close watchdog, and settlement path, and keep the
pull/operatedVoucher multi-delegate surface explicitly out of scope on
both sides.
…yground

Port typescript/examples/playground-api to Go on stdlib net/http: the same
endpoint surface (health/config catalog, faucet, docs browser, charge-gated
stocks/weather/marketplace with multi-recipient splits, the fortune payment
link with the HTML challenge page, session-gated metered SSE stream and
pay-per-call compute with the /__402 side channel and receipt poll, x402
demo routes plus the embedded facilitator endpoints), the same env vars,
and the same gating semantics, so the playground web app runs against it
by only setting PAYKIT_PLAYGROUND_API_URL.

Charges go through the paykit umbrella client (MPP-only accept, matching
the TS pay-kit MPP adapter), sessions through the Go session method with
server-completed channel opens and the 2s idle-close settle, and the x402
routes through the self-hosted Go x402 adapter. The subscription feed is a
documented 501 stub because the Go SDK has no subscription server method;
the catalog omits the entry exactly like the TS server does when its plan
bootstrap fails.
The offline smoke suite boots the full route table against a stub JSON-RPC
server and checks every endpoint's unauthenticated behavior: catalog shape,
MPP charge and session challenges, pre-gate validation, the HTML and
service-worker fortune challenges, side-channel input validation, the
facilitator shapes, x402 challenges, docs path-escape guards, and the CORS
payment-header exposure.

The surfpool-gated e2e mirrors playground-session-e2e.test.ts through the
real playground handler: faucet funding, a real payment-channel open
completed and broadcast by the server, the metered SSE stream, a
side-channel reserve and voucher commit, and the idle-close on-chain settle
confirmed via the receipt poll and getSignatureStatuses. It skips
explicitly when the sandbox is unreachable or under -short.
Mirror the TypeScript example README (setup, env table, endpoint table,
demo wiring via PAYKIT_PLAYGROUND_API_URL, test commands) and list every
divergence prominently: the subscription 501 stub, self-hosted x402
gating, the Yahoo public-endpoint stock data source, and the paykit 402
body shape.
…ht suite

Add a serve-playground justfile recipe and a playground-go job in go.yml
mirroring the TypeScript test-payment-links-demo job in ci.yml: start the
local surfnet proxy, boot the Go playground API on :3002 against it, wait
for /api/v1/health, and drive the html/ payment-link Playwright tests at
FORTUNE_PATH=/api/v1/fortune through the existing test:e2e:go script.
…cking

The push open missing-field guard only checked Transaction and ChannelID
for nil, so a credential with transaction set to the empty string and no
channelId slipped past it, fell through the transaction branch, and
dereferenced the nil ChannelID pointer. Treat empty strings as missing,
mirroring the falsy guard in the TypeScript handler (Session.ts) and
OpenPayload::session_id in rust, so the open rejects gracefully on the
inbound VerifyCredential path.
runClient injects both MPP_HARNESS_TARGET_URL and X402_HARNESS_TARGET_URL
on every client spawn, and the go adapter probed the x402 namespace first,
so MPP cells silently ran the x402 branch: the x402 transport cannot
answer an MPP challenge and reported the server's first 402, failing every
positive charge scenario for the go client (pre-existing on main, the cell
is opt-in and never ran in CI). Dispatch on the explicit per-scenario
PAY_KIT_HARNESS_PROTOCOL hint first, mirroring the go-server and
ruby-server adapters, and keep the namespace probe only as a fallback for
manual single-namespace runs.

go client x rust server charge matrix now passes 20/20 (was 5 failed) and
go client x typescript server passes 22/22 (was 7 failed); the CI-shaped
typescript x go + go-x402 matrix stays green at 23/23.
@EfeDurmaz16 EfeDurmaz16 force-pushed the feat/go-mpp-session branch from a5861b2 to 17cc056 Compare June 12, 2026 14:39
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 12, 2026
…reign vouchers

On a replayed CommitReceipt the server has already settled the delivery, so
its cumulative is authoritative. SessionConsumer::commit_directive now
reconciles the local watermark to that cumulative (advancing when behind, e.g.
a lost response, and never regressing) instead of recording the freshly
prepared higher voucher, which would let a later channel close sign for more
than was settled. ActiveSession::record_voucher also rejects a voucher whose
channel does not match the active session. Adds reconcile_settled plus
regression tests for reconcile, no-regress, and the foreign-channel guard.

Surfaced by Greptile/Codex on the Go and Python session ports (solana-foundation#160, solana-foundation#161).
@EfeDurmaz16 EfeDurmaz16 changed the title feat(go): MPP client-only sessions (session intent + payment-channels client) feat(go): mpp/session client and server + playground-api example Jun 12, 2026
writeJSON(w, http.StatusOK, quote)
})))

mux.Handle("GET /api/v1/stocks/search",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's implement the exact same endpoints we have in typescript/examples/playground-api

Comment thread go/examples/playground-api/constants.go Outdated
usdcDecimals = 6

// tokenProgram is the SPL Token program id.
tokenProgram = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This constants are very likely already available in paykit

Comment thread go/examples/playground-api/main.go Outdated
}

// paykitNetwork maps the playground NETWORK tag onto the paykit enum.
func paykitNetwork(tag string) (paykit.Network, error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

There should already be a helper in paykit

@@ -0,0 +1,432 @@
package main

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Let's clean up this example as much as possible - if we find ourselves writing code that could be helpful for others, we consolidate in pay-kit

Comment on lines +36 to +44
// Network is the Solana network the client wants to pay on, e.g.
// "mainnet", "mainnet-beta", "devnet", or "localnet". "mainnet" and
// "mainnet-beta" are treated as the same network.
Network string

// Currencies are the currency symbols or mint addresses the client wants
// to pay with. A challenge matches when its currency resolves to the same
// mint as any entry.
Currencies []string

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

should we be working with enums here?

Comment thread go/protocols/mpp/client/http_stream.go Outdated
Comment on lines +8 to +9
// Behavior mirrors rust/crates/mpp/src/client/http_stream.rs; the TypeScript
// counterpart is typescript/packages/mpp/src/client/HttpStream.ts.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we remove these comments?

Comment thread go/protocols/mpp/client/http_stream.go Outdated

// Next returns the next application message, or nil once the stream is done.
//
// Mirrors rust ReqwestMeteredSseStream::next.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Remove all these Mirrors comments.

Comment on lines +49 to +55
type ActiveSession struct {
channelID solana.PublicKey
cumulative uint64
nonce uint64
expiresAt int64
signer VoucherSigner
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Generally speaking, can we do a pass and get every single field of every single structure being commented?

…hapes

The Go playground served Yahoo chart metadata where the TypeScript
example serves yahoo-finance2 module results, so the quote, search,
and history bodies had different field sets. The Go example now calls
the same upstream endpoints as yahoo-finance2 (crumb-authenticated v7
quote, v1 search with the package's default parameters, v8 chart) and
applies the same coercions (epoch seconds to ISO millisecond strings,
'low - high' strings to {low, high} objects, indicator columns zipped
into per-day quote rows, dividend/split maps flattened to arrays), so
both servers return identical JSON. Also matches the typographic
apostrophes in the x402 joke strings and drops the now-stale stock
divergence notes from the README.
The example duplicated the USDC mint and the SPL Token / System
program ids that paycore already exports; reference those at the call
sites instead. Only the example-specific knobs (faucet amounts, the
USDC decimal count, which has no exported SDK equivalent) stay in
constants.go.
Network-tag parsing graduates from the example into paykit as the
exported ParseNetwork helper (short configure() tags, the legacy
mainnet-beta alias, and the canonical wire slugs, case-insensitive),
with table-driven coverage. The example's receipt logging now reuses
core.ParseReceipt instead of hand-decoding the header, and the city
normalizer rides strings.ToLower/ReplaceAll instead of a hand-rolled
rune loop.
The selector took the network as a raw string. paycore now exports the
SolanaNetwork slug type with the canonical cluster constants and a
ParseSolanaNetwork helper that folds the legacy mainnet-beta alias
(any case) onto mainnet, and the selector options carry the typed
value. Matching behavior and the wire format are unchanged; currency
entries stay strings since they accept symbols or mint addresses.
Review asked for the Mirrors-style comments to go. Every comment whose
only content was which reference file the code was ported from is
removed; comments that carried real constraints (cross-SDK byte-equal
wire formats, ordering guarantees, response-shape contracts) keep the
constraint and drop the file reference. File-top docs now describe
what each file does.
Review asked for every field of every structure to carry a comment.
Each declared-struct field in the files this PR adds or touches now
has a one-line doc comment stating its semantics: units (base units,
lamports, epoch seconds), encodings (base58, base64url, preimage
byte layout), zero-value meaning, and protocol role. Verified
mechanically with an AST pass that flags fields lacking a doc or
inline comment; the flagged count for the PR file set is now zero.
@lgalabru lgalabru merged commit 33282de into solana-foundation:main Jun 12, 2026
28 checks passed
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 13, 2026
…on#160)

Ports the server half of the mpp/session intent to the Python SDK, mirroring
go/protocols/mpp/server (solana-foundation#160) and the Rust spine (rust/crates/mpp/src/server/
session.rs) as the wire truth. Pairs with the client side already in this PR.

- session_store: ChannelStore + MemoryChannelStore with per-channel-locked
  read-modify-write, clone isolation, and Go-faithful nil-slice (null) delivery
  serialization for byte-parity durable records.
- session_voucher: offline voucher verifier (monotonicity, deposit bound,
  min-delta, signature, expiry, finalized/close-pending guards) in Go check order.
- session_lifecycle: idle-close watchdog.
- session_onchain: open/top-up transaction verification with an optional RPC
  liveness seam (a None client leaves the seam unset, exactly as Go).
- session_method: new_session + the open/voucher/commit/topUp/close handlers,
  including the re-drivable close (a closing channel with no settled signature
  re-drives rather than hard-rejecting, matching Go handleClose) and the
  pull-mode-requires-strategy method-layer guard.
- session_stream: metered streaming writer.
- session_routes / session: HTTP routes with strict typed decode (wrong JSON
  types rejected up front with 400 "invalid request body", matching Go), and
  the public SessionServer entry.

Server-side on-chain settlement broadcast (SubmitOpenTx / SettlementInstructions)
is deferred: it needs a Python transaction-broadcast layer (signer send +
confirm) that does not exist yet. The offline verification core, lifecycle,
routes, and store are complete and tested.

170+ tests across the new modules; full suite 1141 passing, coverage 94.6%.
EfeDurmaz16 added a commit to EfeDurmaz16/mpp-sdk that referenced this pull request Jun 13, 2026
…ation#160)

Ports go/examples/playground-api to a FastAPI app under
python/examples/playground_api/, mirroring the Go module split and route
table: charges (stocks/marketplace/weather with fee splits), sessions
(metered SSE stream + compute + deliveries/commit side channel + receipt
poll, driven by the new mpp/session server), x402 (fact/joke), faucet, docs
browser, health/config catalog, and a subscription parity stub (501).

Wires the already-ported Python mpp session server and charge/x402 surfaces;
reuses pay_kit rather than reimplementing. Added a 'playground' extra
(fastapi + uvicorn). Run: python -m examples.playground_api.main.

The dir uses an underscore (playground_api) so it is an importable package
for the multi-file relative imports, unlike the single-file examples.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants