feat(go): mpp/session client and server + playground-api example#160
Conversation
Greptile SummaryThis 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
Confidence Score: 5/5Safe 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
Sequence DiagramsequenceDiagram
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
Reviews (8): Last reviewed commit: "refactor(go): delegate VoucherData.Messa..." | Re-trigger Greptile |
| 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 |
There was a problem hiding this comment.
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.
| // 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 | ||
| } |
There was a problem hiding this comment.
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!
|
|
||
| if _, err := builder.ValidateAndBuild(); err != nil { | ||
| return nil, fmt.Errorf("build open instruction: %w", err) | ||
| } | ||
| return materialize(builder, builder.GetAccounts()) |
There was a problem hiding this comment.
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!
| // 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 | ||
| } |
There was a problem hiding this comment.
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.
| // 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 | |
| } |
…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).
1ea204e to
473f854
Compare
…reign vouchers On a replayed CommitReceipt the server has already settled the delivery, so its cumulative is authoritative. SessionConsumer::commit_directive now reconciles the local watermark to that cumulative (advancing when behind, e.g. a lost response, and never regressing) instead of recording the freshly prepared higher voucher, which would let a later channel close sign for more than was settled. ActiveSession::record_voucher also rejects a voucher whose channel does not match the active session. Adds reconcile_settled plus regression tests for reconcile, no-regress, and the foreign-channel guard. Surfaced by Greptile/Codex on the Go and Python session ports (solana-foundation#160, solana-foundation#161).
Addresses Greptile + Codex review of solana-foundation#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).
473f854 to
d334a30
Compare
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.
a5861b2 to
17cc056
Compare
…reign vouchers On a replayed CommitReceipt the server has already settled the delivery, so its cumulative is authoritative. SessionConsumer::commit_directive now reconciles the local watermark to that cumulative (advancing when behind, e.g. a lost response, and never regressing) instead of recording the freshly prepared higher voucher, which would let a later channel close sign for more than was settled. ActiveSession::record_voucher also rejects a voucher whose channel does not match the active session. Adds reconcile_settled plus regression tests for reconcile, no-regress, and the foreign-channel guard. Surfaced by Greptile/Codex on the Go and Python session ports (solana-foundation#160, solana-foundation#161).
| writeJSON(w, http.StatusOK, quote) | ||
| }))) | ||
|
|
||
| mux.Handle("GET /api/v1/stocks/search", |
There was a problem hiding this comment.
Let's implement the exact same endpoints we have in typescript/examples/playground-api
| usdcDecimals = 6 | ||
|
|
||
| // tokenProgram is the SPL Token program id. | ||
| tokenProgram = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" |
There was a problem hiding this comment.
This constants are very likely already available in paykit
| } | ||
|
|
||
| // paykitNetwork maps the playground NETWORK tag onto the paykit enum. | ||
| func paykitNetwork(tag string) (paykit.Network, error) { |
There was a problem hiding this comment.
There should already be a helper in paykit
| @@ -0,0 +1,432 @@ | |||
| package main | |||
There was a problem hiding this comment.
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
| // 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 |
There was a problem hiding this comment.
should we be working with enums here?
| // Behavior mirrors rust/crates/mpp/src/client/http_stream.rs; the TypeScript | ||
| // counterpart is typescript/packages/mpp/src/client/HttpStream.ts. |
There was a problem hiding this comment.
Can we remove these comments?
|
|
||
| // Next returns the next application message, or nil once the stream is done. | ||
| // | ||
| // Mirrors rust ReqwestMeteredSseStream::next. |
There was a problem hiding this comment.
Remove all these Mirrors comments.
| type ActiveSession struct { | ||
| channelID solana.PublicKey | ||
| cumulative uint64 | ||
| nonce uint64 | ||
| expiresAt int64 | ||
| signer VoucherSigner | ||
| } |
There was a problem hiding this comment.
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.
…on#160) Ports the server half of the mpp/session intent to the Python SDK, mirroring go/protocols/mpp/server (solana-foundation#160) and the Rust spine (rust/crates/mpp/src/server/ session.rs) as the wire truth. Pairs with the client side already in this PR. - session_store: ChannelStore + MemoryChannelStore with per-channel-locked read-modify-write, clone isolation, and Go-faithful nil-slice (null) delivery serialization for byte-parity durable records. - session_voucher: offline voucher verifier (monotonicity, deposit bound, min-delta, signature, expiry, finalized/close-pending guards) in Go check order. - session_lifecycle: idle-close watchdog. - session_onchain: open/top-up transaction verification with an optional RPC liveness seam (a None client leaves the seam unset, exactly as Go). - session_method: new_session + the open/voucher/commit/topUp/close handlers, including the re-drivable close (a closing channel with no settled signature re-drives rather than hard-rejecting, matching Go handleClose) and the pull-mode-requires-strategy method-layer guard. - session_stream: metered streaming writer. - session_routes / session: HTTP routes with strict typed decode (wrong JSON types rejected up front with 400 "invalid request body", matching Go), and the public SessionServer entry. Server-side on-chain settlement broadcast (SubmitOpenTx / SettlementInstructions) is deferred: it needs a Python transaction-broadcast layer (signer send + confirm) that does not exist yet. The offline verification core, lifecycle, routes, and store are complete and tested. 170+ tests across the new modules; full suite 1141 passing, coverage 94.6%.
…ation#160) Ports go/examples/playground-api to a FastAPI app under python/examples/playground_api/, mirroring the Go module split and route table: charges (stocks/marketplace/weather with fee splits), sessions (metered SSE stream + compute + deliveries/commit side channel + receipt poll, driven by the new mpp/session server), x402 (fact/joke), faucet, docs browser, health/config catalog, and a subscription parity stub (501). Wires the already-ported Python mpp session server and charge/x402 surfaces; reuses pay_kit rather than reimplementing. Added a 'playground' extra (fastapi + uvicorn). Run: python -m examples.playground_api.main. The dir uses an underscore (playground_api) so it is an importable package for the multi-file relative imports, unlike the single-file examples.
feat(go): mpp/session client and server
Comprehensive
mpp/sessionsupport for the Go SDK, rebased onto main after #165 and aligned with the rewrittenskills/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:
SessionRequest, the taggedSessionActionunion,OpenPayload,VoucherData/SignedVoucher,CommitPayload/CommitReceipt,TopUpPayload,ClosePayload,MeteringDirective,MeteringUsage) ingo/protocols/mpp/intents/session.go, mirroringrust/crates/mpp/src/protocol/intents/session.rsfield 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), thecumulativedecode alias withcumulativeAmountthe only serialized name,SessionID()keyed channelId first with tokenAccount fallback,topUp.newDepositas the new total, andDefaultSessionExpiresAt = 4_102_444_800.go/protocols/programs/paymentchannels/(recipe inskills/pay-sdk-implementation/codegen/, reproducible viagenerate-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.channel_id || cumulative u64 LE || expires_at i64 LE) has a single packer thatVoucherData.MessageBytesdelegates to, and is pinned by cross-SDK conformance vectors (harness/vectors/session-voucher.json, including a near-u64-max cumulative case) implemented ingo/cmd/conformance.Client layer, keyed to the skill's component inventory:
ParseSessionChallengeplus challenge selection with mode gating inclient/challenge_selection.go, encoding modes empty or omitted as push-only (TSsessionRequestModessemantics, Rust membership checks).client/payment_channels.go, ported line by line fromrust/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 challengerecentBlockhash, thePENDING_SERVER_SIGNATUREplaceholder (64 ones), and standard base64 with padding for the wire transaction. A per-call program id can be threaded through for non-mainnet clusters.ActiveSessionsigns 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.SessionConsumervalidates the directive session, prepares the voucher forwatermark + amount, and commits with the directive'sdeliveryId. On areplayedreceipt it reconciles the watermark tomin(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.client/http_stream.go, portingrust/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'sdeliveryIdmatches 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.tsandserver/session/*) andrust/crates/mpp/src/server/session.rs:go/protocols/mpp/server/session_store.go: per-channel state store with an atomicUpdateChannelread-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-chainSessionServercore (challenge request building with cap clamping, open/voucher/commit/topUp/close handlers, reservation accountingcumulative + 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 Rustrpc_url = None. Server-broadcast open submission completes the fee-payer signature and broadcasts, recording the settled signature on channel state.session_method.goandsession_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 oftypescript/examples/playground-apiwith endpoint parity for the playground web app. Charges (stock quote, marketplace splits, fortune payment link, faucet), sessions (/sessions/streampay-per-chunk SSE and/sessions/computepay-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. SettingPAYKIT_PLAYGROUND_API_URLpoints the playground'spnpm devat 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
SessionConsumerintentionally 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.go/protocols/programs/paymentchannels/is codegen output and is carved out of the coverage gate alongside the examples; the curated layer ingo/paycore/paymentchannelsis fully covered.mpp/sessionshipped 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.session_e2e_test.goandplayground_e2e_test.godrive 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.harness/vectors/session-voucher.jsonpins the voucher preimage cross-SDK.go/examples/playground-apiand runs the payment-link Playwright suite against it; the harness matrix job honorsPAY_KIT_HARNESS_PROTOCOLin the Go client adapter.gofmtclean; lint configured to skip the generated client only.