Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
adfa679
fix(mpp): bind session state to confirmed channels
EfeDurmaz16 Jul 10, 2026
34e7456
fix(mpp): preserve completed open transaction binding
EfeDurmaz16 Jul 10, 2026
5042a76
fix(python): preserve settlement destination setup
EfeDurmaz16 Jul 10, 2026
b335306
test(python): pin settlement destination setup
EfeDurmaz16 Jul 10, 2026
be02556
test(rust): name session mutation callback
EfeDurmaz16 Jul 10, 2026
bd93d24
fix(mpp): fetch confirmed top-up transactions
EfeDurmaz16 Jul 11, 2026
e20cc52
fix(mpp): align confirmed session state checks
EfeDurmaz16 Jul 11, 2026
9c4a207
fix(rust): decode top-up transactions from wire
EfeDurmaz16 Jul 11, 2026
602a09e
fix(mpp): bind session state to confirmed transactions
EfeDurmaz16 Jul 11, 2026
cc740a2
test(ci): execute Rust session security guards
EfeDurmaz16 Jul 11, 2026
c575a9b
Merge remote-tracking branch 'origin/split/pr216-ci-harness-mega' int…
EfeDurmaz16 Jul 11, 2026
7e1826b
fix(typescript): deduplicate canonical split validation
EfeDurmaz16 Jul 11, 2026
37b3a22
fix(mpp): make session settlement retry-safe
EfeDurmaz16 Jul 11, 2026
dd50cb7
fix(mpp): persist pending settlement state
EfeDurmaz16 Jul 11, 2026
3cdd53f
fix(mpp): recover settlement claims after crashes
EfeDurmaz16 Jul 11, 2026
ff7b468
fix(mpp): persist settlement transaction outbox
EfeDurmaz16 Jul 11, 2026
bdede6d
fix(mpp): retire expired settlement outboxes
EfeDurmaz16 Jul 11, 2026
d716f5f
fix(mpp): require settlement expiry recovery
EfeDurmaz16 Jul 11, 2026
bf2076e
fix(typescript): validate settlement rpc at startup
EfeDurmaz16 Jul 11, 2026
15d92d7
fix(go): simplify verified open state
EfeDurmaz16 Jul 11, 2026
dbc5e2a
fix(mpp): satisfy session static analysis
EfeDurmaz16 Jul 11, 2026
e201d6a
fix(mpp): reject unusable settlement clients
EfeDurmaz16 Jul 12, 2026
3f0ba55
fix(python): verify session transactions from wire
EfeDurmaz16 Jul 12, 2026
e8e45a5
fix(mpp): bind session top-ups to channel state
EfeDurmaz16 Jul 12, 2026
7ecf8e1
fix(python): align session RPC protocol
EfeDurmaz16 Jul 12, 2026
e212cfb
merge: union base for #239 rebuild (#237+#238 stacked, +#228 python)
EfeDurmaz16 Jul 13, 2026
e29c446
fix(mpp): bind Rust session state to confirmed on-chain channels
EfeDurmaz16 Jul 13, 2026
0285194
fix(mpp): bind TypeScript session state to the confirmed channel account
EfeDurmaz16 Jul 13, 2026
600577d
fix(mpp): bind Go session state to the confirmed channel account
EfeDurmaz16 Jul 13, 2026
f00c322
fix(mpp): bind Python session open to the confirmed channel account
EfeDurmaz16 Jul 13, 2026
e8febc8
test(mpp): sign Go session open fixtures for the hardened verifier
EfeDurmaz16 Jul 13, 2026
4bbe09e
merge: #239 Python session open-state binding (rebuild/pr239-python)
EfeDurmaz16 Jul 13, 2026
2dbae8c
merge: #239 TypeScript session channel-account binding (rebuild/pr239…
EfeDurmaz16 Jul 13, 2026
2cd5227
style(mpp): drop em-dash from Rust session error string
EfeDurmaz16 Jul 13, 2026
1e59ae1
test(mpp): reconcile Go session tests to union-base open verification
EfeDurmaz16 Jul 14, 2026
7012008
fix(go): accept the unsigned fee-payer slot in the server-submit open…
EfeDurmaz16 Jul 14, 2026
d6a015d
fix(mpp): type the session open-confirmation slot as int|None and ref…
EfeDurmaz16 Jul 14, 2026
677cc51
test(mpp): cover Go session on-chain verifier fail-closed rejection p…
EfeDurmaz16 Jul 14, 2026
de04b25
style(mpp): format server store re-exports; annotate channel-store gu…
EfeDurmaz16 Jul 14, 2026
0482794
test(mpp): provide recentSlot in the session open defect-verifier fix…
EfeDurmaz16 Jul 14, 2026
6e277d9
style(mpp): move the channel-store nosemgrep marker onto its own line
EfeDurmaz16 Jul 14, 2026
cafaf6c
Merge remote-tracking branch 'origin/fix/mpp-subscription-hardening' …
EfeDurmaz16 Jul 15, 2026
d31fcdf
test(harness): compile a real signed wire in the close-settle fixture
EfeDurmaz16 Jul 15, 2026
5bdf373
Merge remote-tracking branch 'origin/fix/mpp-session-state-hardening'…
EfeDurmaz16 Jul 15, 2026
d92facb
fix(python): align the playground example with the hardened SessionOp…
EfeDurmaz16 Jul 15, 2026
5c3a383
Merge remote-tracking branch 'origin/fix/mpp-subscription-hardening' …
EfeDurmaz16 Jul 15, 2026
e93febf
Merge remote-tracking branch 'origin/fix/python-security-hardening' i…
EfeDurmaz16 Jul 15, 2026
16592d2
Merge remote-tracking branch 'origin/fix/python-security-hardening' i…
EfeDurmaz16 Jul 15, 2026
19ba70a
Merge remote-tracking branch 'origin/fix/python-security-hardening' i…
EfeDurmaz16 Jul 15, 2026
9986000
Merge remote-tracking branch 'origin/fix/python-security-hardening' i…
EfeDurmaz16 Jul 15, 2026
0c9afda
Merge remote-tracking branch 'origin/fix/python-security-hardening' i…
EfeDurmaz16 Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,13 @@ jobs:
working-directory: rust
run: cargo test --locked -p solana-pay-kit --lib x402 --features server,client

# The session implementation is gated behind the server feature. Keep a
# real execution gate here: compiling the feature does not run the
# on-chain binding and transaction-shape regressions in its test module.
- name: Run solana-pay-kit Rust session server tests
working-directory: rust
run: cargo test --locked -p solana-pay-kit --lib mpp::server::session --features mpp,server,client -- --test-threads=1

# Gate self-activation: the mature coverage gate (scripts/check-rust-coverage.py,
# with source-scope inventory + owned per-file exemptions + its own self-test)
# lands with the rust/harness hardening leaves of the #216 redelivery cascade.
Expand Down
43 changes: 35 additions & 8 deletions go/internal/testutil/fakes.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,14 @@ func NewPrivateKey() solana.PrivateKey {
type FakeRPC struct {
mu sync.Mutex

Blockhash solana.Hash
MintOwners map[string]solana.PublicKey
Statuses map[string]*rpc.SignatureStatusesResult
BySig map[string]*solana.Transaction
Blockhash solana.Hash
BlockHeight uint64
BlockHeightErr error
MintOwners map[string]solana.PublicKey
Accounts map[string]*rpc.Account
Statuses map[string]*rpc.SignatureStatusesResult
BySig map[string]*solana.Transaction
LastAccountInfoOpts *rpc.GetAccountInfoOpts

SimulateErr error
SendErr error
Expand All @@ -51,17 +55,31 @@ func NewFakeRPC() *FakeRPC {
return &FakeRPC{
Blockhash: blockhash,
MintOwners: map[string]solana.PublicKey{},
Accounts: map[string]*rpc.Account{},
Statuses: map[string]*rpc.SignatureStatusesResult{},
BySig: map[string]*solana.Transaction{},
}
}

// GetAccountInfoWithOpts looks up the canned mint owner registered for
// account; returns rpc.ErrNotFound when the account is unknown so the
// SDK exercises the same not-found branch as a live RPC.
func (f *FakeRPC) GetAccountInfoWithOpts(_ context.Context, account solana.PublicKey, _ *rpc.GetAccountInfoOpts) (*rpc.GetAccountInfoResult, error) {
// SetAccount registers a complete account for state-binding tests.
func (f *FakeRPC) SetAccount(account solana.PublicKey, owner solana.PublicKey, data []byte) {
f.mu.Lock()
defer f.mu.Unlock()
f.Accounts[account.String()] = &rpc.Account{
Owner: owner,
Data: rpc.DataBytesOrJSONFromBytes(data),
}
}

// GetAccountInfoWithOpts serves complete accounts first, then owner-only mint
// fixtures. Unknown accounts follow the live RPC not-found path.
func (f *FakeRPC) GetAccountInfoWithOpts(_ context.Context, account solana.PublicKey, opts *rpc.GetAccountInfoOpts) (*rpc.GetAccountInfoResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.LastAccountInfoOpts = opts
if acct, ok := f.Accounts[account.String()]; ok {
return &rpc.GetAccountInfoResult{Value: acct}, nil
}
owner, ok := f.MintOwners[account.String()]
if !ok {
return nil, rpc.ErrNotFound
Expand All @@ -80,6 +98,14 @@ func (f *FakeRPC) GetLatestBlockhash(_ context.Context, _ rpc.CommitmentType) (*
}, nil
}

// GetBlockHeight returns the canned block height or configured error.
func (f *FakeRPC) GetBlockHeight(_ context.Context, _ rpc.CommitmentType) (uint64, error) {
if f.BlockHeightErr != nil {
return 0, f.BlockHeightErr
}
return f.BlockHeight, nil
}

// GetSignatureStatuses returns the canned per-signature status, falling
// back to a confirmed status so the WaitForConfirmation poll completes
// in a single round when no override is registered.
Expand All @@ -94,6 +120,7 @@ func (f *FakeRPC) GetSignatureStatuses(_ context.Context, _ bool, signatures ...
}
values = append(values, &rpc.SignatureStatusesResult{
ConfirmationStatus: rpc.ConfirmationStatusConfirmed,
Slot: 1,
})
}
return &rpc.GetSignatureStatusesResult{Value: values}, nil
Expand Down
149 changes: 128 additions & 21 deletions go/protocols/mpp/server/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,19 @@ package server
// once the close-pending state is recorded.
//
// On-chain verification is a seam in this layer: when
// SessionConfig.VerifyOpenTx / VerifyTopUpTx are set, ProcessOpen (push mode)
// SessionConfig.VerifyOpenStateTx / VerifyTopUpStateTx are set, ProcessOpen
// and ProcessTopUp invoke them before persisting channel state, binding the
// payload to the attached transaction and confirming the signature on-chain.
// When nil, the transaction signature and
// deposit amount are trusted as provided, which is suitable only for unit
// tests or deployments that verify transactions out of band.
// payload to authoritative transaction or account facts. The legacy
// VerifyOpenTx / VerifyTopUpTx hooks remain available for compatibility; a
// legacy open verifier is accepted only on localnet. Off-localnet payment
// channel opens require VerifyOpenStateTx so a confirmed signature cannot
// authorize a payload-supplied deposit.

import (
"context"
"fmt"
"math"
"os"
"slices"
"strconv"
"time"
Expand Down Expand Up @@ -64,6 +66,16 @@ type SessionTxVerifier[P any] func(ctx context.Context, payload *P) (string, err
// turn a correctly verified delta into a larger unbacked cap.
type TopUpTxVerifier func(ctx context.Context, payload *intents.TopUpPayload, currentDeposit uint64) error

// SessionOpenStateTxVerifier verifies a payment-channel open and returns the
// authoritative facts extracted from the verified transaction or channel
// account. It is additive to SessionTxVerifier so existing integrations that
// only return the channel payer remain source-compatible.
type SessionOpenStateTxVerifier func(ctx context.Context, payload *intents.OpenPayload) (VerifyOpenTxResult, error)

// SessionTopUpTxVerifier binds a top-up transaction to both its resulting
// on-chain account and the channel identity already held in the store.
type SessionTopUpTxVerifier func(ctx context.Context, payload *intents.TopUpPayload, current ChannelState) error

// SessionConfig is the server configuration for the session intent.
type SessionConfig struct {
// Operator public key (base58). Shown to clients in the challenge.
Expand Down Expand Up @@ -120,14 +132,30 @@ type SessionConfig struct {
// Required when Modes includes pull.
PullVoucherStrategy *intents.SessionPullVoucherStrategy

// VerifyOpenTx, when set, confirms the open transaction on-chain (push
// mode) before ProcessOpen persists channel state. See SessionTxVerifier.
// VerifyOpenTx is the legacy payer-only hook retained for API compatibility.
// It may verify an open on localnet, but off-localnet payment-channel opens
// require VerifyOpenStateTx so payload claims are never persisted as facts.
VerifyOpenTx SessionTxVerifier[intents.OpenPayload]

// VerifyOpenStateTx is the stronger open verifier. When set, ProcessOpen
// persists its returned deposit, salt, payer, and open seeds instead of
// payload claims. It is required for payment-channel opens off localnet.
VerifyOpenStateTx SessionOpenStateTxVerifier

// VerifyTopUpTx, when set, verifies that the confirmed top-up instruction
// targets this channel and funds exactly the claimed deposit delta before
// ProcessTopUp raises the deposit.
// ProcessTopUp raises the deposit. New integrations should prefer
// VerifyTopUpStateTx, which additionally binds the resulting on-chain
// channel account state.
VerifyTopUpTx TopUpTxVerifier

// VerifyTopUpStateTx binds the top-up to the stored channel identity and
// resulting on-chain account state. Required for top-ups off localnet.
VerifyTopUpStateTx SessionTopUpTxVerifier

// AllowUnsafeEphemeralStoreOffLocalnet explicitly permits a process-local
// store outside localnet. This is unsafe for multi-instance deployments.
AllowUnsafeEphemeralStoreOffLocalnet bool
}

// DeliveryRequest is a request to reserve a metered delivery for client-side
Expand Down Expand Up @@ -254,6 +282,9 @@ func (s *SessionServer) supportsMode(mode intents.SessionMode) bool {
// sealed or when the payload's authorized signer differs from the stored
// one.
func (s *SessionServer) ProcessOpen(ctx context.Context, payload *intents.OpenPayload) (ChannelState, error) {
if err := s.requireProductionSessionSafety(); err != nil {
return ChannelState{}, err
}
if !s.supportsMode(payload.Mode) {
return ChannelState{}, fmt.Errorf("session mode %q is not supported by this challenge", payload.Mode)
}
Expand All @@ -262,9 +293,39 @@ func (s *SessionServer) ProcessOpen(ctx context.Context, payload *intents.OpenPa
if err != nil {
return ChannelState{}, err
}
deposit, err := payload.DepositAmount()
if err != nil {
return ChannelState{}, err
paymentChannelBacked := payload.Mode == intents.SessionModePush || payload.Transaction != nil
if paymentChannelBacked && s.config.Network != "localnet" && s.config.VerifyOpenStateTx == nil {
return ChannelState{}, fmt.Errorf("payment-channel open requires an on-chain verifier with authoritative state off localnet")
}

// Transaction-backed pull opens are payment-channel opens too and must pass
// the same verifier as push opens.
var verifiedPayer string
var verifiedState *VerifyOpenTxResult
var deposit uint64
if paymentChannelBacked && s.config.VerifyOpenStateTx != nil {
verified, err := s.config.VerifyOpenStateTx(ctx, payload)
if err != nil {
return ChannelState{}, fmt.Errorf("open tx verification failed: %w", err)
}
if verified.ChannelID != "" && verified.ChannelID != sessionID {
return ChannelState{}, fmt.Errorf("verified open channel %s != session %s", verified.ChannelID, sessionID)
}
verifiedState = &verified
deposit = verified.Deposit
verifiedPayer = verified.Payer
} else {
var err error
deposit, err = payload.DepositAmount()
if err != nil {
return ChannelState{}, err
}
if paymentChannelBacked && s.config.VerifyOpenTx != nil {
verifiedPayer, err = s.config.VerifyOpenTx(ctx, payload)
if err != nil {
return ChannelState{}, fmt.Errorf("open tx verification failed: %w", err)
}
}
}
if deposit == 0 {
return ChannelState{}, fmt.Errorf("deposit must be greater than zero")
Expand All @@ -273,15 +334,15 @@ func (s *SessionServer) ProcessOpen(ctx context.Context, payload *intents.OpenPa
return ChannelState{}, fmt.Errorf("deposit %d exceeds max cap %d", deposit, s.config.MaxCap)
}

// On-chain verification seam (push mode only; pull-mode host integrations
// submit server-broadcast transactions or validate delegated-token state
// before invoking this lower-level store method).
var verifiedPayer string
if payload.Mode == intents.SessionModePush && s.config.VerifyOpenTx != nil {
var err error
verifiedPayer, err = s.config.VerifyOpenTx(ctx, payload)
if err != nil {
return ChannelState{}, fmt.Errorf("open tx verification failed: %w", err)
if verifiedState != nil {
if err := validateAssertedOpenDeposit(payload, verifiedState.Deposit); err != nil {
return ChannelState{}, err
}
if verifiedState.Deposit == 0 {
return ChannelState{}, fmt.Errorf("verified open deposit must be greater than zero")
}
if verifiedState.Deposit > s.config.MaxCap {
return ChannelState{}, fmt.Errorf("verified open deposit %d exceeds max cap %d", verifiedState.Deposit, s.config.MaxCap)
}
}

Expand All @@ -299,6 +360,10 @@ func (s *SessionServer) ProcessOpen(ctx context.Context, payload *intents.OpenPa
OpenSlot: openSlotFromPayload(payload),
Operator: operator,
}
if verifiedState != nil {
fresh.OpenSlot = verifiedState.OpenSlot
fresh.Salt = verifiedState.Salt
}

// Atomic check-and-insert: a replayed open re-passes all checks above
// (the referenced tx is genuinely confirmed), so it MUST NOT overwrite
Expand Down Expand Up @@ -389,6 +454,9 @@ func (s *SessionServer) VerifyVoucher(ctx context.Context, payload *intents.Vouc
// it is the cumulative-as-nonce contract that makes a re-submitted voucher a
// no-charge no-op rather than a fresh serve.
func (s *SessionServer) VerifyVoucherDetailed(ctx context.Context, payload *intents.VoucherPayload) (VoucherAcceptance, error) {
if err := s.requireProductionSessionSafety(); err != nil {
return VoucherAcceptance{}, err
}
voucher := payload.Voucher
channelID := voucher.Data.ChannelID

Expand Down Expand Up @@ -477,6 +545,12 @@ func (s *SessionServer) VerifyVoucherDetailed(ctx context.Context, payload *inte
// configured max cap. Top-ups are rejected once the channel is sealed or a
// close has been requested.
func (s *SessionServer) ProcessTopUp(ctx context.Context, payload *intents.TopUpPayload) (ChannelState, error) {
if err := s.requireProductionSessionSafety(); err != nil {
return ChannelState{}, err
}
if s.config.Network != "localnet" && s.config.VerifyTopUpStateTx == nil {
return ChannelState{}, fmt.Errorf("payment-channel top-up requires a state-aware on-chain verifier off localnet")
}
newDeposit, err := strconv.ParseUint(payload.NewDeposit, 10, 64)
if err != nil {
return ChannelState{}, fmt.Errorf("invalid newDeposit: %s", payload.NewDeposit)
Expand All @@ -485,7 +559,8 @@ func (s *SessionServer) ProcessTopUp(ctx context.Context, payload *intents.TopUp
// Fetch the deposit snapshot before external transaction verification. The
// final mutator below insists this value is unchanged, preventing a
// concurrent top-up from invalidating the verified delta between RPC fetch
// and persistence.
// and persistence. The snapshot is also the channel identity bound by the
// resulting on-chain state check below.
channelID := payload.ChannelID
current, err := s.store.GetChannel(ctx, channelID)
if err != nil {
Expand All @@ -503,6 +578,11 @@ func (s *SessionServer) ProcessTopUp(ctx context.Context, payload *intents.TopUp
return ChannelState{}, fmt.Errorf("top-up tx verification failed: %w", err)
}
}
if s.config.VerifyTopUpStateTx != nil {
if err := s.config.VerifyTopUpStateTx(ctx, payload, *current); err != nil {
return ChannelState{}, fmt.Errorf("top-up tx verification failed: %w", err)
}
}

maxCap := s.config.MaxCap
return s.store.UpdateChannel(ctx, channelID, func(current *ChannelState) (ChannelState, error) {
Expand Down Expand Up @@ -537,13 +617,31 @@ func (s *SessionServer) ProcessTopUp(ctx context.Context, payload *intents.TopUp
})
}

func (s *SessionServer) requireProductionSessionSafety() error {
// Localnet and the explicit config field are dev escapes. The
// PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE env var is the canonical opt-in the
// method-layer construction gate honors, so this defense-in-depth check
// honors it too rather than contradicting a session the constructor allowed.
if s.config.Network == "localnet" || s.config.AllowUnsafeEphemeralStoreOffLocalnet ||
os.Getenv(allowInMemoryReplayStoreEnvVar) == "1" {
return nil
}
if !isDurableSharedSessionStore(s.store) {
return fmt.Errorf("%s", sessionStoreSafetyMessage(s.store))
}
return nil
}

// BeginDelivery reserves capacity for a delivered message/response and
// returns the metering directive the client must commit after processing it.
//
// The reservation requires cumulative + pendingTotal + amount <= deposit,
// assigns the next sequence, and defaults the delivery id to
// "<sessionId>:<sequence>".
func (s *SessionServer) BeginDelivery(ctx context.Context, request DeliveryRequest) (intents.MeteringDirective, error) {
if err := s.requireProductionSessionSafety(); err != nil {
return intents.MeteringDirective{}, err
}
if request.Amount == 0 {
return intents.MeteringDirective{}, fmt.Errorf("delivery amount must be greater than zero")
}
Expand Down Expand Up @@ -642,6 +740,9 @@ func fitsInDeposit(cumulative, pendingTotal, amount, deposit uint64) bool {
// cached receipt with status replayed after re-verifying the voucher
// signature.
func (s *SessionServer) ProcessCommit(ctx context.Context, payload *intents.CommitPayload) (intents.CommitReceipt, error) {
if err := s.requireProductionSessionSafety(); err != nil {
return intents.CommitReceipt{}, err
}
channelID := payload.Voucher.Data.ChannelID
newCumulative, err := strconv.ParseUint(payload.Voucher.Data.Cumulative, 10, 64)
if err != nil {
Expand Down Expand Up @@ -799,6 +900,9 @@ func findCommitted(deliveries []CommittedDelivery, deliveryID string) *Committed
// by the host after this returns; see MarkSealed for the post-settlement
// transition.
func (s *SessionServer) ProcessClose(ctx context.Context, payload *intents.ClosePayload) (ChannelState, error) {
if err := s.requireProductionSessionSafety(); err != nil {
return ChannelState{}, err
}
now := uint64(time.Now().Unix())
channelID := payload.ChannelID
voucher := payload.Voucher
Expand Down Expand Up @@ -863,6 +967,9 @@ func (s *SessionServer) ProcessClose(ctx context.Context, payload *intents.Close
// MarkSealed marks a channel as sealed. Call after the on-chain
// seal transaction confirms.
func (s *SessionServer) MarkSealed(ctx context.Context, channelID string) error {
if err := s.requireProductionSessionSafety(); err != nil {
return err
}
_, err := s.store.MarkSealed(ctx, channelID)
return err
}
Loading
Loading