diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c2192325..98c68af28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. diff --git a/go/internal/testutil/fakes.go b/go/internal/testutil/fakes.go index f7a1d1fb4..2195011b1 100644 --- a/go/internal/testutil/fakes.go +++ b/go/internal/testutil/fakes.go @@ -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 @@ -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 @@ -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. @@ -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 diff --git a/go/protocols/mpp/server/session.go b/go/protocols/mpp/server/session.go index 02a8f9562..32897b99c 100644 --- a/go/protocols/mpp/server/session.go +++ b/go/protocols/mpp/server/session.go @@ -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" @@ -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. @@ -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 @@ -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) } @@ -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") @@ -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) } } @@ -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 @@ -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 @@ -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) @@ -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 { @@ -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) { @@ -537,6 +617,21 @@ 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. // @@ -544,6 +639,9 @@ func (s *SessionServer) ProcessTopUp(ctx context.Context, payload *intents.TopUp // assigns the next sequence, and defaults the delivery id to // ":". 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") } @@ -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 { @@ -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 @@ -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 } diff --git a/go/protocols/mpp/server/session_method.go b/go/protocols/mpp/server/session_method.go index 8105ac2bd..9fc493c03 100644 --- a/go/protocols/mpp/server/session_method.go +++ b/go/protocols/mpp/server/session_method.go @@ -17,15 +17,20 @@ package server import ( "context" + "crypto/rand" + "encoding/hex" + "errors" "fmt" "log/slog" "os" + "reflect" "strconv" "time" solana "github.com/solana-foundation/solana-go/v2" "github.com/solana-foundation/solana-go/v2/rpc" + "github.com/solana-foundation/pay-kit/go/paycore" "github.com/solana-foundation/pay-kit/go/paycore/solanatx" core "github.com/solana-foundation/pay-kit/go/protocols/mpp/core" "github.com/solana-foundation/pay-kit/go/protocols/mpp/intents" @@ -35,6 +40,14 @@ import ( // transaction. type OpenTxSubmitter string +// SettlementRPC is the additional RPC capability required when a Session is +// configured to settle merchant-signed payment channels. Keeping this separate +// from solanatx.RPCClient avoids breaking verification-only RPC consumers. +type SettlementRPC interface { + solanatx.RPCClient + GetBlockHeight(context.Context, rpc.CommitmentType) (uint64, error) +} + const ( // OpenTxSubmitterClient means the client broadcasts the open transaction // itself and the server only verifies it. Default. @@ -107,8 +120,8 @@ type SessionOptions struct { OpenTxSubmitter OpenTxSubmitter // Signer is the merchant signer for the settle_and_seal + distribute - // settlement transaction. Settlement at close (and on idle close) only - // runs when both Signer and RPC are configured. + // settlement transaction. It requires RPC to implement SettlementRPC so + // close and idle-close settlement can recover from expired transactions. Signer solanatx.Signer // PaymentChannelPayerSigner completes the fee-payer signature when the @@ -185,6 +198,19 @@ type Session struct { logger *slog.Logger } +func isNilOption(value any) bool { + if value == nil { + return true + } + reflected := reflect.ValueOf(value) + switch reflected.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return reflected.IsNil() + default: + return false + } +} + // NewSession creates the server-side session method. func NewSession(options SessionOptions) (*Session, error) { if options.Cap == 0 { @@ -266,6 +292,25 @@ func NewSession(options SessionOptions) (*Session, error) { if options.Logger == nil { options.Logger = slog.Default() } + signerConfigured := !isNilOption(options.Signer) + rpcConfigured := !isNilOption(options.RPC) + if options.Signer != nil && !signerConfigured { + return nil, core.NewError(core.ErrCodeInvalidConfig, "signer must not be a typed nil") + } + if options.RPC != nil && !rpcConfigured { + return nil, core.NewError(core.ErrCodeInvalidConfig, "RPC must not be a typed nil") + } + if signerConfigured && !rpcConfigured { + return nil, core.NewError(core.ErrCodeInvalidConfig, + "session settlement RPC is required when signer is configured") + } + if signerConfigured { + _, ok := options.RPC.(SettlementRPC) + if !ok { + return nil, core.NewError(core.ErrCodeInvalidConfig, + "session settlement RPC must implement GetBlockHeight") + } + } config := SessionConfig{ Operator: options.Operator, @@ -281,7 +326,12 @@ func NewSession(options SessionOptions) (*Session, error) { Modes: options.Modes, PullVoucherStrategy: options.PullVoucherStrategy, } + if options.RPC != nil { + config.VerifyOpenTx = NewOpenTxVerifier(config, options.RPC) + config.VerifyOpenStateTx = NewOpenStateTxVerifier(config, options.RPC) + } config.VerifyTopUpTx = NewTopUpTxVerifier(config, options.RPC) + config.VerifyTopUpStateTx = NewTopUpStateTxVerifier(config, options.RPC) session := &Session{ core: NewSessionServer(config, store), secretKey: options.SecretKey, @@ -321,9 +371,10 @@ func (s *Session) touch(channelID string) { } } -// closeOnIdle is the idle-close watchdog handler: settle the channel -// on-chain when both a merchant signer and an RPC client are configured. -// Errors have no synchronous caller to report to and are logged instead. +// closeOnIdle is the idle-close watchdog handler: always flip the channel to +// close-pending, then settle on-chain when both a merchant signer and an RPC +// client are configured. Errors have no synchronous caller to report to and +// are logged instead. func (s *Session) closeOnIdle(channelID string) { if s.signer == nil || s.rpc == nil { return @@ -457,16 +508,24 @@ func (s *Session) VerifyCredential(ctx context.Context, credential core.PaymentC var err error switch { case action.Open != nil: - // Sanity-check the challenge-bound recentSlot: when both the - // HMAC-bound challenge and the payload carry one, they must agree, - // since the payload slot seeds the channel PDA the server re-derives. + // A signature-only push open has no transaction bytes to establish + // the channel incarnation. It must echo the challenge's recentSlot; + // accepting omission would let a confirmed signature for another PDA + // incarnation be paired with this challenge. + if action.Open.Mode == intents.SessionModePush && request.RecentSlot != nil && + action.Open.Transaction == nil && action.Open.ChannelID != nil { + if action.Open.RecentSlot == nil { + return core.Receipt{}, core.NewError(core.ErrCodeInvalidPayload, + "signature-only push open requires recentSlot") + } + } if request.RecentSlot != nil && action.Open.RecentSlot != nil && uint64(*request.RecentSlot) != *action.Open.RecentSlot { return core.Receipt{}, core.NewError(core.ErrCodeInvalidPayload, fmt.Sprintf("open payload recentSlot %d does not match the challenge recentSlot %d", *action.Open.RecentSlot, uint64(*request.RecentSlot))) } - reference, err = s.handleOpen(ctx, action.Open, challengeCap) + reference, err = s.handleOpen(ctx, action.Open, challengeCap, request.RecentSlot) case action.Voucher != nil: reference, err = s.handleVoucher(ctx, action.Voucher) case action.Commit != nil: @@ -532,7 +591,7 @@ func (s *Session) verifyPinnedSessionFields(credential core.PaymentCredential, r // touch on top of the same insert invariant. The duplication is deliberate; // keep the shared invariant (finalized/authorized-signer/deposit checks) in // step with ProcessOpen. -func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload, challengeCap uint64) (string, error) { +func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload, challengeCap uint64, challengeRecentSlot *intents.U64String) (string, error) { mode := payload.Mode if !s.core.supportsMode(mode) { return "", fmt.Errorf("session mode %q is not supported by this challenge", mode) @@ -557,6 +616,9 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload, // Channel open_slot (a PDA seed), read from the verified open transaction // when one is present, else from the payload's recentSlot echo. openSlot := openSlotFromPayload(payload) + var salt uint64 + var openSignature string + var verifiedOpen VerifyOpenTxResult switch { case hasTransaction: @@ -573,6 +635,11 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload, ProgramID: s.core.config.ProgramID, Recipient: s.recipient, Splits: s.core.config.Splits, + GracePeriod: expectedSessionGracePeriod(s.core.config), + } + if challengeRecentSlot != nil { + recentSlot := uint64(*challengeRecentSlot) + expected.RecentSlot = &recentSlot } if s.openTxSubmitter == OpenTxSubmitterServer { if s.rpc == nil { @@ -589,10 +656,17 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload, return "", err } if existing != nil { + if existing.OpenSignature == "" { + return "", fmt.Errorf("server-submitted open %s is missing its persisted broadcast signature", preVerified.ChannelID) + } channelID = preVerified.ChannelID deposit = preVerified.Deposit channelPayer = preVerified.Payer openSlot = preVerified.OpenSlot + salt = preVerified.Salt + signature = existing.OpenSignature + openSignature = existing.OpenSignature + verifiedOpen = preVerified } else { submitted, err := SubmitOpenTx(ctx, expected, payload, s.payerSigner, s.rpc) if err != nil { @@ -602,7 +676,10 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload, deposit = submitted.Deposit channelPayer = submitted.Payer openSlot = submitted.OpenSlot + salt = submitted.Salt signature = submitted.Signature + openSignature = submitted.Signature + verifiedOpen = submitted.VerifyOpenTxResult } } else { if s.network != "localnet" && isPlaceholderSignature(payload.Signature) { @@ -615,48 +692,124 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload, if err != nil { return "", err } + if err := validateAssertedOpenDeposit(payload, verified.Deposit); err != nil { + return "", err + } + verifiedOpen = verified channelID = verified.ChannelID deposit = verified.Deposit channelPayer = verified.Payer openSlot = verified.OpenSlot + salt = verified.Salt + } + if s.rpc == nil { + if s.network != "localnet" { + return "", fmt.Errorf("payment-channel open requires an rpc client to bind the on-chain channel off localnet") + } + } else { + confirmedSlot, err := confirmedTransactionSlot(ctx, s.rpc, signature, "open") + if err != nil { + return "", err + } + channelPDA, err := solana.PublicKeyFromBase58(channelID) + if err != nil { + return "", fmt.Errorf("invalid channelId %q: %w", channelID, err) + } + bound, err := fetchAndBindChannelAccount( + ctx, + s.rpc, + channelPDA, + paycore.ResolveMint(s.currency, s.network), + s.recipient, + s.core.config.Operator, + payload.AuthorizedSigner, + expectedSessionGracePeriod(s.core.config), + sessionDistributionHash(s.core.config.Splits), + true, + s.core.config.ProgramID, + confirmedSlot, + ) + if err != nil { + return "", err + } + if err := validateBoundOpenChannel(bound, &verifiedOpen, s.cap); err != nil { + return "", err + } + if err := validateAssertedOpenDeposit(payload, bound.Deposit); err != nil { + return "", err + } + deposit = bound.Deposit + channelPayer = bound.Payer + openSlot = bound.OpenSlot + salt = bound.Salt } case mode == intents.SessionModePush: - // No transaction in the payload: fetch the transaction named by the - // signature and bind its verified channel/deposit facts before storing. + // No transaction in the payload: with an RPC client, fetch and + // validate the transaction named by the signature before reading the + // channel account. Without one, localnet keeps the explicit + // trust-as-provided test seam. channelID = *payload.ChannelID - if s.rpc != nil && s.network != "localnet" { + if s.rpc != nil { expected := VerifyOpenTxExpected{ AuthorizedSigner: payload.AuthorizedSigner, Currency: s.currency, TokenProgram: s.core.config.TokenProgram, - MaxCap: challengeCap, + MaxCap: s.cap, Network: s.network, Operator: s.core.config.Operator, ProgramID: s.core.config.ProgramID, Recipient: s.recipient, Splits: s.core.config.Splits, + GracePeriod: expectedSessionGracePeriod(s.core.config), + } + if challengeRecentSlot != nil { + recentSlot := uint64(*challengeRecentSlot) + expected.RecentSlot = &recentSlot } - verified, err := verifySignatureOnlyOpen(ctx, expected, payload, s.rpc) + verified, confirmedSlot, err := verifySignatureOnlyOpen(ctx, expected, payload, s.rpc) if err != nil { return "", err } channelID = verified.ChannelID - deposit = verified.Deposit - channelPayer = verified.Payer - openSlot = verified.OpenSlot + channelPDA, err := solana.PublicKeyFromBase58(verified.ChannelID) + if err != nil { + return "", fmt.Errorf("invalid verified channelId %q: %w", verified.ChannelID, err) + } + bound, err := fetchAndBindChannelAccount( + ctx, + s.rpc, + channelPDA, + paycore.ResolveMint(s.currency, s.network), + s.recipient, + s.core.config.Operator, + payload.AuthorizedSigner, + expectedSessionGracePeriod(s.core.config), + sessionDistributionHash(s.core.config.Splits), + true, + s.core.config.ProgramID, + confirmedSlot, + ) + if err != nil { + return "", err + } + if err := validateBoundOpenChannel(bound, &verified, s.cap); err != nil { + return "", err + } + if err := validateAssertedOpenDeposit(payload, bound.Deposit); err != nil { + return "", err + } + deposit = bound.Deposit + channelPayer = bound.Payer + openSlot = bound.OpenSlot + salt = bound.Salt } else if s.network != "localnet" { - return "", fmt.Errorf("signature-only push open requires an rpc client off localnet") + return "", fmt.Errorf("payment-channel push open requires an rpc client to bind the on-chain channel off localnet") } else { var err error deposit, err = payload.DepositAmount() if err != nil { return "", err } - if s.rpc != nil { - if err := confirmTransactionSignature(ctx, s.rpc, signature, "open"); err != nil { - return "", err - } - } } default: // Pull mode without a channel transaction: trust the @@ -693,6 +846,8 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload, AuthorizedSigner: payload.AuthorizedSigner, Deposit: deposit, OpenSlot: openSlot, + Salt: salt, + OpenSignature: openSignature, Operator: operator, } @@ -770,10 +925,11 @@ func (s *Session) handleTopUp(ctx context.Context, payload *intents.TopUpPayload // client are configured. The receipt reference is the on-chain settle // signature when one exists, else the channel id. // -// Unlike SessionServer.ProcessClose, where a second close is always -// rejected, the close here is re-drivable: when a prior close flipped the -// close-pending flag but settlement never recorded a signature, the retry -// proceeds so a transient settlement failure cannot strand the channel. +// Unlike SessionServer.ProcessClose, where a second close is always rejected, +// the close here is re-drivable while the channel remains unsealed. A retry +// either starts a fresh settlement or re-confirms the previously persisted +// broadcast signature, so an uncertain confirmation cannot strand the channel +// or cause a duplicate broadcast. func (s *Session) handleClose(ctx context.Context, payload *intents.ClosePayload) (string, error) { channelID := payload.ChannelID now := uint64(time.Now().Unix()) @@ -786,12 +942,9 @@ func (s *Session) handleClose(ctx context.Context, payload *intents.ClosePayload return ChannelState{}, fmt.Errorf("channel %s is already sealed", channelID) } if current.CloseRequestedAt != nil { - if current.SettledSignature == nil { - // Re-drivable close: leave state untouched and let the - // settlement retry proceed. - return *current, nil - } - return ChannelState{}, fmt.Errorf("close already requested") + // Re-drivable close: leave state untouched and let settlement + // either start or re-confirm its persisted signature. + return *current, nil } next := *current @@ -854,58 +1007,304 @@ func (s *Session) handleClose(ctx context.Context, payload *intents.ClosePayload return reference, nil } -// closeAndSettleChannel builds settle_and_seal (+ the Ed25519 precompile -// when a voucher was accepted) + distribute for a channel that has flipped -// to close-pending, submits them as one merchant-signed transaction, and -// marks the channel sealed with the settled signature. Returns "" when -// the channel does not exist. -func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) (string, error) { - state, err := s.core.store.GetChannel(ctx, channelID) - if err != nil { - return "", err +var ( + errSettlementChannelMissing = errors.New("settlement channel missing") + errSettlementAlreadySealed = errors.New("settlement already sealed") + errSettlementAlreadyClaimed = errors.New("settlement already claimed") +) + +const ( + settlementStateWriteTimeout = 5 * time.Second + settlementClaimLease = 30 * time.Second +) + +type definiteSettlementFailure struct { + detail any +} + +type expiredSettlementOutbox struct { + currentBlockHeight uint64 + lastValidHeight uint64 +} + +func (e *expiredSettlementOutbox) Error() string { + return fmt.Sprintf("settlement transaction expired at block height %d (current %d)", e.lastValidHeight, e.currentBlockHeight) +} + +func (e *definiteSettlementFailure) Error() string { + return fmt.Sprintf("settlement transaction failed on-chain: %v", e.detail) +} + +func settlementStateContext(parent context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.WithoutCancel(parent), settlementStateWriteTimeout) +} + +func newSettlementClaimOwner() (string, error) { + var token [16]byte + if _, err := rand.Read(token[:]); err != nil { + return "", fmt.Errorf("generate settlement claim owner: %w", err) } - if state == nil { - return "", nil + return hex.EncodeToString(token[:]), nil +} + +func waitForSettlementConfirmation(ctx context.Context, rpcClient SettlementRPC, signature solana.Signature, lastValidBlockHeight uint64) error { + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + out, err := rpcClient.GetSignatureStatuses(ctx, true, signature) + notFound := err == nil && (out == nil || len(out.Value) == 0 || out.Value[0] == nil) + if err == nil && out != nil && len(out.Value) > 0 && out.Value[0] != nil { + status := out.Value[0] + if status.Err != nil { + return &definiteSettlementFailure{detail: status.Err} + } + if status.ConfirmationStatus == rpc.ConfirmationStatusConfirmed || + status.ConfirmationStatus == rpc.ConfirmationStatusFinalized || status.Confirmations == nil { + return nil + } + } + if notFound && lastValidBlockHeight != 0 { + currentBlockHeight, heightErr := rpcClient.GetBlockHeight(ctx, rpc.CommitmentConfirmed) + if heightErr == nil && currentBlockHeight > lastValidBlockHeight { + return &expiredSettlementOutbox{ + currentBlockHeight: currentBlockHeight, + lastValidHeight: lastValidBlockHeight, + } + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } } - merchant := s.signer.PublicKey() - // The distribute refund goes to the channel payer (the program enforces - // payer == channel.payer), recorded as state.Operator at open. Never fall - // back to the recipient: refunding the merchant would derive the wrong - // refund token account and fail settlement on-chain — settlement errors - // instead when the payer was never recorded. - instructions, err := s.core.settlementInstructionsForState(*state, channelID, merchant) +} + +// closeAndSettleChannel atomically claims a channel, builds settle_and_seal +// (+ the Ed25519 precompile when a voucher was accepted) + distribute, +// submits them as one merchant-signed transaction, waits for confirmation, +// and only then seals the channel with the settled signature. Definitive +// failures clear the attempt; uncertain outcomes preserve the outbox for +// exact-wire retry. Returns "" when the channel does not exist or another +// caller currently owns a fresh signature-less settlement claim. +func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) (string, error) { + settlementRPC, ok := s.rpc.(SettlementRPC) + if !ok { + return "", core.NewError(core.ErrCodeInvalidConfig, + "session settlement requires an RPC implementing GetBlockHeight") + } + claimOwner, err := newSettlementClaimOwner() if err != nil { return "", err } - blockhash, err := s.rpc.GetLatestBlockhash(ctx, rpc.CommitmentConfirmed) + claimedAt := time.Now() + var recordedSignature string + state, err := s.core.store.UpdateChannel(ctx, channelID, func(current *ChannelState) (ChannelState, error) { + if current == nil { + return ChannelState{}, errSettlementChannelMissing + } + if current.Sealed { + if current.SettledSignature != nil { + recordedSignature = *current.SettledSignature + } + return ChannelState{}, errSettlementAlreadySealed + } + if current.Settling && current.SettledSignature == nil { + claimExpiresAt := time.Unix(current.SettlementClaimedAt, 0).Add(settlementClaimLease) + if current.SettlementClaimedAt != 0 && claimedAt.Before(claimExpiresAt) { + return ChannelState{}, errSettlementAlreadyClaimed + } + } + next := *current + next.Settling = true + next.SettlementClaimOwner = claimOwner + next.SettlementClaimedAt = claimedAt.Unix() + return next, nil + }) if err != nil { - return "", core.WrapError(core.ErrCodeRPC, "fetch settlement blockhash", err) + switch { + case errors.Is(err, errSettlementChannelMissing), errors.Is(err, errSettlementAlreadyClaimed): + return "", nil + case errors.Is(err, errSettlementAlreadySealed): + return recordedSignature, nil + default: + return "", err + } } - if blockhash == nil || blockhash.Value == nil { - return "", core.NewError(core.ErrCodeRPC, "fetch settlement blockhash: empty response") + + clearAttempt := func(settlementErr error, clearOutbox bool) (string, error) { + // Definitive failures clear the owner-checked attempt under a detached, + // bounded context. Uncertain failures do not call this helper: their + // claim and exact signed wire remain durable for idempotent retry. + writeCtx, cancel := settlementStateContext(ctx) + defer cancel() + _, releaseErr := s.core.store.UpdateChannel(writeCtx, channelID, func(current *ChannelState) (ChannelState, error) { + if current == nil { + return ChannelState{}, fmt.Errorf("channel %s disappeared while releasing settlement claim", channelID) + } + if current.Sealed || !current.Settling || current.SettlementClaimOwner != claimOwner { + return *current, nil + } + next := *current + next.Settling = false + next.SettlementClaimOwner = "" + next.SettlementClaimedAt = 0 + if clearOutbox { + next.SettledSignature = nil + next.SettlementWire = "" + next.SettlementLastValidBlockHeight = 0 + } + return next, nil + }) + if releaseErr != nil { + return "", errors.Join(settlementErr, fmt.Errorf("release settlement claim: %w", releaseErr)) + } + return "", settlementErr } - tx, err := solana.NewTransaction(instructions, blockhash.Value.Blockhash, solana.TransactionPayer(merchant)) - if err != nil { - return "", fmt.Errorf("build settlement transaction: %w", err) + + merchant := s.signer.PublicKey() + var signature solana.Signature + var settlementTx *solana.Transaction + lastValidBlockHeight := state.SettlementLastValidBlockHeight + if state.SettlementWire != "" && state.SettledSignature == nil { + return clearAttempt(errors.New("stored settlement wire has no signature"), true) + } + if state.SettledSignature != nil { + signature, err = solana.SignatureFromBase58(*state.SettledSignature) + if err != nil { + return clearAttempt(fmt.Errorf("invalid stored settlement signature %q: %w", *state.SettledSignature, err), true) + } + if state.SettlementWire != "" { + settlementTx, err = solanatx.DecodeTransactionBase64(state.SettlementWire) + if err != nil { + return clearAttempt(fmt.Errorf("decode stored settlement wire: %w", err), true) + } + if len(settlementTx.Signatures) == 0 || settlementTx.Signatures[0] != signature { + return clearAttempt(errors.New("stored settlement wire does not match its signature"), true) + } + } + } else { + // The distribute refund goes to the channel payer (the program enforces + // payer == channel.payer), recorded as state.Operator at open. Never fall + // back to the recipient: refunding the merchant would derive the wrong + // refund token account and fail settlement on-chain; settlement errors + // instead when the payer was never recorded. + instructions, err := s.core.settlementInstructionsForState(state, channelID, merchant) + if err != nil { + return clearAttempt(err, true) + } + blockhash, err := settlementRPC.GetLatestBlockhash(ctx, rpc.CommitmentConfirmed) + if err != nil { + return clearAttempt(core.WrapError(core.ErrCodeRPC, "fetch settlement blockhash", err), true) + } + if blockhash == nil || blockhash.Value == nil { + return clearAttempt(core.NewError(core.ErrCodeRPC, "fetch settlement blockhash: empty response"), true) + } + tx, err := solana.NewTransaction(instructions, blockhash.Value.Blockhash, solana.TransactionPayer(merchant)) + if err != nil { + return clearAttempt(fmt.Errorf("build settlement transaction: %w", err), true) + } + if err := solanatx.SignTransaction(tx, s.signer); err != nil { + return clearAttempt(fmt.Errorf("sign settlement transaction: %w", err), true) + } + if len(tx.Signatures) == 0 || tx.Signatures[0].IsZero() { + return clearAttempt(errors.New("signed settlement transaction has no fee-payer signature"), true) + } + settlementTx = tx + signature = tx.Signatures[0] + settled := signature.String() + wire, err := solanatx.EncodeTransactionBase64(tx) + if err != nil { + return clearAttempt(fmt.Errorf("encode signed settlement transaction: %w", err), true) + } + lastValidBlockHeight = blockhash.Value.LastValidBlockHeight + writeCtx, cancel := settlementStateContext(ctx) + stored, persistErr := s.core.store.UpdateChannel(writeCtx, channelID, func(current *ChannelState) (ChannelState, error) { + if current == nil { + return ChannelState{}, fmt.Errorf("channel %s disappeared before settlement broadcast", channelID) + } + if current.Sealed { + return *current, nil + } + if !current.Settling || current.SettlementClaimOwner != claimOwner { + return ChannelState{}, fmt.Errorf("channel %s lost settlement claim before broadcast", channelID) + } + if current.SettledSignature != nil && *current.SettledSignature != settled { + return ChannelState{}, fmt.Errorf("channel %s already records settlement signature %s", channelID, *current.SettledSignature) + } + next := *current + next.SettledSignature = &settled + next.SettlementWire = wire + next.SettlementLastValidBlockHeight = lastValidBlockHeight + return next, nil + }) + cancel() + if persistErr != nil { + // Nothing has been sent. Keep the durable claim so another instance + // waits for lease expiry before rebuilding the transaction. + return "", fmt.Errorf("persist settlement signature before broadcast: %w", persistErr) + } + if stored.Sealed { + if stored.SettledSignature != nil { + return *stored.SettledSignature, nil + } + return settled, nil + } } - if err := solanatx.SignTransaction(tx, s.signer); err != nil { - return "", fmt.Errorf("sign settlement transaction: %w", err) + + var sendErr error + if settlementTx != nil { + var sentSignature solana.Signature + sentSignature, sendErr = solanatx.SendTransaction(ctx, settlementRPC, settlementTx) + if sendErr == nil && sentSignature != signature { + sendErr = fmt.Errorf("broadcast settlement signature %s != signed signature %s", sentSignature, signature) + } } - signature, err := solanatx.SendTransaction(ctx, s.rpc, tx) - if err != nil { - return "", core.WrapError(core.ErrCodeRPC, "send settlement transaction", err) + + if err := waitForSettlementConfirmation(ctx, settlementRPC, signature, lastValidBlockHeight); err != nil { + var definite *definiteSettlementFailure + if errors.As(err, &definite) { + return clearAttempt(core.WrapError(core.ErrCodeRPC, "confirm settlement transaction", err), true) + } + var expired *expiredSettlementOutbox + if errors.As(err, &expired) { + return clearAttempt(core.WrapError(core.ErrCodeRPC, "confirm settlement transaction", err), true) + } + confirmationErr := core.WrapError(core.ErrCodeRPC, "confirm settlement transaction", err) + if sendErr != nil { + return "", errors.Join(core.WrapError(core.ErrCodeRPC, "send settlement transaction", sendErr), confirmationErr) + } + return "", confirmationErr } + settled := signature.String() - if _, err := s.core.store.UpdateChannel(ctx, channelID, func(current *ChannelState) (ChannelState, error) { + reconcileCtx, cancel := settlementStateContext(ctx) + defer cancel() + stored, err := s.core.store.UpdateChannel(reconcileCtx, channelID, func(current *ChannelState) (ChannelState, error) { if current == nil { return ChannelState{}, fmt.Errorf("channel %s disappeared during settle", channelID) } + if current.Sealed { + return *current, nil + } + if current.SettledSignature == nil || *current.SettledSignature != settled { + return ChannelState{}, fmt.Errorf("channel %s settlement signature changed before seal", channelID) + } next := *current next.Sealed = true next.SettledSignature = &settled + next.SettlementWire = "" + next.SettlementLastValidBlockHeight = 0 + next.Settling = false + next.SettlementClaimOwner = "" + next.SettlementClaimedAt = 0 return next, nil - }); err != nil { - return "", err + }) + if err != nil { + return "", fmt.Errorf("persist confirmed settlement: %w", err) + } + if stored.SettledSignature != nil { + return *stored.SettledSignature, nil } return settled, nil } diff --git a/go/protocols/mpp/server/session_method_branch_test.go b/go/protocols/mpp/server/session_method_branch_test.go index ace99badf..0018ccca9 100644 --- a/go/protocols/mpp/server/session_method_branch_test.go +++ b/go/protocols/mpp/server/session_method_branch_test.go @@ -267,7 +267,7 @@ func TestSessionTopUpMalformedDepositAndStoreFailure(t *testing.T) { } } -func TestSessionCloseUnknownChannelAndSettledDoubleClose(t *testing.T) { +func TestSessionCloseUnknownChannelAndPendingSignatureRetry(t *testing.T) { session := newTestSession(t, nil) if _, err := verifySessionAction(t, session, intents.NewCloseAction(intents.ClosePayload{ ChannelID: solana.NewWallet().PublicKey().String(), @@ -275,8 +275,9 @@ func TestSessionCloseUnknownChannelAndSettledDoubleClose(t *testing.T) { t.Fatalf("unknown channel error = %v", err) } - // A close-pending channel that already recorded a settlement signature - // (but is not yet marked sealed) is not re-drivable. + // A close-pending channel that recorded a broadcast signature but is not + // sealed remains re-drivable. Without RPC/signer configuration this call + // only preserves the pending state. channelID := solana.NewWallet().PublicKey().String() closeRequestedAt := uint64(1) settled := confirmedSignature(0xAB) @@ -287,10 +288,15 @@ func TestSessionCloseUnknownChannelAndSettledDoubleClose(t *testing.T) { CloseRequestedAt: &closeRequestedAt, SettledSignature: &settled, }) - if _, err := verifySessionAction(t, session, intents.NewCloseAction(intents.ClosePayload{ + receipt, err := verifySessionAction(t, session, intents.NewCloseAction(intents.ClosePayload{ ChannelID: channelID, - })); err == nil || !strings.Contains(err.Error(), "close already requested") { - t.Fatalf("settled double-close error = %v", err) + })) + if err != nil { + t.Fatalf("pending-signature close retry: %v", err) + } + state := mustGetChannel(t, session, channelID) + if receipt.Reference != channelID || state.Sealed || state.SettledSignature == nil || *state.SettledSignature != settled { + t.Fatalf("pending-signature retry receipt=%+v state=%+v", receipt, state) } } @@ -310,15 +316,17 @@ func TestCloseAndSettleChannelFailureMatrix(t *testing.T) { t.Fatalf("unknown channel settle = %q, %v", signature, err) } - // Store read failure surfaces. - store := &failingGetStore{ChannelStore: NewMemoryChannelStore(), getErr: errors.New("store offline")} + // Atomic settle-claim write failure surfaces. + store := &failingUpdateStore{ChannelStore: NewMemoryChannelStore()} failingStoreSession := newTestSession(t, func(o *SessionOptions) { o.RPC = fake o.Signer = merchant o.Store = store }) - if _, err := failingStoreSession.closeAndSettleChannel(ctx, "any"); err == nil || - !strings.Contains(err.Error(), "store offline") { + _, failingChannelID := openTrustedChannel(t, failingStoreSession, 1_000) + store.fail = true + if _, err := failingStoreSession.closeAndSettleChannel(ctx, failingChannelID); err == nil || + !strings.Contains(err.Error(), "store write rejected") { t.Fatalf("store failure = %v", err) } @@ -367,7 +375,7 @@ func TestCloseAndSettleChannelFailureMatrix(t *testing.T) { } } -func TestSessionIdleCloseLogsSettlementFailure(t *testing.T) { +func TestSessionIdleCloseConfirmsDespiteSendError(t *testing.T) { fake := &countingBlockhashRPC{FakeRPC: testutil.NewFakeRPC()} fake.SendErr = errors.New("blockhash not found") merchant := testutil.NewPrivateKey() @@ -379,18 +387,22 @@ func TestSessionIdleCloseLogsSettlementFailure(t *testing.T) { _, channelID := openTrustedChannel(t, session, 1_000) baseline := fake.calls() - // The watchdog fires, the settle fails (the broadcast is blocked), and - // the channel stays re-drivable rather than sealed. + // The watchdog's submission returns an error, but the persisted signature + // is reported confirmed. The send error must not prevent reconciliation. deadline := time.Now().Add(3 * time.Second) - for fake.calls() == baseline { + var state *ChannelState + for { + state = mustGetChannel(t, session, channelID) + if fake.calls() > baseline && state.Sealed && state.SettledSignature != nil { + break + } if time.Now().After(deadline) { - t.Fatal("idle-close watchdog never attempted settlement") + t.Fatalf("idle-close watchdog never reconciled confirmed signature: %+v", state) } time.Sleep(5 * time.Millisecond) } - state := mustGetChannel(t, session, channelID) - if state.Sealed || state.SettledSignature != nil { - t.Fatalf("failed settle mutated state: %+v", state) + if !state.Sealed || state.Settling || state.SettledSignature == nil || state.SettlementWire != "" { + t.Fatalf("send-error confirmation state: %+v", state) } } diff --git a/go/protocols/mpp/server/session_method_test.go b/go/protocols/mpp/server/session_method_test.go index b4a723e5c..8e5ea2a63 100644 --- a/go/protocols/mpp/server/session_method_test.go +++ b/go/protocols/mpp/server/session_method_test.go @@ -18,6 +18,7 @@ import ( "testing" "time" + bin "github.com/gagliardetto/binary" solana "github.com/solana-foundation/solana-go/v2" "github.com/solana-foundation/solana-go/v2/rpc" @@ -27,10 +28,24 @@ import ( "github.com/solana-foundation/pay-kit/go/paycore/solanatx" core "github.com/solana-foundation/pay-kit/go/protocols/mpp/core" "github.com/solana-foundation/pay-kit/go/protocols/mpp/intents" + pcgen "github.com/solana-foundation/pay-kit/go/protocols/programs/paymentchannels" ) const sessionMethodSecret = "session-method-secret" +type rpcWithoutBlockHeight struct { + solanatx.RPCClient +} + +type typedNilSessionSigner struct{} + +func (*typedNilSessionSigner) PublicKey() solana.PublicKey { panic("typed-nil signer used") } +func (*typedNilSessionSigner) Sign([]byte) (solana.Signature, error) { + panic("typed-nil signer used") +} + +var _ SettlementRPC = (*testutil.FakeRPC)(nil) + // confirmedSignature returns a base58 signature string registered as // confirmed on the fake RPC. func confirmedSignature(fill byte) string { @@ -159,31 +174,213 @@ func verifySessionAction(t *testing.T, session *Session, action any) (core.Recei return session.VerifyCredential(context.Background(), sessionActionCredential(t, session, action)) } +func registerFetchedOpenTransaction(t *testing.T, fake *testutil.FakeRPC, fixture openTxFixture, slot uint64) { + t.Helper() + if fixture.payload.Transaction == nil { + t.Fatal("open fixture is missing transaction bytes") + } + tx, err := solanatx.DecodeTransactionBase64(*fixture.payload.Transaction) + if err != nil { + t.Fatalf("decode fetched open transaction: %v", err) + } + fake.BySig[fixture.signature] = tx + fake.Statuses[fixture.signature] = &rpc.SignatureStatusesResult{ + ConfirmationStatus: rpc.ConfirmationStatusConfirmed, + Slot: slot, + } +} + // openTrustedChannel opens a transactionless push channel through the -// credential layer and returns the voucher signer plus channel id. The open -// signature is a valid base58 signature so the helper also works on sessions -// with an RPC client configured (the fake RPC confirms unknown signatures). +// credential layer and returns the voucher signer plus channel id. RPC-backed +// tests register a matching fetched transaction for the signature-only payload. func openTrustedChannel(t *testing.T, session *Session, deposit uint64) (testVoucherSigner, string) { t.Helper() signer := newTestVoucherSigner(t) channelID := solana.NewWallet().PublicKey().String() - openSessionChannel(t, session, channelID, deposit, signer.Address(), confirmedSignature(0x99)) + _, channelID = openSessionChannel(t, session, channelID, deposit, signer.Address(), confirmedSignature(0x99)) return signer, channelID } -func openSessionChannel(t *testing.T, session *Session, channelID string, deposit uint64, authorizedSigner, signature string) core.Receipt { +func openSessionChannel(t *testing.T, session *Session, channelID string, deposit uint64, authorizedSigner, signature string) (core.Receipt, string) { t.Helper() payload := intents.OpenPayloadPush(channelID, fmt.Sprintf("%d", deposit), authorizedSigner, signature) // Record a channel payer (the distribute refund destination, which the // program pins to channel.payer) so the bare push open can later settle; // without it the settle path now refuses rather than refunding the merchant. - payer := solana.NewWallet().PublicKey().String() + payerKey := testutil.NewPrivateKey() + payer := payerKey.PublicKey().String() payload.Payer = &payer + if setter, ok := session.rpc.(interface { + SetAccount(solana.PublicKey, solana.PublicKey, []byte) + }); ok { + derived, _, err := paymentchannels.FindChannelPDAForProgram( + solana.MustPublicKeyFromBase58(payer), + solana.MustPublicKeyFromBase58(session.recipient), + solana.MustPublicKeyFromBase58(paycore.ResolveMint(session.currency, session.network)), + solana.MustPublicKeyFromBase58(authorizedSigner), + 7, + 42, + paymentchannels.ProgramPubkey(), + ) + if err != nil { + t.Fatalf("derive channel fixture: %v", err) + } + channelID = derived.String() + payload.ChannelID = &channelID + fake, ok := setter.(*testutil.FakeRPC) + if !ok { + // Embedded FakeRPC test doubles promote SetAccount but keep their own + // dynamic type; seed through a small adapter below. + seedSessionAccountThroughSetter(t, setter, session, channelID, deposit, payer, authorizedSigner) + } else { + seedSessionChannelAccount( + t, + fake, + solana.MustPublicKeyFromBase58(channelID), + deposit, + solana.MustPublicKeyFromBase58(payer), + solana.MustPublicKeyFromBase58(session.recipient), + solana.MustPublicKeyFromBase58(authorizedSigner), + solana.MustPublicKeyFromBase58(paycore.ResolveMint(session.currency, session.network)), + pcgen.ChannelStatus_Open, + ) + } + } + registerSignatureOnlyOpenTransaction(t, session, &payload, payerKey, deposit) receipt, err := verifySessionAction(t, session, intents.NewOpenAction(payload)) if err != nil { t.Fatalf("open: %v", err) } - return receipt + return receipt, channelID +} + +func fakeRPCForSession(rpcClient solanatx.RPCClient) *testutil.FakeRPC { + switch rpc := rpcClient.(type) { + case *testutil.FakeRPC: + return rpc + case *failingBlockhashRPC: + return rpc.FakeRPC + case *countingBlockhashRPC: + return rpc.FakeRPC + default: + return nil + } +} + +func registerSignatureOnlyOpenTransaction( + t *testing.T, + session *Session, + payload *intents.OpenPayload, + payer solana.PrivateKey, + deposit uint64, +) { + t.Helper() + fake := fakeRPCForSession(session.rpc) + if fake == nil || payload.Signature == "" { + return + } + payee, err := solana.PublicKeyFromBase58(session.recipient) + if err != nil { + t.Fatalf("parse session recipient: %v", err) + } + mint, err := solana.PublicKeyFromBase58(paycore.ResolveMint(session.currency, session.network)) + if err != nil { + t.Fatalf("parse session mint: %v", err) + } + authorized, err := solana.PublicKeyFromBase58(payload.AuthorizedSigner) + if err != nil { + t.Fatalf("parse authorized signer: %v", err) + } + operator, err := solana.PublicKeyFromBase58(session.core.config.Operator) + if err != nil { + t.Fatalf("parse session operator: %v", err) + } + tokenProgram, err := solana.PublicKeyFromBase58(paycore.DefaultTokenProgramForCurrency(session.currency, session.network)) + if err != nil { + t.Fatalf("parse token program: %v", err) + } + recipients := make([]paymentchannels.Distribution, 0, len(session.core.config.Splits)) + for _, split := range session.core.config.Splits { + recipients = append(recipients, paymentchannels.Distribution{Recipient: split.Recipient, Bps: split.BPS}) + } + programID := paymentchannels.ProgramPubkey() + if session.core.config.ProgramID != nil { + programID = *session.core.config.ProgramID + } + ix, err := paymentchannels.BuildOpenInstruction(paymentchannels.OpenChannelParams{ + Payer: payer.PublicKey(), + RentPayer: operator, + Payee: payee, + Mint: mint, + AuthorizedSigner: authorized, + Salt: 7, + OpenSlot: 42, + Deposit: deposit, + GracePeriod: expectedSessionGracePeriod(session.core.config), + Recipients: recipients, + TokenProgram: tokenProgram, + ProgramID: programID, + }) + if err != nil { + t.Fatalf("build fetched signature-only open: %v", err) + } + tx, err := solana.NewTransaction([]solana.Instruction{ix}, fake.Blockhash, solana.TransactionPayer(payer.PublicKey())) + if err != nil { + t.Fatalf("build fetched signature-only transaction: %v", err) + } + // Sign the fetched transaction for real so the hardened open verifier can + // cryptographically bind every required signer. The fee payer is the funder + // (payer) and the rentPayer (operator) co-signs; both keys are supplied so a + // confirmed on-chain open reproduces a fully signed transaction. The payload + // signature is then the confirmed transaction's real fee-payer signature. + if _, err := tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + switch key { + case payer.PublicKey(): + return &payer + case sessionTestOperatorKey.PublicKey(): + return &sessionTestOperatorKey + default: + return nil + } + }); err != nil { + t.Fatalf("sign fetched signature-only transaction: %v", err) + } + payload.Signature = tx.Signatures[0].String() + fake.BySig[payload.Signature] = tx +} + +func seedSessionAccountThroughSetter( + t *testing.T, + setter interface { + SetAccount(solana.PublicKey, solana.PublicKey, []byte) + }, + session *Session, + channelID string, + deposit uint64, + payer string, + authorizedSigner string, +) { + t.Helper() + account := pcgen.Channel{ + Discriminator: 1, + Version: 1, + Status: uint8(pcgen.ChannelStatus_Open), + Deposit: deposit, + GracePeriod: 900, + DistributionHash: sessionDistributionHash(session.core.config.Splits), + Payer: solana.MustPublicKeyFromBase58(payer), + Payee: solana.MustPublicKeyFromBase58(session.recipient), + AuthorizedSigner: solana.MustPublicKeyFromBase58(authorizedSigner), + Mint: solana.MustPublicKeyFromBase58(paycore.ResolveMint(session.currency, session.network)), + RentPayer: solana.MustPublicKeyFromBase58(session.core.config.Operator), + Salt: 7, + OpenSlot: 42, + } + buf := new(bytes.Buffer) + if err := account.MarshalWithEncoder(bin.NewBorshEncoder(buf)); err != nil { + t.Fatalf("encode channel account: %v", err) + } + setter.SetAccount(solana.MustPublicKeyFromBase58(channelID), paymentchannels.ProgramPubkey(), buf.Bytes()) } func mustGetChannel(t *testing.T, session *Session, channelID string) *ChannelState { @@ -251,6 +448,57 @@ func TestNewSessionValidation(t *testing.T) { if _, err := NewSession(noSecret); err == nil || !strings.Contains(err.Error(), "missing secret key") { t.Fatalf("missing secret error = %v", err) } + + legacyRPC := &rpcWithoutBlockHeight{RPCClient: testutil.NewFakeRPC()} + missingSettlementRPC := base() + missingSettlementRPC.Network = "localnet" + missingSettlementRPC.Signer = testutil.NewPrivateKey() + if _, err := NewSession(missingSettlementRPC); err == nil || !strings.Contains(err.Error(), "RPC is required") { + t.Fatalf("missing settlement RPC error = %v", err) + } + + typedNilSigner := base() + typedNilSigner.Network = "localnet" + var nilSigner *typedNilSessionSigner + typedNilSigner.Signer = nilSigner + if _, err := NewSession(typedNilSigner); err == nil || !strings.Contains(err.Error(), "typed nil") { + t.Fatalf("typed-nil signer error = %v", err) + } + + typedNilRPC := base() + typedNilRPC.Network = "localnet" + var nilRPC *testutil.FakeRPC + typedNilRPC.RPC = nilRPC + if _, err := NewSession(typedNilRPC); err == nil || !strings.Contains(err.Error(), "typed nil") { + t.Fatalf("typed-nil RPC error = %v", err) + } + + unsupportedSettlement := base() + unsupportedSettlement.Network = "localnet" + unsupportedSettlement.Signer = testutil.NewPrivateKey() + unsupportedSettlement.RPC = legacyRPC + if _, err := NewSession(unsupportedSettlement); err == nil || !strings.Contains(err.Error(), "GetBlockHeight") { + t.Fatalf("settlement RPC capability error = %v", err) + } + + validSettlement := base() + validSettlement.Network = "localnet" + validSettlement.Signer = testutil.NewPrivateKey() + validSettlement.RPC = testutil.NewFakeRPC() + session, err := NewSession(validSettlement) + if err != nil { + t.Fatalf("valid settlement configuration: %v", err) + } + session.Shutdown() + + verificationOnly := base() + verificationOnly.Network = "localnet" + verificationOnly.RPC = legacyRPC + session, err = NewSession(verificationOnly) + if err != nil { + t.Fatalf("verification-only legacy RPC: %v", err) + } + session.Shutdown() } func TestNewSessionDefaults(t *testing.T) { @@ -260,6 +508,7 @@ func TestNewSessionDefaults(t *testing.T) { o.Decimals = 0 o.Network = "" o.OpenTxSubmitter = "" + o.Store = durableTestChannelStore{ChannelStore: NewMemoryChannelStore()} }) if session.currency != "USDC" || session.network != "mainnet" { t.Fatalf("defaults: currency=%q network=%q", session.currency, session.network) @@ -272,6 +521,42 @@ func TestNewSessionDefaults(t *testing.T) { } } +func TestNewSessionRequiresInjectedStoreOffLocalnet(t *testing.T) { + options := SessionOptions{ + Operator: sessionTestRecipient, + Recipient: sessionTestRecipient, + Cap: 1_000, + Network: "devnet", + SecretKey: sessionMethodSecret, + } + if _, err := NewSession(options); err == nil || + !strings.Contains(err.Error(), "no session store configured for devnet") || + !strings.Contains(err.Error(), "configure a shared session ChannelStore") { + t.Fatalf("missing off-localnet store error = %v", err) + } + + options.Store = durableTestChannelStore{ChannelStore: NewMemoryChannelStore()} + session, err := NewSession(options) + if err != nil { + t.Fatalf("NewSession with injected store: %v", err) + } + session.Shutdown() + + // An unmarked store (not the built-in memory store, not durability-declared) + // passes construction, but the union-base production-safety gate rejects it + // at the first guarded operation rather than at construction. + options.Store = struct{ ChannelStore }{ChannelStore: NewMemoryChannelStore()} + unmarkedSession, err := NewSession(options) + if err != nil { + t.Fatalf("NewSession with unmarked store: %v", err) + } + defer unmarkedSession.Shutdown() + if _, err := unmarkedSession.Core().SettlementInstructions(context.Background(), "channel", solana.PublicKey{}); err == nil || + !strings.Contains(err.Error(), "explicitly declare durable shared") { + t.Fatalf("unmarked off-localnet store error = %v", err) + } +} + // ── Challenge ── func TestSessionChallengeCanonicalShape(t *testing.T) { @@ -469,7 +754,7 @@ func TestSessionOpenTrustsChannelIDAndDeposit(t *testing.T) { signer := newTestVoucherSigner(t) channelID := solana.NewWallet().PublicKey().String() - receipt := openSessionChannel(t, session, channelID, 1_000_000, signer.Address(), "sig-1") + receipt, channelID := openSessionChannel(t, session, channelID, 1_000_000, signer.Address(), "sig-1") if receipt.Status != core.ReceiptStatusSuccess { t.Fatalf("status = %q", receipt.Status) } @@ -525,6 +810,122 @@ func TestSessionOpenUsesReducedCurrentCapForOldChallenge(t *testing.T) { } } +func TestSessionSignatureOnlyOpenBindsFetchedTransactionAndDeposit(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + fake := testutil.NewFakeRPC() + registerFetchedOpenTransaction(t, fake, fixture, 777) + session := newTestSession(t, func(o *SessionOptions) { + o.Operator = fixture.payer.PublicKey().String() + o.Recipient = fixture.payee.String() + o.Network = "mainnet" + o.RPC = fake + o.Store = durableTestChannelStore{ChannelStore: NewMemoryChannelStore()} + }) + seedSessionChannelAccountWithSeeds( + t, fake, fixture.channel, openFixtureDeposit, fixture.payer.PublicKey(), fixture.payee, + fixture.authorized, fixture.mint, pcgen.ChannelStatus_Open, openFixtureSalt, openFixtureOpenSlot, + fixture.payer.PublicKey(), + ) + + payload := fixture.payload + payload.Transaction = nil + if _, err := verifySessionAction(t, session, intents.NewOpenAction(payload)); err != nil { + t.Fatalf("signature-only open: %v", err) + } + state := mustGetChannel(t, session, fixture.channel.String()) + if state == nil || state.Deposit != openFixtureDeposit || state.OpenSlot != openFixtureOpenSlot { + t.Fatalf("state = %+v, want fetched transaction/account facts", state) + } + + claimedDeposit := "999999" + payload.Deposit = &claimedDeposit + if _, err := verifySessionAction(t, session, intents.NewOpenAction(payload)); err == nil || + !strings.Contains(err.Error(), "!= asserted deposit") { + t.Fatalf("deposit mismatch error = %v", err) + } +} + +func TestSessionSignatureOnlyOpenRejectsUnrelatedConfirmedTransaction(t *testing.T) { + target := buildOpenTxFixture(t, false) + unrelated := buildOpenTxFixture(t, false) + fake := testutil.NewFakeRPC() + registerFetchedOpenTransaction(t, fake, unrelated, 778) + session := newTestSession(t, func(o *SessionOptions) { + o.Operator = target.payer.PublicKey().String() + o.Recipient = target.payee.String() + o.Network = "mainnet" + o.RPC = fake + o.Store = durableTestChannelStore{ChannelStore: NewMemoryChannelStore()} + }) + seedSessionChannelAccountWithSeeds( + t, fake, target.channel, openFixtureDeposit, target.payer.PublicKey(), target.payee, + target.authorized, target.mint, pcgen.ChannelStatus_Open, openFixtureSalt, openFixtureOpenSlot, + target.payer.PublicKey(), + ) + + payload := target.payload + payload.Transaction = nil + payload.Signature = unrelated.signature + if _, err := verifySessionAction(t, session, intents.NewOpenAction(payload)); err == nil { + t.Fatal("unrelated confirmed transaction authorized the target channel") + } + if state := mustGetChannel(t, session, target.channel.String()); state != nil { + t.Fatalf("target channel persisted after unrelated transaction: %+v", state) + } +} + +func TestSignatureOnlyPushOpenBindsChallengeRecentSlot(t *testing.T) { + session := newTestSession(t, nil) + request := session.Core().BuildChallengeRequest(1_000) + recentSlot := intents.U64String(42) + request.RecentSlot = &recentSlot + requestValue, err := core.NewBase64URLJSONValue(request) + if err != nil { + t.Fatalf("NewBase64URLJSONValue: %v", err) + } + challenge := core.NewChallengeWithSecretFull( + sessionMethodSecret, "api.test", core.NewMethodName("solana"), core.NewIntentName("session"), + requestValue, "", "", "", nil, + ) + signer := newTestVoucherSigner(t) + channelID := solana.NewWallet().PublicKey().String() + + missing := intents.OpenPayloadPush(channelID, "1000", signer.Address(), "sig") + credential, err := core.NewPaymentCredential(challenge.ToEcho(), intents.NewOpenAction(missing)) + if err != nil { + t.Fatalf("missing credential: %v", err) + } + if _, err := session.VerifyCredential(context.Background(), credential); err == nil || !strings.Contains(err.Error(), "requires recentSlot") { + t.Fatalf("missing recentSlot error = %v", err) + } + + wrong := missing + wrongSlot := uint64(43) + wrong.RecentSlot = &wrongSlot + credential, err = core.NewPaymentCredential(challenge.ToEcho(), intents.NewOpenAction(wrong)) + if err != nil { + t.Fatalf("mismatched credential: %v", err) + } + if _, err := session.VerifyCredential(context.Background(), credential); err == nil || !strings.Contains(err.Error(), "does not match the challenge") { + t.Fatalf("mismatched recentSlot error = %v", err) + } + + matching := missing + matchingSlot := uint64(recentSlot) + matching.RecentSlot = &matchingSlot + credential, err = core.NewPaymentCredential(challenge.ToEcho(), intents.NewOpenAction(matching)) + if err != nil { + t.Fatalf("matching credential: %v", err) + } + if _, err := session.VerifyCredential(context.Background(), credential); err != nil { + t.Fatalf("matching recentSlot open: %v", err) + } + state := mustGetChannel(t, session, channelID) + if state == nil || state.OpenSlot != 42 { + t.Fatalf("state = %+v, want challenge open slot 42", state) + } +} + func TestSessionOpenRejectsUnadvertisedMode(t *testing.T) { session := newTestSession(t, nil) signer := newTestVoucherSigner(t) @@ -598,7 +999,7 @@ func TestSessionOpenReplaySemantics(t *testing.T) { } // Idempotent replay preserves the watermark. - openSessionChannel(t, session, channelID, 1_000, signer.Address(), "open-sig") + _, _ = openSessionChannel(t, session, channelID, 1_000, signer.Address(), "open-sig") state := mustGetChannel(t, session, channelID) if state.Cumulative != 250 || state.HighestVoucherSignature == nil { t.Fatalf("replay reset watermark: %+v", state) @@ -638,9 +1039,18 @@ func TestSessionOpenVerifiesSignatureOnChain(t *testing.T) { signer := newTestVoucherSigner(t) channelID := solana.NewWallet().PublicKey().String() - receipt := openSessionChannel(t, session, channelID, 1_000, signer.Address(), okSig) - if receipt.Reference != okSig { - t.Fatalf("reference = %q", receipt.Reference) + receipt, derivedChannel := openSessionChannel(t, session, channelID, 1_000, signer.Address(), okSig) + // The hardened open path rebinds to the real confirmed transaction + // signature and the PDA derived from the on-chain channel account, not the + // dummy inputs the caller supplied. + if receipt.Reference == okSig || receipt.Reference == "" { + t.Fatalf("reference = %q, want the real confirmed open signature", receipt.Reference) + } + if _, ok := fake.BySig[receipt.Reference]; !ok { + t.Fatalf("reference %q is not a confirmed open transaction", receipt.Reference) + } + if mustGetChannel(t, session, derivedChannel) == nil { + t.Fatal("channel not persisted at the derived PDA") } ghostChannel := solana.NewWallet().PublicKey().String() @@ -869,8 +1279,22 @@ func TestSessionTopUpVerifiesSignatureOnChain(t *testing.T) { session := newTestSession(t, func(o *SessionOptions) { o.RPC = fake }) signer := newTestVoucherSigner(t) channelID := solana.NewWallet().PublicKey().String() - openSessionChannel(t, session, channelID, 1_000, signer.Address(), openSig) + // The hardened open derives the real channel PDA; thread it into the + // top-up so both bind the same on-chain account. + _, channelID = openSessionChannel(t, session, channelID, 1_000, signer.Address(), openSig) channel := solana.MustPublicKeyFromBase58(channelID) + // The state-aware top-up verifier binds the resulting on-chain deposit, so + // the authoritative account must reflect the post-top-up balance. Reuse the + // payer the open recorded so the payer binding still holds. + openedState := mustGetChannel(t, session, channelID) + seedSessionChannelAccount( + t, fake, channel, 5_000, + solana.MustPublicKeyFromBase58(*openedState.Operator), + solana.MustPublicKeyFromBase58(sessionTestRecipient), + solana.MustPublicKeyFromBase58(signer.Address()), + solana.MustPublicKeyFromBase58(paycore.ResolveMint(session.currency, session.network)), + pcgen.ChannelStatus_Open, + ) topupTx, topupPayload := buildTopUpTx(t, channel, 1_000, 4_000) fake.BySig[topupPayload.Signature] = topupTx @@ -980,26 +1404,42 @@ func TestSessionCloseRetryAfterFailedSettlement(t *testing.T) { t.Fatalf("voucher: %v", err) } - // First close: settlement broadcast fails; close stays pending and - // re-drivable. - fake.SendErr = fmt.Errorf("blockhash not found") - if _, err := verifySessionAction(t, session, intents.NewCloseAction(intents.ClosePayload{ChannelID: channelID})); err == nil || - !strings.Contains(err.Error(), "blockhash not found") { + // Submission fails before reaching the network and the signature remains + // not found within its validity window. The exact outbox stays uncertain. + crashRPC := &crashBeforeSendRPC{FakeRPC: fake} + session.rpc = crashRPC + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + if _, err := session.handleClose(ctx, &intents.ClosePayload{ChannelID: channelID}); err == nil || + !strings.Contains(err.Error(), "simulated process crash") { t.Fatalf("settlement failure error = %v", err) } state := mustGetChannel(t, session, channelID) - if state.CloseRequestedAt == nil || state.Sealed || state.SettledSignature != nil { + if state.CloseRequestedAt == nil || state.Sealed || !state.Settling || state.SettledSignature == nil || state.SettlementWire == "" { t.Fatalf("state after failed settle = %+v", state) } - // Retry succeeds and seals the channel. - fake.SendErr = nil + // A retry idempotently submits the same wire. A definite failed status then + // clears the outbox, allowing a later retry to build a fresh transaction. + failureRPC := &definiteFailureRPC{FakeRPC: fake} + failureRPC.fail.Store(true) + session.rpc = failureRPC + if _, err := verifySessionAction(t, session, intents.NewCloseAction(intents.ClosePayload{ChannelID: channelID})); err == nil || + !strings.Contains(err.Error(), "failed on-chain") { + t.Fatalf("pending settlement failure = %v", err) + } + state = mustGetChannel(t, session, channelID) + if state.Sealed || state.Settling || state.SettledSignature != nil || state.SettlementWire != "" || + state.SettlementClaimOwner != "" || state.SettlementClaimedAt != 0 { + t.Fatalf("state after definite pending failure = %+v", state) + } + failureRPC.fail.Store(false) receipt, err := verifySessionAction(t, session, intents.NewCloseAction(intents.ClosePayload{ChannelID: channelID})) if err != nil { t.Fatalf("close retry: %v", err) } - if len(fake.Sent) != 1 { - t.Fatalf("settlement broadcasts = %d, want 1", len(fake.Sent)) + if len(fake.Sent) != 2 { + t.Fatalf("settlement broadcasts = %d, want 2", len(fake.Sent)) } state = mustGetChannel(t, session, channelID) if !state.Sealed || state.SettledSignature == nil { @@ -1009,7 +1449,7 @@ func TestSessionCloseRetryAfterFailedSettlement(t *testing.T) { t.Fatalf("reference = %q, want settled signature %q", receipt.Reference, *state.SettledSignature) } - // A third close on the sealed channel rejects. + // A later close on the sealed channel rejects. if _, err := verifySessionAction(t, session, intents.NewCloseAction(intents.ClosePayload{ChannelID: channelID})); err == nil || !strings.Contains(err.Error(), "sealed") { t.Fatalf("third close error = %v", err) @@ -1021,7 +1461,7 @@ func TestSessionCloseWithoutSignerDoesNotSettle(t *testing.T) { session := newTestSession(t, func(o *SessionOptions) { o.RPC = fake }) signer := newTestVoucherSigner(t) channelID := solana.NewWallet().PublicKey().String() - openSessionChannel(t, session, channelID, 1_000, signer.Address(), confirmedSignature(0x77)) + _, channelID = openSessionChannel(t, session, channelID, 1_000, signer.Address(), confirmedSignature(0x77)) if _, err := verifySessionAction(t, session, intents.NewCloseAction(intents.ClosePayload{ChannelID: channelID})); err != nil { t.Fatalf("close: %v", err) @@ -1283,6 +1723,11 @@ func TestSessionServerSubmitterBroadcastsOnceAndReplaysWithoutRebroadcast(t *tes o.OpenTxSubmitter = OpenTxSubmitterServer o.RPC = fake }) + seedSessionChannelAccountWithSeeds( + t, fake, fixture.channel, openFixtureDeposit, fixture.payer.PublicKey(), fixture.payee, + fixture.authorized, fixture.mint, pcgen.ChannelStatus_Open, openFixtureSalt, openFixtureOpenSlot, + fixture.payer.PublicKey(), + ) receipt, err := verifySessionAction(t, session, intents.NewOpenAction(fixture.payload)) if err != nil { @@ -1324,6 +1769,10 @@ func TestSessionServerSubmitterCompletesFeePayerSignature(t *testing.T) { operator := testutil.NewPrivateKey() fixture := buildServerCompletedOpenFixture(t, operator) fake := testutil.NewFakeRPC() + fake.Statuses[fixture.payload.Signature] = &rpc.SignatureStatusesResult{ + ConfirmationStatus: rpc.ConfirmationStatusProcessed, + Slot: 1, + } session := newTestSession(t, func(o *SessionOptions) { o.Recipient = fixture.payee.String() o.Operator = operator.PublicKey().String() @@ -1331,6 +1780,11 @@ func TestSessionServerSubmitterCompletesFeePayerSignature(t *testing.T) { o.PaymentChannelPayerSigner = operator o.RPC = fake }) + seedSessionChannelAccountWithSeeds( + t, fake, fixture.channel, openFixtureDeposit, fixture.payer.PublicKey(), fixture.payee, + fixture.authorized, fixture.mint, pcgen.ChannelStatus_Open, openFixtureSalt, openFixtureOpenSlot, + operator.PublicKey(), + ) receipt, err := verifySessionAction(t, session, intents.NewOpenAction(fixture.payload)) if err != nil { @@ -1345,6 +1799,16 @@ func TestSessionServerSubmitterCompletesFeePayerSignature(t *testing.T) { if receipt.Reference != fake.Sent[0].Signatures[0].String() { t.Fatalf("reference = %q, want broadcast signature", receipt.Reference) } + replay, err := verifySessionAction(t, session, intents.NewOpenAction(fixture.payload)) + if err != nil { + t.Fatalf("server-completed open replay: %v", err) + } + if len(fake.Sent) != 1 { + t.Fatalf("replay rebroadcast the open: %d sends", len(fake.Sent)) + } + if replay.Reference != receipt.Reference { + t.Fatalf("replay reference = %q, want %q", replay.Reference, receipt.Reference) + } } // buildServerCompletedOpenFixture builds an open transaction whose fee payer @@ -1511,8 +1975,25 @@ func TestSessionMiddlewareSkipsBlockhashPrefetchOnVerifyPath(t *testing.T) { } calls := fake.calls() signer := newTestVoucherSigner(t) - credential, err := core.NewPaymentCredential(challenge.ToEcho(), intents.NewOpenAction( - intents.OpenPayloadPush(solana.NewWallet().PublicKey().String(), "1000", signer.Address(), confirmedSignature(0x88)))) + payerKey := testutil.NewPrivateKey() + payer := payerKey.PublicKey() + channel, _, err := paymentchannels.FindChannelPDAForProgram( + payer, + solana.MustPublicKeyFromBase58(session.recipient), + solana.MustPublicKeyFromBase58(paycore.ResolveMint(session.currency, session.network)), + solana.MustPublicKeyFromBase58(signer.Address()), + 7, + 42, + paymentchannels.ProgramPubkey(), + ) + if err != nil { + t.Fatalf("derive channel fixture: %v", err) + } + channelID := channel.String() + seedSessionAccountThroughSetter(t, fake, session, channelID, 1_000, payer.String(), signer.Address()) + payload := intents.OpenPayloadPush(channelID, "1000", signer.Address(), confirmedSignature(0x88)) + registerSignatureOnlyOpenTransaction(t, session, &payload, payerKey, 1_000) + credential, err := core.NewPaymentCredential(challenge.ToEcho(), intents.NewOpenAction(payload)) if err != nil { t.Fatalf("NewPaymentCredential: %v", err) } @@ -1554,6 +2035,60 @@ type countingBlockhashRPC struct { blockhashCalls atomic.Int64 } +// blockingConfirmationRPC pauses the first settlement confirmation poll so a +// competing close path can attempt to claim the same channel deterministically. +type blockingConfirmationRPC struct { + *testutil.FakeRPC + statusEntered chan struct{} + releaseStatus chan struct{} + block atomic.Bool + statusCalls atomic.Int64 +} + +type definiteFailureRPC struct { + *testutil.FakeRPC + fail atomic.Bool +} + +func (d *definiteFailureRPC) GetSignatureStatuses(ctx context.Context, searchHistory bool, signatures ...solana.Signature) (*rpc.GetSignatureStatusesResult, error) { + if d.fail.Load() { + return &rpc.GetSignatureStatusesResult{Value: []*rpc.SignatureStatusesResult{{ + Err: map[string]any{"InstructionError": []any{0, "Custom"}}, + }}}, nil + } + return d.FakeRPC.GetSignatureStatuses(ctx, searchHistory, signatures...) +} + +type cancelOnConfirmationRPC struct { + *testutil.FakeRPC + cancel context.CancelFunc + armed atomic.Bool +} + +func (c *cancelOnConfirmationRPC) GetSignatureStatuses(ctx context.Context, searchHistory bool, signatures ...solana.Signature) (*rpc.GetSignatureStatusesResult, error) { + if c.armed.Load() { + c.cancel() + } + return c.FakeRPC.GetSignatureStatuses(ctx, searchHistory, signatures...) +} + +func (b *blockingConfirmationRPC) GetSignatureStatuses(ctx context.Context, searchHistory bool, signatures ...solana.Signature) (*rpc.GetSignatureStatusesResult, error) { + if !b.block.Load() { + return b.FakeRPC.GetSignatureStatuses(ctx, searchHistory, signatures...) + } + b.statusCalls.Add(1) + select { + case b.statusEntered <- struct{}{}: + default: + } + select { + case <-b.releaseStatus: + return b.FakeRPC.GetSignatureStatuses(ctx, searchHistory, signatures...) + case <-ctx.Done(): + return nil, ctx.Err() + } +} + // calls returns the GetLatestBlockhash call count. func (c *countingBlockhashRPC) calls() int64 { return c.blockhashCalls.Load() } @@ -1593,7 +2128,163 @@ func TestSessionIdleCloseSettlesOnChain(t *testing.T) { } } -func TestSessionIdleCloseWithoutSignerIsInert(t *testing.T) { +func TestExplicitCloseRacingIdleCloseReusesSettlementWire(t *testing.T) { + baseRPC := testutil.NewFakeRPC() + fake := &blockingConfirmationRPC{ + FakeRPC: baseRPC, + statusEntered: make(chan struct{}, 1), + releaseStatus: make(chan struct{}), + } + merchant := testutil.NewPrivateKey() + session := newTestSession(t, func(o *SessionOptions) { + o.RPC = baseRPC + o.Signer = merchant + }) + _, channelID := openTrustedChannel(t, session, 1_000) + session.rpc = fake + fake.block.Store(true) + + explicitDone := make(chan error, 1) + go func() { + _, err := verifySessionAction(t, session, intents.NewCloseAction(intents.ClosePayload{ChannelID: channelID})) + explicitDone <- err + }() + + select { + case <-fake.statusEntered: + case <-time.After(3 * time.Second): + t.Fatal("explicit close never reached confirmation") + } + + // The idle path observes the persisted signature and joins confirmation + // without broadcasting a competing transaction. + idleDone := make(chan struct{}) + go func() { + session.closeOnIdle(channelID) + close(idleDone) + }() + deadline := time.Now().Add(3 * time.Second) + for fake.statusCalls.Load() < 2 { + if time.Now().After(deadline) { + t.Fatal("idle close never joined pending confirmation") + } + time.Sleep(time.Millisecond) + } + if len(fake.Sent) != 2 { + t.Fatalf("idempotent settlement submissions while first close is in flight = %d, want 2", len(fake.Sent)) + } + close(fake.releaseStatus) + <-idleDone + if err := <-explicitDone; err != nil { + t.Fatalf("explicit close: %v", err) + } + + state := mustGetChannel(t, session, channelID) + if !state.Sealed || state.Settling || state.SettledSignature == nil { + t.Fatalf("state after winning settle = %+v", state) + } +} + +func TestSettlementConfirmationFailureReleasesClaimForRetry(t *testing.T) { + baseRPC := testutil.NewFakeRPC() + merchant := testutil.NewPrivateKey() + session := newTestSession(t, func(o *SessionOptions) { + o.RPC = baseRPC + o.Signer = merchant + }) + _, channelID := openTrustedChannel(t, session, 1_000) + session.rpc = &failingStatusRPC{FakeRPC: baseRPC} + + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + if _, err := session.closeAndSettleChannel(ctx, channelID); err == nil || + !strings.Contains(err.Error(), "confirm settlement transaction") { + t.Fatalf("confirmation failure = %v", err) + } + state := mustGetChannel(t, session, channelID) + if state.Sealed || !state.Settling || state.SettledSignature == nil || state.SettlementWire == "" { + t.Fatalf("failed confirmation left channel non-retryable: %+v", state) + } + pendingSignature := *state.SettledSignature + + healthyRPC := testutil.NewFakeRPC() + session.rpc = healthyRPC + settled, err := session.closeAndSettleChannel(context.Background(), channelID) + if err != nil { + t.Fatalf("settlement retry: %v", err) + } + if settled != pendingSignature || len(healthyRPC.Sent) != 1 { + t.Fatalf("settlement retry = %q with %d broadcasts", settled, len(healthyRPC.Sent)) + } + state = mustGetChannel(t, session, channelID) + if !state.Sealed || state.Settling || state.SettledSignature == nil { + t.Fatalf("state after settlement retry = %+v", state) + } +} + +func TestDefiniteSettlementFailureClearsSignatureForRetry(t *testing.T) { + baseRPC := testutil.NewFakeRPC() + rpcClient := &definiteFailureRPC{FakeRPC: baseRPC} + merchant := testutil.NewPrivateKey() + session := newTestSession(t, func(o *SessionOptions) { + o.RPC = baseRPC + o.Signer = merchant + }) + _, channelID := openTrustedChannel(t, session, 1_000) + session.rpc = rpcClient + rpcClient.fail.Store(true) + + if _, err := session.closeAndSettleChannel(context.Background(), channelID); err == nil || + !strings.Contains(err.Error(), "failed on-chain") { + t.Fatalf("definite settlement failure = %v", err) + } + state := mustGetChannel(t, session, channelID) + if state.Sealed || state.Settling || state.SettledSignature != nil || state.SettlementWire != "" || + state.SettlementClaimOwner != "" || state.SettlementClaimedAt != 0 { + t.Fatalf("definite failure did not clear settlement state: %+v", state) + } + if len(baseRPC.Sent) != 1 { + t.Fatalf("broadcasts after definite failure = %d, want 1", len(baseRPC.Sent)) + } + + rpcClient.fail.Store(false) + if _, err := session.closeAndSettleChannel(context.Background(), channelID); err != nil { + t.Fatalf("retry after definite failure: %v", err) + } + if len(baseRPC.Sent) != 2 { + t.Fatalf("broadcasts after safe retry = %d, want 2", len(baseRPC.Sent)) + } +} + +func TestConfirmedSettlementReconcilesAfterRequestCancellation(t *testing.T) { + baseRPC := testutil.NewFakeRPC() + _, stores := newSharedJSONChannelStores(1) + merchant := testutil.NewPrivateKey() + session := newTestSession(t, func(o *SessionOptions) { + o.RPC = baseRPC + o.Signer = merchant + o.Store = stores[0] + }) + _, channelID := openTrustedChannel(t, session, 1_000) + ctx, cancel := context.WithCancel(context.Background()) + rpcClient := &cancelOnConfirmationRPC{FakeRPC: baseRPC, cancel: cancel} + rpcClient.armed.Store(true) + session.rpc = rpcClient + + settled, err := session.closeAndSettleChannel(ctx, channelID) + if err != nil { + t.Fatalf("settlement after confirmation-side cancellation: %v", err) + } + if settled == "" || ctx.Err() == nil { + t.Fatalf("settlement=%q context error=%v", settled, ctx.Err()) + } + state := mustGetChannel(t, session, channelID) + if !state.Sealed || state.Settling || state.SettledSignature == nil || *state.SettledSignature != settled || state.SettlementWire != "" { + t.Fatalf("confirmed settlement was not reconciled: %+v", state) + } +} + +func TestSessionIdleCloseWithoutSignerIsNoOp(t *testing.T) { fake := testutil.NewFakeRPC() session := newTestSession(t, func(o *SessionOptions) { o.RPC = fake @@ -1602,8 +2293,12 @@ func TestSessionIdleCloseWithoutSignerIsInert(t *testing.T) { _, channelID := openTrustedChannel(t, session, 1_000) time.Sleep(80 * time.Millisecond) + // Without a merchant signer the idle watchdog cannot settle, so closeOnIdle + // bails before touching the channel: no off-chain close-pending flip, no + // seal, and no broadcast. It must never partially close a channel it cannot + // settle. state := mustGetChannel(t, session, channelID) - if state.Sealed || len(fake.Sent) != 0 { - t.Fatalf("idle close ran without a signer: state=%+v sends=%d", state, len(fake.Sent)) + if state.CloseRequestedAt != nil || state.Sealed || len(fake.Sent) != 0 { + t.Fatalf("idle close without signer state=%+v sends=%d", state, len(fake.Sent)) } } diff --git a/go/protocols/mpp/server/session_onchain.go b/go/protocols/mpp/server/session_onchain.go index 9463eb5ac..b5290be83 100644 --- a/go/protocols/mpp/server/session_onchain.go +++ b/go/protocols/mpp/server/session_onchain.go @@ -14,7 +14,9 @@ package server // provided. import ( + "bytes" "context" + "crypto/sha256" "encoding/binary" "fmt" "math" @@ -32,6 +34,163 @@ import ( generated "github.com/solana-foundation/pay-kit/go/protocols/programs/paymentchannels" ) +type boundChannel struct { + Deposit uint64 + Payer string + AuthorizedSigner string + Payee string + Mint string + GracePeriod uint32 + Salt uint64 + OpenSlot uint64 +} + +func fetchAndValidateChannel( + ctx context.Context, + rpcClient solanatx.RPCClient, + channelID solana.PublicKey, + expectedMint string, + expectedPayee string, + expectedOperator string, + expectedGracePeriod uint32, + expectedDistributionHash [32]byte, + requireFresh bool, + programID *solana.PublicKey, + minContextSlot uint64, +) (*generated.Channel, error) { + program := paymentchannels.ProgramPubkey() + if programID != nil { + program = *programID + } + opts := &rpc.GetAccountInfoOpts{ + Commitment: rpc.CommitmentConfirmed, + Encoding: solana.EncodingBase64, + } + opts.MinContextSlot = &minContextSlot + info, err := rpcClient.GetAccountInfoWithOpts(ctx, channelID, opts) + if err != nil { + return nil, fmt.Errorf("channel %s account fetch failed: %w", channelID, err) + } + if info == nil || info.Value == nil || info.Value.Data == nil { + return nil, fmt.Errorf("channel %s account not found on-chain", channelID) + } + if !info.Value.Owner.Equals(program) { + return nil, fmt.Errorf("channel %s is not owned by the payment-channels program %s", channelID, program) + } + data := info.Value.Data.GetBinary() + if len(data) != 256 { + return nil, fmt.Errorf("channel %s account data has invalid length %d", channelID, len(data)) + } + channel := new(generated.Channel) + if err := channel.UnmarshalWithDecoder(ag_binary.NewBorshDecoder(data)); err != nil { + return nil, fmt.Errorf("channel %s decode failed: %w", channelID, err) + } + if channel.Discriminator != 1 { + return nil, fmt.Errorf("channel %s has invalid discriminator %d", channelID, channel.Discriminator) + } + if channel.Version != 1 { + return nil, fmt.Errorf("channel %s has unsupported version %d", channelID, channel.Version) + } + if channel.Status != uint8(generated.ChannelStatus_Open) { + return nil, fmt.Errorf("channel %s is not open on-chain (status %d)", channelID, channel.Status) + } + if channel.Mint.String() != expectedMint { + return nil, fmt.Errorf("on-chain channel mint %s != expected mint %s", channel.Mint, expectedMint) + } + if channel.Payee.String() != expectedPayee { + return nil, fmt.Errorf("on-chain channel payee %s != expected recipient %s", channel.Payee, expectedPayee) + } + if expectedOperator == "" || channel.RentPayer.String() != expectedOperator { + return nil, fmt.Errorf("on-chain channel rentPayer %s != expected operator %s", channel.RentPayer, expectedOperator) + } + if requireFresh && (channel.Settlement.Settled != 0 || channel.Settlement.PayoutWatermark != 0) { + return nil, fmt.Errorf("channel %s has nonzero settlement watermarks", channelID) + } + if channel.GracePeriod != expectedGracePeriod { + return nil, fmt.Errorf("on-chain channel gracePeriod %d != expected %d", channel.GracePeriod, expectedGracePeriod) + } + if channel.DistributionHash != expectedDistributionHash { + return nil, fmt.Errorf("on-chain channel distributionHash does not match session splits") + } + return channel, nil +} + +func fetchAndBindChannelAccount( + ctx context.Context, + rpcClient solanatx.RPCClient, + channelID solana.PublicKey, + expectedMint string, + expectedPayee string, + expectedOperator string, + expectedAuthorizedSigner string, + expectedGracePeriod uint32, + expectedDistributionHash [32]byte, + requireFresh bool, + programID *solana.PublicKey, + minContextSlot uint64, +) (boundChannel, error) { + channel, err := fetchAndValidateChannel(ctx, rpcClient, channelID, expectedMint, expectedPayee, expectedOperator, expectedGracePeriod, expectedDistributionHash, requireFresh, programID, minContextSlot) + if err != nil { + return boundChannel{}, err + } + if channel.AuthorizedSigner.String() != expectedAuthorizedSigner { + return boundChannel{}, fmt.Errorf( + "on-chain channel authorizedSigner %s != expected %s", channel.AuthorizedSigner, expectedAuthorizedSigner) + } + program := paymentchannels.ProgramPubkey() + if programID != nil { + program = *programID + } + derivedChannel, _, err := paymentchannels.FindChannelPDAForProgram( + channel.Payer, + channel.Payee, + channel.Mint, + channel.AuthorizedSigner, + channel.Salt, + channel.OpenSlot, + program, + ) + if err != nil { + return boundChannel{}, fmt.Errorf("derive channel PDA from on-chain state: %w", err) + } + if !derivedChannel.Equals(channelID) { + return boundChannel{}, fmt.Errorf("channel account %s != PDA derived from authoritative state %s", channelID, derivedChannel) + } + return boundChannel{ + Deposit: channel.Deposit, + Payer: channel.Payer.String(), + AuthorizedSigner: channel.AuthorizedSigner.String(), + Payee: channel.Payee.String(), + Mint: channel.Mint.String(), + GracePeriod: channel.GracePeriod, + Salt: channel.Salt, + OpenSlot: channel.OpenSlot, + }, nil +} + +func sessionDistributionHash(splits []Split) [32]byte { + hasher := sha256.New() + var count [4]byte + binary.LittleEndian.PutUint32(count[:], uint32(len(splits))) + _, _ = hasher.Write(count[:]) + for _, split := range splits { + _, _ = hasher.Write(split.Recipient.Bytes()) + var bps [2]byte + binary.LittleEndian.PutUint16(bps[:], split.BPS) + _, _ = hasher.Write(bps[:]) + } + var result [32]byte + copy(result[:], hasher.Sum(nil)) + return result +} + +func expectedSessionGracePeriod(config SessionConfig) uint32 { + if config.SettlementWindowSeconds > 0 && config.SettlementWindowSeconds <= int64(^uint32(0)) { + return uint32(config.SettlementWindowSeconds) + } + return 900 +} + // openInstructionDiscriminator is the payment-channel open instruction // discriminator (single-byte Anchor-numeric form, not the 8-byte sha256 // convention). Matches OPEN_DISCRIMINATOR in the vendored Codama clients. @@ -80,9 +239,17 @@ type VerifyOpenTxExpected struct { // base58); the transaction's payee account must match it. Recipient string - // Splits is the ordered payment distribution committed by the challenge. + // Splits is the ordered payout distribution committed by the challenge. // The open instruction must carry the same recipients and basis points. Splits []Split + + // GracePeriod is the challenge's expected close grace period. Zero leaves + // this legacy field unconstrained; configured sessions always populate it. + GracePeriod uint32 + + // RecentSlot is the challenge incarnation when known. An attached open's + // openSlot must match it. + RecentSlot *uint64 } // VerifyOpenTxResult carries the channel facts extracted from a verified open @@ -159,6 +326,9 @@ func verifyOpenTx(ctx context.Context, expected VerifyOpenTxExpected, payload *i if len(tx.Message.AddressTableLookups) > 0 { return VerifyOpenTxResult{}, fmt.Errorf("open transaction uses address lookup tables, which are not supported") } + if len(tx.Message.Instructions) != 1 { + return VerifyOpenTxResult{}, fmt.Errorf("open transaction must contain exactly one instruction, found %d", len(tx.Message.Instructions)) + } // Bind every required signature to the exact message before trusting the // transaction. Server-submit may leave only the fee-payer slot empty; the @@ -216,19 +386,11 @@ func verifyOpenTx(ctx context.Context, expected VerifyOpenTxExpected, payload *i return accountKeys[indices[slot]], nil } - var openIx *solana.CompiledInstruction - for i := range tx.Message.Instructions { - ix := &tx.Message.Instructions[i] - if int(ix.ProgramIDIndex) >= len(accountKeys) || !accountKeys[ix.ProgramIDIndex].Equals(programID) { - continue - } - if len(ix.Data) < 1 || ix.Data[0] != openInstructionDiscriminator { - continue - } - openIx = ix - break + openIx := &tx.Message.Instructions[0] + if int(openIx.ProgramIDIndex) >= len(accountKeys) || !accountKeys[openIx.ProgramIDIndex].Equals(programID) { + return VerifyOpenTxResult{}, fmt.Errorf("no payment-channels open instruction found") } - if openIx == nil { + if len(openIx.Data) < 1 || openIx.Data[0] != openInstructionDiscriminator { return VerifyOpenTxResult{}, fmt.Errorf("no payment-channels open instruction found") } if len(tx.Message.Instructions) != 1 { @@ -315,6 +477,9 @@ func verifyOpenTx(ctx context.Context, expected VerifyOpenTxExpected, payload *i return VerifyOpenTxResult{}, fmt.Errorf("open recipient[%d] does not match expected split", i) } } + if expected.GracePeriod != 0 && openArgs.GracePeriod != expected.GracePeriod { + return VerifyOpenTxResult{}, fmt.Errorf("open gracePeriod %d != expected %d", openArgs.GracePeriod, expected.GracePeriod) + } if deposit == 0 { return VerifyOpenTxResult{}, fmt.Errorf("open deposit must be greater than zero") @@ -337,6 +502,49 @@ func verifyOpenTx(ctx context.Context, expected VerifyOpenTxExpected, payload *i if payload.RecentSlot != nil && *payload.RecentSlot != openSlot { return VerifyOpenTxResult{}, fmt.Errorf("openPayload.recentSlot %d != transaction openSlot %d", *payload.RecentSlot, openSlot) } + if expected.RecentSlot != nil && *expected.RecentSlot != openSlot { + return VerifyOpenTxResult{}, fmt.Errorf("transaction openSlot %d != challenge recentSlot %d", openSlot, *expected.RecentSlot) + } + + // Rebuild the canonical instruction from the verified values. This rejects + // trailing/malformed Borsh data and pins every account in the fixed layout, + // including both token accounts, sysvars, event authority, and self-program. + // Use the token program already read from account 8 and verified against the + // expected program above, so Token-2022 mints rebuild against their real + // program rather than the currency default. + canonical, err := paymentchannels.BuildOpenInstruction(paymentchannels.OpenChannelParams{ + Payer: payer, + RentPayer: rentPayer, + Payee: payee, + Mint: mint, + AuthorizedSigner: authorizedSigner, + Salt: salt, + OpenSlot: openSlot, + Deposit: deposit, + GracePeriod: gracePeriod, + Recipients: openSplits(openArgs.Recipients), + TokenProgram: tokenProgram, + ProgramID: programID, + }) + if err != nil { + return VerifyOpenTxResult{}, fmt.Errorf("build canonical open instruction: %w", err) + } + canonicalData, err := canonical.Data() + if err != nil { + return VerifyOpenTxResult{}, fmt.Errorf("encode canonical open instruction: %w", err) + } + if !bytes.Equal(openIx.Data, canonicalData) { + return VerifyOpenTxResult{}, fmt.Errorf("open instruction data is not canonical") + } + canonicalAccounts := canonical.Accounts() + if len(canonicalAccounts) != len(openIx.Accounts) { + return VerifyOpenTxResult{}, fmt.Errorf("open instruction account count %d != canonical count %d", len(openIx.Accounts), len(canonicalAccounts)) + } + for i, meta := range canonicalAccounts { + if int(openIx.Accounts[i]) >= len(accountKeys) || !accountKeys[openIx.Accounts[i]].Equals(meta.PublicKey) { + return VerifyOpenTxResult{}, fmt.Errorf("open instruction account[%d] does not match canonical account %s", i, meta.PublicKey) + } + } // Optional liveness check: only when the caller provides an RPC client // and the client already populated the transaction signature. @@ -375,6 +583,7 @@ func NewOpenTxVerifier(config SessionConfig, rpcClient solanatx.RPCClient) Sessi ProgramID: config.ProgramID, Recipient: config.Recipient, Splits: config.Splits, + GracePeriod: expectedSessionGracePeriod(config), } result, err := VerifyOpenTx(ctx, expected, payload, rpcClient) return result.Payer, err @@ -392,12 +601,246 @@ func NewOpenTxVerifier(config SessionConfig, rpcClient solanatx.RPCClient) Sessi ProgramID: config.ProgramID, Recipient: config.Recipient, Splits: config.Splits, + GracePeriod: expectedSessionGracePeriod(config), } - result, err := verifySignatureOnlyOpen(ctx, expected, payload, rpcClient) + result, _, err := verifySignatureOnlyOpen(ctx, expected, payload, rpcClient) return result.Payer, err } } +// NewOpenStateTxVerifier returns the authoritative open verifier used by new +// SessionServer instances. Every payment-channel open must have a real, +// wire-bound signature that is confirmed on-chain before the current channel +// account is read at the confirmed slot; the returned facts come from that +// account rather than from transaction bytes. +func NewOpenStateTxVerifier(config SessionConfig, rpcClient solanatx.RPCClient) SessionOpenStateTxVerifier { + return func(ctx context.Context, payload *intents.OpenPayload) (VerifyOpenTxResult, error) { + if payload == nil { + return VerifyOpenTxResult{}, fmt.Errorf("open state verification requires a payload") + } + if rpcClient == nil { + return VerifyOpenTxResult{}, fmt.Errorf("authoritative open verification requires an RPC client to confirm and bind the channel") + } + + expected := VerifyOpenTxExpected{ + AuthorizedSigner: payload.AuthorizedSigner, + Currency: config.Currency, + MaxCap: config.MaxCap, + Network: config.Network, + Operator: config.Operator, + ProgramID: config.ProgramID, + Recipient: config.Recipient, + Splits: config.Splits, + GracePeriod: expectedSessionGracePeriod(config), + } + + var ( + channelID solana.PublicKey + verified *VerifyOpenTxResult + ) + if payload.Transaction != nil && *payload.Transaction != "" { + if isPlaceholderSignature(payload.Signature) { + return VerifyOpenTxResult{}, fmt.Errorf( + "authoritative open verification requires a real wire-bound confirmed signature; placeholder signatures are accepted only by NewOpenTxVerifier") + } + // VerifyOpenTx is deliberately structural here. It binds the real + // payload signature to the transaction; the status lookup below is + // the separate confirmation gate for the state-aware path. + decoded, err := VerifyOpenTx(ctx, expected, payload, nil) + if err != nil { + return VerifyOpenTxResult{}, err + } + verified = &decoded + slot, err := confirmedTransactionSlot(ctx, rpcClient, payload.Signature, "open") + if err != nil { + return VerifyOpenTxResult{}, err + } + channelID, err = solana.PublicKeyFromBase58(decoded.ChannelID) + if err != nil { + return VerifyOpenTxResult{}, fmt.Errorf("invalid verified channelId %q: %w", decoded.ChannelID, err) + } + bound, err := bindConfirmedOpenChannel(ctx, rpcClient, config, payload, channelID, slot, true) + if err != nil { + return VerifyOpenTxResult{}, err + } + if err := validateBoundOpenChannel(bound, verified, config.MaxCap); err != nil { + return VerifyOpenTxResult{}, err + } + if err := validateAssertedOpenDeposit(payload, bound.Deposit); err != nil { + return VerifyOpenTxResult{}, err + } + return VerifyOpenTxResult{ + ChannelID: channelID.String(), Deposit: bound.Deposit, OpenSlot: bound.OpenSlot, + Payer: bound.Payer, Salt: bound.Salt, GracePeriod: bound.GracePeriod, + }, nil + } + + if isPlaceholderSignature(payload.Signature) { + return VerifyOpenTxResult{}, fmt.Errorf( + "authoritative open verification requires a real confirmed signature; placeholder signatures are accepted only by NewOpenTxVerifier") + } + if payload.Mode == intents.SessionModePush && payload.RecentSlot == nil { + return VerifyOpenTxResult{}, fmt.Errorf("signature-only push open requires recentSlot") + } + if payload.ChannelID == nil || *payload.ChannelID == "" { + return VerifyOpenTxResult{}, fmt.Errorf("signature-only open requires channelId") + } + decoded, slot, err := verifySignatureOnlyOpen(ctx, expected, payload, rpcClient) + if err != nil { + return VerifyOpenTxResult{}, err + } + verified = &decoded + channelID, err = solana.PublicKeyFromBase58(verified.ChannelID) + if err != nil { + return VerifyOpenTxResult{}, fmt.Errorf("invalid verified channelId %q: %w", verified.ChannelID, err) + } + bound, err := bindConfirmedOpenChannel(ctx, rpcClient, config, payload, channelID, slot, true) + if err != nil { + return VerifyOpenTxResult{}, err + } + if err := validateBoundOpenChannel(bound, verified, config.MaxCap); err != nil { + return VerifyOpenTxResult{}, err + } + if err := validateAssertedOpenDeposit(payload, bound.Deposit); err != nil { + return VerifyOpenTxResult{}, err + } + return VerifyOpenTxResult{ + ChannelID: channelID.String(), Deposit: bound.Deposit, OpenSlot: bound.OpenSlot, + Payer: bound.Payer, Salt: bound.Salt, GracePeriod: bound.GracePeriod, + }, nil + } +} + +// verifySignatureOnlyOpen fetches the transaction named by a compact open's +// signature and sends that exact wire transaction through VerifyOpenTx. A +// status lookup alone is insufficient: it can confirm an unrelated +// transaction before the channel account is read separately. +func verifySignatureOnlyOpen( + ctx context.Context, + expected VerifyOpenTxExpected, + payload *intents.OpenPayload, + rpcClient solanatx.RPCClient, +) (VerifyOpenTxResult, uint64, error) { + if rpcClient == nil { + return VerifyOpenTxResult{}, 0, fmt.Errorf("signature-only open verification requires an RPC client") + } + if payload == nil { + return VerifyOpenTxResult{}, 0, fmt.Errorf("signature-only open verification requires a payload") + } + if isPlaceholderSignature(payload.Signature) { + return VerifyOpenTxResult{}, 0, fmt.Errorf("signature-only open requires a real confirmed signature") + } + if payload.ChannelID == nil || *payload.ChannelID == "" { + return VerifyOpenTxResult{}, 0, fmt.Errorf("signature-only open requires channelId") + } + + confirmedSlot, err := confirmedTransactionSlot(ctx, rpcClient, payload.Signature, "open") + if err != nil { + return VerifyOpenTxResult{}, 0, err + } + parsedSignature, err := solana.SignatureFromBase58(payload.Signature) + if err != nil { + return VerifyOpenTxResult{}, 0, fmt.Errorf("invalid open tx signature %q: %w", payload.Signature, err) + } + maxSupportedTransactionVersion := uint64(0) + result, err := rpcClient.GetTransaction(ctx, parsedSignature, &rpc.GetTransactionOpts{ + Commitment: rpc.CommitmentConfirmed, + Encoding: solana.EncodingBase64, + MaxSupportedTransactionVersion: &maxSupportedTransactionVersion, + }) + if err != nil { + return VerifyOpenTxResult{}, 0, fmt.Errorf("fetch confirmed open transaction: %w", err) + } + if result == nil || result.Transaction == nil { + return VerifyOpenTxResult{}, 0, fmt.Errorf("confirmed open transaction %q is missing", payload.Signature) + } + tx, err := result.Transaction.GetTransaction() + if err != nil { + return VerifyOpenTxResult{}, 0, fmt.Errorf("decode confirmed open transaction: %w", err) + } + if tx == nil { + return VerifyOpenTxResult{}, 0, fmt.Errorf("confirmed open transaction %q decoded to nil", payload.Signature) + } + if len(tx.Signatures) == 0 || tx.Signatures[0] != parsedSignature { + return VerifyOpenTxResult{}, 0, fmt.Errorf("confirmed open transaction fee-payer signature does not match payload signature") + } + wire, err := solanatx.EncodeTransactionBase64(tx) + if err != nil { + return VerifyOpenTxResult{}, 0, fmt.Errorf("encode confirmed open transaction: %w", err) + } + boundPayload := *payload + boundPayload.Transaction = &wire + verified, err := VerifyOpenTx(ctx, expected, &boundPayload, nil) + if err != nil { + return VerifyOpenTxResult{}, 0, fmt.Errorf("verify confirmed open transaction: %w", err) + } + return verified, confirmedSlot, nil +} + +func bindConfirmedOpenChannel( + ctx context.Context, + rpcClient solanatx.RPCClient, + config SessionConfig, + payload *intents.OpenPayload, + channelID solana.PublicKey, + minContextSlot uint64, + requireFresh bool, +) (boundChannel, error) { + mint := paycore.ResolveMint(config.Currency, config.Network) + if mint == "" { + return boundChannel{}, fmt.Errorf("payment-channel open requires an SPL token, got currency %q", config.Currency) + } + return fetchAndBindChannelAccount( + ctx, rpcClient, channelID, mint, config.Recipient, config.Operator, + payload.AuthorizedSigner, expectedSessionGracePeriod(config), sessionDistributionHash(config.Splits), requireFresh, + config.ProgramID, minContextSlot, + ) +} + +func validateBoundOpenChannel(bound boundChannel, verified *VerifyOpenTxResult, maxCap uint64) error { + if bound.Deposit == 0 { + return fmt.Errorf("on-chain open channel deposit must be greater than zero") + } + if bound.Deposit > maxCap { + return fmt.Errorf("on-chain open channel deposit %d exceeds max cap %d", bound.Deposit, maxCap) + } + if verified == nil { + return nil + } + if bound.Payer != verified.Payer { + return fmt.Errorf("on-chain channel payer %s != transaction payer %s", bound.Payer, verified.Payer) + } + if bound.Deposit != verified.Deposit { + return fmt.Errorf("on-chain channel deposit %d != transaction deposit %d", bound.Deposit, verified.Deposit) + } + if bound.Salt != verified.Salt { + return fmt.Errorf("on-chain channel salt %d != transaction salt %d", bound.Salt, verified.Salt) + } + if bound.OpenSlot != verified.OpenSlot { + return fmt.Errorf("on-chain channel openSlot %d != transaction openSlot %d", bound.OpenSlot, verified.OpenSlot) + } + return nil +} + +func validateAssertedOpenDeposit(payload *intents.OpenPayload, authoritativeDeposit uint64) error { + assertedDeposit, err := payload.DepositAmount() + if err != nil { + return err + } + if authoritativeDeposit != assertedDeposit { + return fmt.Errorf("on-chain channel deposit %d != asserted deposit %d", authoritativeDeposit, assertedDeposit) + } + return nil +} + +func openSplits(entries []generated.DistributionEntry) []paymentchannels.Distribution { + result := make([]paymentchannels.Distribution, 0, len(entries)) + for _, entry := range entries { + result = append(result, paymentchannels.Distribution{Recipient: entry.Recipient, Bps: entry.Bps}) + } + return result +} + // NewTopUpTxVerifier returns the on-chain top-up verifier to install on // SessionConfig.VerifyTopUpTx. It fetches the confirmed transaction and // requires a payment-channels top_up instruction for the payload channel with @@ -487,6 +930,94 @@ func verifyTopUpTx(ctx context.Context, rpcClient solanatx.RPCClient, programID return nil } +// NewTopUpStateTxVerifier confirms the transaction and binds a top-up to the +// resulting on-chain channel state. New session construction installs this +// stronger state-aware verifier alongside the legacy callback seam. +func NewTopUpStateTxVerifier(config SessionConfig, rpcClient solanatx.RPCClient) SessionTopUpTxVerifier { + if rpcClient == nil { + if config.Network == "localnet" { + return nil + } + return func(context.Context, *intents.TopUpPayload, ChannelState) error { + return fmt.Errorf("payment-channel top-up requires an rpc client to bind the on-chain channel off localnet") + } + } + return func(ctx context.Context, payload *intents.TopUpPayload, current ChannelState) error { + slot, err := confirmedTransactionSlot(ctx, rpcClient, payload.Signature, "top-up") + if err != nil { + return err + } + newDeposit, err := strconv.ParseUint(payload.NewDeposit, 10, 64) + if err != nil { + return fmt.Errorf("invalid newDeposit: %s", payload.NewDeposit) + } + expectedMint := paycore.ResolveMint(config.Currency, config.Network) + if expectedMint == "" { + return fmt.Errorf("payment-channel top-up requires an SPL token, got currency %q", config.Currency) + } + channelID, err := solana.PublicKeyFromBase58(payload.ChannelID) + if err != nil { + return fmt.Errorf("invalid channelId %q: %w", payload.ChannelID, err) + } + if config.Network != "localnet" { + parsedSignature, err := solana.SignatureFromBase58(payload.Signature) + if err != nil { + return fmt.Errorf("invalid top-up tx signature %q: %w", payload.Signature, err) + } + tx, _, err := solanatx.FetchTransaction(ctx, rpcClient, parsedSignature) + if err != nil { + return fmt.Errorf("fetch top-up transaction: %w", err) + } + if len(tx.Signatures) == 0 || tx.Signatures[0] != parsedSignature { + return fmt.Errorf("top-up transaction signature does not match payload signature") + } + program := paymentchannels.ProgramPubkey() + if config.ProgramID != nil { + program = *config.ProgramID + } + var funded uint64 + var matched int + for _, instruction := range tx.Message.Instructions { + if int(instruction.ProgramIDIndex) >= len(tx.Message.AccountKeys) || + !tx.Message.AccountKeys[instruction.ProgramIDIndex].Equals(program) || + len(instruction.Data) != 9 || instruction.Data[0] != topUpInstructionDiscriminator || + len(instruction.Accounts) < 2 || int(instruction.Accounts[1]) >= len(tx.Message.AccountKeys) || + !tx.Message.AccountKeys[instruction.Accounts[1]].Equals(channelID) { + continue + } + matched++ + if matched > 1 { + return fmt.Errorf("top-up transaction must contain exactly one payment-channels top_up instruction for channel %s, found %d", channelID, matched) + } + funded = binary.LittleEndian.Uint64(instruction.Data[1:]) + } + if matched != 1 { + return fmt.Errorf("top-up transaction must contain exactly one payment-channels top_up instruction for channel %s, found %d", channelID, matched) + } + if newDeposit <= current.Deposit || funded != newDeposit-current.Deposit { + return fmt.Errorf("top-up amount %d != claimed deposit delta %d", funded, newDeposit-current.Deposit) + } + } + channel, err := fetchAndValidateChannel( + ctx, rpcClient, channelID, expectedMint, config.Recipient, config.Operator, + expectedSessionGracePeriod(config), sessionDistributionHash(config.Splits), false, config.ProgramID, slot, + ) + if err != nil { + return err + } + if channel.AuthorizedSigner.String() != current.AuthorizedSigner { + return fmt.Errorf("on-chain channel authorizedSigner %s != stored signer %s", channel.AuthorizedSigner, current.AuthorizedSigner) + } + if current.Operator == nil || channel.Payer.String() != *current.Operator { + return fmt.Errorf("on-chain channel payer %s does not match stored payer", channel.Payer) + } + if channel.Deposit != newDeposit { + return fmt.Errorf("on-chain channel deposit %d != asserted newDeposit %d", channel.Deposit, newDeposit) + } + return nil + } +} + // SettlementInstructions builds the on-chain settlement sequence for a // channel: settle_and_seal over the stored watermark (preceded by the // Ed25519 precompile instruction when a voucher was accepted) plus the @@ -498,6 +1029,9 @@ func verifyTopUpTx(ctx context.Context, rpcClient solanatx.RPCClient, programID // program comes from the authoritative session configuration (Token-2022 // for known PYUSD/USDG/CASH, or the resolved owner for an arbitrary mint). func (s *SessionServer) SettlementInstructions(ctx context.Context, channelID string, merchant solana.PublicKey) ([]solana.Instruction, error) { + if err := s.requireProductionSessionSafety(); err != nil { + return nil, err + } state, err := s.store.GetChannel(ctx, channelID) if err != nil { return nil, err @@ -658,15 +1192,45 @@ func SubmitOpenTx(ctx context.Context, expected VerifyOpenTxExpected, payload *i if rpcClient == nil { return SubmitOpenTxResult{}, fmt.Errorf("SubmitOpenTx requires an RPC client") } - // Structural validation only: the transaction has not been broadcast yet, - // so there is no on-chain liveness to check. - verified, err := verifyOpenTx(ctx, expected, payload, nil, true) + verified, tx, signature, err := prepareOpenTx(ctx, expected, payload, payerSigner) if err != nil { return SubmitOpenTxResult{}, err } + sentSignature, err := solanatx.SendTransaction(ctx, rpcClient, tx) + if err != nil { + return SubmitOpenTxResult{}, fmt.Errorf("broadcast open transaction: %w", err) + } + if sentSignature != signature { + return SubmitOpenTxResult{}, fmt.Errorf("broadcast open signature %s != prepared signature %s", sentSignature, signature) + } + if err := solanatx.WaitForConfirmation(ctx, rpcClient, signature); err != nil { + return SubmitOpenTxResult{}, fmt.Errorf("confirm open transaction: %w", err) + } + return SubmitOpenTxResult{VerifyOpenTxResult: verified, Signature: signature.String()}, nil +} + +func prepareOpenTx(ctx context.Context, expected VerifyOpenTxExpected, payload *intents.OpenPayload, payerSigner solanatx.Signer) (VerifyOpenTxResult, *solana.Transaction, solana.Signature, error) { + // Structural validation only: the transaction may not have been broadcast. + // Permit an unsigned fee-payer slot (allowUnsignedFeePayer=true): this is + // the server-submit path, where the client signs only as the channel payer + // and leaves the operator/fee-payer slot for the server to complete below. + // The payer signature is still verified; the co-sign step and the + // post-co-sign zero-signature guard keep this fail-closed. Matches + // handleOpen's pre-verification. + verified, err := verifyOpenTx(ctx, expected, payload, nil, true) + if err != nil { + return VerifyOpenTxResult{}, nil, solana.Signature{}, err + } tx, err := solanatx.DecodeTransactionBase64(*payload.Transaction) if err != nil { - return SubmitOpenTxResult{}, fmt.Errorf("decode open transaction: %w", err) + return VerifyOpenTxResult{}, nil, solana.Signature{}, fmt.Errorf("decode open transaction: %w", err) + } + operator, err := solana.PublicKeyFromBase58(expected.Operator) + if err != nil { + return VerifyOpenTxResult{}, nil, solana.Signature{}, fmt.Errorf("invalid expected operator %q: %w", expected.Operator, err) + } + if len(tx.Message.AccountKeys) == 0 || !tx.Message.AccountKeys[0].Equals(operator) { + return VerifyOpenTxResult{}, nil, solana.Signature{}, fmt.Errorf("open transaction fee payer does not match expected operator %s", expected.Operator) } // Complete the fee-payer signature when the client left the slot for the // server (the createServerOpenedPaymentChannelSessionOpener flow builds @@ -674,20 +1238,13 @@ func SubmitOpenTx(ctx context.Context, expected VerifyOpenTxExpected, payload *i // channel payer). if payerSigner != nil && signerIsRequired(tx, payerSigner.PublicKey()) { if err := solanatx.SignTransaction(tx, payerSigner); err != nil { - return SubmitOpenTxResult{}, fmt.Errorf("co-sign open transaction: %w", err) + return VerifyOpenTxResult{}, nil, solana.Signature{}, fmt.Errorf("co-sign open transaction: %w", err) } } if len(tx.Signatures) == 0 || tx.Signatures[0].IsZero() { - return SubmitOpenTxResult{}, fmt.Errorf("open transaction is missing the fee-payer signature") - } - signature, err := solanatx.SendTransaction(ctx, rpcClient, tx) - if err != nil { - return SubmitOpenTxResult{}, fmt.Errorf("broadcast open transaction: %w", err) - } - if err := solanatx.WaitForConfirmation(ctx, rpcClient, signature); err != nil { - return SubmitOpenTxResult{}, fmt.Errorf("confirm open transaction: %w", err) + return VerifyOpenTxResult{}, nil, solana.Signature{}, fmt.Errorf("open transaction is missing the fee-payer signature") } - return SubmitOpenTxResult{VerifyOpenTxResult: verified, Signature: signature.String()}, nil + return verified, tx, tx.Signatures[0], nil } // signerIsRequired reports whether key is one of the transaction's required @@ -705,79 +1262,30 @@ func signerIsRequired(tx *solana.Transaction, key solana.PublicKey) bool { // base58 signature names a known, successful transaction. label names the // transaction in error messages ("open", "top-up"). func confirmTransactionSignature(ctx context.Context, rpcClient solanatx.RPCClient, signature, label string) error { + _, err := confirmedTransactionSlot(ctx, rpcClient, signature, label) + return err +} + +func confirmedTransactionSlot(ctx context.Context, rpcClient solanatx.RPCClient, signature, label string) (uint64, error) { parsed, err := solana.SignatureFromBase58(signature) if err != nil { - return fmt.Errorf("invalid %s tx signature %q: %w", label, signature, err) + return 0, fmt.Errorf("invalid %s tx signature %q: %w", label, signature, err) } out, err := rpcClient.GetSignatureStatuses(ctx, true, parsed) if err != nil { - return fmt.Errorf("RPC error verifying %s tx: %w", label, err) + return 0, fmt.Errorf("RPC error verifying %s tx: %w", label, err) } if out == nil || len(out.Value) == 0 || out.Value[0] == nil { - return fmt.Errorf("%s tx %q not found; not yet confirmed or does not exist", label, signature) + return 0, fmt.Errorf("%s tx %q not found; not yet confirmed or does not exist", label, signature) } if out.Value[0].Err != nil { - return fmt.Errorf("%s tx %q failed on-chain: %v", label, signature, out.Value[0].Err) + return 0, fmt.Errorf("%s tx %q failed on-chain: %v", label, signature, out.Value[0].Err) } status := out.Value[0].ConfirmationStatus if status != rpc.ConfirmationStatusConfirmed && status != rpc.ConfirmationStatusFinalized { - return fmt.Errorf("%s tx %q is only %s; confirmed or finalized required", label, signature, status) - } - return nil -} - -// verifySignatureOnlyOpen fetches the transaction named by a compact open and -// runs the same structural verifier used for attached transactions. A status -// lookup alone could otherwise bind an unrelated successful transaction. -func verifySignatureOnlyOpen( - ctx context.Context, - expected VerifyOpenTxExpected, - payload *intents.OpenPayload, - rpcClient solanatx.RPCClient, -) (VerifyOpenTxResult, error) { - if rpcClient == nil { - return VerifyOpenTxResult{}, fmt.Errorf("signature-only open verification requires an RPC client") - } - if payload == nil || isPlaceholderSignature(payload.Signature) { - return VerifyOpenTxResult{}, fmt.Errorf("signature-only open requires a real confirmed signature") - } - parsedSignature, err := solana.SignatureFromBase58(payload.Signature) - if err != nil { - return VerifyOpenTxResult{}, fmt.Errorf("invalid open tx signature %q: %w", payload.Signature, err) - } - if err := confirmTransactionSignature(ctx, rpcClient, payload.Signature, "open"); err != nil { - return VerifyOpenTxResult{}, err - } - maxSupportedTransactionVersion := uint64(0) - result, err := rpcClient.GetTransaction(ctx, parsedSignature, &rpc.GetTransactionOpts{ - Commitment: rpc.CommitmentConfirmed, - Encoding: solana.EncodingBase64, - MaxSupportedTransactionVersion: &maxSupportedTransactionVersion, - }) - if err != nil { - return VerifyOpenTxResult{}, fmt.Errorf("fetch confirmed open transaction: %w", err) - } - if result == nil || result.Transaction == nil { - return VerifyOpenTxResult{}, fmt.Errorf("confirmed open transaction %q is missing", payload.Signature) - } - tx, err := result.Transaction.GetTransaction() - if err != nil { - return VerifyOpenTxResult{}, fmt.Errorf("decode confirmed open transaction: %w", err) - } - if tx == nil || len(tx.Signatures) == 0 || tx.Signatures[0] != parsedSignature { - return VerifyOpenTxResult{}, fmt.Errorf("confirmed open transaction fee-payer signature does not match payload signature") - } - wire, err := solanatx.EncodeTransactionBase64(tx) - if err != nil { - return VerifyOpenTxResult{}, fmt.Errorf("encode confirmed open transaction: %w", err) - } - boundPayload := *payload - boundPayload.Transaction = &wire - verified, err := VerifyOpenTx(ctx, expected, &boundPayload, nil) - if err != nil { - return VerifyOpenTxResult{}, fmt.Errorf("verify confirmed open transaction: %w", err) + return 0, fmt.Errorf("%s tx %q is only %s; confirmed or finalized required", label, signature, status) } - return verified, nil + return out.Value[0].Slot, nil } // isPlaceholderSignature reports whether the signature is the pending diff --git a/go/protocols/mpp/server/session_onchain_coverage_test.go b/go/protocols/mpp/server/session_onchain_coverage_test.go new file mode 100644 index 000000000..cb1c1e767 --- /dev/null +++ b/go/protocols/mpp/server/session_onchain_coverage_test.go @@ -0,0 +1,480 @@ +package server + +// Fail-closed branch coverage for the #239 session on-chain verifiers: +// the signature-only authoritative open path, the bound-channel economic +// checks, token-program resolution, and the top-up reject branches. These +// exercise the rejection paths the happy-path tests skip. + +import ( + "context" + "strings" + "testing" + + solana "github.com/solana-foundation/solana-go/v2" + "github.com/solana-foundation/solana-go/v2/rpc" + + "github.com/solana-foundation/pay-kit/go/internal/testutil" + "github.com/solana-foundation/pay-kit/go/paycore" + "github.com/solana-foundation/pay-kit/go/paycore/paymentchannels" + "github.com/solana-foundation/pay-kit/go/paycore/solanatx" + "github.com/solana-foundation/pay-kit/go/protocols/mpp/intents" + pcgen "github.com/solana-foundation/pay-kit/go/protocols/programs/paymentchannels" +) + +// ── validateBoundOpenChannel: economic and cross-check rejections ── + +func TestValidateBoundOpenChannelRejections(t *testing.T) { + match := func() (boundChannel, *VerifyOpenTxResult) { + bound := boundChannel{Deposit: 1_000, Payer: "payerA", Salt: 7, OpenSlot: 42} + verified := &VerifyOpenTxResult{Deposit: 1_000, Payer: "payerA", Salt: 7, OpenSlot: 42} + return bound, verified + } + + t.Run("zero deposit", func(t *testing.T) { + bound, verified := match() + bound.Deposit = 0 + if err := validateBoundOpenChannel(bound, verified, 5_000); err == nil || !strings.Contains(err.Error(), "greater than zero") { + t.Fatalf("err = %v, want zero-deposit rejection", err) + } + }) + t.Run("over cap", func(t *testing.T) { + bound, verified := match() + if err := validateBoundOpenChannel(bound, verified, 500); err == nil || !strings.Contains(err.Error(), "exceeds max cap") { + t.Fatalf("err = %v, want over-cap rejection", err) + } + }) + t.Run("nil verified is accepted", func(t *testing.T) { + bound, _ := match() + if err := validateBoundOpenChannel(bound, nil, 5_000); err != nil { + t.Fatalf("nil verified must skip the transaction cross-check, got %v", err) + } + }) + t.Run("payer mismatch", func(t *testing.T) { + bound, verified := match() + verified.Payer = "payerB" + if err := validateBoundOpenChannel(bound, verified, 5_000); err == nil || !strings.Contains(err.Error(), "transaction payer") { + t.Fatalf("err = %v, want payer rejection", err) + } + }) + t.Run("deposit mismatch", func(t *testing.T) { + bound, verified := match() + verified.Deposit = 999 + if err := validateBoundOpenChannel(bound, verified, 5_000); err == nil || !strings.Contains(err.Error(), "transaction deposit") { + t.Fatalf("err = %v, want deposit rejection", err) + } + }) + t.Run("salt mismatch", func(t *testing.T) { + bound, verified := match() + verified.Salt = 8 + if err := validateBoundOpenChannel(bound, verified, 5_000); err == nil || !strings.Contains(err.Error(), "salt") { + t.Fatalf("err = %v, want salt rejection", err) + } + }) + t.Run("open slot mismatch", func(t *testing.T) { + bound, verified := match() + verified.OpenSlot = 43 + if err := validateBoundOpenChannel(bound, verified, 5_000); err == nil || !strings.Contains(err.Error(), "openSlot") { + t.Fatalf("err = %v, want openSlot rejection", err) + } + }) + t.Run("all match", func(t *testing.T) { + bound, verified := match() + if err := validateBoundOpenChannel(bound, verified, 5_000); err != nil { + t.Fatalf("matching bound channel rejected: %v", err) + } + }) +} + +// ── resolveSessionTokenProgram: config validation ── + +func TestResolveSessionTokenProgramErrors(t *testing.T) { + arbitraryMint := solana.NewWallet().PublicKey().String() + t.Run("arbitrary mint requires explicit program", func(t *testing.T) { + if _, err := resolveSessionTokenProgram("", arbitraryMint, "mainnet"); err == nil || !strings.Contains(err.Error(), "token program is required") { + t.Fatalf("err = %v, want arbitrary-mint program requirement", err) + } + }) + t.Run("invalid program string", func(t *testing.T) { + if _, err := resolveSessionTokenProgram("not-base58!!!", "USDC", "mainnet"); err == nil || !strings.Contains(err.Error(), "invalid session token program") { + t.Fatalf("err = %v, want invalid-program rejection", err) + } + }) + t.Run("unsupported program", func(t *testing.T) { + other := solana.NewWallet().PublicKey().String() + if _, err := resolveSessionTokenProgram(other, "USDC", "mainnet"); err == nil || !strings.Contains(err.Error(), "unsupported session token program") { + t.Fatalf("err = %v, want unsupported-program rejection", err) + } + }) + t.Run("configured legacy token program is accepted", func(t *testing.T) { + got, err := resolveSessionTokenProgram(solana.TokenProgramID.String(), "USDC", "mainnet") + if err != nil || !got.Equals(solana.TokenProgramID) { + t.Fatalf("got %s, err %v; want the legacy token program", got, err) + } + }) +} + +// ── distribution hash / grace period ── + +func TestSessionDistributionHashCommitsSplits(t *testing.T) { + splits := []Split{ + {Recipient: solana.NewWallet().PublicKey(), BPS: 7_000}, + {Recipient: solana.NewWallet().PublicKey(), BPS: 3_000}, + } + withSplits := sessionDistributionHash(splits) + if withSplits == sessionDistributionHash(nil) { + t.Fatal("non-empty splits must hash differently from the empty distribution") + } + if withSplits != sessionDistributionHash(splits) { + t.Fatal("distribution hash is not deterministic for identical splits") + } + reordered := []Split{splits[1], splits[0]} + if withSplits == sessionDistributionHash(reordered) { + t.Fatal("distribution hash must be order-sensitive") + } +} + +func TestExpectedSessionGracePeriod(t *testing.T) { + if got := expectedSessionGracePeriod(SessionConfig{SettlementWindowSeconds: 3_600}); got != 3_600 { + t.Fatalf("configured window = %d, want 3600", got) + } + if got := expectedSessionGracePeriod(SessionConfig{SettlementWindowSeconds: 0}); got != 900 { + t.Fatalf("zero window = %d, want default 900", got) + } + if got := expectedSessionGracePeriod(SessionConfig{SettlementWindowSeconds: int64(^uint32(0)) + 1}); got != 900 { + t.Fatalf("out-of-range window = %d, want default 900", got) + } +} + +// ── NewOpenStateTxVerifier: signature-only (transactionless) path ── + +// signatureOnlyOpenPayload strips the attached transaction so the fixture +// drives the authoritative signature-only branch of the state verifier. +func signatureOnlyOpenPayload(fixture openTxFixture) intents.OpenPayload { + payload := fixture.payload + payload.Transaction = nil + return payload +} + +func TestNewOpenStateTxVerifierSignatureOnlySucceeds(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + config := authoritativeOpenSessionConfig(fixture) + fake := testutil.NewFakeRPC() + tx, err := solanatx.DecodeTransactionBase64(*fixture.payload.Transaction) + if err != nil { + t.Fatalf("decode fixture transaction: %v", err) + } + fake.BySig[fixture.signature] = tx + confirmedSlot := uint64(4242) + fake.Statuses[fixture.signature] = &rpc.SignatureStatusesResult{ + ConfirmationStatus: rpc.ConfirmationStatusConfirmed, + Slot: confirmedSlot, + } + seedSessionChannelAccountWithSeeds( + t, fake, fixture.channel, openFixtureDeposit, fixture.payer.PublicKey(), fixture.payee, + fixture.authorized, fixture.mint, pcgen.ChannelStatus_Open, openFixtureSalt, openFixtureOpenSlot, + fixture.payer.PublicKey(), + ) + + payload := signatureOnlyOpenPayload(fixture) + result, err := NewOpenStateTxVerifier(config, fake)(context.Background(), &payload) + if err != nil { + t.Fatalf("signature-only state verifier: %v", err) + } + if result.ChannelID != fixture.channel.String() || result.Deposit != openFixtureDeposit || + result.Payer != fixture.payer.PublicKey().String() { + t.Fatalf("result = %+v, want account-derived channel/deposit/payer", result) + } + if result.Salt != openFixtureSalt || result.OpenSlot != openFixtureOpenSlot || result.GracePeriod != openFixtureGrace { + t.Fatalf("result = %+v, want account-derived seeds/grace", result) + } + if fake.LastAccountInfoOpts == nil || fake.LastAccountInfoOpts.MinContextSlot == nil || + *fake.LastAccountInfoOpts.MinContextSlot != confirmedSlot { + t.Fatalf("account read minContextSlot = %#v, want %d", fake.LastAccountInfoOpts, confirmedSlot) + } +} + +func TestNewOpenStateTxVerifierSignatureOnlyRejections(t *testing.T) { + t.Run("placeholder signature", func(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + config := authoritativeOpenSessionConfig(fixture) + payload := signatureOnlyOpenPayload(fixture) + payload.Signature = strings.Repeat("1", 64) + if _, err := NewOpenStateTxVerifier(config, testutil.NewFakeRPC())(context.Background(), &payload); err == nil || + !strings.Contains(err.Error(), "placeholder") { + t.Fatalf("err = %v, want placeholder rejection", err) + } + }) + t.Run("push open requires recentSlot", func(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + config := authoritativeOpenSessionConfig(fixture) + payload := signatureOnlyOpenPayload(fixture) + payload.RecentSlot = nil + if _, err := NewOpenStateTxVerifier(config, testutil.NewFakeRPC())(context.Background(), &payload); err == nil || + !strings.Contains(err.Error(), "recentSlot") { + t.Fatalf("err = %v, want recentSlot rejection", err) + } + }) + t.Run("requires channelId", func(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + config := authoritativeOpenSessionConfig(fixture) + payload := signatureOnlyOpenPayload(fixture) + payload.ChannelID = nil + if _, err := NewOpenStateTxVerifier(config, testutil.NewFakeRPC())(context.Background(), &payload); err == nil || + !strings.Contains(err.Error(), "channelId") { + t.Fatalf("err = %v, want channelId rejection", err) + } + }) + t.Run("nil rpc client", func(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + config := authoritativeOpenSessionConfig(fixture) + payload := signatureOnlyOpenPayload(fixture) + if _, err := NewOpenStateTxVerifier(config, nil)(context.Background(), &payload); err == nil || + !strings.Contains(err.Error(), "RPC client") { + t.Fatalf("err = %v, want nil-rpc rejection", err) + } + }) + t.Run("nil payload", func(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + config := authoritativeOpenSessionConfig(fixture) + if _, err := NewOpenStateTxVerifier(config, testutil.NewFakeRPC())(context.Background(), nil); err == nil || + !strings.Contains(err.Error(), "payload") { + t.Fatalf("err = %v, want nil-payload rejection", err) + } + }) +} + +// ── verifySignatureOnlyOpen: precondition and fetch rejections ── + +func TestVerifySignatureOnlyOpenRejections(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + fake := testutil.NewFakeRPC() + ctx := context.Background() + + t.Run("nil rpc", func(t *testing.T) { + payload := signatureOnlyOpenPayload(fixture) + if _, _, err := verifySignatureOnlyOpen(ctx, fixture.expected, &payload, nil); err == nil || + !strings.Contains(err.Error(), "requires an RPC client") { + t.Fatalf("err = %v", err) + } + }) + t.Run("nil payload", func(t *testing.T) { + if _, _, err := verifySignatureOnlyOpen(ctx, fixture.expected, nil, fake); err == nil || + !strings.Contains(err.Error(), "requires a payload") { + t.Fatalf("err = %v", err) + } + }) + t.Run("placeholder signature", func(t *testing.T) { + payload := signatureOnlyOpenPayload(fixture) + payload.Signature = strings.Repeat("1", 64) + if _, _, err := verifySignatureOnlyOpen(ctx, fixture.expected, &payload, fake); err == nil || + !strings.Contains(err.Error(), "real confirmed signature") { + t.Fatalf("err = %v", err) + } + }) + t.Run("missing channelId", func(t *testing.T) { + payload := signatureOnlyOpenPayload(fixture) + payload.ChannelID = nil + if _, _, err := verifySignatureOnlyOpen(ctx, fixture.expected, &payload, fake); err == nil || + !strings.Contains(err.Error(), "channelId") { + t.Fatalf("err = %v", err) + } + }) + t.Run("confirmed transaction missing on rpc", func(t *testing.T) { + payload := signatureOnlyOpenPayload(fixture) + // Signature confirms (default fake status) but no transaction is + // registered, so the fetch fails closed instead of binding nothing. + if _, _, err := verifySignatureOnlyOpen(ctx, fixture.expected, &payload, testutil.NewFakeRPC()); err == nil || + !strings.Contains(err.Error(), "fetch confirmed open transaction") { + t.Fatalf("err = %v", err) + } + }) +} + +// ── fetchAndBindChannelAccount: authoritative-state rejections ── + +func TestFetchAndBindChannelAccountRejections(t *testing.T) { + config := sessionTestConfig() + mint := solana.MustPublicKeyFromBase58(paycore.ResolveMint(config.Currency, config.Network)) + payee := solana.MustPublicKeyFromBase58(config.Recipient) + payer := solana.NewWallet().PublicKey() + signer := solana.NewWallet().PublicKey() + program := paymentchannels.ProgramPubkey() + ctx := context.Background() + + t.Run("account not found fails closed", func(t *testing.T) { + fake := testutil.NewFakeRPC() + _, err := fetchAndBindChannelAccount( + ctx, fake, solana.NewWallet().PublicKey(), mint.String(), config.Recipient, payer.String(), + signer.String(), 900, sessionDistributionHash(nil), true, &program, 1, + ) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("err = %v, want missing-account rejection", err) + } + }) + + t.Run("authorized signer mismatch", func(t *testing.T) { + fake := testutil.NewFakeRPC() + channelID := solana.NewWallet().PublicKey() + seedSessionChannelAccount(t, fake, channelID, 2_000, payer, payee, signer, mint, pcgen.ChannelStatus_Open, payer) + _, err := fetchAndBindChannelAccount( + ctx, fake, channelID, mint.String(), config.Recipient, payer.String(), + solana.NewWallet().PublicKey().String(), 900, sessionDistributionHash(nil), true, &program, 1, + ) + if err == nil || !strings.Contains(err.Error(), "authorizedSigner") { + t.Fatalf("err = %v, want authorized-signer rejection", err) + } + }) + + t.Run("channel account is not its own PDA", func(t *testing.T) { + fake := testutil.NewFakeRPC() + channelID := solana.NewWallet().PublicKey() // arbitrary account, not the derived PDA + seedSessionChannelAccount(t, fake, channelID, 2_000, payer, payee, signer, mint, pcgen.ChannelStatus_Open, payer) + _, err := fetchAndBindChannelAccount( + ctx, fake, channelID, mint.String(), config.Recipient, payer.String(), + signer.String(), 900, sessionDistributionHash(nil), true, &program, 1, + ) + if err == nil || !strings.Contains(err.Error(), "PDA derived from authoritative state") { + t.Fatalf("err = %v, want PDA-mismatch rejection", err) + } + }) +} + +// ── verifyTopUpTx: precondition rejections ── + +func TestVerifyTopUpTxPreconditionRejections(t *testing.T) { + program := paymentchannels.ProgramPubkey() + fake := testutil.NewFakeRPC() + ctx := context.Background() + channel := solana.NewWallet().PublicKey().String() + + t.Run("nil payload", func(t *testing.T) { + if err := verifyTopUpTx(ctx, fake, program, nil, 1_000); err == nil || !strings.Contains(err.Error(), "required") { + t.Fatalf("err = %v", err) + } + }) + t.Run("invalid new deposit", func(t *testing.T) { + payload := &intents.TopUpPayload{ChannelID: channel, NewDeposit: "abc", Signature: confirmedSignature(0x51)} + if err := verifyTopUpTx(ctx, fake, program, payload, 1_000); err == nil || !strings.Contains(err.Error(), "invalid newDeposit") { + t.Fatalf("err = %v", err) + } + }) + t.Run("new deposit not increasing", func(t *testing.T) { + payload := &intents.TopUpPayload{ChannelID: channel, NewDeposit: "1000", Signature: confirmedSignature(0x52)} + if err := verifyTopUpTx(ctx, fake, program, payload, 1_000); err == nil || !strings.Contains(err.Error(), "must exceed current deposit") { + t.Fatalf("err = %v", err) + } + }) + t.Run("invalid channel id", func(t *testing.T) { + payload := &intents.TopUpPayload{ChannelID: "not-base58!!!", NewDeposit: "2000", Signature: confirmedSignature(0x53)} + if err := verifyTopUpTx(ctx, fake, program, payload, 1_000); err == nil || !strings.Contains(err.Error(), "invalid top-up channel id") { + t.Fatalf("err = %v", err) + } + }) + t.Run("invalid signature", func(t *testing.T) { + payload := &intents.TopUpPayload{ChannelID: channel, NewDeposit: "2000", Signature: "not-base58!!!"} + if err := verifyTopUpTx(ctx, fake, program, payload, 1_000); err == nil || !strings.Contains(err.Error(), "invalid top-up tx signature") { + t.Fatalf("err = %v", err) + } + }) +} + +// ── NewTopUpStateTxVerifier: confirmation and binding rejections ── + +func TestNewTopUpStateTxVerifierRejectsUnconfirmedSignature(t *testing.T) { + config := sessionTestConfig() + config.Network = "mainnet" + fake := testutil.NewFakeRPC() + signature := confirmedSignature(0x61) + fake.Statuses[signature] = &rpc.SignatureStatusesResult{ + ConfirmationStatus: rpc.ConfirmationStatusProcessed, + Slot: 77, + } + verifier := NewTopUpStateTxVerifier(config, fake) + payload := &intents.TopUpPayload{ChannelID: solana.NewWallet().PublicKey().String(), NewDeposit: "2000", Signature: signature} + if err := verifier(context.Background(), payload, ChannelState{}); err == nil || !strings.Contains(err.Error(), "only processed") { + t.Fatalf("err = %v, want unconfirmed rejection before any account read", err) + } + if fake.LastAccountInfoOpts != nil { + t.Fatal("channel account was read before signature confirmation") + } +} + +func TestNewTopUpStateTxVerifierRejectsSignatureMismatch(t *testing.T) { + config := sessionTestConfig() + config.Network = "mainnet" + channel := solana.NewWallet().PublicKey() + tx, payload := buildTopUpTx(t, channel, 1_000_000, 500_000) + fake := testutil.NewFakeRPC() + // Register the confirmed transaction under a signature that is not its own + // fee-payer signature, so the fetched tx fails the signature-binding check. + wrongSignature := confirmedSignature(0x62) + fake.BySig[wrongSignature] = tx + payload.Signature = wrongSignature + verifier := NewTopUpStateTxVerifier(config, fake) + current := ChannelState{AuthorizedSigner: solana.NewWallet().PublicKey().String(), Deposit: 1_000_000} + if err := verifier(context.Background(), payload, current); err == nil || + !strings.Contains(err.Error(), "signature does not match payload signature") { + t.Fatalf("err = %v, want fetched-signature mismatch rejection", err) + } +} + +func TestNewTopUpStateTxVerifierRejectsStoredPayerMismatch(t *testing.T) { + config := sessionTestConfig() // localnet: binds directly to on-chain state + fake := testutil.NewFakeRPC() + channelID := solana.NewWallet().PublicKey() + payer := solana.NewWallet().PublicKey() + signer := solana.NewWallet().PublicKey() + mint := solana.MustPublicKeyFromBase58(paycore.ResolveMint(config.Currency, config.Network)) + seedSessionChannelAccount( + t, fake, channelID, 3_000, payer, + solana.MustPublicKeyFromBase58(config.Recipient), signer, mint, pcgen.ChannelStatus_Open, + ) + verifier := NewTopUpStateTxVerifier(config, fake) + otherPayer := solana.NewWallet().PublicKey().String() + current := ChannelState{AuthorizedSigner: signer.String(), Operator: &otherPayer, Deposit: 1_000} + payload := &intents.TopUpPayload{ChannelID: channelID.String(), NewDeposit: "3000", Signature: confirmedSignature(0x63)} + if err := verifier(context.Background(), payload, current); err == nil || + !strings.Contains(err.Error(), "does not match stored payer") { + t.Fatalf("err = %v, want stored-payer mismatch rejection", err) + } +} + +// ── NewTopUpStateTxVerifier: precondition rejections ── + +func TestNewTopUpStateTxVerifierPreconditionRejections(t *testing.T) { + ctx := context.Background() + + t.Run("invalid new deposit", func(t *testing.T) { + config := sessionTestConfig() + config.Network = "mainnet" + fake := testutil.NewFakeRPC() + verifier := NewTopUpStateTxVerifier(config, fake) + payload := &intents.TopUpPayload{ChannelID: solana.NewWallet().PublicKey().String(), NewDeposit: "abc", Signature: confirmedSignature(0x54)} + if err := verifier(ctx, payload, ChannelState{}); err == nil || !strings.Contains(err.Error(), "invalid newDeposit") { + t.Fatalf("err = %v", err) + } + }) + + t.Run("unresolvable currency mint", func(t *testing.T) { + config := sessionTestConfig() + config.Network = "mainnet" + config.Currency = "SOL" // ResolveMint returns "" for the native mint + fake := testutil.NewFakeRPC() + verifier := NewTopUpStateTxVerifier(config, fake) + payload := &intents.TopUpPayload{ChannelID: solana.NewWallet().PublicKey().String(), NewDeposit: "2000", Signature: confirmedSignature(0x55)} + if err := verifier(ctx, payload, ChannelState{}); err == nil || !strings.Contains(err.Error(), "requires an SPL token") { + t.Fatalf("err = %v", err) + } + }) + + t.Run("invalid channel id", func(t *testing.T) { + config := sessionTestConfig() + config.Network = "mainnet" + fake := testutil.NewFakeRPC() + verifier := NewTopUpStateTxVerifier(config, fake) + payload := &intents.TopUpPayload{ChannelID: "not-base58!!!", NewDeposit: "2000", Signature: confirmedSignature(0x56)} + if err := verifier(ctx, payload, ChannelState{}); err == nil || !strings.Contains(err.Error(), "invalid channelId") { + t.Fatalf("err = %v", err) + } + }) +} diff --git a/go/protocols/mpp/server/session_onchain_test.go b/go/protocols/mpp/server/session_onchain_test.go index a57a6dc7b..a5178652b 100644 --- a/go/protocols/mpp/server/session_onchain_test.go +++ b/go/protocols/mpp/server/session_onchain_test.go @@ -21,6 +21,7 @@ import ( "github.com/solana-foundation/pay-kit/go/paycore/paymentchannels" "github.com/solana-foundation/pay-kit/go/paycore/solanatx" "github.com/solana-foundation/pay-kit/go/protocols/mpp/intents" + pcgen "github.com/solana-foundation/pay-kit/go/protocols/programs/paymentchannels" ) // openTxFixture bundles a freshly built and signed payment-channel open @@ -266,6 +267,64 @@ func TestVerifyOpenTxRejectsAddressLookupTables(t *testing.T) { } } +func TestSubmitOpenTxRejectsExtraAndDuplicateInstructionsBeforeCosigning(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + openIx, err := paymentchannels.BuildOpenInstruction(paymentchannels.OpenChannelParams{ + Payer: fixture.payer.PublicKey(), RentPayer: fixture.payer.PublicKey(), Payee: fixture.payee, + Mint: fixture.mint, AuthorizedSigner: fixture.authorized, Salt: openFixtureSalt, + OpenSlot: openFixtureOpenSlot, Deposit: openFixtureDeposit, GracePeriod: openFixtureGrace, + TokenProgram: solana.TokenProgramID, + }) + if err != nil { + t.Fatalf("BuildOpenInstruction: %v", err) + } + drain, err := solanatx.BuildSOLTransfer(fixture.payer.PublicKey(), testutil.NewPrivateKey().PublicKey(), 1) + if err != nil { + t.Fatalf("BuildSOLTransfer: %v", err) + } + + for _, tc := range []struct { + name string + ix []solana.Instruction + }{ + {name: "operator drain", ix: []solana.Instruction{openIx, drain}}, + {name: "duplicate open", ix: []solana.Instruction{openIx, openIx}}, + } { + t.Run(tc.name, func(t *testing.T) { + tx, err := solana.NewTransaction(tc.ix, + solana.MustHashFromBase58("EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N"), + solana.TransactionPayer(fixture.payer.PublicKey())) + if err != nil { + t.Fatalf("NewTransaction: %v", err) + } + if _, err := tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(fixture.payer.PublicKey()) { + payer := fixture.payer + return &payer + } + return nil + }); err != nil { + t.Fatalf("sign transaction: %v", err) + } + encoded, err := solanatx.EncodeTransactionBase64(tx) + if err != nil { + t.Fatalf("EncodeTransactionBase64: %v", err) + } + payload := fixture.payload + payload.Transaction = &encoded + payload.Signature = tx.Signatures[0].String() + fake := testutil.NewFakeRPC() + _, err = SubmitOpenTx(context.Background(), fixture.expected, &payload, nil, fake) + if err == nil || !strings.Contains(err.Error(), "exactly one instruction") { + t.Fatalf("SubmitOpenTx error = %v, want pre-sign instruction rejection", err) + } + if len(fake.Sent) != 0 { + t.Fatalf("broadcasts = %d, want zero before co-sign/broadcast", len(fake.Sent)) + } + }) + } +} + func TestVerifyOpenTxHonorsExplicitMintAndProgramOverrides(t *testing.T) { fixture := buildOpenTxFixture(t, false) fixture.expected.Currency = "not-a-currency" @@ -307,7 +366,7 @@ func TestVerifySignatureOnlyOpenFetchesAndBindsTransaction(t *testing.T) { payload := fixture.payload payload.Transaction = nil - verified, err := verifySignatureOnlyOpen(context.Background(), fixture.expected, &payload, fake) + verified, _, err := verifySignatureOnlyOpen(context.Background(), fixture.expected, &payload, fake) if err != nil { t.Fatalf("verifySignatureOnlyOpen: %v", err) } @@ -322,7 +381,7 @@ func TestVerifySignatureOnlyOpenFetchesAndBindsTransaction(t *testing.T) { } fake.BySig[unrelated.signature] = unrelatedTx payload.Signature = unrelated.signature - if _, err := verifySignatureOnlyOpen(context.Background(), fixture.expected, &payload, fake); err == nil { + if _, _, err := verifySignatureOnlyOpen(context.Background(), fixture.expected, &payload, fake); err == nil { t.Fatal("unrelated confirmed transaction was accepted") } } @@ -692,6 +751,131 @@ func TestNewOpenTxVerifierWithoutTransactionVerifiesFetchedTransaction(t *testin } } +func TestNewOpenTxVerifierRejectsPlaceholderSignature(t *testing.T) { + // Base #214 removed placeholder acceptance: an attached open transaction + // must carry a real, wire-bound fee-payer signature. The structural + // verifier now rejects the pending placeholder through the seam. + fixture := buildOpenTxFixture(t, false) + config := openSessionConfig(fixture) + verifier := NewOpenTxVerifier(config, nil) + payload := fixture.payload + payload.Signature = strings.Repeat("1", 64) + if _, err := verifier(context.Background(), &payload); err == nil || + !strings.Contains(err.Error(), "must be a real transaction signature") { + t.Fatalf("err = %v, want placeholder-signature rejection", err) + } +} + +func authoritativeOpenSessionConfig(fixture openTxFixture) SessionConfig { + config := openSessionConfig(fixture) + config.Network = "mainnet" + return config +} + +func TestNewOpenStateTxVerifierRejectsPlaceholderTransaction(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + config := authoritativeOpenSessionConfig(fixture) + verifier := NewOpenStateTxVerifier(config, testutil.NewFakeRPC()) + payload := fixture.payload + payload.Signature = strings.Repeat("1", 64) + + if _, err := verifier(context.Background(), &payload); err == nil || !strings.Contains(err.Error(), "placeholder") { + t.Fatalf("err = %v, want direct placeholder rejection", err) + } +} + +func TestNewOpenStateTxVerifierRejectsUnconfirmedTransactionBeforeAccountRead(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + config := authoritativeOpenSessionConfig(fixture) + fake := testutil.NewFakeRPC() + fake.Statuses[fixture.signature] = &rpc.SignatureStatusesResult{ + ConfirmationStatus: rpc.ConfirmationStatusProcessed, + Slot: 777, + } + verifier := NewOpenStateTxVerifier(config, fake) + + if _, err := verifier(context.Background(), &fixture.payload); err == nil || !strings.Contains(err.Error(), "only processed") { + t.Fatalf("err = %v, want unconfirmed rejection", err) + } + if fake.LastAccountInfoOpts != nil { + t.Fatal("channel account was read before signature confirmation") + } +} + +func TestNewOpenStateTxVerifierRejectsStaleChannelAccount(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + config := authoritativeOpenSessionConfig(fixture) + fake := testutil.NewFakeRPC() + fake.Statuses[fixture.signature] = &rpc.SignatureStatusesResult{ + ConfirmationStatus: rpc.ConfirmationStatusConfirmed, + Slot: 777, + } + seedSessionChannelAccountWithSeeds( + t, fake, fixture.channel, openFixtureDeposit, fixture.payer.PublicKey(), fixture.payee, + fixture.authorized, fixture.mint, pcgen.ChannelStatus_Open, openFixtureSalt, openFixtureOpenSlot+1, + fixture.payer.PublicKey(), + ) + + verifier := NewOpenStateTxVerifier(config, fake) + if _, err := verifier(context.Background(), &fixture.payload); err == nil || !strings.Contains(err.Error(), "PDA") { + t.Fatalf("err = %v, want stale-channel rejection", err) + } +} + +func TestNewOpenStateTxVerifierReturnsConfirmedAuthoritativeChannelFacts(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + config := authoritativeOpenSessionConfig(fixture) + fake := testutil.NewFakeRPC() + confirmedSlot := uint64(777) + fake.Statuses[fixture.signature] = &rpc.SignatureStatusesResult{ + ConfirmationStatus: rpc.ConfirmationStatusConfirmed, + Slot: confirmedSlot, + } + accountDeposit := openFixtureDeposit + seedSessionChannelAccountWithSeeds( + t, fake, fixture.channel, accountDeposit, fixture.payer.PublicKey(), fixture.payee, + fixture.authorized, fixture.mint, pcgen.ChannelStatus_Open, openFixtureSalt, openFixtureOpenSlot, + fixture.payer.PublicKey(), + ) + + result, err := NewOpenStateTxVerifier(config, fake)(context.Background(), &fixture.payload) + if err != nil { + t.Fatalf("state verifier: %v", err) + } + if result.ChannelID != fixture.channel.String() || result.Deposit != accountDeposit || result.Payer != fixture.payer.PublicKey().String() { + t.Fatalf("result = %+v, want account-derived channel/deposit/payer", result) + } + if result.Salt != openFixtureSalt || result.OpenSlot != openFixtureOpenSlot || result.GracePeriod != openFixtureGrace { + t.Fatalf("result = %+v, want account-derived seeds/grace", result) + } + if fake.LastAccountInfoOpts == nil || fake.LastAccountInfoOpts.MinContextSlot == nil || + *fake.LastAccountInfoOpts.MinContextSlot != confirmedSlot { + t.Fatalf("account read minContextSlot = %#v, want %d", fake.LastAccountInfoOpts, confirmedSlot) + } +} + +func TestNewOpenStateTxVerifierRejectsAssertedDepositMismatch(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + config := authoritativeOpenSessionConfig(fixture) + fake := testutil.NewFakeRPC() + fake.Statuses[fixture.signature] = &rpc.SignatureStatusesResult{ + ConfirmationStatus: rpc.ConfirmationStatusConfirmed, + Slot: 777, + } + seedSessionChannelAccountWithSeeds( + t, fake, fixture.channel, openFixtureDeposit, fixture.payer.PublicKey(), fixture.payee, + fixture.authorized, fixture.mint, pcgen.ChannelStatus_Open, openFixtureSalt, openFixtureOpenSlot, + fixture.payer.PublicKey(), + ) + claimedDeposit := "999999" + payload := fixture.payload + payload.Deposit = &claimedDeposit + verifier := NewOpenStateTxVerifier(config, fake) + if _, err := verifier(context.Background(), &payload); err == nil || !strings.Contains(err.Error(), "!= asserted deposit") { + t.Fatalf("err = %v, want asserted-deposit rejection", err) + } +} + // ── NewTopUpTxVerifier ── func TestNewTopUpTxVerifierNilRPCDisablesTheSeam(t *testing.T) { @@ -803,6 +987,52 @@ func TestNewTopUpTxVerifierRejectsUnrelatedOrMismatchedTransaction(t *testing.T) }) } +func TestNewTopUpTxVerifierRejectsTwoMatchingInstructions(t *testing.T) { + payer := testutil.NewPrivateKey() + channelID := solana.NewWallet().PublicKey() + mint := solana.MustPublicKeyFromBase58(paycore.USDCMainnetMint) + buildTopUp := func(amount uint64) solana.Instruction { + t.Helper() + instruction, err := paymentchannels.BuildTopUpInstruction(paymentchannels.TopUpParams{ + Payer: payer.PublicKey(), Channel: channelID, Mint: mint, + Amount: amount, TokenProgram: solana.TokenProgramID, + }) + if err != nil { + t.Fatalf("BuildTopUpInstruction: %v", err) + } + return instruction + } + tx, err := solana.NewTransaction( + []solana.Instruction{buildTopUp(400), buildTopUp(600)}, + solana.MustHashFromBase58("EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N"), + solana.TransactionPayer(payer.PublicKey()), + ) + if err != nil { + t.Fatalf("NewTransaction: %v", err) + } + if _, err := tx.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(payer.PublicKey()) { + return &payer + } + return nil + }); err != nil { + t.Fatalf("sign top-up transaction: %v", err) + } + + fake := testutil.NewFakeRPC() + signature := tx.Signatures[0] + fake.BySig[signature.String()] = tx + config := sessionTestConfig() + config.Network = "mainnet" + payload := &intents.TopUpPayload{ + ChannelID: channelID.String(), NewDeposit: "2000", Signature: signature.String(), + } + err = NewTopUpStateTxVerifier(config, fake)(context.Background(), payload, ChannelState{Deposit: 1_000}) + if err == nil || !strings.Contains(err.Error(), "exactly one") || !strings.Contains(err.Error(), "found 2") { + t.Fatalf("duplicate top-up instruction error = %v", err) + } +} + func TestNewTopUpTxVerifierSurfacesFailureAndNotFound(t *testing.T) { signer := testutil.NewPrivateKey() signature, err := signer.Sign([]byte("top-up")) @@ -971,6 +1201,19 @@ func TestSettlementInstructionsResolvesToken2022FromCurrency(t *testing.T) { config := sessionTestConfig() config.Currency = "PYUSD" config.Network = "mainnet" + config.AllowUnsafeEphemeralStoreOffLocalnet = true + config.VerifyOpenTx = func(_ context.Context, payload *intents.OpenPayload) (string, error) { + return *payload.Payer, nil + } + config.VerifyOpenStateTx = func(_ context.Context, payload *intents.OpenPayload) (VerifyOpenTxResult, error) { + return VerifyOpenTxResult{ + ChannelID: *payload.ChannelID, + Deposit: 1_000_000, + Payer: *payload.Payer, + Salt: openFixtureSalt, + OpenSlot: openFixtureOpenSlot, + }, nil + } server := newSessionTestServer(config) payer := testutil.NewPrivateKey().PublicKey() merchant := testutil.NewPrivateKey().PublicKey() diff --git a/go/protocols/mpp/server/session_server_test.go b/go/protocols/mpp/server/session_server_test.go index c45251efb..9304ed236 100644 --- a/go/protocols/mpp/server/session_server_test.go +++ b/go/protocols/mpp/server/session_server_test.go @@ -14,10 +14,17 @@ import ( solana "github.com/solana-foundation/solana-go/v2" + "github.com/solana-foundation/pay-kit/go/internal/testutil" + "github.com/solana-foundation/pay-kit/go/paycore" "github.com/solana-foundation/pay-kit/go/protocols/mpp/intents" ) -const sessionTestRecipient = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY" +// sessionTestOperatorKey backs sessionTestRecipient with a real signing key so +// signature-only open fixtures can co-sign the operator (rentPayer) slot that +// the hardened open verifier cryptographically checks. +var sessionTestOperatorKey = testutil.NewPrivateKey() + +var sessionTestRecipient = sessionTestOperatorKey.PublicKey().String() func sessionTestConfig() SessionConfig { return SessionConfig{ @@ -147,6 +154,35 @@ func TestProcessOpenStoresState(t *testing.T) { } } +func TestProcessOpenPersistsAuthoritativeVerifiedFacts(t *testing.T) { + config := sessionTestConfig() + config.VerifyOpenStateTx = func(context.Context, *intents.OpenPayload) (VerifyOpenTxResult, error) { + return VerifyOpenTxResult{ + ChannelID: "chan1", + Deposit: 4_000, + Payer: "verified-payer", + Salt: 7, + OpenSlot: 42, + }, nil + } + server := newSessionTestServer(config) + payload := intents.OpenPayloadPaymentChannel( + "chan1", "4000", "claimed-payer", sessionTestRecipient, paycore.USDCMainnetMint, + 999, 900, 999, "signer1", "confirmed-open", + ) + + state, err := server.ProcessOpen(context.Background(), &payload) + if err != nil { + t.Fatalf("ProcessOpen: %v", err) + } + if state.Deposit != 4_000 || state.Salt != 7 || state.OpenSlot != 42 { + t.Fatalf("state = %+v, want verified deposit/salt/openSlot", state) + } + if state.Operator == nil || *state.Operator != "verified-payer" { + t.Fatalf("operator = %v, want verified payer", state.Operator) + } +} + func TestProcessOpenZeroDepositRejected(t *testing.T) { server := newSessionTestServer(sessionTestConfig()) if _, err := server.ProcessOpen(context.Background(), sessionOpenPayload("chan1", 0, "signer1")); err == nil { diff --git a/go/protocols/mpp/server/session_settlement_durability_test.go b/go/protocols/mpp/server/session_settlement_durability_test.go new file mode 100644 index 000000000..c5d9440e0 --- /dev/null +++ b/go/protocols/mpp/server/session_settlement_durability_test.go @@ -0,0 +1,603 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + solana "github.com/solana-foundation/solana-go/v2" + "github.com/solana-foundation/solana-go/v2/rpc" + + "github.com/solana-foundation/pay-kit/go/internal/testutil" + "github.com/solana-foundation/pay-kit/go/paycore/solanatx" +) + +// sharedJSONChannelBackend models a durable database shared by independent +// ChannelStore clients. Every update crosses a JSON serialization boundary. +type sharedJSONChannelBackend struct { + mu sync.Mutex + data map[string][]byte +} + +type sharedJSONChannelStore struct { + backend *sharedJSONChannelBackend +} + +func newSharedJSONChannelStores(count int) (*sharedJSONChannelBackend, []*sharedJSONChannelStore) { + backend := &sharedJSONChannelBackend{data: make(map[string][]byte)} + stores := make([]*sharedJSONChannelStore, count) + for i := range stores { + stores[i] = &sharedJSONChannelStore{backend: backend} + } + return backend, stores +} + +func (*sharedJSONChannelStore) SessionStoreDurability() SessionStoreDurability { + return SessionStoreDurabilityDurableShared +} + +func checkStoreContext(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + return nil + } +} + +func decodeJSONChannel(data []byte) (*ChannelState, error) { + if data == nil { + return nil, nil + } + var state ChannelState + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + return &state, nil +} + +func (s *sharedJSONChannelStore) GetChannel(ctx context.Context, channelID string) (*ChannelState, error) { + if err := checkStoreContext(ctx); err != nil { + return nil, err + } + s.backend.mu.Lock() + defer s.backend.mu.Unlock() + return decodeJSONChannel(s.backend.data[channelID]) +} + +func (s *sharedJSONChannelStore) UpdateChannel(ctx context.Context, channelID string, mutator ChannelMutator) (ChannelState, error) { + if err := checkStoreContext(ctx); err != nil { + return ChannelState{}, err + } + s.backend.mu.Lock() + defer s.backend.mu.Unlock() + current, err := decodeJSONChannel(s.backend.data[channelID]) + if err != nil { + return ChannelState{}, err + } + next, err := mutator(current) + if err != nil { + return ChannelState{}, err + } + encoded, err := json.Marshal(next) + if err != nil { + return ChannelState{}, err + } + s.backend.data[channelID] = encoded + stored, err := decodeJSONChannel(encoded) + if err != nil { + return ChannelState{}, err + } + return *stored, nil +} + +func (s *sharedJSONChannelStore) DeleteChannel(ctx context.Context, channelID string) error { + if err := checkStoreContext(ctx); err != nil { + return err + } + s.backend.mu.Lock() + defer s.backend.mu.Unlock() + delete(s.backend.data, channelID) + return nil +} + +func (s *sharedJSONChannelStore) ListChannels(ctx context.Context, filter *ListChannelsFilter) ([]ChannelState, error) { + if err := checkStoreContext(ctx); err != nil { + return nil, err + } + s.backend.mu.Lock() + defer s.backend.mu.Unlock() + states := make([]ChannelState, 0, len(s.backend.data)) + for _, encoded := range s.backend.data { + state, err := decodeJSONChannel(encoded) + if err != nil { + return nil, err + } + if filter != nil { + if filter.Sealed != nil && state.Sealed != *filter.Sealed { + continue + } + if filter.ClosePending != nil && (state.CloseRequestedAt != nil) != *filter.ClosePending { + continue + } + } + states = append(states, *state) + } + return states, nil +} + +func (s *sharedJSONChannelStore) MarkSealed(ctx context.Context, channelID string) (ChannelState, error) { + return s.UpdateChannel(ctx, channelID, func(current *ChannelState) (ChannelState, error) { + if current == nil { + return ChannelState{}, fmt.Errorf("channel %s not found", channelID) + } + next := *current + next.Sealed = true + next.SettlementWire = "" + next.SettlementLastValidBlockHeight = 0 + next.Settling = false + next.SettlementClaimOwner = "" + next.SettlementClaimedAt = 0 + return next, nil + }) +} + +func (b *sharedJSONChannelBackend) raw(channelID string) string { + b.mu.Lock() + defer b.mu.Unlock() + return string(b.data[channelID]) +} + +type preBroadcastStateRPC struct { + *testutil.FakeRPC + store ChannelStore + channelID string + observed bool +} + +func (p *preBroadcastStateRPC) SendTransactionWithOpts(ctx context.Context, tx *solana.Transaction, opts rpc.TransactionOpts) (solana.Signature, error) { + state, err := p.store.GetChannel(ctx, p.channelID) + if err != nil { + return solana.Signature{}, err + } + wire, encodeErr := solanatx.EncodeTransactionBase64(tx) + if encodeErr != nil { + return solana.Signature{}, encodeErr + } + if state == nil || !state.Settling || state.SettlementClaimOwner == "" || state.SettlementClaimedAt == 0 || + state.SettledSignature == nil || len(tx.Signatures) == 0 || *state.SettledSignature != tx.Signatures[0].String() || + state.SettlementWire != wire { + return solana.Signature{}, fmt.Errorf("settlement state was not durably persisted before send: %+v", state) + } + p.observed = true + return p.FakeRPC.SendTransactionWithOpts(ctx, tx, opts) +} + +type crashBeforeSendRPC struct { + *testutil.FakeRPC + attemptedWire string + lastValid uint64 + blockHeight uint64 +} + +func (c *crashBeforeSendRPC) SendTransactionWithOpts(_ context.Context, tx *solana.Transaction, _ rpc.TransactionOpts) (solana.Signature, error) { + wire, err := solanatx.EncodeTransactionBase64(tx) + if err != nil { + return solana.Signature{}, err + } + c.attemptedWire = wire + return solana.Signature{}, errors.New("simulated process crash before network send") +} + +func (c *crashBeforeSendRPC) GetLatestBlockhash(_ context.Context, _ rpc.CommitmentType) (*rpc.GetLatestBlockhashResult, error) { + return &rpc.GetLatestBlockhashResult{Value: &rpc.LatestBlockhashResult{ + Blockhash: c.Blockhash, + LastValidBlockHeight: c.lastValid, + }}, nil +} + +func (c *crashBeforeSendRPC) GetSignatureStatuses(context.Context, bool, ...solana.Signature) (*rpc.GetSignatureStatusesResult, error) { + return &rpc.GetSignatureStatusesResult{Value: []*rpc.SignatureStatusesResult{nil}}, nil +} + +func (c *crashBeforeSendRPC) GetBlockHeight(context.Context, rpc.CommitmentType) (uint64, error) { + return c.blockHeight, nil +} + +type sendErrorConfirmedRPC struct { + *testutil.FakeRPC +} + +func (s *sendErrorConfirmedRPC) SendTransactionWithOpts(ctx context.Context, tx *solana.Transaction, opts rpc.TransactionOpts) (solana.Signature, error) { + if _, err := s.FakeRPC.SendTransactionWithOpts(ctx, tx, opts); err != nil { + return solana.Signature{}, err + } + return solana.Signature{}, errors.New("transaction already processed") +} + +func TestSharedJSONStoresObserveSettlementClaimAndPendingSignature(t *testing.T) { + backend, stores := newSharedJSONChannelStores(2) + baseRPC := testutil.NewFakeRPC() + blockingRPC := &blockingConfirmationRPC{ + FakeRPC: baseRPC, + statusEntered: make(chan struct{}, 1), + releaseStatus: make(chan struct{}), + } + merchant := testutil.NewPrivateKey() + newWithStore := func(store ChannelStore) *Session { + return newTestSession(t, func(o *SessionOptions) { + o.Store = store + o.RPC = baseRPC + o.Signer = merchant + }) + } + first := newWithStore(stores[0]) + second := newWithStore(stores[1]) + _, channelID := openTrustedChannel(t, first, 1_000) + first.rpc = blockingRPC + second.rpc = blockingRPC + blockingRPC.block.Store(true) + + type settleResult struct { + signature string + err error + } + firstDone := make(chan settleResult, 1) + go func() { + signature, err := first.closeAndSettleChannel(context.Background(), channelID) + firstDone <- settleResult{signature: signature, err: err} + }() + + select { + case <-blockingRPC.statusEntered: + case <-time.After(3 * time.Second): + t.Fatal("first store client never reached settlement confirmation") + } + + raw := backend.raw(channelID) + if !strings.Contains(raw, `"settling":true`) || !strings.Contains(raw, `"settled_signature":`) || + !strings.Contains(raw, `"settlement_wire":`) { + t.Fatalf("in-flight durable state = %s", raw) + } + state, err := stores[1].GetChannel(context.Background(), channelID) + if err != nil || state == nil || !state.Settling || state.SettledSignature == nil { + t.Fatalf("second store client state=%+v err=%v", state, err) + } + secondDone := make(chan settleResult, 1) + go func() { + signature, err := second.closeAndSettleChannel(context.Background(), channelID) + secondDone <- settleResult{signature: signature, err: err} + }() + deadline := time.Now().Add(3 * time.Second) + for blockingRPC.statusCalls.Load() < 2 { + if time.Now().After(deadline) { + t.Fatal("second store client never joined pending confirmation") + } + time.Sleep(time.Millisecond) + } + if len(baseRPC.Sent) != 2 { + t.Fatalf("shared-store idempotent submissions = %d, want 2", len(baseRPC.Sent)) + } + firstWire, err := solanatx.EncodeTransactionBase64(baseRPC.Sent[0]) + if err != nil { + t.Fatalf("encode first submission: %v", err) + } + secondWire, err := solanatx.EncodeTransactionBase64(baseRPC.Sent[1]) + if err != nil { + t.Fatalf("encode second submission: %v", err) + } + if firstWire != secondWire { + t.Fatal("shared-store retry submitted different settlement bytes") + } + + close(blockingRPC.releaseStatus) + result := <-firstDone + if result.err != nil || result.signature == "" { + t.Fatalf("winning store settle = %q, %v", result.signature, result.err) + } + secondResult := <-secondDone + if secondResult.err != nil || secondResult.signature != result.signature { + t.Fatalf("joining store settle = %q, %v; want %q", secondResult.signature, secondResult.err, result.signature) + } + state, err = stores[1].GetChannel(context.Background(), channelID) + if err != nil || state == nil || !state.Sealed || state.Settling || state.SettledSignature == nil { + t.Fatalf("reconciled shared state=%+v err=%v", state, err) + } +} + +func TestSharedJSONStoreRetryRebroadcastsExactPendingWire(t *testing.T) { + _, stores := newSharedJSONChannelStores(2) + baseRPC := testutil.NewFakeRPC() + merchant := testutil.NewPrivateKey() + first := newTestSession(t, func(o *SessionOptions) { + o.Store = stores[0] + o.RPC = baseRPC + o.Signer = merchant + }) + second := newTestSession(t, func(o *SessionOptions) { + o.Store = stores[1] + o.RPC = baseRPC + o.Signer = merchant + }) + _, channelID := openTrustedChannel(t, first, 1_000) + first.rpc = &failingStatusRPC{FakeRPC: baseRPC} + + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + if _, err := first.closeAndSettleChannel(ctx, channelID); err == nil { + t.Fatal("uncertain settlement confirmation unexpectedly succeeded") + } + pending, err := stores[1].GetChannel(context.Background(), channelID) + if err != nil || pending == nil || pending.Sealed || !pending.Settling || pending.SettledSignature == nil || pending.SettlementWire == "" { + t.Fatalf("pending shared state=%+v err=%v", pending, err) + } + pendingSignature := *pending.SettledSignature + + retryRPC := testutil.NewFakeRPC() + second.rpc = retryRPC + settled, err := second.closeAndSettleChannel(context.Background(), channelID) + if err != nil { + t.Fatalf("shared-store settlement retry: %v", err) + } + if settled != pendingSignature || len(retryRPC.Sent) != 1 { + t.Fatalf("retry signature=%q submissions=%d want signature=%q submissions=1", settled, len(retryRPC.Sent), pendingSignature) + } +} + +func TestSettlementSignatureIsPersistedBeforeBroadcast(t *testing.T) { + _, stores := newSharedJSONChannelStores(1) + baseRPC := testutil.NewFakeRPC() + merchant := testutil.NewPrivateKey() + session := newTestSession(t, func(o *SessionOptions) { + o.Store = stores[0] + o.RPC = baseRPC + o.Signer = merchant + }) + _, channelID := openTrustedChannel(t, session, 1_000) + inspectingRPC := &preBroadcastStateRPC{ + FakeRPC: baseRPC, + store: stores[0], + channelID: channelID, + } + session.rpc = inspectingRPC + + if _, err := session.closeAndSettleChannel(context.Background(), channelID); err != nil { + t.Fatalf("settlement: %v", err) + } + if !inspectingRPC.observed || len(baseRPC.Sent) != 1 { + t.Fatalf("pre-broadcast persistence observed=%v sends=%d", inspectingRPC.observed, len(baseRPC.Sent)) + } +} + +func TestPreSendCrashRestartRebroadcastsExactPersistedWire(t *testing.T) { + _, stores := newSharedJSONChannelStores(2) + baseRPC := testutil.NewFakeRPC() + merchant := testutil.NewPrivateKey() + first := newTestSession(t, func(o *SessionOptions) { + o.Store = stores[0] + o.RPC = baseRPC + o.Signer = merchant + }) + restarted := newTestSession(t, func(o *SessionOptions) { + o.Store = stores[1] + o.RPC = baseRPC + o.Signer = merchant + }) + _, channelID := openTrustedChannel(t, first, 1_000) + crashRPC := &crashBeforeSendRPC{FakeRPC: baseRPC, lastValid: 100, blockHeight: 50} + first.rpc = crashRPC + + crashCtx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + if _, err := first.closeAndSettleChannel(crashCtx, channelID); err == nil || + !strings.Contains(err.Error(), "simulated process crash") { + t.Fatalf("pre-send crash error = %v", err) + } + if len(baseRPC.Sent) != 0 { + t.Fatalf("pre-send crash reached network %d times", len(baseRPC.Sent)) + } + pending, err := stores[1].GetChannel(context.Background(), channelID) + if err != nil || pending == nil || !pending.Settling || pending.SettledSignature == nil || pending.SettlementWire == "" || + pending.SettlementLastValidBlockHeight != 100 { + t.Fatalf("persisted pre-send outbox=%+v err=%v", pending, err) + } + if crashRPC.attemptedWire != pending.SettlementWire { + t.Fatal("RPC boundary did not receive the persisted settlement wire") + } + pendingSignature := *pending.SettledSignature + + settled, err := restarted.closeAndSettleChannel(context.Background(), channelID) + if err != nil { + t.Fatalf("restart outbox delivery: %v", err) + } + if settled != pendingSignature || len(baseRPC.Sent) != 1 { + t.Fatalf("restart signature=%q submissions=%d want=%q,1", settled, len(baseRPC.Sent), pendingSignature) + } + rebroadcastWire, err := solanatx.EncodeTransactionBase64(baseRPC.Sent[0]) + if err != nil { + t.Fatalf("encode restart submission: %v", err) + } + if rebroadcastWire != pending.SettlementWire { + t.Fatal("restart rebuilt settlement instead of delivering exact persisted wire") + } + sealed, err := stores[0].GetChannel(context.Background(), channelID) + if err != nil || sealed == nil || !sealed.Sealed || sealed.SettlementWire != "" || sealed.Settling { + t.Fatalf("sealed outbox state=%+v err=%v", sealed, err) + } +} + +func TestSendErrorStillConfirmsPersistedSettlement(t *testing.T) { + _, stores := newSharedJSONChannelStores(1) + baseRPC := testutil.NewFakeRPC() + merchant := testutil.NewPrivateKey() + session := newTestSession(t, func(o *SessionOptions) { + o.Store = stores[0] + o.RPC = baseRPC + o.Signer = merchant + }) + _, channelID := openTrustedChannel(t, session, 1_000) + session.rpc = &sendErrorConfirmedRPC{FakeRPC: baseRPC} + + settled, err := session.closeAndSettleChannel(context.Background(), channelID) + if err != nil { + t.Fatalf("send error with confirmed signature: %v", err) + } + if settled == "" || len(baseRPC.Sent) != 1 { + t.Fatalf("settlement=%q submissions=%d", settled, len(baseRPC.Sent)) + } + state, err := stores[0].GetChannel(context.Background(), channelID) + if err != nil || state == nil || !state.Sealed || state.SettlementWire != "" || state.Settling { + t.Fatalf("confirmed send-error state=%+v err=%v", state, err) + } +} + +func TestExpiredUnsentOutboxRetiresThenRebuilds(t *testing.T) { + _, stores := newSharedJSONChannelStores(2) + baseRPC := testutil.NewFakeRPC() + merchant := testutil.NewPrivateKey() + first := newTestSession(t, func(o *SessionOptions) { + o.Store = stores[0] + o.RPC = baseRPC + o.Signer = merchant + }) + restarted := newTestSession(t, func(o *SessionOptions) { + o.Store = stores[1] + o.RPC = baseRPC + o.Signer = merchant + }) + _, channelID := openTrustedChannel(t, first, 1_000) + crashRPC := &crashBeforeSendRPC{FakeRPC: baseRPC, lastValid: 100, blockHeight: 50} + first.rpc = crashRPC + + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + if _, err := first.closeAndSettleChannel(ctx, channelID); err == nil { + t.Fatal("unexpired unsent outbox unexpectedly resolved") + } + pending, err := stores[1].GetChannel(context.Background(), channelID) + if err != nil || pending == nil || pending.SettlementWire == "" || pending.SettlementLastValidBlockHeight != 100 { + t.Fatalf("unexpired outbox=%+v err=%v", pending, err) + } + oldWire := pending.SettlementWire + + crashRPC.blockHeight = 101 + restarted.rpc = crashRPC + if _, err := restarted.closeAndSettleChannel(context.Background(), channelID); err == nil || + !strings.Contains(err.Error(), "expired at block height 100") { + t.Fatalf("expired outbox result = %v", err) + } + retired, err := stores[0].GetChannel(context.Background(), channelID) + if err != nil || retired == nil || retired.SettledSignature != nil || retired.SettlementWire != "" || + retired.SettlementLastValidBlockHeight != 0 || retired.Settling || retired.SettlementClaimOwner != "" { + t.Fatalf("retired outbox=%+v err=%v", retired, err) + } + + baseRPC.Blockhash = solana.MustHashFromBase58("EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N") + restarted.rpc = baseRPC + settled, err := restarted.closeAndSettleChannel(context.Background(), channelID) + if err != nil { + t.Fatalf("fresh settlement after expiry: %v", err) + } + if settled == "" || len(baseRPC.Sent) != 1 { + t.Fatalf("fresh settlement=%q submissions=%d", settled, len(baseRPC.Sent)) + } + newWire, err := solanatx.EncodeTransactionBase64(baseRPC.Sent[0]) + if err != nil { + t.Fatalf("encode rebuilt settlement: %v", err) + } + if newWire == oldWire { + t.Fatal("expired outbox was reused instead of rebuilding with a fresh blockhash") + } +} + +func TestRestartReconfirmsPersistedPendingSignatureDespiteSettling(t *testing.T) { + _, stores := newSharedJSONChannelStores(2) + baseRPC := testutil.NewFakeRPC() + merchant := testutil.NewPrivateKey() + first := newTestSession(t, func(o *SessionOptions) { + o.Store = stores[0] + o.RPC = baseRPC + o.Signer = merchant + }) + restarted := newTestSession(t, func(o *SessionOptions) { + o.Store = stores[1] + o.RPC = baseRPC + o.Signer = merchant + }) + _, channelID := openTrustedChannel(t, first, 1_000) + pendingSignature := confirmedSignature(0xD1) + if _, err := stores[0].UpdateChannel(context.Background(), channelID, func(current *ChannelState) (ChannelState, error) { + next := *current + next.Settling = true + next.SettlementClaimOwner = "crashed-worker" + next.SettlementClaimedAt = time.Now().Unix() + next.SettledSignature = &pendingSignature + return next, nil + }); err != nil { + t.Fatalf("seed pending crash state: %v", err) + } + + settled, err := restarted.closeAndSettleChannel(context.Background(), channelID) + if err != nil { + t.Fatalf("restart confirmation: %v", err) + } + if settled != pendingSignature || len(baseRPC.Sent) != 0 { + t.Fatalf("restart signature=%q sends=%d", settled, len(baseRPC.Sent)) + } + state, err := stores[0].GetChannel(context.Background(), channelID) + if err != nil || state == nil || !state.Sealed || state.Settling || state.SettlementClaimOwner != "" || state.SettlementClaimedAt != 0 { + t.Fatalf("restart reconciliation state=%+v err=%v", state, err) + } +} + +func TestRestartTakesOverOnlyStaleSignaturelessClaim(t *testing.T) { + _, stores := newSharedJSONChannelStores(2) + baseRPC := testutil.NewFakeRPC() + merchant := testutil.NewPrivateKey() + first := newTestSession(t, func(o *SessionOptions) { + o.Store = stores[0] + o.RPC = baseRPC + o.Signer = merchant + }) + restarted := newTestSession(t, func(o *SessionOptions) { + o.Store = stores[1] + o.RPC = baseRPC + o.Signer = merchant + }) + _, channelID := openTrustedChannel(t, first, 1_000) + setClaimTime := func(claimedAt int64) { + t.Helper() + if _, err := stores[0].UpdateChannel(context.Background(), channelID, func(current *ChannelState) (ChannelState, error) { + next := *current + next.Settling = true + next.SettlementClaimOwner = "crashed-worker" + next.SettlementClaimedAt = claimedAt + next.SettledSignature = nil + return next, nil + }); err != nil { + t.Fatalf("seed claim-only state: %v", err) + } + } + + setClaimTime(time.Now().Unix()) + if signature, err := restarted.closeAndSettleChannel(context.Background(), channelID); err != nil || signature != "" { + t.Fatalf("fresh claim result=%q err=%v", signature, err) + } + if len(baseRPC.Sent) != 0 { + t.Fatalf("fresh claim allowed %d broadcasts", len(baseRPC.Sent)) + } + + setClaimTime(time.Now().Add(-settlementClaimLease - time.Second).Unix()) + settled, err := restarted.closeAndSettleChannel(context.Background(), channelID) + if err != nil { + t.Fatalf("stale claim takeover: %v", err) + } + if settled == "" || len(baseRPC.Sent) != 1 { + t.Fatalf("stale takeover signature=%q sends=%d", settled, len(baseRPC.Sent)) + } +} diff --git a/go/protocols/mpp/server/session_state_binding_test.go b/go/protocols/mpp/server/session_state_binding_test.go new file mode 100644 index 000000000..c1595f0d6 --- /dev/null +++ b/go/protocols/mpp/server/session_state_binding_test.go @@ -0,0 +1,376 @@ +package server + +import ( + "bytes" + "context" + "strings" + "testing" + + bin "github.com/gagliardetto/binary" + solana "github.com/solana-foundation/solana-go/v2" + "github.com/solana-foundation/solana-go/v2/rpc" + + "github.com/solana-foundation/pay-kit/go/internal/testutil" + "github.com/solana-foundation/pay-kit/go/paycore" + "github.com/solana-foundation/pay-kit/go/paycore/paymentchannels" + "github.com/solana-foundation/pay-kit/go/protocols/mpp/intents" + pcgen "github.com/solana-foundation/pay-kit/go/protocols/programs/paymentchannels" +) + +type durableTestChannelStore struct{ ChannelStore } + +func (durableTestChannelStore) SessionStoreDurability() SessionStoreDurability { + return SessionStoreDurabilityDurableShared +} + +func seedSessionChannelAccount( + t *testing.T, + fake *testutil.FakeRPC, + channelID solana.PublicKey, + deposit uint64, + payer, payee, signer, mint solana.PublicKey, + status pcgen.ChannelStatus, + rentPayers ...solana.PublicKey, +) { + seedSessionChannelAccountWithSeeds(t, fake, channelID, deposit, payer, payee, signer, mint, status, 7, 42, rentPayers...) +} + +func seedSessionChannelAccountWithSeeds( + t *testing.T, + fake *testutil.FakeRPC, + channelID solana.PublicKey, + deposit uint64, + payer, payee, signer, mint solana.PublicKey, + status pcgen.ChannelStatus, + salt, openSlot uint64, + rentPayers ...solana.PublicKey, +) { + t.Helper() + rentPayer := payee + if len(rentPayers) > 0 { + rentPayer = rentPayers[0] + } + account := pcgen.Channel{ + Discriminator: 1, + Version: 1, + Status: uint8(status), + Deposit: deposit, + GracePeriod: 900, + DistributionHash: sessionDistributionHash(nil), + Payer: payer, + Payee: payee, + AuthorizedSigner: signer, + Mint: mint, + RentPayer: rentPayer, + Salt: salt, + OpenSlot: openSlot, + } + buf := new(bytes.Buffer) + if err := account.MarshalWithEncoder(bin.NewBorshEncoder(buf)); err != nil { + t.Fatalf("encode channel account: %v", err) + } + fake.SetAccount(channelID, paymentchannels.ProgramPubkey(), buf.Bytes()) +} + +func TestSessionBarePushOpenUsesAuthoritativeChannelState(t *testing.T) { + fake := testutil.NewFakeRPC() + payerKey := testutil.NewPrivateKey() + payer := payerKey.PublicKey() + signer := solana.NewWallet().PublicKey() + mint := solana.MustPublicKeyFromBase58(paycore.ResolveMint("USDC", "localnet")) + recipient := solana.MustPublicKeyFromBase58(sessionTestRecipient) + channelID, _, err := paymentchannels.FindChannelPDAForProgram( + payer, recipient, mint, signer, 7, 42, paymentchannels.ProgramPubkey(), + ) + if err != nil { + t.Fatalf("derive channel: %v", err) + } + seedSessionChannelAccount( + t, fake, channelID, 4_000, payer, + recipient, signer, mint, pcgen.ChannelStatus_Open, + ) + + session := newTestSession(t, func(options *SessionOptions) { options.RPC = fake }) + payload := intents.OpenPayloadPush(channelID.String(), "4000", signer.String(), confirmedSignature(0x31)) + claimedPayer := solana.NewWallet().PublicKey().String() + payload.Payer = &claimedPayer + registerSignatureOnlyOpenTransaction(t, session, &payload, payerKey, 4_000) + if _, err := verifySessionAction(t, session, intents.NewOpenAction(payload)); err != nil { + t.Fatalf("open: %v", err) + } + state := mustGetChannel(t, session, channelID.String()) + if state.Deposit != 4_000 { + t.Fatalf("deposit = %d, want on-chain 4000", state.Deposit) + } + if state.Operator == nil || *state.Operator != payer.String() { + t.Fatalf("payer = %v, want on-chain %s", state.Operator, payer) + } + if state.OpenSlot != 42 { + t.Fatalf("open slot = %d, want authoritative 42", state.OpenSlot) + } + if state.Salt != 7 { + t.Fatalf("salt = %d, want authoritative 7", state.Salt) + } +} + +func TestSessionServerRejectsLegacyOpenVerifierOffLocalnet(t *testing.T) { + fake := testutil.NewFakeRPC() + config := sessionTestConfig() + config.Network = "devnet" + config.VerifyOpenTx = NewOpenTxVerifier(config, fake) + server := NewSessionServer(config, durableTestChannelStore{ChannelStore: NewMemoryChannelStore()}) + + signer := solana.NewWallet().PublicKey() + signature := confirmedSignature(0x61) + fake.Statuses[signature] = &rpc.SignatureStatusesResult{ + ConfirmationStatus: rpc.ConfirmationStatusConfirmed, + Slot: 99, + } + payload := intents.OpenPayloadPush( + solana.NewWallet().PublicKey().String(), + "999999", + signer.String(), + signature, + ) + if _, err := server.ProcessOpen(context.Background(), &payload); err == nil || + !strings.Contains(err.Error(), "authoritative state") { + t.Fatalf("legacy verifier error = %v, want authoritative-state rejection", err) + } + state, err := server.Store().GetChannel(context.Background(), *payload.ChannelID) + if err != nil { + t.Fatalf("GetChannel: %v", err) + } + if state != nil { + t.Fatalf("channel persisted despite legacy verifier: %+v", state) + } +} + +func TestSessionServerStateAwareOpenPersistsAuthoritativeChannelFacts(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + fake := testutil.NewFakeRPC() + config := authoritativeOpenSessionConfig(fixture) + config.VerifyOpenStateTx = NewOpenStateTxVerifier(config, fake) + server := NewSessionServer(config, durableTestChannelStore{ChannelStore: NewMemoryChannelStore()}) + fake.Statuses[fixture.signature] = &rpc.SignatureStatusesResult{ + ConfirmationStatus: rpc.ConfirmationStatusConfirmed, + Slot: 777, + } + seedSessionChannelAccountWithSeeds( + t, fake, fixture.channel, openFixtureDeposit, fixture.payer.PublicKey(), fixture.payee, + fixture.authorized, fixture.mint, pcgen.ChannelStatus_Open, openFixtureSalt, openFixtureOpenSlot, + fixture.payer.PublicKey(), + ) + + state, err := server.ProcessOpen(context.Background(), &fixture.payload) + if err != nil { + t.Fatalf("ProcessOpen: %v", err) + } + if state.Deposit != openFixtureDeposit || state.Salt != openFixtureSalt || state.OpenSlot != openFixtureOpenSlot { + t.Fatalf("state = %+v, want authoritative deposit/salt/openSlot", state) + } + if state.Operator == nil || *state.Operator != fixture.payer.PublicKey().String() { + t.Fatalf("operator = %v, want on-chain payer %s", state.Operator, fixture.payer.PublicKey()) + } +} + +func TestSessionCoreRejectsPreloadedEphemeralStateOperations(t *testing.T) { + config := sessionTestConfig() + config.Network = "mainnet" + server := NewSessionServer(config, NewMemoryChannelStore()) + ctx := context.Background() + tests := map[string]func() error{ + "voucher": func() error { + _, err := server.VerifyVoucherDetailed(ctx, &intents.VoucherPayload{}) + return err + }, + "delivery": func() error { + _, err := server.BeginDelivery(ctx, DeliveryRequest{}) + return err + }, + "commit": func() error { + _, err := server.ProcessCommit(ctx, &intents.CommitPayload{}) + return err + }, + "close": func() error { + _, err := server.ProcessClose(ctx, &intents.ClosePayload{}) + return err + }, + "settlement": func() error { + _, err := server.SettlementInstructions(ctx, "channel", solana.PublicKey{}) + return err + }, + "seal": func() error { return server.MarkSealed(ctx, "channel") }, + } + for name, run := range tests { + t.Run(name, func(t *testing.T) { + if err := run(); err == nil || !strings.Contains(err.Error(), "ephemeral session store") { + t.Fatalf("safety error = %v", err) + } + }) + } +} + +func TestTopUpVerifierBindsResultingDepositAndStatus(t *testing.T) { + fake := testutil.NewFakeRPC() + config := sessionTestConfig() + channelID := solana.NewWallet().PublicKey() + payer := solana.NewWallet().PublicKey() + signer := solana.NewWallet().PublicKey() + mint := solana.MustPublicKeyFromBase58(paycore.ResolveMint(config.Currency, config.Network)) + seedSessionChannelAccount( + t, fake, channelID, 2_000, payer, + solana.MustPublicKeyFromBase58(config.Recipient), signer, mint, pcgen.ChannelStatus_Open, + ) + verifier := NewTopUpStateTxVerifier(config, fake) + storedPayer := payer.String() + current := ChannelState{AuthorizedSigner: signer.String(), Operator: &storedPayer, Deposit: 1_000} + payload := &intents.TopUpPayload{ + ChannelID: channelID.String(), NewDeposit: "3000", Signature: confirmedSignature(0x32), + } + current.AuthorizedSigner = solana.NewWallet().PublicKey().String() + if err := verifier(context.Background(), payload, current); err == nil || !strings.Contains(err.Error(), "stored signer") { + t.Fatalf("authorized signer mismatch error = %v", err) + } + current.AuthorizedSigner = signer.String() + if err := verifier(context.Background(), payload, current); err == nil || !strings.Contains(err.Error(), "!= asserted") { + t.Fatalf("mismatched deposit error = %v", err) + } + + seedSessionChannelAccount( + t, fake, channelID, 3_000, payer, + solana.MustPublicKeyFromBase58(config.Recipient), signer, mint, pcgen.ChannelStatus_Sealed, + ) + if err := verifier(context.Background(), payload, current); err == nil || !strings.Contains(err.Error(), "not open") { + t.Fatalf("sealed channel error = %v", err) + } +} + +func TestTopUpVerifierFailsClosedWithoutRPCOffLocalnet(t *testing.T) { + config := sessionTestConfig() + config.Network = "mainnet" + verifier := NewTopUpStateTxVerifier(config, nil) + if verifier == nil { + t.Fatal("off-localnet verifier must fail closed, not be nil") + } + err := verifier(context.Background(), &intents.TopUpPayload{}, ChannelState{}) + if err == nil || !strings.Contains(err.Error(), "requires an rpc client") { + t.Fatalf("error = %v", err) + } +} + +func TestSessionCoreRejectsDirectNonlocalnetBypasses(t *testing.T) { + durable := durableTestChannelStore{ChannelStore: NewMemoryChannelStore()} + config := sessionTestConfig() + config.Network = "mainnet" + config.VerifyTopUpTx = func(context.Context, *intents.TopUpPayload, uint64) error { return nil } + server := NewSessionServer(config, durable) + payload := intents.OpenPayloadPush(solana.NewWallet().PublicKey().String(), "1000", solana.NewWallet().PublicKey().String(), confirmedSignature(0x44)) + if _, err := server.ProcessOpen(context.Background(), &payload); err == nil || !strings.Contains(err.Error(), "requires an on-chain verifier") { + t.Fatalf("direct open bypass error = %v", err) + } + _, _ = durable.UpdateChannel(context.Background(), "topup", func(*ChannelState) (ChannelState, error) { + payer := solana.NewWallet().PublicKey().String() + return ChannelState{ChannelID: "topup", AuthorizedSigner: solana.NewWallet().PublicKey().String(), Deposit: 1_000, Operator: &payer}, nil + }) + if _, err := server.ProcessTopUp(context.Background(), &intents.TopUpPayload{ChannelID: "topup", NewDeposit: "2000", Signature: confirmedSignature(0x46)}); err == nil || !strings.Contains(err.Error(), "state-aware on-chain verifier") { + t.Fatalf("direct top-up bypass error = %v", err) + } + if _, err := NewSessionServer(config, NewMemoryChannelStore()).ProcessOpen(context.Background(), &payload); err == nil || !strings.Contains(err.Error(), "ephemeral") { + t.Fatalf("direct memory-store bypass error = %v", err) + } + unknown := struct{ ChannelStore }{ChannelStore: NewMemoryChannelStore()} + if _, err := NewSessionServer(config, unknown).ProcessOpen(context.Background(), &payload); err == nil || !strings.Contains(err.Error(), "explicitly declare durable shared") { + t.Fatalf("direct unmarked-store bypass error = %v", err) + } +} + +func TestConfirmedTransactionSlotRejectsProcessedAndPinsAccountRead(t *testing.T) { + fake := testutil.NewFakeRPC() + signature := confirmedSignature(0x45) + fake.Statuses[signature] = &rpc.SignatureStatusesResult{ + ConfirmationStatus: rpc.ConfirmationStatusProcessed, + Slot: 77, + } + if _, err := confirmedTransactionSlot(context.Background(), fake, signature, "top-up"); err == nil || !strings.Contains(err.Error(), "only processed") { + t.Fatalf("processed status error = %v", err) + } + fake.Statuses[signature].ConfirmationStatus = rpc.ConfirmationStatusConfirmed + config := sessionTestConfig() + channelID := solana.NewWallet().PublicKey() + payer := solana.NewWallet().PublicKey() + signer := solana.NewWallet().PublicKey() + mint := solana.MustPublicKeyFromBase58(paycore.ResolveMint(config.Currency, config.Network)) + seedSessionChannelAccount(t, fake, channelID, 2_000, payer, solana.MustPublicKeyFromBase58(config.Recipient), signer, mint, pcgen.ChannelStatus_Open) + storedPayer := payer.String() + err := NewTopUpStateTxVerifier(config, fake)(context.Background(), &intents.TopUpPayload{ + ChannelID: channelID.String(), NewDeposit: "2000", Signature: signature, + }, ChannelState{AuthorizedSigner: signer.String(), Deposit: 1_000, Operator: &storedPayer}) + if err != nil { + t.Fatalf("top-up verifier: %v", err) + } + if fake.LastAccountInfoOpts == nil || fake.LastAccountInfoOpts.MinContextSlot == nil || *fake.LastAccountInfoOpts.MinContextSlot != 77 { + t.Fatalf("account read minContextSlot = %#v", fake.LastAccountInfoOpts) + } +} + +func TestChannelAccountRejectsInvalidHeaderAndLength(t *testing.T) { + fake := testutil.NewFakeRPC() + config := sessionTestConfig() + channelID := solana.NewWallet().PublicKey() + payer := solana.NewWallet().PublicKey() + signer := solana.NewWallet().PublicKey() + mint := solana.MustPublicKeyFromBase58(paycore.ResolveMint(config.Currency, config.Network)) + seedSessionChannelAccount(t, fake, channelID, 2_000, payer, solana.MustPublicKeyFromBase58(config.Recipient), signer, mint, pcgen.ChannelStatus_Open) + valid := append([]byte(nil), fake.Accounts[channelID.String()].Data.GetBinary()...) + for name, data := range map[string][]byte{ + "discriminator": append([]byte{9}, valid[1:]...), + "version": append(append([]byte(nil), valid[:1]...), append([]byte{9}, valid[2:]...)...), + "length": valid[:len(valid)-1], + } { + t.Run(name, func(t *testing.T) { + fake.SetAccount(channelID, paymentchannels.ProgramPubkey(), data) + _, err := fetchAndValidateChannel(context.Background(), fake, channelID, mint.String(), config.Recipient, config.Operator, 900, sessionDistributionHash(nil), true, config.ProgramID, 1) + if err == nil { + t.Fatal("malformed Channel account accepted") + } + }) + } +} + +func TestChannelAccountRejectsSpentOrEconomicallyMismatchedState(t *testing.T) { + fake := testutil.NewFakeRPC() + config := sessionTestConfig() + channelID := solana.NewWallet().PublicKey() + payer := solana.NewWallet().PublicKey() + signer := solana.NewWallet().PublicKey() + mint := solana.MustPublicKeyFromBase58(paycore.ResolveMint(config.Currency, config.Network)) + payee := solana.MustPublicKeyFromBase58(config.Recipient) + seedSessionChannelAccount(t, fake, channelID, 2_000, payer, payee, signer, mint, pcgen.ChannelStatus_Open) + valid := append([]byte(nil), fake.Accounts[channelID.String()].Data.GetBinary()...) + var decoded pcgen.Channel + if err := decoded.UnmarshalWithDecoder(bin.NewBorshDecoder(valid)); err != nil { + t.Fatalf("decode channel: %v", err) + } + + cases := map[string]func(*pcgen.Channel){ + "settled": func(channel *pcgen.Channel) { channel.Settlement.Settled = 1 }, + "payout watermark": func(channel *pcgen.Channel) { channel.Settlement.PayoutWatermark = 1 }, + "grace period": func(channel *pcgen.Channel) { channel.GracePeriod++ }, + "distribution hash": func(channel *pcgen.Channel) { channel.DistributionHash[0] ^= 0xff }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + channel := decoded + mutate(&channel) + buf := new(bytes.Buffer) + if err := channel.MarshalWithEncoder(bin.NewBorshEncoder(buf)); err != nil { + t.Fatalf("encode channel: %v", err) + } + fake.SetAccount(channelID, paymentchannels.ProgramPubkey(), buf.Bytes()) + _, err := fetchAndValidateChannel(context.Background(), fake, channelID, mint.String(), config.Recipient, config.Operator, 900, sessionDistributionHash(nil), true, config.ProgramID, 1) + if err == nil { + t.Fatal("economically mismatched Channel account accepted") + } + }) + } +} diff --git a/go/protocols/mpp/server/session_store.go b/go/protocols/mpp/server/session_store.go index f08ba9822..c5ed16b8e 100644 --- a/go/protocols/mpp/server/session_store.go +++ b/go/protocols/mpp/server/session_store.go @@ -80,6 +80,13 @@ type ChannelState struct { // for opens that never carried it. OpenSlot uint64 `json:"open_slot"` + // Salt is the authoritative channel PDA salt read from on-chain state. + Salt uint64 `json:"salt"` + + // OpenSignature is the confirmed signature of a server-broadcast open. + // Retries reuse it instead of confirming a newly signed, unbroadcast tx. + OpenSignature string `json:"open_signature,omitempty"` + // Cumulative is the highest cumulative amount accepted by the server (the // settled watermark). Cumulative uint64 `json:"cumulative"` @@ -101,16 +108,43 @@ type ChannelState struct { // was requested. Once set, no further vouchers are accepted. CloseRequestedAt *uint64 `json:"close_requested_at"` - // SettledSignature is the signature (base58) of the broadcast - // settle-and-distribute transaction. A close-pending channel with no - // settled signature is re-drivable: a close retry may attempt settlement - // again. + // SettledSignature is the signature (base58) of the signed + // settle-and-distribute transaction. It is persisted under the settlement + // claim before broadcast. When Sealed is false, retries rebroadcast the + // exact stored wire and confirm this same transaction. // // An extension beyond the core channel-state shape, recorded only when // this server drives on-chain settlement. Serialized with omitempty so a // channel state without a settlement round-trips cleanly. SettledSignature *string `json:"settled_signature,omitempty"` + // SettlementWire is the exact signed transaction encoded as base64. New + // settlement attempts persist it atomically with SettledSignature before + // broadcast, forming a transactional outbox. Retries decode and rebroadcast + // these exact bytes, preserving the transaction signature. + SettlementWire string `json:"settlement_wire,omitempty"` + + // SettlementLastValidBlockHeight is the block-height validity ceiling from + // the blockhash used by SettlementWire. A not-found signature is retired + // only after the current block height is proven greater than this value. + SettlementLastValidBlockHeight uint64 `json:"settlement_last_valid_block_height,omitempty"` + + // Settling is the durable in-flight claim acquired atomically before this + // server builds a settlement transaction. A fresh signature-less claim + // blocks competing builds; once the exact wire is persisted, other servers + // may safely take over and idempotently submit that same transaction. + Settling bool `json:"settling,omitempty"` + + // SettlementClaimOwner identifies the settlement attempt that currently + // owns the claim. Release operations compare this token so an older attempt + // cannot clear a claim taken over by another server. + SettlementClaimOwner string `json:"settlement_claim_owner,omitempty"` + + // SettlementClaimedAt is the Unix timestamp when the current claim was + // acquired. A signature-less claim may be taken over after the bounded + // lease expires, recovering from a crash before signature persistence. + SettlementClaimedAt int64 `json:"settlement_claimed_at,omitempty"` + // Operator is the client wallet pubkey (base58) for pull-mode sessions; // nil for push sessions. Operator *string `json:"operator"` @@ -237,6 +271,28 @@ type ChannelStore interface { MarkSealed(ctx context.Context, channelID string) (ChannelState, error) } +// SessionStoreDurability is a store's explicitly declared session-state +// capability. Unknown stores are not safe for production session handling. +type SessionStoreDurability uint8 + +const ( + // SessionStoreDurabilityUnknown is the safe default for custom stores that + // have not explicitly declared whether their state is durable and shared. + SessionStoreDurabilityUnknown SessionStoreDurability = iota + // SessionStoreDurabilityEphemeral identifies process-local state. + SessionStoreDurabilityEphemeral + // SessionStoreDurabilityDurableShared identifies state that survives restarts + // and is shared by every serving process. + SessionStoreDurabilityDurableShared +) + +// SessionStoreCapabilities lets stores affirmatively declare their +// session-state durability. Production session construction accepts only +// SessionStoreDurabilityDurableShared. +type SessionStoreCapabilities interface { + SessionStoreDurability() SessionStoreDurability +} + // MemoryChannelStore is an in-memory ChannelStore with per-channel locking: // UpdateChannel calls for the same channel id run strictly sequentially while // calls for different ids run concurrently. @@ -261,6 +317,24 @@ func NewMemoryChannelStore() *MemoryChannelStore { } } +// SessionStoreDurability marks the built-in memory store as process-local. +func (*MemoryChannelStore) SessionStoreDurability() SessionStoreDurability { + return SessionStoreDurabilityEphemeral +} + +func sessionStoreSafetyMessage(store ChannelStore) string { + if capabilities, ok := store.(SessionStoreCapabilities); ok && + capabilities.SessionStoreDurability() == SessionStoreDurabilityEphemeral { + return "ephemeral session store is unsafe off localnet; inject a durable shared ChannelStore" + } + return "session store must explicitly declare durable shared capability off localnet; inject a durable shared ChannelStore" +} + +func isDurableSharedSessionStore(store ChannelStore) bool { + capabilities, ok := store.(SessionStoreCapabilities) + return ok && capabilities.SessionStoreDurability() == SessionStoreDurabilityDurableShared +} + // channelLock returns the mutex serializing updates for channelID. func (s *MemoryChannelStore) channelLock(channelID string) *sync.Mutex { s.mu.Lock() @@ -358,6 +432,11 @@ func (s *MemoryChannelStore) MarkSealed(ctx context.Context, channelID string) ( } next := *current next.Sealed = true + next.SettlementWire = "" + next.SettlementLastValidBlockHeight = 0 + next.Settling = false + next.SettlementClaimOwner = "" + next.SettlementClaimedAt = 0 return next, nil }) } diff --git a/go/protocols/mpp/server/session_store_test.go b/go/protocols/mpp/server/session_store_test.go index 1e553ec05..87d4b4d3b 100644 --- a/go/protocols/mpp/server/session_store_test.go +++ b/go/protocols/mpp/server/session_store_test.go @@ -354,3 +354,37 @@ func TestChannelStateRejectsLegacyFinalizedRecord(t *testing.T) { t.Fatalf("current record decoded incorrectly: %+v", state) } } + +func TestChannelStatePersistsSettlementClaimAndPendingSignature(t *testing.T) { + signature := "pending-settlement-signature" + state := testChannelState("c1", 1_000) + state.Settling = true + state.SettledSignature = &signature + state.SettlementWire = "c2lnbmVkLXdpcmU=" + state.SettlementLastValidBlockHeight = 456 + state.SettlementClaimOwner = "worker-1" + state.SettlementClaimedAt = 123 + + encoded, err := json.Marshal(state) + if err != nil { + t.Fatalf("marshal channel state: %v", err) + } + if !strings.Contains(string(encoded), `"settling":true`) || + !strings.Contains(string(encoded), `"settled_signature":"pending-settlement-signature"`) || + !strings.Contains(string(encoded), `"settlement_wire":"c2lnbmVkLXdpcmU="`) || + !strings.Contains(string(encoded), `"settlement_last_valid_block_height":456`) || + !strings.Contains(string(encoded), `"settlement_claim_owner":"worker-1"`) || + !strings.Contains(string(encoded), `"settlement_claimed_at":123`) { + t.Fatalf("serialized settlement state = %s", encoded) + } + + var decoded ChannelState + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("unmarshal channel state: %v", err) + } + if !decoded.Settling || decoded.SettledSignature == nil || *decoded.SettledSignature != signature || + decoded.SettlementWire != "c2lnbmVkLXdpcmU=" || decoded.SettlementLastValidBlockHeight != 456 || + decoded.SettlementClaimOwner != "worker-1" || decoded.SettlementClaimedAt != 123 { + t.Fatalf("decoded settlement state = %+v", decoded) + } +} diff --git a/harness/test/session-close-settle-verify.test.ts b/harness/test/session-close-settle-verify.test.ts index d5e17868a..286ac403b 100644 --- a/harness/test/session-close-settle-verify.test.ts +++ b/harness/test/session-close-settle-verify.test.ts @@ -38,14 +38,24 @@ import { findAssociatedTokenPda } from "@solana-program/token"; import { AccountRole, + appendTransactionMessageInstructions, + createTransactionMessage, generateKeyPairSigner, getAddressDecoder, getArrayDecoder, getBase58Decoder, + getBase64EncodedWireTransaction, + getSignatureFromTransaction, getStructDecoder, getU16Decoder, getU8Decoder, + pipe, + setTransactionMessageFeePayerSigner, + setTransactionMessageLifetimeUsingBlockhash, + signTransactionMessageWithSigners, type Address, + type Blockhash, + type Instruction, type KeyPairSigner, type Signature, } from "@solana/kit"; @@ -149,6 +159,8 @@ describe("session close settle+distribute composer — accept", () => { let sendCount = 0; let broadcastWire: string | undefined; + let builtWire: string | undefined; + let builtSignature: Signature | undefined; let result: Awaited>; beforeAll(async () => { @@ -172,10 +184,27 @@ describe("session close settle+distribute composer — accept", () => { const signed = await signHighestVoucher(voucherSigner, channel.address, CUMULATIVE, EXPIRES_AT); result = await submitSettleAndDistribute({ - buildAndSignWireTransaction: async () => { - // The composer hands us the composed instruction list; we don't need a - // real compiled tx — assertions run against result.instructions. - return "BASE64_WIRE_TX"; + buildAndSignWireTransaction: async (composed) => { + // Compile the composed instruction list into a REAL signed wire tx: + // the settle path decodes it to bind the broadcast signature before + // submitting, so a placeholder string no longer satisfies the contract. + const message = pipe( + createTransactionMessage({ version: 0 }), + (m) => setTransactionMessageFeePayerSigner(merchant, m), + (m) => + setTransactionMessageLifetimeUsingBlockhash( + { + blockhash: "11111111111111111111111111111111" as Blockhash, + lastValidBlockHeight: 0n, + }, + m, + ), + (m) => appendTransactionMessageInstructions(composed as unknown as Instruction[], m), + ); + const signedTx = await signTransactionMessageWithSigners(message); + builtWire = getBase64EncodedWireTransaction(signedTx); + builtSignature = getSignatureFromTransaction(signedTx); + return builtWire; }, channelId: channel.address, mint: mint.address, @@ -199,8 +228,11 @@ describe("session close settle+distribute composer — accept", () => { it("BROADCASTS the settlement (not a fake receipt ref that never submits)", () => { expect(sendCount, "settle+distribute must be submitted exactly once").toBe(1); - expect(broadcastWire).toBe("BASE64_WIRE_TX"); - expect(result.signature).toBe(BROADCAST_SIGNATURE); + expect(broadcastWire).toBe(builtWire); + // The reported signature is DERIVED from the signed wire (bound before + // broadcast), never trusted from the RPC's response value. + expect(result.signature).toBe(builtSignature); + expect(result.signature).not.toBe(BROADCAST_SIGNATURE); }); it("composes ed25519-precompile + settle_and_finalize + distribute", () => { diff --git a/python/src/solana_pay_kit/_paycore/rpc.py b/python/src/solana_pay_kit/_paycore/rpc.py index 55f4d2bc2..dd2e5ce29 100644 --- a/python/src/solana_pay_kit/_paycore/rpc.py +++ b/python/src/solana_pay_kit/_paycore/rpc.py @@ -145,12 +145,17 @@ async def get_slot(self, commitment: str = "confirmed") -> int: raise _RpcError("getSlot returned a non-integer slot", code="payment_invalid") return result - async def get_account_info(self, address: str, commitment: str = "confirmed") -> tuple[bytes, str] | None: + async def get_account_info( + self, address: str, commitment: str = "confirmed", min_context_slot: int | None = None + ) -> tuple[bytes, str] | None: """Fetch an account's raw data bytes and owner (base58), or ``None`` when the account is missing. Used to read on-chain payment-channel state during x402 ``upto`` verification; the generated ``Channel.decode`` then parses the returned bytes.""" - result = await self._call("getAccountInfo", [address, {"encoding": "base64", "commitment": commitment}]) + config: dict[str, Any] = {"encoding": "base64", "commitment": commitment} + if min_context_slot is not None: + config["minContextSlot"] = min_context_slot + result = await self._call("getAccountInfo", [address, config]) value = (result or {}).get("value") if isinstance(result, dict) else None if not isinstance(value, dict): return None @@ -198,15 +203,22 @@ async def confirm_transaction(self, signature: Any, *_args: Any, **_kwargs: Any) await asyncio.sleep(0.25) return _RpcResponse([{"err": "timeout"}]) - async def get_transaction(self, signature: Any, **_kwargs: Any) -> Any: + async def get_transaction( + self, + signature: Any, + *, + encoding: str = "jsonParsed", + commitment: str = "confirmed", + max_supported_transaction_version: int = 0, + ) -> Any: result = await self._call( "getTransaction", [ str(signature), { - "encoding": "jsonParsed", - "commitment": "confirmed", - "maxSupportedTransactionVersion": 0, + "encoding": encoding, + "commitment": commitment, + "maxSupportedTransactionVersion": max_supported_transaction_version, }, ], ) diff --git a/python/src/solana_pay_kit/protocols/mpp/server/session.py b/python/src/solana_pay_kit/protocols/mpp/server/session.py index 15902d60e..60fdac7b1 100644 --- a/python/src/solana_pay_kit/protocols/mpp/server/session.py +++ b/python/src/solana_pay_kit/protocols/mpp/server/session.py @@ -28,7 +28,7 @@ import time from collections.abc import Awaitable, Callable from dataclasses import dataclass, field -from typing import TypeVar +from typing import Protocol, TypeVar from solana_pay_kit._paycore.errors import PaymentError from solana_pay_kit.protocols.mpp.intents.session import ( @@ -66,6 +66,9 @@ __all__ = [ "Split", "SessionTxVerifier", + "SessionOpenTxVerifier", + "SessionOpenStateTxVerifier", + "VerifiedOpenFacts", "TopUpTxVerifier", "TopUpStateTxVerifier", "SessionConfig", @@ -84,6 +87,23 @@ # skips verification. Raising signals a verification failure. SessionTxVerifier = Callable[[_P], Awaitable[None]] + +class VerifiedOpenFacts(Protocol): + """Authoritative facts returned by a verified payment-channel open.""" + + channel_id: str + deposit: int + salt: int + open_slot: int + payer: str + + +# SessionOpenTxVerifier is the legacy structural/payload-only open callback. +SessionOpenTxVerifier = Callable[[OpenPayload], Awaitable[None]] +# SessionOpenStateTxVerifier returns facts bound to the confirmed on-chain +# Channel account so payload economics are never persisted as facts. +SessionOpenStateTxVerifier = Callable[[OpenPayload], Awaitable[VerifiedOpenFacts]] + # TopUpTxVerifier is the legacy payload-only callback. Keep it stable for hosts # that installed custom verification before the state-aware binding seam. TopUpTxVerifier = SessionTxVerifier[TopUpPayload] @@ -165,6 +185,16 @@ class SessionConfig: # mode) before process_open persists channel state. verify_open_tx: SessionTxVerifier[OpenPayload] | None = None + # VerifyOpenStateTx returns facts bound to the confirmed on-chain channel + # account. It is required for payment-channel-backed opens off localnet so a + # confirmed signature can never authorize payload-supplied channel economics. + verify_open_state_tx: SessionOpenStateTxVerifier | None = None + + # OpenTxSubmitter identifies who broadcasts transaction-backed opens. Only + # server-broadcast signatures are retained for idempotent replay; client + # signatures are request data and do not belong in server state. + open_tx_submitter: str = "client" + # VerifyTopUpTx is the legacy payload-only top-up callback retained for # existing host integrations. verify_top_up_tx: TopUpTxVerifier | None = None @@ -348,16 +378,31 @@ async def process_open(self, payload: OpenPayload) -> ChannelState: raise ValueError(f"session mode {payload.mode!r} is not supported by this challenge") session_id = payload.session_id() - deposit = payload.deposit_amount() - if deposit == 0: - raise ValueError("deposit must be greater than zero") - if deposit > self._config.max_cap: - raise ValueError(f"deposit {deposit} exceeds max cap {self._config.max_cap}") + payment_channel_backed = payload.mode == "push" or payload.transaction is not None + + # Fail closed off localnet: a payment-channel open must be bound to the + # confirmed on-chain Channel account. A legacy structural/payload-only + # verifier cannot authorize the persisted economics, so the state-aware + # seam is required rather than trusting the payload's claimed deposit. + if payment_channel_backed and self._config.network != "localnet" and self._config.verify_open_state_tx is None: + raise ValueError( + "payment-channel open requires an authoritative verifier with channel facts off localnet" + ) - # On-chain verification seam (push mode only; pull-mode host - # integrations submit server-broadcast transactions or validate - # delegated-token state before invoking this lower-level store method). - if payload.mode == "push" and self._config.verify_open_tx is not None: + # On-chain verification seam. The state-aware verifier returns facts + # bound to the confirmed channel account; the legacy seam only validates + # and returns nothing (payload economics stay trusted, localnet only). + verified: VerifiedOpenFacts | None = None + if payment_channel_backed and self._config.verify_open_state_tx is not None: + try: + verified = await self._config.verify_open_state_tx(payload) + except PaymentError: + raise + except Exception as exc: + raise _wrap("open tx verification failed", exc) from exc + if verified is None: + raise ValueError("authoritative open verifier must return authoritative channel facts") + elif payment_channel_backed and self._config.verify_open_tx is not None: try: await self._config.verify_open_tx(payload) except PaymentError: @@ -365,19 +410,38 @@ async def process_open(self, payload: OpenPayload) -> ChannelState: except Exception as exc: raise _wrap("open tx verification failed", exc) from exc - operator = payload.owner - if operator is None: - operator = payload.payer + if verified is None: + deposit = payload.deposit_amount() + operator = payload.owner + if operator is None: + operator = payload.payer + # The payload's recentSlot is the channel openSlot (a channel PDA + # seed); persist it so the channel address can be re-derived and the + # rent reclaimed later. Zero when the payload carries none. + open_slot = payload.recent_slot or 0 + salt = payload.salt or 0 + else: + if verified.channel_id != session_id: + raise ValueError(f"verified open channel {verified.channel_id} != session {session_id}") + # The verifier bound these facts to the confirmed on-chain channel + # account. Payload echoes are not authoritative for state. + deposit = verified.deposit + operator = verified.payer + open_slot = verified.open_slot + salt = verified.salt + if deposit == 0: + raise ValueError("deposit must be greater than zero") + if deposit > self._config.max_cap: + raise ValueError(f"deposit {deposit} exceeds max cap {self._config.max_cap}") + fresh = ChannelState( channel_id=session_id, authorized_signer=payload.authorized_signer, deposit=deposit, operator=operator, - # The payload's recentSlot is the channel openSlot (a channel PDA - # seed); persist it so the channel address can be re-derived and - # the rent reclaimed later. Zero when the payload does not carry - # one (pull opens, trusted opens). - open_slot=payload.recent_slot or 0, + open_slot=open_slot, + salt=salt, + open_signature=(payload.signature or None) if self._config.open_tx_submitter == "server" else None, ) def mutator(existing: ChannelState | None) -> ChannelState: diff --git a/python/src/solana_pay_kit/protocols/mpp/server/session_method.py b/python/src/solana_pay_kit/protocols/mpp/server/session_method.py index 25c8f016a..6443bb212 100644 --- a/python/src/solana_pay_kit/protocols/mpp/server/session_method.py +++ b/python/src/solana_pay_kit/protocols/mpp/server/session_method.py @@ -83,6 +83,7 @@ broadcast_prepared_transaction, complete_open_transaction, confirm_transaction_signature, + new_open_state_tx_verifier, new_top_up_state_tx_verifier, settle_and_seal_channel, verify_open_tx, @@ -921,6 +922,14 @@ async def _handle_open(self, payload: OpenPayload, challenge_recent_slot: int | code="invalid-payload", ) elif mode == "push" and self._rpc is not None: + # Off localnet the authoritative state verifier (process_open) binds + # the confirmed on-chain Channel account, so propagate the challenge + # incarnation onto the payload first: the verifier rejects a channel + # opened against a different recentSlot before state is persisted. + if payload.recent_slot in (None, 0): + payload.recent_slot = challenge_recent_slot + # Confirm liveness inline as well so a localnet signature-only push + # (where no state verifier is installed) still verifies on-chain. await confirm_transaction_signature(self._rpc, payload.signature, "open") # else: no transaction is attached. Reachable by a pull open (the channel # id / token account and deposit are trusted as provided, mirroring the TS @@ -1308,8 +1317,14 @@ def new_session(options: SessionOptions) -> Session: # Shared with SessionServer's constructor guard so the factory and a # direct construction enforce one channel-store deployment policy. + # enforce_channel_store_policy has already rejected an absent/in-memory + # store off-localnet without the explicit opt-in, so the memory fallback + # below is only reachable on localnet or under the acknowledged opt-in. enforce_channel_store_policy(options.store, network) + # Fail-closed via the shared enforce_channel_store_policy guard above + # (a form the radar rule's inline if/raise pattern cannot see). + # nosemgrep: failopen-default-store-python store = options.store if options.store is not None else MemoryChannelStore() config = SessionConfig( @@ -1325,10 +1340,18 @@ def new_session(options: SessionOptions) -> Session: modes=options.modes, pull_voucher_strategy=options.pull_voucher_strategy, ) - # Open verification remains in the method layer because server-broadcast - # opens need request-specific signing. Top-ups use the state-aware core - # seam so it can bind the confirmed transaction's delta to the exact - # channel snapshot and recheck that snapshot atomically after the RPC await. + config.open_tx_submitter = open_tx_submitter + # Off localnet the authoritative open verifier binds the confirmed on-chain + # Channel account so payload economics are never persisted as facts. It + # needs an RPC client; when absent the core fail-closes for non-localnet + # payment-channel opens instead of trusting the payload. On localnet the + # method layer keeps its existing structural/liveness checks, so the state + # seam is left unset to preserve the in-memory development flow. + if options.rpc is not None and network != "localnet": + config.verify_open_state_tx = new_open_state_tx_verifier(config, options.rpc) + # Top-ups use the state-aware core seam so it can bind the confirmed + # transaction's delta to the exact channel snapshot and recheck that + # snapshot atomically after the RPC await. config.verify_top_up_state_tx = new_top_up_state_tx_verifier(config, options.rpc) core = SessionServer(config, store) session = Session( diff --git a/python/src/solana_pay_kit/protocols/mpp/server/session_onchain.py b/python/src/solana_pay_kit/protocols/mpp/server/session_onchain.py index 3d0d62f36..70c5bda03 100644 --- a/python/src/solana_pay_kit/protocols/mpp/server/session_onchain.py +++ b/python/src/solana_pay_kit/protocols/mpp/server/session_onchain.py @@ -20,9 +20,11 @@ import asyncio import base64 +import hashlib +import struct import time from collections.abc import Awaitable, Callable -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, Any, Protocol, cast, overload from solders.hash import Hash # type: ignore[import-untyped] @@ -52,16 +54,19 @@ from solana_pay_kit.protocols.mpp.server.session_store import ChannelState __all__ = [ + "BoundChannel", "PreparedTransaction", "VerifyOpenTxExpected", "VerifyOpenTxResult", "broadcast_prepared_transaction", "complete_open_transaction", "cosign_and_broadcast_open", + "fetch_and_bind_channel_account", "prepare_settle_and_seal_channel", "settle_and_seal_channel", "verify_open_tx", "new_open_tx_verifier", + "new_open_state_tx_verifier", "new_top_up_tx_verifier", "new_top_up_state_tx_verifier", "confirm_transaction_signature", @@ -74,6 +79,8 @@ _TOP_UP_INSTRUCTION_DISCRIMINATOR = 3 _U64_MAX = (1 << 64) - 1 _BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" +# On-chain Channel account status byte for an open (unsealed) channel. +_CHANNEL_STATUS_OPEN = 0 class RpcClient(Protocol): @@ -105,6 +112,9 @@ async def get_account_info( #: A verifier seam installed on the session config: validates a payload (open #: or top-up) and raises on rejection. OpenTxVerifier = Callable[[OpenPayload], Awaitable[None]] +# The state-aware open verifier returns facts bound to the confirmed on-chain +# Channel account rather than payload echoes. +OpenStateTxVerifier = Callable[[OpenPayload], Awaitable["VerifyOpenTxResult"]] TopUpTxVerifier = Callable[[TopUpPayload], Awaitable[None]] TopUpStateTxVerifier = Callable[[TopUpPayload, "ChannelState"], Awaitable[None]] @@ -119,6 +129,7 @@ class OpenVerifierConfig(Protocol): recipient: str max_cap: int operator: str + settlement_window: int splits: list[Split] @property @@ -192,6 +203,19 @@ class PreparedTransaction: signature: str +@dataclass +class BoundChannel: + """Authoritative facts decoded from an on-chain Channel account.""" + + deposit: int + payer: str + authorized_signer: str + payee: str + mint: str + salt: int + open_slot: int + + def is_placeholder_signature(signature: str) -> bool: """Report whether ``signature`` is the pending placeholder produced by the server-completed open flow (an empty string or a run of 40+ ``'1'`` @@ -500,6 +524,330 @@ async def verifier(payload: OpenPayload) -> None: return verifier +def _confirmed_transaction_wire(transaction: dict[str, Any], label: str) -> str: + """Return the exact base64 wire transaction from a confirmed RPC result.""" + meta = transaction.get("meta") + if not isinstance(meta, dict): + raise PaymentError(f"confirmed {label} transaction has malformed metadata", code="invalid-payload") + if meta.get("err") is not None: + raise PaymentError(f"{label} transaction failed on-chain", code="transaction-failed") + value = transaction.get("transaction") + if not isinstance(value, list) or len(value) < 2 or not isinstance(value[0], str) or value[1] != "base64": + raise PaymentError(f"confirmed {label} transaction is not base64 wire data", code="invalid-payload") + return value[0] + + +async def _fetch_and_verify_signature_only_open( + expected: VerifyOpenTxExpected, + payload: OpenPayload, + rpc_client: RpcClient, +) -> tuple[int | None, VerifyOpenTxResult]: + """Confirm and bind a signature-only open to its canonical transaction.""" + confirmed_slot = await confirm_transaction_signature(rpc_client, payload.signature, "open") + get_transaction: Any = getattr(rpc_client, "get_transaction", None) + if not callable(get_transaction): + raise PaymentError( + "signature-only open verification requires an RPC client with get_transaction", + code="invalid-config", + ) + try: + pending: Any = get_transaction( + payload.signature, + commitment="confirmed", + encoding="base64", + max_supported_transaction_version=0, + ) + response: Any = await pending + except Exception as exc: + raise PaymentError(f"fetch open transaction: {exc}", code="transaction-not-found") from exc + transaction = _transaction_dict(response) + if transaction is None: + raise PaymentError("open transaction not found or not yet confirmed", code="transaction-not-found") + + wire_payload = replace(payload, transaction=_confirmed_transaction_wire(transaction, "open")) + structural = await verify_open_tx(expected, wire_payload, None) + return confirmed_slot, structural + + +def _assert_signature_only_deposit( + payload: OpenPayload, + structural: VerifyOpenTxResult, + bound: BoundChannel, +) -> None: + """Keep the asserted amount bound to both the open wire and Channel state.""" + try: + asserted_deposit = payload.deposit_amount() + except ValueError as exc: + raise PaymentError(str(exc), code="invalid-payload") from exc + if structural.deposit != bound.deposit: + raise PaymentError( + f"on-chain channel deposit {bound.deposit} != open transaction deposit {structural.deposit}", + code="invalid-payload", + ) + if bound.deposit != asserted_deposit: + raise PaymentError( + f"on-chain channel deposit {bound.deposit} != asserted deposit {asserted_deposit}", + code="invalid-payload", + ) + + +def new_open_state_tx_verifier(config: OpenVerifierConfig, rpc_client: RpcClient | None) -> OpenStateTxVerifier: + """Return the authoritative verifier for payment-channel opens. + + This is deliberately separate from :func:`new_open_tx_verifier`: a + placeholder signature may pass structural validation while a server is + completing a partial transaction, but it can never authorize persisted + channel state. Every successful path here confirms a real signature, + fetches the current open Channel account at the confirmation slot, and + returns only account-derived economics and PDA seeds. + """ + + async def verifier(payload: OpenPayload) -> VerifyOpenTxResult: + if rpc_client is None: + raise PaymentError( + "authoritative open verification requires an RPC client", + code="invalid-config", + ) + if is_placeholder_signature(payload.signature): + raise PaymentError( + "authoritative open verification requires a real transaction signature", + code="invalid-payload", + ) + + expected_open_slot = payload.recent_slot if payload.recent_slot not in (None, 0) else None + expected = VerifyOpenTxExpected( + authorized_signer=payload.authorized_signer, + currency=config.currency, + max_cap=config.max_cap, + network=config.network, + operator=config.operator, + program_id=( + Pubkey.from_string(config.program_id) if isinstance(config.program_id, str) else config.program_id + ), + recipient=config.recipient, + recipients=[(split.recipient, split.bps) for split in config.splits], + recent_slot=expected_open_slot, + ) + + structural: VerifyOpenTxResult | None = None + confirmed_slot: int | None = None + if payload.transaction: + # Structural validation binds the real payload signature to the + # transaction wire, but its decoded facts are never returned. + structural = await verify_open_tx(expected, payload, None) + confirmed_slot = await confirm_transaction_signature(rpc_client, payload.signature, "open") + else: + if payload.mode == "push" and not payload.channel_id: + raise PaymentError( + "signature-only push open requires channelId", + code="invalid-payload", + ) + try: + payload.session_id() + except ValueError as exc: + raise PaymentError(str(exc), code="invalid-payload") from exc + if payload.mode == "push" and expected_open_slot is None: + raise PaymentError( + "signature-only push open requires recentSlot", + code="invalid-payload", + ) + confirmed_slot, structural = await _fetch_and_verify_signature_only_open(expected, payload, rpc_client) + assert structural is not None and confirmed_slot is not None + expected_mint = resolve_mint(config.currency, config.network) + if not expected_mint: + raise PaymentError( + f"payment-channel open requires an SPL token, got currency {config.currency!r}", + code="invalid-config", + ) + bound = await fetch_and_bind_channel_account( + rpc_client, + structural.channel_id, + program_id=config.program_id, + max_cap=config.max_cap, + expected_authorized_signer=payload.authorized_signer, + expected_payee=config.recipient, + expected_mint=expected_mint, + expected_operator=config.operator, + min_context_slot=confirmed_slot, + expected_grace_period=_expected_session_grace_period(config.settlement_window), + expected_splits=config.splits, + expected_open_slot=expected_open_slot, + ) + _assert_signature_only_deposit(payload, structural, bound) + return VerifyOpenTxResult( + channel_id=structural.channel_id, + deposit=bound.deposit, + grace_period=_expected_session_grace_period(config.settlement_window), + salt=bound.salt, + open_slot=bound.open_slot, + payer=bound.payer, + ) + + return verifier + + +async def _fetch_and_validate_channel( + rpc_client: RpcClient, + channel_id: str, + *, + program_id: Pubkey | str | None, + expected_payee: str, + expected_mint: str, + expected_operator: str, + expected_grace_period: int, + expected_distribution_hash: bytes, + require_fresh: bool, + min_context_slot: int, +) -> Any: + from solana_pay_kit.protocols.programs.paymentchannels.accounts.channel import Channel + + if program_id is None: + resolved_program = PROGRAM_ID + elif isinstance(program_id, str): + resolved_program = Pubkey.from_string(program_id) + else: + resolved_program = program_id + account = await _require_account_info_rpc(rpc_client).get_account_info( + channel_id, commitment="confirmed", min_context_slot=min_context_slot + ) + if account is None: + raise PaymentError(f"channel {channel_id} account not found on-chain", code="invalid-payload") + data, owner = account + if owner != str(resolved_program): + raise PaymentError( + f"channel {channel_id} is not owned by the payment-channels program", + code="invalid-payload", + ) + if len(data) != 256: + raise PaymentError(f"channel {channel_id} account data has invalid length {len(data)}", code="invalid-payload") + if data[0] != 1: + raise PaymentError(f"channel {channel_id} has invalid discriminator {data[0]}", code="invalid-payload") + try: + channel = Channel.decode(data) + except Exception as exc: + raise PaymentError(f"channel {channel_id} account decode failed: {exc}", code="invalid-payload") from exc + if int(channel.version) != 1: + raise PaymentError(f"channel {channel_id} has unsupported version {channel.version}", code="invalid-payload") + if int(channel.status) != _CHANNEL_STATUS_OPEN: + raise PaymentError( + f"channel {channel_id} is not open on-chain (status {channel.status})", + code="invalid-payload", + ) + if str(channel.mint) != expected_mint: + raise PaymentError( + f"on-chain channel mint {channel.mint} != expected mint {expected_mint}", + code="invalid-payload", + ) + if str(channel.payee) != expected_payee: + raise PaymentError( + f"on-chain channel payee {channel.payee} != expected recipient {expected_payee}", + code="invalid-payload", + ) + if not expected_operator or str(channel.rentPayer) != expected_operator: + raise PaymentError( + f"on-chain channel rentPayer {channel.rentPayer} != expected operator {expected_operator}", + code="invalid-payload", + ) + if require_fresh and (int(channel.settlement.settled) != 0 or int(channel.settlement.payoutWatermark) != 0): + raise PaymentError(f"channel {channel_id} has nonzero settlement watermarks", code="invalid-payload") + if int(channel.gracePeriod) != expected_grace_period: + raise PaymentError( + f"on-chain channel gracePeriod {channel.gracePeriod} != expected {expected_grace_period}", + code="invalid-payload", + ) + if bytes(channel.distributionHash) != expected_distribution_hash: + raise PaymentError("on-chain channel distributionHash does not match session splits", code="invalid-payload") + return channel + + +def _session_distribution_hash(splits: list[Any]) -> bytes: + hasher = hashlib.sha256() + hasher.update(struct.pack(" int: + return settlement_window if settlement_window is not None and settlement_window > 0 else 900 + + +async def fetch_and_bind_channel_account( + rpc_client: RpcClient, + channel_id: str, + *, + program_id: Pubkey | str | None, + max_cap: int, + expected_authorized_signer: str, + expected_payee: str, + expected_mint: str, + expected_operator: str, + min_context_slot: int, + expected_grace_period: int = 900, + expected_splits: list[Any] | None = None, + require_fresh: bool = True, + expected_open_slot: int | None = None, +) -> BoundChannel: + channel = await _fetch_and_validate_channel( + rpc_client, + channel_id, + program_id=program_id, + expected_payee=expected_payee, + expected_mint=expected_mint, + expected_operator=expected_operator, + expected_grace_period=expected_grace_period, + expected_distribution_hash=_session_distribution_hash(expected_splits or []), + require_fresh=require_fresh, + min_context_slot=min_context_slot, + ) + if expected_open_slot is not None and int(channel.openSlot) != expected_open_slot: + raise PaymentError( + f"on-chain channel openSlot {channel.openSlot} != challenge recentSlot {expected_open_slot}", + code="invalid-payload", + ) + if str(channel.authorizedSigner) != expected_authorized_signer: + raise PaymentError( + f"on-chain channel authorized_signer {channel.authorizedSigner} != expected {expected_authorized_signer}", + code="invalid-payload", + ) + resolved_program = ( + PROGRAM_ID + if program_id is None + else Pubkey.from_string(program_id) + if isinstance(program_id, str) + else program_id + ) + derived_channel, _ = find_channel_pda( + channel.payer, + channel.payee, + channel.mint, + channel.authorizedSigner, + int(channel.salt), + int(channel.openSlot), + resolved_program, + ) + if str(derived_channel) != channel_id: + raise PaymentError( + f"channel account {channel_id} != PDA derived from authoritative state {derived_channel}", + code="invalid-payload", + ) + deposit = int(channel.deposit) + if deposit == 0: + raise PaymentError(f"on-chain channel {channel_id} deposit is zero", code="invalid-payload") + if deposit > max_cap: + raise PaymentError(f"on-chain channel deposit {deposit} exceeds max cap {max_cap}", code="invalid-payload") + return BoundChannel( + deposit=deposit, + payer=str(channel.payer), + authorized_signer=str(channel.authorizedSigner), + payee=str(channel.payee), + mint=str(channel.mint), + salt=int(channel.salt), + open_slot=int(channel.openSlot), + ) + + class _TopUpVerifierMissing: """Sentinel that distinguishes the legacy and state-aware factory forms.""" @@ -734,10 +1082,17 @@ async def confirm_transaction_signature( timeout_seconds: float = 30.0, poll_interval_seconds: float = 1.0, search_transaction_history: bool = False, -) -> None: +) -> int | None: """Poll ``getSignatureStatuses`` until ``signature`` reaches at least ``confirmed`` commitment, or raise. + Returns the confirmation ``slot`` when the RPC reports one (the state-aware + open verifier pins its Channel-account read to this slot with + ``min_context_slot`` so it cannot observe a pre-confirmation snapshot). A + ``slot`` present in the status but not a non-negative integer is rejected; + a status that omits ``slot`` entirely confirms and returns ``None`` for + backward compatibility with callers that only need liveness. + ``label`` names the transaction in error messages ("open", "top-up", "settle"). A freshly broadcast signature commonly returns ``None`` from ``getSignatureStatuses`` for hundreds of milliseconds to seconds, so the @@ -780,7 +1135,18 @@ async def confirm_transaction_signature( # status once the transaction has landed; treat that as # confirmed, mirroring the TS helper. if level is None or level in ("confirmed", "finalized"): - return + # A ``slot`` echoed on the confirmed status pins the follow-on + # account read. Reject a present-but-malformed slot; accept a + # status that omits it (older callers do not need the slot). + if "slot" in status: + slot = status.get("slot") + if isinstance(slot, bool) or not isinstance(slot, int) or slot < 0: + raise PaymentError( + f"{label} tx {signature!r} confirmation response has an invalid slot", + code="transaction-not-confirmed", + ) + return slot + return None now = time.monotonic() if now >= deadline: diff --git a/python/src/solana_pay_kit/protocols/mpp/server/session_store.py b/python/src/solana_pay_kit/protocols/mpp/server/session_store.py index 59f8676f4..d7af01709 100644 --- a/python/src/solana_pay_kit/protocols/mpp/server/session_store.py +++ b/python/src/solana_pay_kit/protocols/mpp/server/session_store.py @@ -24,6 +24,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass, field, replace +from enum import StrEnum from typing import Any from solana_pay_kit._paycore.errors import PaymentError @@ -35,6 +36,7 @@ "ChannelState", "ListChannelsFilter", "ChannelMutator", + "SessionStoreDurability", "ChannelStore", "ProductionChannelStore", "is_production_channel_store", @@ -47,6 +49,23 @@ _ALLOW_INMEMORY_CHANNEL_STORE_ENV = "PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE" +class SessionStoreDurability(StrEnum): + """Explicit safety capability for session-channel state stores.""" + + EPHEMERAL = "ephemeral" + DURABLE_SHARED = "durable-shared" + + +def session_store_safety_message(store: ChannelStore) -> str: + """Return the production-safety error for an unsafe store capability.""" + if store.session_store_durability == SessionStoreDurability.EPHEMERAL: + return "ephemeral session store is unsafe off localnet; inject a durable shared ChannelStore" + return ( + "session store must explicitly declare durable shared capability off localnet; " + "inject a durable shared ChannelStore" + ) + + @dataclass class PendingDelivery: """One delivery the server has reserved against a channel but not yet @@ -152,6 +171,13 @@ class ChannelState: # distribution. Zero when unknown (e.g. pull sessions or trusted opens). open_slot: int = 0 + # Authoritative channel PDA salt read from on-chain state. + salt: int = 0 + + # Confirmed signature of a server-broadcast open. Retries reuse it rather + # than confirming a newly signed transaction that was never broadcast. + open_signature: str | None = None + # HighestVoucherSignature is the signature of the highest accepted voucher # (base58). Stored for idempotent replay detection. highest_voucher_signature: str | None = None @@ -232,6 +258,7 @@ def to_dict(self) -> dict[str, Any]: "cumulative": self.cumulative, "sealed": self.sealed, "open_slot": self.open_slot, + "salt": self.salt, "highest_voucher_signature": self.highest_voucher_signature, "highest_voucher_expires_at": self.highest_voucher_expires_at, "close_requested_at": self.close_requested_at, @@ -249,6 +276,8 @@ def to_dict(self) -> dict[str, Any]: # before the top-up replay fence stay byte-identical across SDKs. if self.consumed_top_up_signatures: d["consumed_top_up_signatures"] = list(self.consumed_top_up_signatures) + if self.open_signature is not None: + d["open_signature"] = self.open_signature return d @classmethod @@ -270,6 +299,8 @@ def from_dict(cls, data: dict[str, Any]) -> ChannelState: cumulative=int(data.get("cumulative", 0)), sealed=bool(data.get("sealed", False)), open_slot=int(data.get("open_slot", 0)), + salt=int(data.get("salt", 0)), + open_signature=data.get("open_signature"), highest_voucher_signature=data.get("highest_voucher_signature"), highest_voucher_expires_at=( None if data.get("highest_voucher_expires_at") is None else int(data["highest_voucher_expires_at"]) @@ -320,6 +351,10 @@ class ChannelStore: implementations. """ + # Custom stores must opt in to durable shared behavior. An omitted marker + # is treated as unsafe outside localnet. + session_store_durability: SessionStoreDurability | None = None + async def get_channel(self, channel_id: str) -> ChannelState | None: """Read a channel. Returns None when it does not exist.""" raise NotImplementedError @@ -442,6 +477,8 @@ class MemoryChannelStore(ChannelStore): in and out so callers never share memory with the store. """ + session_store_durability = SessionStoreDurability.EPHEMERAL + def __init__(self) -> None: # _data maps channel id to stored state. self._data: dict[str, ChannelState] = {} diff --git a/python/tests/test_rpc_methods.py b/python/tests/test_rpc_methods.py index 8eed388f9..494bcca53 100644 --- a/python/tests/test_rpc_methods.py +++ b/python/tests/test_rpc_methods.py @@ -105,6 +105,27 @@ async def test_get_transaction_returns_wrapped_value(): assert body["params"][1]["maxSupportedTransactionVersion"] == 0 +@pytest.mark.asyncio +async def test_get_transaction_forwards_base64_wire_options(): + rpc = _rpc({"result": {"slot": 101}, "id": 1}) + await rpc.get_transaction( + "sig", + encoding="base64", + commitment="finalized", + max_supported_transaction_version=1, + ) + + body = rpc._client.last_body # type: ignore[attr-defined] + assert body["params"] == [ + "sig", + { + "encoding": "base64", + "commitment": "finalized", + "maxSupportedTransactionVersion": 1, + }, + ] + + @pytest.mark.asyncio async def test_confirm_transaction_success(): payload = {"result": {"value": [{"confirmationStatus": "finalized", "err": None}]}, "id": 1} diff --git a/python/tests/test_session_onchain.py b/python/tests/test_session_onchain.py index 4ad28a7ee..967894016 100644 --- a/python/tests/test_session_onchain.py +++ b/python/tests/test_session_onchain.py @@ -596,6 +596,7 @@ class _OpenConfig: recipient: str max_cap: int operator: str = "" + settlement_window: int = 0 program_id: Pubkey | None = None splits: list[Split] = field(default_factory=list) diff --git a/python/tests/test_session_state_binding.py b/python/tests/test_session_state_binding.py new file mode 100644 index 000000000..4f6f4f111 --- /dev/null +++ b/python/tests/test_session_state_binding.py @@ -0,0 +1,464 @@ +from __future__ import annotations + +import base64 +import hashlib +import struct +from typing import Any + +import pytest +from solders.hash import Hash # type: ignore[import-untyped] +from solders.keypair import Keypair # type: ignore[import-untyped] +from solders.message import Message # type: ignore[import-untyped] +from solders.pubkey import Pubkey # type: ignore[import-untyped] +from solders.signature import Signature # type: ignore[import-untyped] +from solders.transaction import Transaction # type: ignore[import-untyped] + +from solana_pay_kit._paycore.errors import PaymentError +from solana_pay_kit._paycore.paymentchannels import PAYMENT_CHANNELS_PROGRAM_ID +from solana_pay_kit._paycore.solana import TOKEN_PROGRAM, resolve_mint +from solana_pay_kit.protocols.mpp._paymentchannels import OpenChannelParams, build_open_instruction, find_channel_pda +from solana_pay_kit.protocols.mpp.intents.session import OpenPayload +from solana_pay_kit.protocols.mpp.server.session import SessionConfig, SessionServer +from solana_pay_kit.protocols.mpp.server.session_method import SessionOptions, new_session +from solana_pay_kit.protocols.mpp.server.session_onchain import ( + confirm_transaction_signature, + fetch_and_bind_channel_account, +) +from solana_pay_kit.protocols.mpp.server.session_store import ( + ChannelMutator, + ChannelState, + ChannelStore, + ListChannelsFilter, + MemoryChannelStore, + ProductionChannelStore, +) +from solana_pay_kit.protocols.programs.paymentchannels.accounts.channel import Channel + + +def _wallet(seed: int) -> str: + return str(Keypair.from_seed(bytes([seed] * 32)).pubkey()) + + +def _signature(seed: int) -> str: + return str(Signature.from_bytes(bytes([seed] * 64))) + + +class _DelegatingChannelStore(ChannelStore): + """Process-local stand-in that carries the production marker so it passes + the off-localnet channel-store policy without being the bundled + MemoryChannelStore instance the policy rejects.""" + + def __init__(self) -> None: + self._delegate = MemoryChannelStore() + + async def get_channel(self, channel_id: str) -> ChannelState | None: + return await self._delegate.get_channel(channel_id) + + async def update_channel(self, channel_id: str, mutator: ChannelMutator) -> ChannelState: + return await self._delegate.update_channel(channel_id, mutator) + + async def delete_channel(self, channel_id: str) -> None: + await self._delegate.delete_channel(channel_id) + + async def list_channels(self, filter: ListChannelsFilter | None = None) -> list[ChannelState]: + return await self._delegate.list_channels(filter) + + async def mark_sealed(self, channel_id: str) -> ChannelState: + return await self._delegate.mark_sealed(channel_id) + + +class _ProductionChannelStore(_DelegatingChannelStore, ProductionChannelStore): + """Application-asserted production backend accepted off localnet.""" + + +def _channel_bytes( + *, + deposit: int, + payer: str, + payee: str, + signer: str, + mint: str, + status: int = 0, + settled: int = 0, + payout_watermark: int = 0, + grace_period: int = 900, + distribution_hash: bytes | None = None, +) -> bytes: + if distribution_hash is None: + distribution_hash = hashlib.sha256(struct.pack(" tuple[str, str]: + operator = Keypair.from_seed(bytes([1] * 32)) + payer_keypair = Keypair.from_seed(bytes([4] * 32)) + instruction = build_open_instruction( + OpenChannelParams( + payer=Pubkey.from_string(payer), + rent_payer=operator.pubkey(), + payee=Pubkey.from_string(payee), + mint=Pubkey.from_string(mint), + authorized_signer=Pubkey.from_string(signer), + salt=7, + deposit=deposit, + grace_period=900, + open_slot=42, + token_program=Pubkey.from_string(TOKEN_PROGRAM), + ) + ) + blockhash = Hash.from_string("EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N") + message = Message.new_with_blockhash([instruction], operator.pubkey(), blockhash) + transaction = Transaction([operator, payer_keypair], message, blockhash) + encoded = base64.b64encode(bytes(transaction)).decode("ascii") + return encoded, str(transaction.signatures[0]) + + +class _Rpc: + def __init__(self) -> None: + self.accounts: dict[str, tuple[bytes, str] | None] = {} + self.status: dict | None = {"err": None, "confirmationStatus": "confirmed", "slot": 42} + self.transaction: dict | None = None + self.min_context_slot: int | None = None + + async def get_account_info( + self, address: str, commitment: str = "confirmed", min_context_slot: int | None = None + ) -> tuple[bytes, str] | None: + self.min_context_slot = min_context_slot + return self.accounts.get(address) + + async def get_signature_statuses( + self, signatures: list[str], *, search_transaction_history: bool = False + ) -> list[dict | None]: + return [self.status for _ in signatures] + + async def get_transaction(self, signature: str, **kwargs): # noqa: ANN003, ANN201 + return self.transaction + + async def get_latest_blockhash(self, commitment: str = "confirmed"): # noqa: ANN201 + raise NotImplementedError + + async def send_raw_transaction(self, raw_tx: bytes): # noqa: ANN201 + raise NotImplementedError + + +async def test_bare_push_open_persists_authoritative_channel_deposit_and_payer() -> None: + rpc = _Rpc() + recipient = _wallet(1) + signer = _wallet(3) + payer = _wallet(4) + mint = resolve_mint("USDC", "mainnet") + assert mint is not None + channel_id = str( + find_channel_pda( + Pubkey.from_string(payer), + Pubkey.from_string(recipient), + Pubkey.from_string(mint), + Pubkey.from_string(signer), + 7, + 42, + Pubkey.from_string(PAYMENT_CHANNELS_PROGRAM_ID), + )[0] + ) + rpc.accounts[channel_id] = ( + _channel_bytes(deposit=4_000, payer=payer, payee=recipient, signer=signer, mint=mint), + PAYMENT_CHANNELS_PROGRAM_ID, + ) + transaction, signature = _open_transaction( + deposit=4_000, payer=payer, payee=recipient, signer=signer, mint=mint + ) + rpc.transaction = {"meta": {"err": None}, "transaction": [transaction, "base64"]} + store = _ProductionChannelStore() + session = new_session( + SessionOptions( + operator=recipient, + recipient=recipient, + cap=10_000, + currency="USDC", + network="mainnet", + secret_key="test-secret", + realm="api.test", + rpc=rpc, + store=store, + ) + ) + payload = OpenPayload.payment_channel( + channel_id, "4000", _wallet(5), recipient, mint, 7, 900, 0, signer, signature + ) + payload.transaction = None + await session._handle_open(payload, challenge_recent_slot=42) + state = await store.get_channel(channel_id) + assert state is not None + assert state.deposit == 4_000 + assert state.operator == payer + assert state.open_slot == 42 + assert state.salt == 7 + + +async def test_bare_push_open_rejects_asserted_deposit_mismatch() -> None: + rpc = _Rpc() + recipient = _wallet(1) + signer = _wallet(3) + payer = _wallet(4) + mint = resolve_mint("USDC", "mainnet") + assert mint is not None + channel_id = str( + find_channel_pda( + Pubkey.from_string(payer), + Pubkey.from_string(recipient), + Pubkey.from_string(mint), + Pubkey.from_string(signer), + 7, + 42, + Pubkey.from_string(PAYMENT_CHANNELS_PROGRAM_ID), + )[0] + ) + rpc.accounts[channel_id] = ( + _channel_bytes(deposit=4_000, payer=payer, payee=recipient, signer=signer, mint=mint), + PAYMENT_CHANNELS_PROGRAM_ID, + ) + transaction, signature = _open_transaction( + deposit=4_000, payer=payer, payee=recipient, signer=signer, mint=mint + ) + rpc.transaction = {"meta": {"err": None}, "transaction": [transaction, "base64"]} + store = _ProductionChannelStore() + session = new_session( + SessionOptions( + operator=recipient, + recipient=recipient, + cap=10_000, + currency="USDC", + network="mainnet", + secret_key="test-secret", + realm="api.test", + rpc=rpc, + store=store, + ) + ) + payload = OpenPayload.payment_channel( + channel_id, "1000", _wallet(5), recipient, mint, 7, 900, 0, signer, signature + ) + payload.transaction = None + + with pytest.raises(PaymentError, match="asserted deposit"): + await session._handle_open(payload, challenge_recent_slot=42) + assert await store.get_channel(channel_id) is None + + +async def test_bare_push_open_rejects_channel_from_wrong_challenge_incarnation() -> None: + """A signature-only open must bind the confirmed channel openSlot to the + challenge recentSlot, even when the payload omits or forges that echo.""" + rpc = _Rpc() + recipient = _wallet(1) + signer = _wallet(3) + payer = _wallet(4) + mint = resolve_mint("USDC", "mainnet") + assert mint is not None + channel_id = str( + find_channel_pda( + Pubkey.from_string(payer), + Pubkey.from_string(recipient), + Pubkey.from_string(mint), + Pubkey.from_string(signer), + 7, + 42, + Pubkey.from_string(PAYMENT_CHANNELS_PROGRAM_ID), + )[0] + ) + rpc.accounts[channel_id] = ( + _channel_bytes(deposit=4_000, payer=payer, payee=recipient, signer=signer, mint=mint), + PAYMENT_CHANNELS_PROGRAM_ID, + ) + transaction, signature = _open_transaction( + deposit=4_000, payer=payer, payee=recipient, signer=signer, mint=mint + ) + rpc.transaction = {"meta": {"err": None}, "transaction": [transaction, "base64"]} + store = _ProductionChannelStore() + session = new_session( + SessionOptions( + operator=recipient, + recipient=recipient, + cap=10_000, + currency="USDC", + network="mainnet", + secret_key="test-secret", + realm="api.test", + rpc=rpc, + store=store, + ) + ) + payload = OpenPayload.payment_channel( + channel_id, "4000", _wallet(10), recipient, mint, 99, 900, 0, signer, signature + ) + payload.transaction = None + payload.recent_slot = None + + with pytest.raises(PaymentError, match="recentSlot 43 .*openSlot 42"): + await session._handle_open(payload, challenge_recent_slot=43) + assert await store.get_channel(channel_id) is None + + +async def test_core_direct_construction_rejects_nonlocalnet_bypasses() -> None: + recipient = _wallet(31) + payload = OpenPayload.push(_wallet(32), "1000", _wallet(33), _signature(34)) + config = SessionConfig(recipient=recipient, max_cap=10_000, currency="USDC", network="mainnet") + + # The bundled process-local store is rejected at construction off localnet, + # so a direct SessionServer cannot bypass the factory's store policy. + with pytest.raises(PaymentError, match="ProductionChannelStore"): + SessionServer(config, MemoryChannelStore()) + + # A production-marked store constructs, but a payment-channel open off + # localnet still fails closed without the authoritative state verifier: the + # payload's claimed economics are never persisted as facts. + store = _ProductionChannelStore() + with pytest.raises(ValueError, match="requires an authoritative verifier"): + await SessionServer(config, store).process_open(payload) + + async def signature_only_without_state(_payload: OpenPayload) -> None: + return None + + # A legacy structural/payload-only verifier does not lift the requirement. + config.verify_open_tx = signature_only_without_state + with pytest.raises(ValueError, match="requires an authoritative verifier"): + await SessionServer(config, store).process_open(payload) + + config.verify_open_state_tx = signature_only_without_state # type: ignore[assignment] + with pytest.raises(ValueError, match="authoritative channel facts"): + await SessionServer(config, store).process_open(payload) + + +async def test_preloaded_ephemeral_store_cannot_serve_state_operations_off_localnet() -> None: + config = SessionConfig(recipient=_wallet(51), max_cap=10_000, currency="USDC", network="mainnet") + # An ephemeral store can never back money-path state operations off localnet + # because the deployment policy rejects it at construction. + with pytest.raises(PaymentError, match="ProductionChannelStore"): + SessionServer(config, MemoryChannelStore()) + + +async def test_processed_signature_rejected_and_account_read_is_slot_pinned() -> None: + rpc = _Rpc() + rpc.status = {"err": None, "confirmationStatus": "processed", "slot": 88} + with pytest.raises(PaymentError, match="not confirmed"): + await confirm_transaction_signature(rpc, _signature(35), "open", timeout_seconds=0) + + for invalid_slot in (None, -1, True, "88"): + rpc.status = {"err": None, "confirmationStatus": "confirmed", "slot": invalid_slot} + with pytest.raises(PaymentError, match="confirmation response has an invalid slot"): + await confirm_transaction_signature(rpc, _signature(35), "open", timeout_seconds=0) + + rpc.status = {"err": None, "confirmationStatus": "confirmed", "slot": 88} + recipient = _wallet(36) + signer = _wallet(38) + payer = _wallet(39) + mint = resolve_mint("USDC", "mainnet") + assert mint is not None + channel_id = str( + find_channel_pda( + Pubkey.from_string(payer), + Pubkey.from_string(recipient), + Pubkey.from_string(mint), + Pubkey.from_string(signer), + 7, + 42, + Pubkey.from_string(PAYMENT_CHANNELS_PROGRAM_ID), + )[0] + ) + rpc.accounts[channel_id] = ( + _channel_bytes(deposit=2_000, payer=payer, payee=recipient, signer=signer, mint=mint), + PAYMENT_CHANNELS_PROGRAM_ID, + ) + await fetch_and_bind_channel_account( + rpc, + channel_id, + program_id=None, + max_cap=10_000, + expected_authorized_signer=signer, + expected_payee=recipient, + expected_mint=mint, + expected_operator=recipient, + min_context_slot=88, + ) + assert rpc.min_context_slot == 88 + + +async def test_channel_account_rejects_invalid_discriminator_version_and_length() -> None: + rpc = _Rpc() + recipient = _wallet(41) + channel_id = _wallet(42) + signer = _wallet(43) + payer = _wallet(44) + mint = resolve_mint("USDC", "mainnet") + assert mint is not None + valid = _channel_bytes(deposit=2_000, payer=payer, payee=recipient, signer=signer, mint=mint) + malformed = [bytes([9]) + valid[1:], valid[:1] + bytes([9]) + valid[2:], valid[:-1]] + for data in malformed: + rpc.accounts[channel_id] = (data, PAYMENT_CHANNELS_PROGRAM_ID) + with pytest.raises(PaymentError): + await fetch_and_bind_channel_account( + rpc, + channel_id, + program_id=None, + max_cap=10_000, + expected_authorized_signer=signer, + expected_payee=recipient, + expected_mint=mint, + expected_operator=recipient, + min_context_slot=1, + ) + + +@pytest.mark.parametrize( + "overrides", + [ + {"settled": 1}, + {"payout_watermark": 1}, + {"grace_period": 901}, + {"distribution_hash": bytes([0xAA] * 32)}, + ], +) +async def test_channel_account_rejects_spent_or_economically_mismatched_state( + overrides: dict[str, Any], +) -> None: + rpc = _Rpc() + recipient = _wallet(61) + channel_id = _wallet(62) + signer = _wallet(63) + payer = _wallet(64) + mint = resolve_mint("USDC", "mainnet") + assert mint is not None + rpc.accounts[channel_id] = ( + _channel_bytes(deposit=2_000, payer=payer, payee=recipient, signer=signer, mint=mint, **overrides), + PAYMENT_CHANNELS_PROGRAM_ID, + ) + with pytest.raises(PaymentError): + await fetch_and_bind_channel_account( + rpc, + channel_id, + program_id=None, + max_cap=10_000, + expected_authorized_signer=signer, + expected_payee=recipient, + expected_mint=mint, + expected_operator=recipient, + min_context_slot=1, + ) diff --git a/python/tests/test_session_store.py b/python/tests/test_session_store.py index 18ff72cb7..d8badb717 100644 --- a/python/tests/test_session_store.py +++ b/python/tests/test_session_store.py @@ -314,6 +314,21 @@ def test_to_dict_emits_null_for_empty_delivery_lists() -> None: assert d["committed_deliveries"] is None +def test_round_trips_authoritative_open_identity() -> None: + state = ChannelState( + channel_id="c1", + authorized_signer="signer1", + open_signature="broadcast-signature", + open_slot=42, + salt=7, + ) + + restored = ChannelState.from_dict(state.to_dict()) + assert restored.open_signature == "broadcast-signature" + assert restored.open_slot == 42 + assert restored.salt == 7 + + def test_to_dict_emits_lists_when_deliveries_present() -> None: """Non-empty delivery lists still serialize as JSON arrays.""" state = ChannelState( diff --git a/rust/crates/kit/src/core/session.rs b/rust/crates/kit/src/core/session.rs index 3732be740..ff6516af0 100644 --- a/rust/crates/kit/src/core/session.rs +++ b/rust/crates/kit/src/core/session.rs @@ -231,6 +231,8 @@ mod tests { highest_voucher_expires_at: None, close_requested_at: None, open_slot: None, + salt: None, + open_signature: None, operator: None, next_delivery_sequence: 0, pending_deliveries: vec![], @@ -291,6 +293,8 @@ mod tests { highest_voucher_expires_at: None, close_requested_at: None, open_slot: None, + salt: None, + open_signature: None, operator: None, next_delivery_sequence: 0, pending_deliveries: vec![], diff --git a/rust/crates/kit/src/core/store.rs b/rust/crates/kit/src/core/store.rs index 3aa8041a0..fb522f0f2 100644 --- a/rust/crates/kit/src/core/store.rs +++ b/rust/crates/kit/src/core/store.rs @@ -45,6 +45,19 @@ pub enum StoreError { Serialization(String), } +/// Explicit safety capability for session-channel state stores. +/// +/// Custom stores default to [`SessionStoreDurability::Unknown`], which is +/// rejected off localnet. Production session servers require an affirmative +/// `DurableShared` declaration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SessionStoreDurability { + #[default] + Unknown, + Ephemeral, + DurableShared, +} + /// In-memory store backed by a HashMap. pub struct MemoryStore { data: std::sync::Mutex>, @@ -200,6 +213,14 @@ pub struct ChannelState { #[serde(default)] pub open_slot: Option, + /// Authoritative channel PDA salt read from on-chain state. + #[serde(default)] + pub salt: Option, + + /// Confirmed signature of a server-broadcast open, when supported. + #[serde(default)] + pub open_signature: Option, + /// Pull-mode only: the client's wallet pubkey (base58). /// /// `Some` for pull sessions (SPL delegation); `None` for push sessions. @@ -225,6 +246,12 @@ pub struct ChannelState { /// Implementations MUST guarantee that `advance_cumulative` is atomic to /// prevent double-spend under concurrent requests. pub trait ChannelStore: Send + Sync { + /// Explicit session-store capability. Unknown is safe-by-default for + /// custom implementations: production session construction rejects it. + fn session_store_durability(&self) -> SessionStoreDurability { + SessionStoreDurability::Unknown + } + fn get_channel( &self, channel_id: &str, @@ -292,6 +319,10 @@ impl MemoryChannelStore { } impl ChannelStore for MemoryChannelStore { + fn session_store_durability(&self) -> SessionStoreDurability { + SessionStoreDurability::Ephemeral + } + fn get_channel( &self, channel_id: &str, @@ -419,6 +450,8 @@ mod tests { highest_voucher_expires_at: None, close_requested_at: None, open_slot: None, + salt: None, + open_signature: None, operator: None, next_delivery_sequence: 0, pending_deliveries: vec![], diff --git a/rust/crates/kit/src/mpp/mod.rs b/rust/crates/kit/src/mpp/mod.rs index d2d6f02af..4fbda52cd 100644 --- a/rust/crates/kit/src/mpp/mod.rs +++ b/rust/crates/kit/src/mpp/mod.rs @@ -95,7 +95,7 @@ pub use protocol::solana::{ // Store types pub use store::{ ChannelState, ChannelStore, CommittedDelivery, MemoryChannelStore, MemoryStore, - PendingDelivery, Store, StoreError, + PendingDelivery, SessionStoreDurability, Store, StoreError, }; // Re-export crates callers need to use with the charge builder. diff --git a/rust/crates/kit/src/mpp/protocol/core/types.rs b/rust/crates/kit/src/mpp/protocol/core/types.rs index a3f17aa55..3273317c2 100644 --- a/rust/crates/kit/src/mpp/protocol/core/types.rs +++ b/rust/crates/kit/src/mpp/protocol/core/types.rs @@ -389,6 +389,7 @@ mod tests { fn base64url_json_decode_invalid_base64() { let b = Base64UrlJson::from_raw("!!!invalid!!!"); assert!(b.decode_value().is_err()); + assert!(b.decode::().is_err()); } #[test] @@ -396,6 +397,7 @@ mod tests { // base64url of "not json" let b = Base64UrlJson::from_raw(base64url_encode(b"not json")); assert!(b.decode_value().is_err()); + assert!(b.decode::().is_err()); } #[test] diff --git a/rust/crates/kit/src/mpp/server/session.rs b/rust/crates/kit/src/mpp/server/session.rs index 0b0e3c6cb..7d100b4dd 100644 --- a/rust/crates/kit/src/mpp/server/session.rs +++ b/rust/crates/kit/src/mpp/server/session.rs @@ -17,13 +17,9 @@ //! # Note on on-chain verification //! //! When `SessionConfig::rpc_url` is set, `process_open` (push mode) and -//! `process_topup` confirm the provided transaction signature on-chain via -//! `getSignatureStatuses` before persisting channel state. When `rpc_url` is -//! `None`, the transaction signature and deposit amount are trusted as -//! provided — suitable only for unit tests or deployments that verify -//! transactions out of band. Even with `rpc_url` set, the deposit amount is -//! taken from the payload; for full trustlessness, wire up RPC account -//! verification before persisting channel state. +//! `process_topup` confirm the provided transaction signature and bind the +//! persisted state to the resulting on-chain Channel account. Without an RPC, +//! those operations fail closed off localnet. //! //! Replayed `open` payloads for an existing channel are idempotent: they //! never reset the voucher watermark or any other channel state. @@ -40,7 +36,8 @@ use crate::mpp::protocol::intents::session::{ }; use crate::mpp::protocol::solana::{default_token_program_for_currency, resolve_stablecoin_mint}; use crate::mpp::store::{ - ChannelState, ChannelStore, CommittedDelivery, PendingDelivery, StoreError, + ChannelState, ChannelStore, CommittedDelivery, PendingDelivery, SessionStoreDurability, + StoreError, }; // ── Configuration ── @@ -131,10 +128,14 @@ pub struct SessionConfig { /// Solana RPC URL for on-chain open-transaction verification. /// - /// When set, `process_open` calls `getSignatureStatuses` to confirm the - /// open transaction was accepted by the network before persisting channel - /// state. Leave `None` in unit tests or when you want to skip verification. + /// Required for payment-channel opens and top-ups outside localnet. Those + /// paths bind the confirmed transaction to its Channel account before + /// persisting any state. pub rpc_url: Option, + + /// Explicit development escape hatch for a process-local channel store + /// outside localnet. Production deployments should always leave this off. + pub allow_unsafe_ephemeral_store_off_localnet: bool, } impl Default for SessionConfig { @@ -153,6 +154,7 @@ impl Default for SessionConfig { modes: vec![SessionMode::Push], pull_voucher_strategy: None, rpc_url: None, + allow_unsafe_ephemeral_store_off_localnet: false, } } } @@ -486,6 +488,7 @@ impl SessionServer { /// existing channel are rejected when the channel is sealed or when the /// payload's authorized signer differs from the stored one. pub async fn process_open(&self, payload: &OpenPayload) -> Result { + self.require_production_session_safety()?; let supports_mode = if self.config.modes.is_empty() { payload.mode == SessionMode::Push } else { @@ -514,27 +517,298 @@ impl SessionServer { ))); } - // On-chain verification: confirm the open transaction was accepted. - // - // Pull mode: host integrations submit server-broadcast transactions or - // validate delegated-token state before invoking this lower-level store - // method. Skip tx-sig verification here. + // Confirm the signature and bind every persisted payment-channel fact + // to the authoritative on-chain account. // - // Push mode: verify the payment-channel open tx is confirmed before persisting. - if payload.mode == SessionMode::Push { - if let Some(ref rpc_url) = self.config.rpc_url { - verify_transaction_signature(&payload.signature, rpc_url, VerifiedTx::Open).map_err(|e| { - tracing::warn!(signature = %payload.signature, %e, "open tx verification failed"); - e - })?; - tracing::debug!(signature = %payload.signature, "open tx confirmed on-chain"); + // Any transaction-backed open is a payment-channel open, including + // clientVoucher pull mode, and must bind the same authoritative state. + let mut bound_deposit = deposit; + let mut bound_payer = payload.owner.clone().or_else(|| payload.payer.clone()); + let mut open_slot = payload.recent_slot; + let mut bound_salt = payload.salt; + let payment_channel_backed = + payload.mode == SessionMode::Push || payload.transaction.is_some(); + let compact_payment_channel_open = payload.payer.is_none() + && payload.payee.is_none() + && payload.mint.is_none() + && payload.salt.is_none() + && payload.grace_period.is_none() + && payload.recent_slot.is_none(); + if payment_channel_backed { + match self.config.rpc_url.as_deref() { + Some(rpc_url) => { + if compact_payment_channel_open { + let bound_signature = bound_client_open_signature(payload)?; + let confirmed_slot = verify_transaction_signature( + bound_signature, + rpc_url, + VerifiedTx::Open, + ) + .map_err(|e| { + tracing::warn!(signature = %payload.signature, %e, "open tx verification failed"); + e + })?; + let channel_pda = parse_pubkey_field(session_id, "channelId")?; + let program_id = self + .config + .program_id + .unwrap_or_else(payment_channels::default_program_id); + let channel = fetch_channel_account( + rpc_url, + &channel_pda, + &program_id, + confirmed_slot, + )?; + if channel.status != CHANNEL_STATUS_OPEN { + return Err(Error::Other(format!( + "channel {session_id} is not open on-chain (status {})", + channel.status + ))); + } + if payment_channels::from_address(&channel.mint) + != expected_payment_channel_mint(&self.config)? + { + return Err(Error::Other("on-chain channel mint mismatch".to_string())); + } + if payment_channels::from_address(&channel.payee) + != parse_pubkey_field(&self.config.recipient, "recipient")? + { + return Err(Error::Other( + "on-chain channel payee mismatch".to_string(), + )); + } + if payment_channels::from_address(&channel.authorized_signer) + != parse_pubkey_field(&payload.authorized_signer, "authorizedSigner")? + { + return Err(Error::Other( + "on-chain channel authorized_signer mismatch".to_string(), + )); + } + if payment_channels::from_address(&channel.rent_payer) + != parse_required_operator(&self.config.operator)? + { + return Err(Error::Other( + "on-chain channel rent_payer mismatch".to_string(), + )); + } + if channel.deposit != deposit { + return Err(Error::Other(format!( + "on-chain channel deposit {} != asserted deposit {deposit}", + channel.deposit + ))); + } + if channel.settlement.settled != 0 + || channel.settlement.payout_watermark != 0 + { + return Err(Error::Other( + "channel has nonzero settlement watermarks".to_string(), + )); + } + if channel.grace_period != self.config.grace_period_seconds { + return Err(Error::Other(format!( + "on-chain channel grace_period {} != expected {}", + channel.grace_period, self.config.grace_period_seconds + ))); + } + let expected_distribution: Vec = self + .config + .splits + .iter() + .map(|split| payment_channels::Distribution { + recipient: split.recipient, + bps: split.bps, + }) + .collect(); + if channel.distribution_hash + != payment_channels::distribution_hash(&expected_distribution) + { + return Err(Error::Other( + "on-chain channel distribution_hash does not match session splits" + .to_string(), + )); + } + if channel.deposit > self.config.max_cap { + return Err(Error::Other(format!( + "on-chain channel deposit {} exceeds max cap {}", + channel.deposit, self.config.max_cap + ))); + } + let authoritative_params = payment_channels::OpenChannelParams { + payer: payment_channels::from_address(&channel.payer), + rent_payer: payment_channels::from_address(&channel.rent_payer), + payee: payment_channels::from_address(&channel.payee), + mint: payment_channels::from_address(&channel.mint), + authorized_signer: payment_channels::from_address( + &channel.authorized_signer, + ), + salt: channel.salt, + deposit: channel.deposit, + grace_period: channel.grace_period, + open_slot: channel.open_slot, + recipients: expected_distribution, + token_program: parse_pubkey_field( + default_token_program_for_currency( + &self.config.currency, + Some(self.config.network.as_str()), + ), + "token program", + )?, + program_id, + }; + let authoritative_channel = + payment_channels::derive_channel_addresses(&authoritative_params) + .channel; + if authoritative_channel != channel_pda { + return Err(Error::Other( + "channel account does not match PDA derived from authoritative state" + .to_string(), + )); + } + verify_open_transaction(rpc_url, bound_signature, &authoritative_params)?; + bound_deposit = channel.deposit; + bound_payer = Some(payment_channels::pubkey_string( + &payment_channels::from_address(&channel.payer), + )); + open_slot = Some(channel.open_slot); + bound_salt = Some(channel.salt); + } else { + let params = self.payment_channel_open_params(payload)?; + let bound_signature = bound_client_open_signature(payload)?; + let confirmed_slot = verify_transaction_signature( + bound_signature, + rpc_url, + VerifiedTx::Open, + ) + .map_err(|e| { + tracing::warn!(signature = %payload.signature, %e, "open tx verification failed"); + e + })?; + verify_open_transaction(rpc_url, bound_signature, ¶ms)?; + let channel_pda = parse_pubkey_field(session_id, "channelId")?; + let channel = fetch_channel_account( + rpc_url, + &channel_pda, + ¶ms.program_id, + confirmed_slot, + )?; + if channel.status != CHANNEL_STATUS_OPEN { + return Err(Error::Other(format!( + "channel {session_id} is not open on-chain (status {})", + channel.status + ))); + } + if payment_channels::from_address(&channel.mint) != params.mint { + return Err(Error::Other("on-chain channel mint mismatch".to_string())); + } + if payment_channels::from_address(&channel.payee) != params.payee { + return Err(Error::Other( + "on-chain channel payee mismatch".to_string(), + )); + } + if payment_channels::from_address(&channel.authorized_signer) + != params.authorized_signer + { + return Err(Error::Other( + "on-chain channel authorized_signer mismatch".to_string(), + )); + } + if payment_channels::from_address(&channel.payer) != params.payer { + return Err(Error::Other( + "on-chain channel payer mismatch".to_string(), + )); + } + if payment_channels::from_address(&channel.rent_payer) != params.rent_payer + { + return Err(Error::Other( + "on-chain channel rent_payer mismatch".to_string(), + )); + } + if channel.settlement.settled != 0 + || channel.settlement.payout_watermark != 0 + { + return Err(Error::Other( + "channel has nonzero settlement watermarks".to_string(), + )); + } + if channel.grace_period != self.config.grace_period_seconds { + return Err(Error::Other(format!( + "on-chain channel grace_period {} != expected {}", + channel.grace_period, self.config.grace_period_seconds + ))); + } + let expected_distribution: Vec = self + .config + .splits + .iter() + .map(|split| payment_channels::Distribution { + recipient: split.recipient, + bps: split.bps, + }) + .collect(); + if channel.distribution_hash + != payment_channels::distribution_hash(&expected_distribution) + { + return Err(Error::Other( + "on-chain channel distribution_hash does not match session splits" + .to_string(), + )); + } + let authoritative_params = payment_channels::OpenChannelParams { + payer: payment_channels::from_address(&channel.payer), + rent_payer: payment_channels::from_address(&channel.rent_payer), + payee: payment_channels::from_address(&channel.payee), + mint: payment_channels::from_address(&channel.mint), + authorized_signer: payment_channels::from_address( + &channel.authorized_signer, + ), + salt: channel.salt, + deposit: channel.deposit, + grace_period: channel.grace_period, + open_slot: channel.open_slot, + recipients: expected_distribution, + token_program: params.token_program, + program_id: params.program_id, + }; + let authoritative_channel = + payment_channels::derive_channel_addresses(&authoritative_params) + .channel; + if authoritative_channel != channel_pda { + return Err(Error::Other( + "channel account does not match PDA derived from authoritative state" + .to_string(), + )); + } + if channel.deposit == 0 { + return Err(Error::Other( + "on-chain channel deposit is zero".to_string(), + )); + } + if channel.deposit > self.config.max_cap { + return Err(Error::Other(format!( + "on-chain channel deposit {} exceeds max cap {}", + channel.deposit, self.config.max_cap + ))); + } + bound_deposit = channel.deposit; + bound_payer = Some(payment_channels::pubkey_string(¶ms.payer)); + open_slot = Some(channel.open_slot); + bound_salt = Some(channel.salt); + } + } + None if self.config.network != "localnet" => { + return Err(Error::Other( + "payment-channel push open requires an rpc_url to bind the on-chain channel off localnet" + .to_string(), + )); + } + None => {} } } let fresh_state = ChannelState { channel_id: session_id.to_string(), authorized_signer: payload.authorized_signer.clone(), - deposit, + deposit: bound_deposit, cumulative: 0, sealed: false, highest_voucher_signature: None, @@ -544,8 +818,10 @@ impl SessionServer { // gate evaluated later (the payload's `recentSlot` is the // program's openSlot). `None` for opens that don't carry it // (pull/legacy payloads). - open_slot: payload.recent_slot, - operator: payload.owner.clone().or_else(|| payload.payer.clone()), + open_slot, + salt: bound_salt, + open_signature: None, + operator: bound_payer, next_delivery_sequence: 0, pending_deliveries: vec![], committed_deliveries: vec![], @@ -600,6 +876,7 @@ impl SessionServer { /// /// Uses atomic read-modify-write to prevent double-spend under concurrent requests. pub async fn verify_voucher(&self, payload: &VoucherPayload) -> Result { + self.require_production_session_safety()?; let voucher = &payload.voucher; let new_cumulative: u64 = voucher .data @@ -639,19 +916,134 @@ impl SessionServer { /// signature and deposit amount are trusted as-is; only use that mode in /// unit tests or when the caller verifies the transaction out of band. pub async fn process_topup(&self, payload: &TopUpPayload) -> Result { + self.require_production_session_safety()?; let new_deposit: u64 = payload .new_deposit .parse() .map_err(|_| Error::Other("Invalid new_deposit".to_string()))?; - - // On-chain verification: confirm the top-up transaction was accepted - // (same RPC path as process_open). - if let Some(ref rpc_url) = self.config.rpc_url { - verify_transaction_signature(&payload.signature, rpc_url, VerifiedTx::TopUp).map_err(|e| { - tracing::warn!(signature = %payload.signature, %e, "top-up tx verification failed"); - e - })?; - tracing::debug!(signature = %payload.signature, "top-up tx confirmed on-chain"); + if self.config.rpc_url.is_none() && self.config.network != "localnet" { + return Err(Error::Other( + "payment-channel top-up requires an rpc_url to bind the on-chain channel off localnet" + .to_string(), + )); + } + let existing = self + .store + .get_channel(&payload.channel_id) + .await + .map_err(store_err)? + .ok_or_else(|| Error::Other(format!("Channel {} not found", payload.channel_id)))?; + let topup_delta = new_deposit.checked_sub(existing.deposit).ok_or_else(|| { + Error::Other(format!( + "New deposit {new_deposit} must exceed current deposit {}", + existing.deposit + )) + })?; + + match self.config.rpc_url.as_deref() { + Some(rpc_url) => { + let confirmed_slot = verify_transaction_signature(&payload.signature, rpc_url, VerifiedTx::TopUp) + .map_err(|e| { + tracing::warn!(signature = %payload.signature, %e, "top-up tx verification failed"); + e + })?; + let channel_pda = parse_pubkey_field(&payload.channel_id, "channelId")?; + let program_id = self + .config + .program_id + .unwrap_or_else(payment_channels::default_program_id); + if self.config.network != "localnet" { + verify_top_up_transaction( + rpc_url, + &payload.signature, + &payload.channel_id, + &program_id, + topup_delta, + )?; + } + let channel = + fetch_channel_account(rpc_url, &channel_pda, &program_id, confirmed_slot)?; + if channel.status != CHANNEL_STATUS_OPEN { + return Err(Error::Other(format!( + "channel {} is not open on-chain (status {})", + payload.channel_id, channel.status + ))); + } + if payment_channels::from_address(&channel.mint) + != expected_payment_channel_mint(&self.config)? + { + return Err(Error::Other("on-chain channel mint mismatch".to_string())); + } + if payment_channels::from_address(&channel.payee) + != parse_pubkey_field(&self.config.recipient, "recipient")? + { + return Err(Error::Other("on-chain channel payee mismatch".to_string())); + } + if payment_channels::pubkey_string(&payment_channels::from_address( + &channel.rent_payer, + )) != self.config.operator + { + return Err(Error::Other( + "on-chain channel rent_payer mismatch".to_string(), + )); + } + if payment_channels::pubkey_string(&payment_channels::from_address( + &channel.authorized_signer, + )) != existing.authorized_signer + { + return Err(Error::Other( + "on-chain channel authorized_signer does not match stored channel" + .to_string(), + )); + } + let stored_payer = existing + .operator + .as_deref() + .ok_or_else(|| Error::Other("stored channel payer is missing".to_string()))?; + if payment_channels::pubkey_string(&payment_channels::from_address(&channel.payer)) + != stored_payer + { + return Err(Error::Other( + "on-chain channel payer does not match stored channel".to_string(), + )); + } + if channel.grace_period != self.config.grace_period_seconds { + return Err(Error::Other(format!( + "on-chain channel grace_period {} != expected {}", + channel.grace_period, self.config.grace_period_seconds + ))); + } + let expected_distribution: Vec = self + .config + .splits + .iter() + .map(|split| payment_channels::Distribution { + recipient: split.recipient, + bps: split.bps, + }) + .collect(); + if channel.distribution_hash + != payment_channels::distribution_hash(&expected_distribution) + { + return Err(Error::Other( + "on-chain channel distribution_hash does not match session splits" + .to_string(), + )); + } + if channel.deposit != new_deposit { + return Err(Error::Other(format!( + "on-chain channel deposit {} != asserted new_deposit {new_deposit}", + channel.deposit + ))); + } + } + None if self.config.network != "localnet" => { + return Err(Error::Other( + "payment-channel top-up requires an rpc_url to bind the on-chain channel off localnet" + .to_string(), + )); + } + None => {} } let max_cap = self.config.max_cap; @@ -693,9 +1085,27 @@ impl SessionServer { .map_err(store_err) } + fn require_production_session_safety(&self) -> Result<()> { + if self.config.network != "localnet" + && self.store.session_store_durability() != SessionStoreDurability::DurableShared + && !self.config.allow_unsafe_ephemeral_store_off_localnet + { + let message = if self.store.session_store_durability() + == SessionStoreDurability::Ephemeral + { + "ephemeral session store is unsafe off localnet; inject a durable shared ChannelStore" + } else { + "session store must explicitly declare durable shared capability off localnet; inject a durable shared ChannelStore" + }; + return Err(Error::Other(message.to_string())); + } + Ok(()) + } + /// Reserve capacity for a delivered message/response and return the /// metering directive the client must commit after processing it. pub async fn begin_delivery(&self, request: DeliveryRequest) -> Result { + self.require_production_session_safety()?; if request.amount == 0 { return Err(Error::Other( "Delivery amount must be greater than zero".to_string(), @@ -734,18 +1144,34 @@ impl SessionServer { .to_string(), )); } - let pending_total = state - .pending_deliveries - .iter() - .map(|delivery| delivery.amount) - .sum::(); - if state.cumulative + pending_total + amount > state.deposit { + let pending_total = + state + .pending_deliveries + .iter() + .try_fold(0u64, |sum, delivery| { + sum.checked_add(delivery.amount).ok_or_else(|| { + StoreError::Internal( + "Pending delivery total overflow".to_string(), + ) + }) + })?; + let reserved = state + .cumulative + .checked_add(pending_total) + .and_then(|value| value.checked_add(amount)) + .ok_or_else(|| { + StoreError::Internal("Delivery capacity overflow".to_string()) + })?; + if reserved > state.deposit { return Err(StoreError::Internal(format!( "Delivery amount {amount} exceeds available deposit" ))); } - let sequence = state.next_delivery_sequence + 1; + let sequence = + state.next_delivery_sequence.checked_add(1).ok_or_else(|| { + StoreError::Internal("Delivery sequence overflow".to_string()) + })?; let delivery_id = requested_delivery_id .clone() .unwrap_or_else(|| format!("{session_id}:{sequence}")); @@ -798,6 +1224,7 @@ impl SessionServer { /// Commit a reserved delivery by verifying the attached voucher and /// advancing the settled watermark. pub async fn process_commit(&self, payload: &CommitPayload) -> Result { + self.require_production_session_safety()?; let channel_id = payload.voucher.data.channel_id.clone(); let new_cumulative: u64 = payload .voucher @@ -972,6 +1399,7 @@ impl SessionServer { /// Process a `close` action: atomically set close-pending, accept a final /// voucher if provided, and return the parameters needed for on-chain settlement. pub async fn process_close(&self, payload: &ClosePayload) -> Result { + self.require_production_session_safety()?; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -1064,6 +1492,7 @@ impl SessionServer { /// Return seal parameters for a channel ready for on-chain settlement. pub async fn seal_params(&self, channel_id: &str) -> Result { + self.require_production_session_safety()?; let state = self .store .get_channel(channel_id) @@ -1113,6 +1542,7 @@ impl SessionServer { /// Mark a channel as sealed (call after the on-chain seal tx confirms). pub async fn mark_sealed(&self, channel_id: &str) -> Result<()> { + self.require_production_session_safety()?; self.store.mark_sealed(channel_id).await.map_err(store_err) } } @@ -1140,6 +1570,30 @@ enum VerifiedTx { TopUp, } +#[cfg(feature = "server")] +fn bound_client_open_signature(payload: &OpenPayload) -> Result<&str> { + if let Some(transaction) = payload.transaction.as_deref() { + let decoded = payment_channels::decode_transaction(transaction)?; + let transaction_signature = decoded.signatures.first().ok_or_else(|| { + Error::Other("open transaction has no fee-payer signature slot".to_string()) + })?; + if *transaction_signature == solana_signature::Signature::default() { + return Err(Error::Other( + "Rust session server does not broadcast partially signed opens; broadcast the completed transaction and provide its signature" + .to_string(), + )); + } + let transaction_signature = transaction_signature.to_string(); + if payload.signature != transaction_signature { + return Err(Error::Other(format!( + "open payload signature {} does not match transaction signature {transaction_signature}", + payload.signature + ))); + } + } + Ok(&payload.signature) +} + #[cfg(feature = "server")] impl std::fmt::Display for VerifiedTx { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -1157,7 +1611,7 @@ impl std::fmt::Display for VerifiedTx { /// Returns an error if the signature is malformed, the tx was rejected, or /// the tx is not found (not yet processed or doesn't exist). #[cfg(feature = "server")] -fn verify_transaction_signature(sig_str: &str, rpc_url: &str, tx: VerifiedTx) -> Result<()> { +fn verify_transaction_signature(sig_str: &str, rpc_url: &str, tx: VerifiedTx) -> Result { use solana_rpc_client::rpc_client::RpcClient; use solana_signature::Signature; use std::str::FromStr; @@ -1167,16 +1621,339 @@ fn verify_transaction_signature(sig_str: &str, rpc_url: &str, tx: VerifiedTx) -> let rpc = RpcClient::new(rpc_url.to_string()); - match rpc - .get_signature_status(&sig) + let response = rpc + .get_signature_statuses_with_history(&[sig]) .map_err(|e| Error::Other(format!("RPC error verifying {tx} tx: {e}")))? + .value + .into_iter() + .next() + .flatten() + .ok_or_else(|| { + Error::Other(format!( + "{tx} tx '{sig_str}' not found; not yet confirmed or does not exist" + )) + })?; + if let Some(error) = response.err { + return Err(Error::Other(format!( + "{tx} tx was rejected on-chain: {error:?}" + ))); + } + let level = response + .confirmation_status + .ok_or_else(|| Error::Other(format!("{tx} tx '{sig_str}' has no confirmation status")))?; + if !matches!( + level, + solana_transaction_status_client_types::TransactionConfirmationStatus::Confirmed + | solana_transaction_status_client_types::TransactionConfirmationStatus::Finalized + ) { + return Err(Error::Other(format!( + "{tx} tx '{sig_str}' is only processed" + ))); + } + Ok(response.slot) +} + +#[cfg(feature = "server")] +const CHANNEL_STATUS_OPEN: u8 = 0; + +#[cfg(feature = "server")] +fn fetch_channel_account( + rpc_url: &str, + channel_id: &Pubkey, + program_id: &Pubkey, + min_context_slot: u64, +) -> Result { + use solana_rpc_client::rpc_client::RpcClient; + use solana_rpc_client_api::config::RpcAccountInfoConfig; + + let response = RpcClient::new(rpc_url.to_string()) + .get_ui_account_with_config( + channel_id, + RpcAccountInfoConfig { + commitment: Some(solana_commitment_config::CommitmentConfig::confirmed()), + min_context_slot: Some(min_context_slot), + ..RpcAccountInfoConfig::default() + }, + ) + .map_err(|e| Error::Other(format!("channel account fetch failed: {e}")))?; + let account = response + .value + .ok_or_else(|| Error::Other("channel account not found".to_string()))?; + let owner = parse_pubkey(&account.owner)?; + let data = account + .data + .decode() + .ok_or_else(|| Error::Other("channel account data is undecodable".to_string()))?; + if owner != *program_id { + return Err(Error::Other(format!( + "channel is owned by {} instead of payment-channels program {program_id}", + owner + ))); + } + if data.len() != 256 { + return Err(Error::Other(format!( + "channel account has invalid length {}", + data.len() + ))); + } + let channel = payment_channels::generated::accounts::Channel::from_bytes(&data) + .map_err(|e| Error::Other(format!("channel decode failed: {e}")))?; + if channel.discriminator != 1 { + return Err(Error::Other(format!( + "channel has invalid discriminator {}", + channel.discriminator + ))); + } + if channel.version != 1 { + return Err(Error::Other(format!( + "channel has unsupported version {}", + channel.version + ))); + } + Ok(channel) +} + +#[cfg(feature = "server")] +fn verify_top_up_transaction( + rpc_url: &str, + signature: &str, + channel_id: &str, + program_id: &Pubkey, + expected_delta: u64, +) -> Result<()> { + use solana_rpc_client::rpc_client::RpcClient; + use solana_rpc_client_api::config::RpcTransactionConfig; + use solana_signature::Signature; + use solana_transaction_status_client_types::option_serializer::OptionSerializer; + use solana_transaction_status_client_types::UiTransactionEncoding; + use std::str::FromStr; + + let signature = Signature::from_str(signature) + .map_err(|e| Error::Other(format!("invalid top-up signature: {e}")))?; + let transaction = RpcClient::new(rpc_url.to_string()) + .get_transaction_with_config( + &signature, + RpcTransactionConfig { + encoding: Some(UiTransactionEncoding::Base64), + commitment: Some(solana_commitment_config::CommitmentConfig::confirmed()), + max_supported_transaction_version: Some(0), + }, + ) + .map_err(|e| Error::Other(format!("fetch top-up transaction: {e}")))?; + if transaction + .transaction + .meta + .as_ref() + .and_then(|meta| meta.err.as_ref()) + .is_some() + { + return Err(Error::Other( + "top-up transaction failed on-chain".to_string(), + )); + } + let decoded = transaction + .transaction + .transaction + .decode() + .ok_or_else(|| { + Error::Other("top-up transaction is not valid base64 wire data".to_string()) + })?; + let loaded_addresses = match transaction.transaction.meta.as_ref() { + Some(meta) => match &meta.loaded_addresses { + OptionSerializer::Some(addresses) => addresses + .writable + .iter() + .chain(&addresses.readonly) + .map(|address| { + address.parse::().map_err(|e| { + Error::Other(format!( + "top-up transaction has invalid loaded address: {e}" + )) + }) + }) + .collect::>>()?, + OptionSerializer::None | OptionSerializer::Skip => Vec::new(), + }, + None => Vec::new(), + }; + let channel_id = channel_id + .parse::() + .map_err(|e| Error::Other(format!("invalid top-up channel id: {e}")))?; + verify_top_up_wire_transaction( + &decoded, + &loaded_addresses, + &channel_id, + program_id, + expected_delta, + ) +} + +#[cfg(feature = "server")] +fn verify_top_up_wire_transaction( + transaction: &solana_transaction::versioned::VersionedTransaction, + loaded_addresses: &[Pubkey], + channel_id: &Pubkey, + program_id: &Pubkey, + expected_delta: u64, +) -> Result<()> { + let mut account_keys = transaction.message.static_account_keys().to_vec(); + account_keys.extend_from_slice(loaded_addresses); + let mut matches = 0usize; + let mut total = 0u64; + for instruction in transaction.message.instructions() { + if account_keys.get(usize::from(instruction.program_id_index)) != Some(program_id) { + continue; + } + if instruction.data.first().copied() != Some(3) { + continue; + } + let channel_index = *instruction + .accounts + .get(1) + .ok_or_else(|| Error::Other("top-up instruction has invalid accounts".to_string()))?; + if account_keys.get(usize::from(channel_index)) != Some(channel_id) { + continue; + } + let amount_bytes: [u8; 8] = instruction + .data + .get(1..9) + .ok_or_else(|| Error::Other("top-up instruction has invalid length".to_string()))? + .try_into() + .map_err(|_| Error::Other("top-up instruction has invalid length".to_string()))?; + if instruction.data.len() != 9 { + return Err(Error::Other( + "top-up instruction has trailing data".to_string(), + )); + } + total = total + .checked_add(u64::from_le_bytes(amount_bytes)) + .ok_or_else(|| Error::Other("top-up instruction amount overflow".to_string()))?; + matches += 1; + } + if matches != 1 { + return Err(Error::Other(format!( + "expected exactly one top-up instruction, found {matches}" + ))); + } + if total != expected_delta { + return Err(Error::Other(format!( + "top-up amount {total} != expected delta {expected_delta}" + ))); + } + Ok(()) +} + +#[cfg(feature = "server")] +fn verify_open_transaction( + rpc_url: &str, + signature: &str, + params: &payment_channels::OpenChannelParams, +) -> Result<()> { + use solana_rpc_client::rpc_client::RpcClient; + use solana_rpc_client_api::config::RpcTransactionConfig; + use solana_signature::Signature; + use solana_transaction_status_client_types::option_serializer::OptionSerializer; + use solana_transaction_status_client_types::UiTransactionEncoding; + use std::str::FromStr; + + let signature = Signature::from_str(signature) + .map_err(|e| Error::Other(format!("invalid open signature: {e}")))?; + let transaction = RpcClient::new(rpc_url.to_string()) + .get_transaction_with_config( + &signature, + RpcTransactionConfig { + encoding: Some(UiTransactionEncoding::Base64), + commitment: Some(solana_commitment_config::CommitmentConfig::confirmed()), + max_supported_transaction_version: Some(0), + }, + ) + .map_err(|e| Error::Other(format!("fetch open transaction: {e}")))?; + if transaction + .transaction + .meta + .as_ref() + .and_then(|meta| meta.err.as_ref()) + .is_some() { - Some(Ok(())) => Ok(()), - Some(Err(e)) => Err(Error::Other(format!("{tx} tx was rejected on-chain: {e}"))), - None => Err(Error::Other(format!( - "{tx} tx '{sig_str}' not found — not yet confirmed or does not exist" - ))), + return Err(Error::Other("open transaction failed on-chain".to_string())); + } + let decoded = transaction + .transaction + .transaction + .decode() + .ok_or_else(|| { + Error::Other("open transaction is not valid base64 wire data".to_string()) + })?; + let loaded_addresses = match transaction.transaction.meta.as_ref() { + Some(meta) => match &meta.loaded_addresses { + OptionSerializer::Some(addresses) => addresses + .writable + .iter() + .chain(&addresses.readonly) + .map(|address| { + address.parse::().map_err(|e| { + Error::Other(format!("open transaction has invalid loaded address: {e}")) + }) + }) + .collect::>>()?, + OptionSerializer::None | OptionSerializer::Skip => Vec::new(), + }, + None => Vec::new(), + }; + verify_open_wire_transaction( + &decoded, + &loaded_addresses, + &payment_channels::build_open_instruction(params), + ) +} + +#[cfg(feature = "server")] +fn verify_open_wire_transaction( + transaction: &solana_transaction::versioned::VersionedTransaction, + loaded_addresses: &[Pubkey], + expected: &solana_instruction::Instruction, +) -> Result<()> { + let mut account_keys = transaction.message.static_account_keys().to_vec(); + account_keys.extend_from_slice(loaded_addresses); + let mut matches = 0usize; + + for instruction in transaction.message.instructions() { + let program_id = account_keys + .get(usize::from(instruction.program_id_index)) + .ok_or_else(|| Error::Other("open instruction program id out of range".to_string()))?; + if *program_id != expected.program_id { + continue; + } + let accounts = instruction + .accounts + .iter() + .map(|index| { + account_keys + .get(usize::from(*index)) + .copied() + .ok_or_else(|| { + Error::Other("open instruction account out of range".to_string()) + }) + }) + .collect::>>()?; + if accounts + == expected + .accounts + .iter() + .map(|account| account.pubkey) + .collect::>() + && instruction.data == expected.data + { + matches += 1; + } } + + if matches != 1 { + return Err(Error::Other(format!( + "expected exactly one exact payment-channel open instruction, found {matches}" + ))); + } + Ok(()) } fn parse_pubkey(s: &str) -> Result { @@ -1276,12 +2053,103 @@ pub fn compute_distribution_hash(_recipient: &Pubkey, splits: &[(Pubkey, u16)]) #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "client")] + use crate::mpp::protocol::intents::session::SessionAction; use crate::mpp::protocol::intents::session::{ ClosePayload, CommitPayload, CommitStatus, OpenPayload, SessionMode, SessionPullVoucherStrategy, VoucherData, VoucherPayload, }; use crate::mpp::store::MemoryChannelStore; + struct TestStore { + durability: SessionStoreDurability, + } + + impl TestStore { + fn new(durability: SessionStoreDurability) -> Self { + Self { durability } + } + } + + impl ChannelStore for TestStore { + fn session_store_durability(&self) -> SessionStoreDurability { + self.durability + } + + fn get_channel( + &self, + _channel_id: &str, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = std::result::Result, StoreError>, + > + Send + + '_, + >, + > { + Box::pin(async { Err(StoreError::Internal("test store was used".to_string())) }) + } + + fn put_channel( + &self, + _channel_id: &str, + _state: ChannelState, + ) -> std::pin::Pin< + Box> + Send + '_>, + > { + Box::pin(async { Err(StoreError::Internal("test store was used".to_string())) }) + } + + fn update_channel( + &self, + _channel_id: &str, + _updater: Box< + dyn FnOnce(Option) -> std::result::Result + + Send, + >, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + '_, + >, + > { + Box::pin(async { Err(StoreError::Internal("test store was used".to_string())) }) + } + + fn advance_cumulative( + &self, + _channel_id: &str, + _expected: u64, + _new: u64, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + Send + '_, + >, + > { + Box::pin(async { Err(StoreError::Internal("test store was used".to_string())) }) + } + + fn update_deposit( + &self, + _channel_id: &str, + _new_deposit: u64, + ) -> std::pin::Pin< + Box> + Send + '_>, + > { + Box::pin(async { Err(StoreError::Internal("test store was used".to_string())) }) + } + + fn mark_sealed( + &self, + _channel_id: &str, + ) -> std::pin::Pin< + Box> + Send + '_>, + > { + Box::pin(async { Err(StoreError::Internal("test store was used".to_string())) }) + } + } + const RECIPIENT: &str = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY"; fn make_server() -> SessionServer { @@ -1300,6 +2168,7 @@ mod tests { modes: vec![SessionMode::Push], pull_voucher_strategy: None, rpc_url: None, + allow_unsafe_ephemeral_store_off_localnet: false, }, MemoryChannelStore::new(), ) @@ -1321,6 +2190,7 @@ mod tests { modes: vec![SessionMode::Push], pull_voucher_strategy: None, rpc_url: None, + allow_unsafe_ephemeral_store_off_localnet: false, }, MemoryChannelStore::new(), ) @@ -1921,6 +2791,7 @@ mod tests { modes: vec![SessionMode::Push], pull_voucher_strategy: None, rpc_url: None, + allow_unsafe_ephemeral_store_off_localnet: false, }; let server = SessionServer::new(config, MemoryChannelStore::new()); let req = server.build_challenge_request(5_000_000); @@ -1944,6 +2815,7 @@ mod tests { modes: vec![SessionMode::Push], pull_voucher_strategy: None, rpc_url: None, + allow_unsafe_ephemeral_store_off_localnet: false, }; let server = SessionServer::new(config, MemoryChannelStore::new()); let req = server.build_challenge_request(5_000_000); @@ -1973,6 +2845,7 @@ mod tests { modes: vec![SessionMode::Push, SessionMode::Pull], pull_voucher_strategy: Some(SessionPullVoucherStrategy::ClientVoucher), rpc_url: None, + allow_unsafe_ephemeral_store_off_localnet: false, }; let server = SessionServer::new(config, MemoryChannelStore::new()); let req = server.build_challenge_request(1_000_000); @@ -2330,6 +3203,1216 @@ mod tests { ); } + #[cfg(feature = "server")] + struct SessionAccountRpc { + url: String, + stop: std::sync::Arc, + thread: Option>, + } + + #[cfg(feature = "server")] + impl SessionAccountRpc { + fn start(account_data: Vec, owner: Pubkey) -> Self { + Self::start_with_transaction(account_data, owner, None) + } + + fn start_with_transaction( + account_data: Vec, + owner: Pubkey, + transaction: Option>, + ) -> Self { + use base64::Engine as _; + use std::io::{Read, Write}; + use std::sync::atomic::Ordering; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let thread_stop = stop.clone(); + let data = base64::engine::general_purpose::STANDARD.encode(account_data); + let owner = payment_channels::pubkey_string(&owner); + let transaction = transaction + .map(|transaction| base64::engine::general_purpose::STANDARD.encode(transaction)); + let thread = std::thread::spawn(move || { + while !thread_stop.load(Ordering::Relaxed) { + let (mut stream, _) = match listener.accept() { + Ok(value) => value, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(5)); + continue; + } + Err(_) => break, + }; + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(1))); + let mut request = Vec::new(); + let mut buf = [0u8; 4096]; + loop { + match stream.read(&mut buf) { + Ok(0) => break, + Ok(read) => { + request.extend_from_slice(&buf[..read]); + if let Some(header_end) = + request.windows(4).position(|w| w == b"\r\n\r\n") + { + let headers = String::from_utf8_lossy(&request[..header_end]); + let length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|value| { + value.trim().parse::().ok() + }) + }) + .unwrap_or(0); + if request.len() >= header_end + 4 + length { + break; + } + } + } + Err(_) => break, + } + } + let body_start = request + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map(|i| i + 4); + let request_json: serde_json::Value = body_start + .and_then(|start| serde_json::from_slice(&request[start..]).ok()) + .unwrap_or_default(); + let id = request_json + .get("id") + .cloned() + .unwrap_or(serde_json::Value::from(1)); + let result = match request_json.get("method").and_then(|value| value.as_str()) { + Some("getSignatureStatuses") => serde_json::json!({ + "context": {"slot": 1}, + "value": [{ + "slot": 1, + "confirmations": null, + "status": {"Ok": null}, + "err": null, + "confirmationStatus": "finalized" + }] + }), + Some("getAccountInfo") => serde_json::json!({ + "context": {"slot": 1}, + "value": { + "data": [data, "base64"], + "executable": false, + "lamports": 1, + "owner": owner, + "rentEpoch": 0, + "space": 0 + } + }), + Some("getTransaction") => { + transaction + .as_ref() + .map_or(serde_json::Value::Null, |transaction| { + serde_json::json!({ + "slot": 1, + "transaction": [transaction, "base64"], + "meta": null, + "version": "legacy", + "blockTime": null + }) + }) + } + _ => serde_json::json!({ + "context": {"slot": 1}, + "value": [{ + "slot": 1, + "confirmations": null, + "status": {"Ok": null}, + "err": null, + "confirmationStatus": "finalized" + }] + }), + }; + let response = + serde_json::json!({"jsonrpc": "2.0", "id": id, "result": result}) + .to_string(); + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + response.len() + ); + let _ = stream.write_all(headers.as_bytes()); + let _ = stream.write_all(response.as_bytes()); + } + }); + Self { + url: format!("http://{address}"), + stop, + thread: Some(thread), + } + } + } + + #[cfg(feature = "server")] + impl Drop for SessionAccountRpc { + fn drop(&mut self) { + use std::sync::atomic::Ordering; + self.stop.store(true, Ordering::Relaxed); + let _ = std::net::TcpStream::connect(self.url.trim_start_matches("http://")); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } + } + + #[cfg(feature = "server")] + fn encoded_channel( + status: u8, + deposit: u64, + payer: Pubkey, + payee: Pubkey, + signer: Pubkey, + mint: Pubkey, + ) -> Vec { + use crate::generated::payment_channels::generated::accounts::Channel; + use crate::generated::payment_channels::generated::types::SettlementWatermarks; + + borsh::to_vec(&Channel { + discriminator: 1, + version: 1, + bump: 255, + status, + salt: 7, + deposit, + settlement: SettlementWatermarks { + settled: 0, + payout_watermark: 0, + }, + closure_started_at: 0, + payer_withdrawn_at: 0, + grace_period: payment_channels::DEFAULT_GRACE_PERIOD_SECONDS, + distribution_hash: payment_channels::distribution_hash(&[]), + payer: payment_channels::to_address(&payer), + payee: payment_channels::to_address(&payee), + authorized_signer: payment_channels::to_address(&signer), + mint: payment_channels::to_address(&mint), + rent_payer: payment_channels::to_address(&payee), + open_slot: 42, + }) + .unwrap() + } + + #[cfg(feature = "server")] + fn stored_topup_channel(channel_id: String, payer: Pubkey, signer: Pubkey) -> ChannelState { + ChannelState { + channel_id, + authorized_signer: payment_channels::pubkey_string(&signer), + deposit: 1_000, + cumulative: 0, + sealed: false, + highest_voucher_signature: None, + highest_voucher_expires_at: None, + close_requested_at: None, + open_slot: Some(42), + salt: Some(7), + open_signature: None, + operator: Some(payment_channels::pubkey_string(&payer)), + next_delivery_sequence: 0, + pending_deliveries: vec![], + committed_deliveries: vec![], + } + } + + #[cfg(feature = "server")] + fn signed_open_transaction(params: &payment_channels::OpenChannelParams) -> (Vec, String) { + let mut transaction = + solana_transaction::Transaction::new_unsigned(solana_message::Message::new( + &[payment_channels::build_open_instruction(params)], + Some(¶ms.rent_payer), + )); + let signature = solana_signature::Signature::from([9u8; 64]); + transaction.signatures[0] = signature; + ( + bincode::serialize(&solana_transaction::versioned::VersionedTransaction::from( + transaction, + )) + .unwrap(), + signature.to_string(), + ) + } + + #[cfg(feature = "server")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn process_open_binds_authoritative_channel_account() { + let base = make_server(); + let payer = Pubkey::new_unique(); + let payee = parse_pubkey(&base.config.recipient).unwrap(); + let signer = Pubkey::new_unique(); + let mint = expected_payment_channel_mint(&base.config).unwrap(); + let params = payment_channels::OpenChannelParams { + payer, + rent_payer: payee, + payee, + mint, + authorized_signer: signer, + salt: 7, + deposit: 1_000, + grace_period: payment_channels::DEFAULT_GRACE_PERIOD_SECONDS, + open_slot: 42, + recipients: vec![], + token_program: parse_pubkey(default_token_program_for_currency( + "USDC", + Some("localnet"), + )) + .unwrap(), + program_id: payment_channels::default_program_id(), + }; + let channel_id = payment_channels::derive_channel_addresses(¶ms).channel; + let open_instruction = payment_channels::build_open_instruction(¶ms); + let mut open_transaction = solana_transaction::Transaction::new_unsigned( + solana_message::Message::new(&[open_instruction], Some(&payee)), + ); + open_transaction.signatures[0] = solana_signature::Signature::from([9u8; 64]); + let open_transaction = bincode::serialize( + &solana_transaction::versioned::VersionedTransaction::from(open_transaction), + ) + .unwrap(); + let rpc = SessionAccountRpc::start_with_transaction( + encoded_channel(CHANNEL_STATUS_OPEN, 4_000, payer, payee, signer, mint), + params.program_id, + Some(open_transaction.clone()), + ); + let server = SessionServer::new( + SessionConfig { + rpc_url: Some(rpc.url.clone()), + modes: vec![SessionMode::Pull], + ..base.config + }, + MemoryChannelStore::new(), + ); + let mut payload = OpenPayload::payment_channel( + payment_channels::pubkey_string(&channel_id), + "1000".to_string(), + payment_channels::pubkey_string(&payer), + payment_channels::pubkey_string(&payee), + payment_channels::pubkey_string(&mint), + 7, + payment_channels::DEFAULT_GRACE_PERIOD_SECONDS, + 42, + payment_channels::pubkey_string(&signer), + bs58::encode([9u8; 64]).into_string(), + ); + payload.mode = SessionMode::Pull; + payload.transaction = Some(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + open_transaction, + )); + let state = server.process_open(&payload).await.unwrap(); + assert_eq!(state.deposit, 4_000); + assert_eq!(state.salt, Some(7)); + assert_eq!( + state.operator.as_deref(), + Some(payment_channels::pubkey_string(&payer).as_str()) + ); + } + + #[cfg(all(feature = "server", feature = "client"))] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn process_open_accepts_signature_only_public_open_action() { + let base = make_server(); + let payer = Pubkey::new_unique(); + let payee = parse_pubkey(&base.config.recipient).unwrap(); + let (mut active, authorized_signer, _, _) = make_e2e_session(); + let signer = parse_pubkey(&authorized_signer).unwrap(); + let mint = expected_payment_channel_mint(&base.config).unwrap(); + let params = payment_channels::OpenChannelParams { + payer, + rent_payer: payee, + payee, + mint, + authorized_signer: signer, + salt: 7, + deposit: 4_000, + grace_period: payment_channels::DEFAULT_GRACE_PERIOD_SECONDS, + open_slot: 42, + recipients: vec![], + token_program: parse_pubkey(default_token_program_for_currency( + "USDC", + Some("localnet"), + )) + .unwrap(), + program_id: payment_channels::default_program_id(), + }; + let channel_id = payment_channels::derive_channel_addresses(¶ms).channel; + active.channel_id = channel_id; + let (wire, signature) = signed_open_transaction(¶ms); + let rpc = SessionAccountRpc::start_with_transaction( + encoded_channel(CHANNEL_STATUS_OPEN, 4_000, payer, payee, signer, mint), + params.program_id, + Some(wire), + ); + let payload = match active.open_action(4_000, &signature) { + SessionAction::Open(payload) => payload, + _ => panic!("expected open action"), + }; + let server = SessionServer::new( + SessionConfig { + rpc_url: Some(rpc.url.clone()), + ..base.config + }, + MemoryChannelStore::new(), + ); + + let state = server.process_open(&payload).await.unwrap(); + assert_eq!(state.deposit, 4_000); + assert_eq!(state.open_slot, Some(42)); + assert_eq!(state.salt, Some(7)); + assert_eq!( + state.operator.as_deref(), + Some(payment_channels::pubkey_string(&payer).as_str()) + ); + } + + #[cfg(all(feature = "server", feature = "client"))] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn process_open_accepts_signature_only_public_payment_channel_action() { + let base = make_server(); + let payer = Pubkey::new_unique(); + let payee = parse_pubkey(&base.config.recipient).unwrap(); + let (mut active, authorized_signer, _, _) = make_e2e_session(); + let signer = parse_pubkey(&authorized_signer).unwrap(); + let mint = expected_payment_channel_mint(&base.config).unwrap(); + let params = payment_channels::OpenChannelParams { + payer, + rent_payer: payee, + payee, + mint, + authorized_signer: signer, + salt: 7, + deposit: 4_000, + grace_period: payment_channels::DEFAULT_GRACE_PERIOD_SECONDS, + open_slot: 42, + recipients: vec![], + token_program: parse_pubkey(default_token_program_for_currency( + "USDC", + Some("localnet"), + )) + .unwrap(), + program_id: payment_channels::default_program_id(), + }; + let channel_id = payment_channels::derive_channel_addresses(¶ms).channel; + active.channel_id = channel_id; + let (wire, signature) = signed_open_transaction(¶ms); + let rpc = SessionAccountRpc::start_with_transaction( + encoded_channel(CHANNEL_STATUS_OPEN, 4_000, payer, payee, signer, mint), + params.program_id, + Some(wire), + ); + let payload = match active.open_payment_channel_action( + 4_000, + &payment_channels::pubkey_string(&payer), + &payment_channels::pubkey_string(&payee), + &payment_channels::pubkey_string(&mint), + 7, + payment_channels::DEFAULT_GRACE_PERIOD_SECONDS, + 42, + &signature, + ) { + SessionAction::Open(payload) => payload, + _ => panic!("expected open action"), + }; + let server = SessionServer::new( + SessionConfig { + rpc_url: Some(rpc.url.clone()), + ..base.config + }, + MemoryChannelStore::new(), + ); + + let state = server.process_open(&payload).await.unwrap(); + assert_eq!(state.deposit, 4_000); + assert_eq!(state.open_slot, Some(42)); + assert_eq!(state.salt, Some(7)); + } + + #[cfg(all(feature = "server", feature = "client"))] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn process_open_rejects_signature_only_open_payload_mismatch() { + let base = make_server(); + let payer = Pubkey::new_unique(); + let payee = parse_pubkey(&base.config.recipient).unwrap(); + let (mut active, _, _, _) = make_e2e_session(); + let authorized_signer = active.authorized_signer(); + let signer = parse_pubkey(&authorized_signer).unwrap(); + let mint = expected_payment_channel_mint(&base.config).unwrap(); + let params = payment_channels::OpenChannelParams { + payer, + rent_payer: payee, + payee, + mint, + authorized_signer: signer, + salt: 7, + deposit: 4_000, + grace_period: payment_channels::DEFAULT_GRACE_PERIOD_SECONDS, + open_slot: 42, + recipients: vec![], + token_program: parse_pubkey(default_token_program_for_currency( + "USDC", + Some("localnet"), + )) + .unwrap(), + program_id: payment_channels::default_program_id(), + }; + let channel_id = payment_channels::derive_channel_addresses(¶ms).channel; + active.channel_id = channel_id; + let (wire, signature) = signed_open_transaction(¶ms); + let rpc = SessionAccountRpc::start_with_transaction( + encoded_channel(CHANNEL_STATUS_OPEN, 4_000, payer, payee, signer, mint), + params.program_id, + Some(wire), + ); + let payload = match active.open_action(3_000, &signature) { + SessionAction::Open(payload) => payload, + _ => panic!("expected open action"), + }; + let server = SessionServer::new( + SessionConfig { + rpc_url: Some(rpc.url.clone()), + ..base.config + }, + MemoryChannelStore::new(), + ); + + let error = server.process_open(&payload).await.unwrap_err(); + assert!(error.to_string().contains("asserted deposit")); + } + + #[cfg(all(feature = "server", feature = "client"))] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn process_open_rejects_signature_only_open_with_unrelated_transaction() { + let base = make_server(); + let payer = Pubkey::new_unique(); + let payee = parse_pubkey(&base.config.recipient).unwrap(); + let (mut active, _, _, _) = make_e2e_session(); + let authorized_signer = active.authorized_signer(); + let signer = parse_pubkey(&authorized_signer).unwrap(); + let mint = expected_payment_channel_mint(&base.config).unwrap(); + let params = payment_channels::OpenChannelParams { + payer, + rent_payer: payee, + payee, + mint, + authorized_signer: signer, + salt: 7, + deposit: 4_000, + grace_period: payment_channels::DEFAULT_GRACE_PERIOD_SECONDS, + open_slot: 42, + recipients: vec![], + token_program: parse_pubkey(default_token_program_for_currency( + "USDC", + Some("localnet"), + )) + .unwrap(), + program_id: payment_channels::default_program_id(), + }; + let channel_id = payment_channels::derive_channel_addresses(¶ms).channel; + active.channel_id = channel_id; + let unrelated = solana_instruction::Instruction { + program_id: Pubkey::new_unique(), + accounts: vec![], + data: vec![], + }; + let mut transaction = solana_transaction::Transaction::new_unsigned( + solana_message::Message::new(&[unrelated], Some(&payee)), + ); + let signature = solana_signature::Signature::from([9u8; 64]); + transaction.signatures[0] = signature; + let wire = bincode::serialize(&solana_transaction::versioned::VersionedTransaction::from( + transaction, + )) + .unwrap(); + let rpc = SessionAccountRpc::start_with_transaction( + encoded_channel(CHANNEL_STATUS_OPEN, 4_000, payer, payee, signer, mint), + params.program_id, + Some(wire), + ); + let payload = match active.open_action(4_000, &signature.to_string()) { + SessionAction::Open(payload) => payload, + _ => panic!("expected open action"), + }; + let server = SessionServer::new( + SessionConfig { + rpc_url: Some(rpc.url.clone()), + ..base.config + }, + MemoryChannelStore::new(), + ); + + let error = server.process_open(&payload).await.unwrap_err(); + assert!(error.to_string().contains("exact payment-channel open")); + } + + #[cfg(feature = "server")] + fn transaction_bound_open_payload( + transaction_signature: solana_signature::Signature, + claimed_signature: String, + ) -> OpenPayload { + let payer = Pubkey::new_unique(); + let mut transaction = solana_transaction::Transaction::new_unsigned( + solana_message::Message::new(&[], Some(&payer)), + ); + transaction.signatures[0] = transaction_signature; + let transaction = solana_transaction::versioned::VersionedTransaction::from(transaction); + let mut payload = open_payload("channel", 1_000, "signer"); + payload.signature = claimed_signature; + payload.transaction = Some(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + bincode::serialize(&transaction).unwrap(), + )); + payload + } + + #[cfg(feature = "server")] + #[test] + fn open_transaction_signature_binding_rejects_placeholder_and_mismatch() { + let placeholder = transaction_bound_open_payload( + solana_signature::Signature::default(), + solana_signature::Signature::default().to_string(), + ); + assert!(bound_client_open_signature(&placeholder) + .unwrap_err() + .to_string() + .contains("does not broadcast partially signed opens")); + + let transaction_signature = solana_signature::Signature::from([9u8; 64]); + let mismatched = transaction_bound_open_payload( + transaction_signature, + solana_signature::Signature::from([8u8; 64]).to_string(), + ); + assert!(bound_client_open_signature(&mismatched) + .unwrap_err() + .to_string() + .contains("does not match transaction signature")); + } + + #[cfg(feature = "server")] + #[test] + fn open_wire_verifier_rejects_unrelated_confirmed_transaction() { + let payer = Pubkey::new_unique(); + let params = payment_channels::OpenChannelParams { + payer, + rent_payer: Pubkey::new_unique(), + payee: Pubkey::new_unique(), + mint: Pubkey::new_unique(), + authorized_signer: Pubkey::new_unique(), + salt: 7, + open_slot: 42, + deposit: 1_000, + grace_period: payment_channels::DEFAULT_GRACE_PERIOD_SECONDS, + recipients: vec![], + token_program: Pubkey::new_unique(), + program_id: payment_channels::default_program_id(), + }; + let unrelated = solana_instruction::Instruction { + program_id: Pubkey::new_unique(), + accounts: vec![], + data: vec![], + }; + let transaction = solana_transaction::versioned::VersionedTransaction::from( + solana_transaction::Transaction::new_unsigned(solana_message::Message::new( + &[unrelated], + Some(&payer), + )), + ); + + let error = verify_open_wire_transaction( + &transaction, + &[], + &payment_channels::build_open_instruction(¶ms), + ) + .unwrap_err(); + assert!(error.to_string().contains("exact payment-channel open")); + } + + #[cfg(feature = "server")] + #[test] + fn open_wire_verifier_accepts_exact_v0_open_with_loaded_address() { + let params = payment_channels::OpenChannelParams { + payer: Pubkey::new_unique(), + rent_payer: Pubkey::new_unique(), + payee: Pubkey::new_unique(), + mint: Pubkey::new_unique(), + authorized_signer: Pubkey::new_unique(), + salt: 7, + open_slot: 42, + deposit: 1_000, + grace_period: payment_channels::DEFAULT_GRACE_PERIOD_SECONDS, + recipients: vec![], + token_program: Pubkey::new_unique(), + program_id: payment_channels::default_program_id(), + }; + let expected = payment_channels::build_open_instruction(¶ms); + let loaded_channel = expected.accounts[5].pubkey; + let lookup_table = solana_message::AddressLookupTableAccount { + key: Pubkey::new_unique(), + addresses: vec![loaded_channel], + }; + let message = solana_message::v0::Message::try_compile( + ¶ms.rent_payer, + std::slice::from_ref(&expected), + &[lookup_table], + solana_hash::Hash::default(), + ) + .unwrap(); + assert!(!message.address_table_lookups.is_empty()); + let transaction = solana_transaction::versioned::VersionedTransaction { + signatures: vec![ + solana_signature::Signature::default(); + message.header.num_required_signatures as usize + ], + message: solana_message::VersionedMessage::V0(message), + }; + + verify_open_wire_transaction(&transaction, &[loaded_channel], &expected).unwrap(); + } + + #[cfg(feature = "server")] + #[test] + fn top_up_wire_verifier_binds_compiled_instruction() { + let payer = Pubkey::new_unique(); + let channel = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let token_program = Pubkey::new_unique(); + let program_id = payment_channels::default_program_id(); + let instruction = payment_channels::build_top_up_instruction( + &payer, + &channel, + &mint, + 1_000, + &token_program, + &program_id, + ); + let transaction = solana_transaction::versioned::VersionedTransaction::from( + solana_transaction::Transaction::new_unsigned(solana_message::Message::new( + std::slice::from_ref(&instruction), + Some(&payer), + )), + ); + + verify_top_up_wire_transaction(&transaction, &[], &channel, &program_id, 1_000).unwrap(); + assert!( + verify_top_up_wire_transaction(&transaction, &[], &channel, &program_id, 999) + .unwrap_err() + .to_string() + .contains("expected delta") + ); + + let duplicate = solana_transaction::versioned::VersionedTransaction::from( + solana_transaction::Transaction::new_unsigned(solana_message::Message::new( + &[instruction.clone(), instruction], + Some(&payer), + )), + ); + assert!( + verify_top_up_wire_transaction(&duplicate, &[], &channel, &program_id, 2_000) + .unwrap_err() + .to_string() + .contains("exactly one") + ); + } + + #[cfg(feature = "server")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn process_topup_rejects_resulting_deposit_mismatch() { + let base = make_server(); + let channel = Pubkey::new_unique(); + let channel_id = payment_channels::pubkey_string(&channel); + let payer = Pubkey::new_unique(); + let payee = parse_pubkey(&base.config.recipient).unwrap(); + let signer = Pubkey::new_unique(); + let mint = expected_payment_channel_mint(&base.config).unwrap(); + let program_id = payment_channels::default_program_id(); + let rpc = SessionAccountRpc::start( + encoded_channel(CHANNEL_STATUS_OPEN, 2_000, payer, payee, signer, mint), + program_id, + ); + let server = SessionServer::new( + SessionConfig { + rpc_url: Some(rpc.url.clone()), + ..base.config + }, + MemoryChannelStore::new(), + ); + let mut stored = ChannelState { + channel_id: channel_id.clone(), + authorized_signer: payment_channels::pubkey_string(&Pubkey::new_unique()), + deposit: 1_000, + cumulative: 0, + sealed: false, + highest_voucher_signature: None, + highest_voucher_expires_at: None, + close_requested_at: None, + open_slot: Some(42), + salt: Some(7), + open_signature: None, + operator: Some(payment_channels::pubkey_string(&payer)), + next_delivery_sequence: 0, + pending_deliveries: vec![], + committed_deliveries: vec![], + }; + server + .store + .put_channel(&channel_id, stored.clone()) + .await + .unwrap(); + + let identity_error = server + .process_topup(&TopUpPayload { + channel_id: channel_id.clone(), + new_deposit: "2000".to_string(), + signature: bs58::encode([10u8; 64]).into_string(), + }) + .await + .unwrap_err(); + assert!(identity_error + .to_string() + .contains("authorized_signer does not match stored channel")); + + stored.authorized_signer = payment_channels::pubkey_string(&signer); + server.store.put_channel(&channel_id, stored).await.unwrap(); + + let error = server + .process_topup(&TopUpPayload { + channel_id: channel_id.clone(), + new_deposit: "3000".to_string(), + signature: bs58::encode([10u8; 64]).into_string(), + }) + .await + .unwrap_err(); + assert!(error + .to_string() + .contains("on-chain channel deposit 2000 != asserted new_deposit 3000")); + assert_eq!( + server + .store + .get_channel(&channel_id) + .await + .unwrap() + .unwrap() + .deposit, + 1_000 + ); + } + + #[cfg(feature = "server")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn process_topup_rejects_grace_period_mismatch() { + let base = make_server(); + let channel = Pubkey::new_unique(); + let channel_id = payment_channels::pubkey_string(&channel); + let payer = Pubkey::new_unique(); + let payee = parse_pubkey(&base.config.recipient).unwrap(); + let signer = Pubkey::new_unique(); + let mint = expected_payment_channel_mint(&base.config).unwrap(); + let rpc = SessionAccountRpc::start( + encoded_channel(CHANNEL_STATUS_OPEN, 2_000, payer, payee, signer, mint), + payment_channels::default_program_id(), + ); + let server = SessionServer::new( + SessionConfig { + rpc_url: Some(rpc.url.clone()), + grace_period_seconds: payment_channels::DEFAULT_GRACE_PERIOD_SECONDS + 1, + ..base.config + }, + MemoryChannelStore::new(), + ); + server + .store + .put_channel( + &channel_id, + stored_topup_channel(channel_id.clone(), payer, signer), + ) + .await + .unwrap(); + + let error = server + .process_topup(&TopUpPayload { + channel_id: channel_id.clone(), + new_deposit: "2000".to_string(), + signature: bs58::encode([10u8; 64]).into_string(), + }) + .await + .unwrap_err(); + + assert!(error + .to_string() + .contains("on-chain channel grace_period 900 != expected 901")); + assert_eq!( + server + .store + .get_channel(&channel_id) + .await + .unwrap() + .unwrap() + .deposit, + 1_000 + ); + } + + #[cfg(feature = "server")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn process_topup_rejects_distribution_hash_mismatch() { + let base = make_server(); + let channel = Pubkey::new_unique(); + let channel_id = payment_channels::pubkey_string(&channel); + let payer = Pubkey::new_unique(); + let payee = parse_pubkey(&base.config.recipient).unwrap(); + let signer = Pubkey::new_unique(); + let mint = expected_payment_channel_mint(&base.config).unwrap(); + let rpc = SessionAccountRpc::start( + encoded_channel(CHANNEL_STATUS_OPEN, 2_000, payer, payee, signer, mint), + payment_channels::default_program_id(), + ); + let server = SessionServer::new( + SessionConfig { + rpc_url: Some(rpc.url.clone()), + splits: vec![Split { + recipient: Pubkey::new_unique(), + bps: 1_000, + }], + ..base.config + }, + MemoryChannelStore::new(), + ); + server + .store + .put_channel( + &channel_id, + stored_topup_channel(channel_id.clone(), payer, signer), + ) + .await + .unwrap(); + + let error = server + .process_topup(&TopUpPayload { + channel_id: channel_id.clone(), + new_deposit: "2000".to_string(), + signature: bs58::encode([10u8; 64]).into_string(), + }) + .await + .unwrap_err(); + + assert!(error + .to_string() + .contains("on-chain channel distribution_hash does not match session splits")); + assert_eq!( + server + .store + .get_channel(&channel_id) + .await + .unwrap() + .unwrap() + .deposit, + 1_000 + ); + } + + #[cfg(feature = "server")] + #[test] + fn channel_account_rejects_invalid_discriminator_version_and_length() { + let base = make_server(); + let payer = Pubkey::new_unique(); + let payee = parse_pubkey(&base.config.recipient).unwrap(); + let signer = Pubkey::new_unique(); + let mint = expected_payment_channel_mint(&base.config).unwrap(); + let program = payment_channels::default_program_id(); + let channel = Pubkey::new_unique(); + let valid = encoded_channel(CHANNEL_STATUS_OPEN, 1_000, payer, payee, signer, mint); + let mut bad_discriminator = valid.clone(); + bad_discriminator[0] = 9; + let mut bad_version = valid.clone(); + bad_version[1] = 9; + for data in [ + bad_discriminator, + bad_version, + valid[..valid.len() - 1].to_vec(), + ] { + let rpc = SessionAccountRpc::start(data, program); + assert!(fetch_channel_account(&rpc.url, &channel, &program, 1).is_err()); + } + } + + #[cfg(feature = "server")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn process_open_rejects_spent_or_economically_mismatched_channel_state() { + use crate::generated::payment_channels::generated::accounts::Channel; + + let base = make_server(); + let payer = Pubkey::new_unique(); + let payee = parse_pubkey(&base.config.recipient).unwrap(); + let signer = Pubkey::new_unique(); + let mint = expected_payment_channel_mint(&base.config).unwrap(); + let program_id = payment_channels::default_program_id(); + let params = payment_channels::OpenChannelParams { + payer, + rent_payer: payee, + payee, + mint, + authorized_signer: signer, + salt: 7, + deposit: 1_000, + grace_period: payment_channels::DEFAULT_GRACE_PERIOD_SECONDS, + open_slot: 42, + recipients: vec![], + token_program: parse_pubkey(default_token_program_for_currency( + "USDC", + Some("localnet"), + )) + .unwrap(), + program_id, + }; + let channel_id = payment_channels::derive_channel_addresses(¶ms).channel; + let payload = OpenPayload::payment_channel( + payment_channels::pubkey_string(&channel_id), + "1000".to_string(), + payment_channels::pubkey_string(&payer), + payment_channels::pubkey_string(&payee), + payment_channels::pubkey_string(&mint), + 7, + payment_channels::DEFAULT_GRACE_PERIOD_SECONDS, + 42, + payment_channels::pubkey_string(&signer), + bs58::encode([9u8; 64]).into_string(), + ); + + type ChannelMutation = Box; + let cases: Vec<(&str, ChannelMutation)> = vec![ + ( + "settled", + Box::new(|channel| channel.settlement.settled = 1), + ), + ( + "payout watermark", + Box::new(|channel| channel.settlement.payout_watermark = 1), + ), + ( + "grace period", + Box::new(|channel| channel.grace_period += 1), + ), + ( + "distribution hash", + Box::new(|channel| channel.distribution_hash[0] ^= 0xff), + ), + ("salt", Box::new(|channel| channel.salt += 1)), + ("open slot", Box::new(|channel| channel.open_slot += 1)), + ]; + for (name, mutate) in cases { + let mut channel = Channel::from_bytes(&encoded_channel( + CHANNEL_STATUS_OPEN, + 1_000, + payer, + payee, + signer, + mint, + )) + .unwrap(); + mutate(&mut channel); + let rpc = SessionAccountRpc::start(borsh::to_vec(&channel).unwrap(), program_id); + let server = SessionServer::new( + SessionConfig { + rpc_url: Some(rpc.url.clone()), + ..base.config.clone() + }, + MemoryChannelStore::new(), + ); + let error = server.process_open(&payload).await.unwrap_err(); + assert!(!error.to_string().is_empty(), "{name} was accepted"); + } + } + + #[tokio::test] + async fn begin_delivery_rejects_capacity_and_sequence_overflow() { + let server = make_server(); + server + .store + .put_channel( + "overflow", + ChannelState { + channel_id: "overflow".to_string(), + authorized_signer: "signer".to_string(), + deposit: u64::MAX, + cumulative: 0, + sealed: false, + highest_voucher_signature: None, + highest_voucher_expires_at: None, + close_requested_at: None, + open_slot: None, + salt: None, + open_signature: None, + operator: None, + next_delivery_sequence: 0, + pending_deliveries: vec![ + PendingDelivery { + delivery_id: "a".to_string(), + amount: u64::MAX, + sequence: 1, + expires_at: i64::MAX, + }, + PendingDelivery { + delivery_id: "b".to_string(), + amount: 1, + sequence: 2, + expires_at: i64::MAX, + }, + ], + committed_deliveries: vec![], + }, + ) + .await + .unwrap(); + let error = server + .begin_delivery(DeliveryRequest { + session_id: "overflow".to_string(), + amount: 1, + delivery_id: None, + commit_url: None, + proof: None, + expires_at: None, + }) + .await + .unwrap_err(); + assert!(error.to_string().contains("overflow")); + + server + .store + .update_channel( + "overflow", + Box::new(|state| { + let mut state = state.unwrap(); + state.pending_deliveries.clear(); + state.next_delivery_sequence = u64::MAX; + Ok(state) + }), + ) + .await + .unwrap(); + let error = server + .begin_delivery(DeliveryRequest { + session_id: "overflow".to_string(), + amount: 1, + delivery_id: None, + commit_url: None, + proof: None, + expires_at: None, + }) + .await + .unwrap_err(); + assert!(error.to_string().contains("sequence overflow")); + } + + #[tokio::test] + async fn process_open_without_rpc_fails_closed_off_localnet() { + let server = SessionServer::new( + SessionConfig { + network: "devnet".to_string(), + allow_unsafe_ephemeral_store_off_localnet: true, + ..make_server().config + }, + MemoryChannelStore::new(), + ); + let error = server + .process_open(&open_payload("chan1", 1_000, "signer1")) + .await + .unwrap_err(); + assert!(error.to_string().contains("requires an rpc_url")); + } + + #[tokio::test] + async fn process_open_rejects_ephemeral_store_off_localnet() { + let server = SessionServer::new( + SessionConfig { + network: "devnet".to_string(), + ..make_server().config + }, + MemoryChannelStore::new(), + ); + let error = server + .process_open(&open_payload("chan1", 1_000, "signer1")) + .await + .unwrap_err(); + assert!(error.to_string().contains("ephemeral session store")); + } + + #[tokio::test] + async fn process_open_rejects_unmarked_store_off_localnet() { + let server = SessionServer::new( + SessionConfig { + network: "devnet".to_string(), + ..make_server().config + }, + TestStore::new(SessionStoreDurability::Unknown), + ); + let error = server + .process_open(&open_payload("chan1", 1_000, "signer1")) + .await + .unwrap_err(); + assert!(error + .to_string() + .contains("explicitly declare durable shared")); + } + + #[tokio::test] + async fn state_operations_reject_ephemeral_store_off_localnet() { + let server = SessionServer::new( + SessionConfig { + network: "devnet".to_string(), + ..make_server().config + }, + MemoryChannelStore::new(), + ); + let error = server + .begin_delivery(DeliveryRequest::new("preloaded", 1)) + .await + .unwrap_err(); + assert!(error.to_string().contains("ephemeral session store")); + let error = server.mark_sealed("preloaded").await.unwrap_err(); + assert!(error.to_string().contains("ephemeral session store")); + } + + #[tokio::test] + async fn process_open_accepts_marked_durable_store_before_rpc_gate() { + let server = SessionServer::new( + SessionConfig { + network: "devnet".to_string(), + ..make_server().config + }, + TestStore::new(SessionStoreDurability::DurableShared), + ); + let error = server + .process_open(&open_payload("chan1", 1_000, "signer1")) + .await + .unwrap_err(); + assert!(error.to_string().contains("requires an rpc_url")); + } + + #[tokio::test] + async fn process_topup_without_rpc_fails_closed_off_localnet() { + let server = SessionServer::new( + SessionConfig { + network: "mainnet".to_string(), + allow_unsafe_ephemeral_store_off_localnet: true, + ..make_server().config + }, + MemoryChannelStore::new(), + ); + let error = server + .process_topup(&TopUpPayload { + channel_id: "chan1".to_string(), + new_deposit: "3000".to_string(), + signature: "sig".to_string(), + }) + .await + .unwrap_err(); + assert!(error.to_string().contains("requires an rpc_url")); + } + // ── process_close ───────────────────────────────────────────────────────── #[tokio::test] @@ -2539,6 +4622,8 @@ mod tests { highest_voucher_expires_at: None, close_requested_at: None, open_slot: None, + salt: None, + open_signature: None, operator: None, next_delivery_sequence: 0, pending_deliveries: vec![], diff --git a/rust/crates/kit/src/x402/server/batch_settlement.rs b/rust/crates/kit/src/x402/server/batch_settlement.rs index ba13773e4..a47f96c41 100644 --- a/rust/crates/kit/src/x402/server/batch_settlement.rs +++ b/rust/crates/kit/src/x402/server/batch_settlement.rs @@ -486,6 +486,8 @@ impl X402BatchSettlement { close_requested_at: None, // Persisted for PDA re-derivation and the reclaim gate. open_slot: Some(open_slot), + salt: Some(channel.salt), + open_signature: None, // Stash the payer here so settlement/distribute can refund it // without an extra account fetch. operator: Some(pc::pubkey_string(&payer)), @@ -959,6 +961,8 @@ mod tests { highest_voucher_expires_at: None, close_requested_at: None, open_slot: None, + salt: None, + open_signature: None, operator: None, next_delivery_sequence: 0, pending_deliveries: vec![], diff --git a/typescript/packages/mpp/src/__tests__/session-on-chain.test.ts b/typescript/packages/mpp/src/__tests__/session-on-chain.test.ts index 1730cdbc2..c2e844d4f 100644 --- a/typescript/packages/mpp/src/__tests__/session-on-chain.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-on-chain.test.ts @@ -63,6 +63,29 @@ async function loadFixedSigners() { ]); } +async function buildTestOpen(payer: KeyPairSigner, payee: KeyPairSigner, authorizedSigner: KeyPairSigner) { + return await buildOpenPaymentChannelTransaction({ + authorizedSigner: authorizedSigner.address, + deposit: 1_000_000n, + gracePeriod: 900, + programAddress: PAYMENT_CHANNELS_PROGRAM_ID, + request: { + cap: '1000000', + currency: USDC.mainnet!, + decimals: 6, + modes: ['pull'], + network: 'localnet', + operator: payer.address, + pullVoucherStrategy: 'clientVoucher', + recentBlockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N' as never, + recentSlot: '123456', + recipient: payee.address, + }, + salt: 7n, + signer: payer, + }); +} + // ── encodeVoucherMessageBytes parity check ───────────────────────────────── describe('encodeVoucherMessageBytes', () => { @@ -448,7 +471,7 @@ describe('verifyOpenTx', () => { test('accepts a freshly built open transaction', async () => { const [payer, , payee, authorizedSigner] = await loadFixedSigners(); - const { open } = await buildClientOpen(payer, payee, authorizedSigner); + const open = await buildTestOpen(payer, payee, authorizedSigner); const result = await verifyOpenTx({ expected: { authorizedSigner: authorizedSigner.address, @@ -514,6 +537,32 @@ describe('verifyOpenTx', () => { ).rejects.toThrow(/challenge-issued openSlot/); }); + test('rejects an open whose grace period differs from the configured session window', async () => { + const [payer, , payee, authorizedSigner] = await loadFixedSigners(); + const { open } = await buildClientOpen(payer, payee, authorizedSigner); + + await expect( + verifyOpenTx({ + expected: { + authorizedSigner: authorizedSigner.address, + currency: USDC.mainnet!, + gracePeriod: 600, + maxCap: 5_000_000n, + network: 'localnet', + operator: payer.address, + programId: 'CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX', + recipient: payee.address, + }, + openPayload: { + authorizedSigner: authorizedSigner.address, + mode: 'pull', + signature: '1'.repeat(88), + transaction: open.transaction, + }, + }), + ).rejects.toThrow(/gracePeriod 900 != expected 600/); + }); + test('rejects an open transaction that uses address-lookup tables', async () => { const [payer, , payee, authorizedSigner] = await loadFixedSigners(); const { open } = await buildClientOpen(payer, payee, authorizedSigner); @@ -617,7 +666,7 @@ describe('verifyOpenTx', () => { getSignatureStatuses: (sigs: readonly Signature[]) => ({ send: async () => { calls.push([...sigs]); - return { value: [{ err: null }] }; + return { context: { slot: 42 }, value: [{ confirmationStatus: 'confirmed', err: null }] }; }, }), }; @@ -689,6 +738,38 @@ describe('verifyOpenTx', () => { }); }); +describe('verifyTopUpTransaction', () => { + test('resolves v0 instruction accounts from static and loaded addresses', async () => { + const [payer, , payee, authorizedSigner] = await loadFixedSigners(); + const open = await buildTestOpen(payer, payee, authorizedSigner); + const transaction = reencodeV0TopUpWithLoadedChannel(open.transaction, open.channelId, 1_234n); + const configs: unknown[] = []; + + await verifyTopUpTransaction({ + amount: 1_234n, + channelId: open.channelId, + programId: PAYMENT_CHANNELS_PROGRAM_ID, + rpc: { + getTransaction: (_signature, config) => ({ + send: async () => { + configs.push(config); + return { + meta: { + err: null, + loadedAddresses: { readonly: [], writable: [open.channelId] }, + }, + transaction: [transaction, 'base64'], + } as const; + }, + }), + }, + signature: 'top-up-signature' as Signature, + }); + + expect(configs).toEqual([{ commitment: 'confirmed', encoding: 'base64', maxSupportedTransactionVersion: 0 }]); + }); +}); + // ── helpers ──────────────────────────────────────────────────────────────── /** Extract the first (fee-payer) signature of a base64-encoded transaction. */ @@ -725,6 +806,59 @@ function injectAddressTableLookup(transactionBase64: string): string { return getBase64Codec().decode(new Uint8Array(rebuilt)); } +function reencodeV0TopUpWithLoadedChannel(transactionBase64: string, channelId: string, amount: bigint): string { + const tx = getTransactionDecoder().decode(getBase64Codec().encode(transactionBase64)); + const message = getCompiledTransactionMessageDecoder().decode(tx.messageBytes) as never as { + instructions: readonly { + accountIndices?: readonly number[]; + data?: Uint8Array; + programAddressIndex: number; + }[]; + staticAccounts: readonly string[]; + }; + const openInstruction = message.instructions[0]; + if (!openInstruction?.accountIndices) throw new Error('open fixture has no account indices'); + const channelIndex = openInstruction.accountIndices[5]; + if (channelIndex === undefined || message.staticAccounts[channelIndex] !== channelId) { + throw new Error('open fixture channel account was not found'); + } + + const staticAccounts = message.staticAccounts.filter(account => account !== channelId); + const remap = (index: number): number => { + const account = message.staticAccounts[index]; + if (account === undefined) throw new Error(`missing fixture account at index ${index}`); + if (account === channelId) return staticAccounts.length; + const mapped = staticAccounts.indexOf(account); + if (mapped < 0) throw new Error(`fixture account ${account} was not remapped`); + return mapped; + }; + const data = new Uint8Array(1 + 8); + data[0] = 3; + new DataView(data.buffer).setBigUint64(1, amount, true); + const messageBytes = getCompiledTransactionMessageEncoder().encode({ + ...message, + addressTableLookups: [ + { + lookupTableAddress: '11111111111111111111111111111111', + readonlyIndexes: [], + writableIndexes: [0], + }, + ], + instructions: [ + { + ...openInstruction, + accountIndices: [remap(openInstruction.accountIndices[0]!), remap(channelIndex)], + data, + programAddressIndex: remap(openInstruction.programAddressIndex), + }, + ], + staticAccounts, + version: 0, + } as never); + const rebuilt = getTransactionEncoder().encode({ ...tx, messageBytes } as never); + return getBase64Codec().decode(new Uint8Array(rebuilt)); +} + /** Minimal base58 encoder for fixed-byte signatures used in tests. */ function bs58Encode(bytes: Uint8Array): string { const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; diff --git a/typescript/packages/mpp/src/__tests__/session-server-on-chain.test.ts b/typescript/packages/mpp/src/__tests__/session-server-on-chain.test.ts index 215710ff6..99e531e0c 100644 --- a/typescript/packages/mpp/src/__tests__/session-server-on-chain.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-server-on-chain.test.ts @@ -50,7 +50,12 @@ async function loadFixedSigners() { ]); } -async function buildClientOpen(payer: KeyPairSigner, payee: KeyPairSigner, authorizedSigner: KeyPairSigner) { +async function buildClientOpen( + payer: KeyPairSigner, + payee: KeyPairSigner, + authorizedSigner: KeyPairSigner, + splits: SessionRequest['splits'] = [], +) { const request: SessionRequest = { cap: '1000000', currency: USDC.mainnet!, @@ -60,6 +65,7 @@ async function buildClientOpen(payer: KeyPairSigner, payee: KeyPairSigner, autho recentBlockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N' as never, recentSlot: '4242', recipient: payee.address, + ...(splits.length > 0 ? { splits } : {}), }; const open = await buildOpenPaymentChannelTransaction({ authorizedSigner: authorizedSigner.address, @@ -73,7 +79,12 @@ async function buildClientOpen(payer: KeyPairSigner, payee: KeyPairSigner, autho return { open, request }; } -function expectedFor(payer: KeyPairSigner, payee: KeyPairSigner, authorizedSigner: KeyPairSigner) { +function expectedFor( + payer: KeyPairSigner, + payee: KeyPairSigner, + authorizedSigner: KeyPairSigner, + splits: SessionRequest['splits'] = [], +) { return { authorizedSigner: authorizedSigner.address, currency: USDC.mainnet!, @@ -82,6 +93,7 @@ function expectedFor(payer: KeyPairSigner, payee: KeyPairSigner, authorizedSigne operator: payer.address, programId: PAYMENT_CHANNELS_PROGRAM_ID as string, recipient: payee.address, + ...(splits.length > 0 ? { splits } : {}), }; } @@ -106,6 +118,67 @@ function reencodeAsLegacy(transactionBase64: string): string { return getBase64Codec().decode(legacyTx); } +function appendOpenInstruction(transactionBase64: string, duplicate: boolean): string { + const tx = getTransactionDecoder().decode(getBase64Codec().encode(transactionBase64)); + const message = getCompiledTransactionMessageDecoder().decode(tx.messageBytes) as never as { + instructions: readonly Record[]; + }; + const openInstruction = message.instructions[0]; + if (!openInstruction) throw new Error('open fixture has no instruction'); + const extra = duplicate ? openInstruction : { ...openInstruction, data: new Uint8Array([0]) }; + const messageBytes = getCompiledTransactionMessageEncoder().encode({ + ...message, + instructions: [...message.instructions, extra], + } as never); + const rebuilt = getTransactionEncoder().encode({ ...tx, messageBytes } as never); + return getBase64Codec().decode(new Uint8Array(rebuilt)); +} + +type TestOpenMessage = { + readonly instructions: readonly { + readonly accountIndices?: readonly number[]; + readonly data?: Uint8Array; + readonly programAddressIndex: number; + }[]; + readonly staticAccounts: readonly string[]; +}; + +function rewriteOpenTransaction( + transactionBase64: string, + rewrite: (message: TestOpenMessage, openInstruction: TestOpenMessage['instructions'][number]) => object, +): string { + const tx = getTransactionDecoder().decode(getBase64Codec().encode(transactionBase64)); + const message = getCompiledTransactionMessageDecoder().decode(tx.messageBytes) as never as TestOpenMessage; + const openInstruction = message.instructions[0]; + if (!openInstruction) throw new Error('open fixture has no instruction'); + const messageBytes = getCompiledTransactionMessageEncoder().encode({ + ...message, + ...rewrite(message, openInstruction), + } as never); + const rebuilt = getTransactionEncoder().encode({ ...tx, messageBytes: messageBytes as never }); + return getBase64Codec().decode(new Uint8Array(rebuilt)); +} + +function replaceOpenAccount(transactionBase64: string, slot: number, replacement: string): string { + return rewriteOpenTransaction(transactionBase64, (message, openInstruction) => { + const accountIndex = openInstruction.accountIndices?.[slot]; + if (accountIndex === undefined) throw new Error(`open fixture has no account at slot ${slot}`); + const staticAccounts = [...message.staticAccounts]; + staticAccounts[accountIndex] = replacement; + return { staticAccounts }; + }); +} + +function appendOpenDataByte(transactionBase64: string): string { + return rewriteOpenTransaction(transactionBase64, (message, openInstruction) => { + if (!openInstruction.data) throw new Error('open fixture has no instruction data'); + const instructions = message.instructions.map((instruction, index) => + index === 0 ? { ...instruction, data: new Uint8Array([...openInstruction.data!, 0]) } : instruction, + ); + return { instructions }; + }); +} + const PLACEHOLDER_SIG = '1'.repeat(88); // ── verifyOpenTx: signature binding ───────────────────────────────────── @@ -178,6 +251,124 @@ describe('verifyOpenTx signature binding', () => { }); expect(result.deposit).toBe(1_000_000n); }); + + test.each(['arbitrary extra', 'duplicate open'])('rejects %s instructions before server co-signing', async kind => { + const [payer, payee, authorizedSigner] = await loadFixedSigners(); + const { open } = await buildClientOpen(payer, payee, authorizedSigner); + const transaction = appendOpenInstruction(open.transaction, kind === 'duplicate open'); + let signerCalls = 0; + const payerSigner = { + address: payer.address, + signTransactions: async () => { + signerCalls += 1; + throw new Error('co-signing should not run for an invalid open'); + }, + }; + const rpc = makeSubmitRpc([{ confirmationStatus: 'confirmed', err: null }]); + + await expect( + submitOpenTx({ + confirm: { pollIntervalMs: 1, timeoutMs: 2_000 }, + expected: expectedFor(payer, payee, authorizedSigner), + openPayload: { + authorizedSigner: authorizedSigner.address, + mode: 'push', + signature: PLACEHOLDER_SIG, + transaction, + }, + payerSigner: payerSigner as never, + rpc, + }), + ).rejects.toThrow(/exactly one instruction/); + expect(signerCalls).toBe(0); + expect(rpc.sends).toHaveLength(0); + }); + + test('rejects an altered split before co-signing or broadcast', async () => { + const [payer, payee, authorizedSigner] = await loadFixedSigners(); + const { open } = await buildClientOpen(payer, payee, authorizedSigner, [ + { bps: 100, recipient: payee.address }, + ]); + const expected = expectedFor(payer, payee, authorizedSigner, [{ bps: 100, recipient: payer.address }]); + const rpc = makeSubmitRpc([{ confirmationStatus: 'confirmed', err: null }]); + + await expect( + submitOpenTx({ + expected, + openPayload: { + authorizedSigner: authorizedSigner.address, + mode: 'push', + signature: PLACEHOLDER_SIG, + transaction: open.transaction, + }, + rpc, + }), + ).rejects.toThrow(/recipient\[0\]/); + expect(rpc.sends).toHaveLength(0); + }); + + test('rejects an altered payer token account before co-signing or broadcast', async () => { + const [payer, payee, authorizedSigner] = await loadFixedSigners(); + const { open } = await buildClientOpen(payer, payee, authorizedSigner); + const transaction = replaceOpenAccount(open.transaction, 6, authorizedSigner.address); + const rpc = makeSubmitRpc([{ confirmationStatus: 'confirmed', err: null }]); + + await expect( + submitOpenTx({ + expected: expectedFor(payer, payee, authorizedSigner), + openPayload: { + authorizedSigner: authorizedSigner.address, + mode: 'push', + signature: PLACEHOLDER_SIG, + transaction, + }, + rpc, + }), + ).rejects.toThrow(/account\[6\]/); + expect(rpc.sends).toHaveLength(0); + }); + + test('rejects an altered fixed rent sysvar account before co-signing or broadcast', async () => { + const [payer, payee, authorizedSigner] = await loadFixedSigners(); + const { open } = await buildClientOpen(payer, payee, authorizedSigner); + const transaction = replaceOpenAccount(open.transaction, 10, authorizedSigner.address); + const rpc = makeSubmitRpc([{ confirmationStatus: 'confirmed', err: null }]); + + await expect( + submitOpenTx({ + expected: expectedFor(payer, payee, authorizedSigner), + openPayload: { + authorizedSigner: authorizedSigner.address, + mode: 'push', + signature: PLACEHOLDER_SIG, + transaction, + }, + rpc, + }), + ).rejects.toThrow(/account\[10\]/); + expect(rpc.sends).toHaveLength(0); + }); + + test('rejects trailing open instruction data before co-signing or broadcast', async () => { + const [payer, payee, authorizedSigner] = await loadFixedSigners(); + const { open } = await buildClientOpen(payer, payee, authorizedSigner); + const transaction = appendOpenDataByte(open.transaction); + const rpc = makeSubmitRpc([{ confirmationStatus: 'confirmed', err: null }]); + + await expect( + submitOpenTx({ + expected: expectedFor(payer, payee, authorizedSigner), + openPayload: { + authorizedSigner: authorizedSigner.address, + mode: 'push', + signature: PLACEHOLDER_SIG, + transaction, + }, + rpc, + }), + ).rejects.toThrow(/not canonical/); + expect(rpc.sends).toHaveLength(0); + }); }); // ── verifyOpenTx: legacy transaction encoding ─────────────────────────── @@ -213,10 +404,12 @@ interface MockStatus { function makeSubmitRpc(statusSequence: (MockStatus | null)[]) { const sends: string[] = []; + const statusSignatures: string[] = []; let statusCalls = 0; return { - getSignatureStatuses: (_sigs: readonly Signature[]) => ({ + getSignatureStatuses: (sigs: readonly Signature[]) => ({ send: async () => { + statusSignatures.push(...sigs); const status = statusCalls < statusSequence.length ? statusSequence[statusCalls] @@ -233,6 +426,7 @@ function makeSubmitRpc(statusSequence: (MockStatus | null)[]) { }), sends, statusCallCount: () => statusCalls, + statusSignatures, }; } @@ -262,6 +456,29 @@ describe('submitOpenTx confirmation', () => { expect(rpc.statusCallCount()).toBeGreaterThanOrEqual(3); }); + test.each([ + ['null status', null], + ['omitted confirmationStatus', { err: null }], + ['processed status', { confirmationStatus: 'processed', err: null }], + ] as const)('does not accept %s as confirmed', async (_label, initialStatus) => { + const [payer, payee, authorizedSigner] = await loadFixedSigners(); + const { open } = await buildClientOpen(payer, payee, authorizedSigner); + const rpc = makeSubmitRpc([initialStatus, { confirmationStatus: 'confirmed', err: null }]); + + await submitOpenTx({ + confirm: { pollIntervalMs: 1, timeoutMs: 2_000 }, + expected: expectedFor(payer, payee, authorizedSigner), + openPayload: { + authorizedSigner: authorizedSigner.address, + mode: 'push', + signature: PLACEHOLDER_SIG, + transaction: open.transaction, + }, + rpc, + }); + expect(rpc.statusCallCount()).toBeGreaterThanOrEqual(2); + }); + test('throws when confirmation never arrives within the timeout', async () => { const [payer, payee, authorizedSigner] = await loadFixedSigners(); const { open } = await buildClientOpen(payer, payee, authorizedSigner); @@ -381,6 +598,8 @@ describe("session() openTxSubmitter='server' replay", () => { const state = await store.getChannel(open.channelId); expect(state?.deposit).toBe(1_000_000n); expect(state?.cumulative).toBe(0n); + expect(state?.openSignature).toBe('OpenSig1111111111111111111111111111111111111111111111111111111'); + expect(rpc.statusSignatures).not.toContain(PLACEHOLDER_SIG); }); }); @@ -392,6 +611,7 @@ describe('submitInitMultiDelegateTxIfMissing', () => { const accountLookups: string[] = []; return { accountLookups, + getBlockHeight: () => ({ send: async () => 0n }), getAccountInfo: (addr: string) => ({ send: async () => { accountLookups.push(addr); diff --git a/typescript/packages/mpp/src/__tests__/session-server.test.ts b/typescript/packages/mpp/src/__tests__/session-server.test.ts index 9197b13bd..cb32d2fae 100644 --- a/typescript/packages/mpp/src/__tests__/session-server.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-server.test.ts @@ -5,12 +5,19 @@ // vouchers. The 402 challenge body is also snapshotted against the // canonical Methods.ts schema so future schema drifts are caught here. -import { generateKeyPairSigner, getBase58Decoder, type KeyPairSigner } from '@solana/kit'; +import { + generateKeyPairSigner, + getBase58Decoder, + getBase64Codec, + getSignatureFromTransaction, + getTransactionDecoder, + type KeyPairSigner, +} from '@solana/kit'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import * as Methods from '../Methods.js'; import { session } from '../server/Session.js'; -import { type ChannelState, createMemorySessionStore } from '../server/session/store.js'; +import { type ChannelState, createMemorySessionStore, type SessionStore } from '../server/session/store.js'; import type { SignedVoucher, VoucherData } from '../shared/session-types.js'; import { encodeVoucherMessage } from '../shared/voucher.js'; @@ -31,6 +38,10 @@ afterAll(() => { else process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE = priorInMemoryOptIn; }); +function signedWireSignature(wire: string): string { + return getSignatureFromTransaction(getTransactionDecoder().decode(getBase64Codec().encode(wire))); +} + /** * Minimal RPC mock exposing `getSignatureStatuses` driven by a lookup * table. Unknown signatures resolve to `null` (not found). @@ -42,7 +53,13 @@ function mockStatusRpc(statuses: Record ({ send: async () => { calls.push(...sigs); - return { value: sigs.map(sig => statuses[sig] ?? null) }; + return { + context: { slot: 42 }, + value: sigs.map(sig => { + const status = statuses[sig]; + return status ? { ...status, confirmationStatus: 'confirmed' } : null; + }), + }; }, }), }; @@ -214,7 +231,7 @@ describe('session() verify() open', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -247,7 +264,7 @@ describe('session() verify() open', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -273,7 +290,7 @@ describe('session() verify() open', () => { cap: 1_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -305,7 +322,7 @@ describe('session() verify() voucher', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -342,7 +359,7 @@ describe('session() verify() voucher', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -364,7 +381,7 @@ describe('session() verify() voucher', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -407,7 +424,7 @@ describe('session() verify() topUp', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -540,7 +557,7 @@ describe('session() verify() close', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -578,7 +595,7 @@ describe('session() verify() close', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -617,7 +634,7 @@ describe('session() verify() commit', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -697,7 +714,7 @@ describe('session.routes()', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -768,7 +785,7 @@ describe('session() verify() open replay', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -846,7 +863,7 @@ describe('session() verify() open signature verification', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -878,7 +895,7 @@ describe('session() verify() open signature verification', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -910,7 +927,7 @@ describe('session() verify() open signature verification', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -945,7 +962,7 @@ describe('session() verify() pull open keying', () => { currency: 'USDC', decimals: 6, modes: ['pull'], - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, pullVoucherStrategy: 'clientVoucher', @@ -983,7 +1000,7 @@ describe('session() verify() voucher wire compatibility', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1061,7 +1078,7 @@ describe('session() verify() voucher wire compatibility', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1119,7 +1136,7 @@ describe('session() verify() topUp hardening', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1143,7 +1160,7 @@ describe('session() verify() topUp hardening', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1168,7 +1185,7 @@ describe('session() verify() topUp hardening', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1200,7 +1217,7 @@ describe('session() verify() topUp hardening', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1236,7 +1253,7 @@ describe('session() verify() close monotonicity', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1306,15 +1323,71 @@ describe('session() verify() close monotonicity', () => { // ── verify() — close retry after a failed settlement ──────────────────── describe('session() verify() close retry', () => { - test('a failed settlement leaves close re-drivable; the retry settles', async () => { + test('construction rejects signer-driven settlement without getBlockHeight before store or lifecycle use', async () => { + const store = createMemorySessionStore(); + const merchant = await generateKeyPairSigner(); + const rpc = { + getLatestBlockhash: () => ({ + send: async () => ({ + value: { + blockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N', + lastValidBlockHeight: 100n, + }, + }), + }), + getSignatureStatuses: (sigs: readonly string[]) => ({ + send: async () => ({ + context: { slot: 42 }, + value: sigs.map(() => ({ confirmationStatus: 'confirmed', err: null })), + }), + }), + sendTransaction: () => ({ send: async () => 'unused' }), + }; + + expect(() => + session({ + cap: 1_000_000n, + closeDelayMs: 1, + currency: 'USDC', + decimals: 6, + network: 'localnet', + operator: OPERATOR, + pricing: {}, + recipient: RECIPIENT, + rpc: rpc as never, + signer: merchant, + store, + }), + ).toThrow(/requires rpc\.getBlockHeight/); + expect(await store.listChannels()).toEqual([]); + + expect(() => + session({ + cap: 1_000_000n, + closeDelayMs: 1, + currency: 'USDC', + decimals: 6, + network: 'localnet', + operator: OPERATOR, + pricing: {}, + recipient: RECIPIENT, + rpc: rpc as never, + store, + }), + ).not.toThrow(); + }); + + test('an uncertain confirmation retries by rebroadcasting the persisted wire', async () => { const store = createMemorySessionStore(); const signer = await generateKeyPairSigner(); const merchant = await generateKeyPairSigner(); const channelId = '11111111111111111111111111111111'; + let settleSignature: string | undefined; - let sendFailures = 1; + let settlementStatusCalls = 0; const sends: string[] = []; const rpc = { + getBlockHeight: () => ({ send: async () => 0n }), getLatestBlockhash: () => ({ send: async () => ({ value: { @@ -1324,16 +1397,25 @@ describe('session() verify() close retry', () => { }), }), getSignatureStatuses: (sigs: readonly string[]) => ({ - send: async () => ({ value: sigs.map(() => ({ err: null })) }), + send: async () => { + if ( + settleSignature !== undefined && + sigs.includes(settleSignature) && + settlementStatusCalls++ === 0 + ) { + throw new Error('status RPC unavailable'); + } + return { + context: { slot: 42 }, + value: sigs.map(() => ({ confirmationStatus: 'confirmed', err: null })), + }; + }, }), sendTransaction: (wire: string) => ({ send: async () => { - if (sendFailures > 0) { - sendFailures -= 1; - throw new Error('blockhash not found'); - } sends.push(wire); - return 'SettleSig11111111111111111111111111111111111111111111111111111111'; + settleSignature = signedWireSignature(wire); + return settleSignature; }, }), }; @@ -1342,7 +1424,7 @@ describe('session() verify() close retry', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1364,25 +1446,37 @@ describe('session() verify() close retry', () => { request: {} as never, }); - // First close: settlement submit fails — close stays pending. + // The broadcast succeeded but confirmation is uncertain. The outbox + // retains the exact signed transaction for idempotent recovery. await expect( method.verify({ credential: makeCred({ action: 'close', channelId }), request: {} as never }), - ).rejects.toThrow(/blockhash not found/); + ).rejects.toThrow(/status RPC unavailable/); let state = await store.getChannel(channelId); expect(state?.closeRequestedAt).toBeDefined(); expect(state?.sealed).toBe(false); + expect(state?.settlementPendingLastValidBlockHeight).toBe(0n); + expect(state?.settlementPendingSignature).toBe(settleSignature); + expect(state?.settlementPendingWire).toBe(sends[0]); + expect(state?.settling).toBe(false); expect(state?.settledSignature).toBeUndefined(); + expect(sends).toHaveLength(1); - // Retry succeeds and seals the channel. + // Retry rebroadcasts the exact same signed transaction, never a newly + // built transaction with a different signature. const receipt = await method.verify({ credential: makeCred({ action: 'close', channelId }), request: {} as never, }); expect(receipt.status).toBe('success'); - expect(sends).toHaveLength(1); + expect(sends).toHaveLength(2); + expect(new Set(sends).size).toBe(1); state = await store.getChannel(channelId); expect(state?.sealed).toBe(true); - expect(state?.settledSignature).toBeDefined(); + expect(state?.settlementPendingLastValidBlockHeight).toBeUndefined(); + expect(state?.settlementPendingSignature).toBeUndefined(); + expect(state?.settlementPendingWire).toBeUndefined(); + expect(state?.settling).toBe(false); + expect(state?.settledSignature).toBe(settleSignature); // A third close on the sealed channel rejects. await expect( @@ -1390,19 +1484,505 @@ describe('session() verify() close retry', () => { ).rejects.toThrow(/sealed/); }); + test('a send error still seals when the persisted signature is already confirmed', async () => { + const store = createMemorySessionStore(); + const signer = await generateKeyPairSigner(); + const merchant = await generateKeyPairSigner(); + const channelId = '11111111111111111111111111111111'; + const sends: string[] = []; + const rpc = { + getBlockHeight: () => ({ send: async () => 0n }), + getLatestBlockhash: () => ({ + send: async () => ({ + value: { + blockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N', + lastValidBlockHeight: 100n, + }, + }), + }), + getSignatureStatuses: (sigs: readonly string[]) => ({ + send: async () => ({ + context: { slot: 42 }, + value: sigs.map(() => ({ confirmationStatus: 'confirmed', err: null })), + }), + }), + sendTransaction: (wire: string) => ({ + send: async () => { + sends.push(wire); + throw new Error('already processed'); + }, + }), + }; + const method = session({ + cap: 1_000_000n, + currency: 'USDC', + decimals: 6, + network: 'localnet', + operator: OPERATOR, + pricing: {}, + recipient: RECIPIENT, + rpc: rpc as never, + signer: merchant, + store, + }); + await method.verify({ + credential: makeCred({ + action: 'open', + authorizedSigner: signer.address, + channelId, + deposit: '1000', + mode: 'push', + payer: signer.address, + signature: 'open-sig', + }), + request: {} as never, + }); + + const receipt = await method.verify({ + credential: makeCred({ action: 'close', channelId }), + request: {} as never, + }); + expect(receipt.status).toBe('success'); + expect(sends).toHaveLength(1); + const state = await store.getChannel(channelId); + expect(state?.sealed).toBe(true); + expect(state?.settledSignature).toBe(signedWireSignature(sends[0]!)); + expect(state?.settlementPendingLastValidBlockHeight).toBeUndefined(); + expect(state?.settlementPendingSignature).toBeUndefined(); + expect(state?.settlementPendingWire).toBeUndefined(); + }); + + test('an expired unsent outbox is retired and rebuilt after restart', async () => { + const durableStore = createMemorySessionStore(); + const signer = await generateKeyPairSigner(); + const merchant = await generateKeyPairSigner(); + const channelId = '11111111111111111111111111111111'; + const sends: string[] = []; + let blockhashCalls = 0; + let crashBeforeSend = true; + let phase: 'crash' | 'expired' | 'fresh' = 'crash'; + const crashingStore: SessionStore = { + ...durableStore, + async updateChannel(id, mutator) { + const previous = await durableStore.getChannel(id); + const next = await durableStore.updateChannel(id, mutator); + if ( + crashBeforeSend && + previous?.settlementPendingWire === undefined && + next.settlementPendingWire !== undefined + ) { + crashBeforeSend = false; + throw new Error('simulated crash after outbox persistence'); + } + return next; + }, + }; + const rpc = { + getLatestBlockhash: () => ({ + send: async () => { + blockhashCalls += 1; + return { + value: { + blockhash: + blockhashCalls === 1 + ? 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N' + : '11111111111111111111111111111111', + lastValidBlockHeight: blockhashCalls === 1 ? 100n : 200n, + }, + }; + }, + }), + getSignatureStatuses: (sigs: readonly string[]) => ({ + send: async () => ({ + context: { slot: 42 }, + value: sigs.map(() => + phase === 'expired' ? null : { confirmationStatus: 'confirmed', err: null }, + ), + }), + }), + getBlockHeight: () => ({ send: async () => 101n }), + sendTransaction: (wire: string) => ({ + send: async () => { + sends.push(wire); + if (phase === 'expired') throw new Error('blockhash not found'); + return signedWireSignature(wire); + }, + }), + }; + const createMethod = (store: SessionStore) => + session({ + cap: 1_000_000n, + currency: 'USDC', + decimals: 6, + network: 'localnet', + operator: OPERATOR, + pricing: {}, + recipient: RECIPIENT, + rpc: rpc as never, + settlementConfirmation: { pollIntervalMs: 1, timeoutMs: 20 }, + signer: merchant, + store, + }); + const crashedMethod = createMethod(crashingStore); + await crashedMethod.verify({ + credential: makeCred({ + action: 'open', + authorizedSigner: signer.address, + channelId, + deposit: '1000', + mode: 'push', + payer: signer.address, + signature: 'open-sig', + }), + request: {} as never, + }); + + await expect( + crashedMethod.verify({ credential: makeCred({ action: 'close', channelId }), request: {} as never }), + ).rejects.toThrow(/simulated crash/); + const pending = await durableStore.getChannel(channelId); + expect(pending?.settlementPendingSignature).toBeDefined(); + expect(pending?.settlementPendingWire).toBeDefined(); + expect(pending?.settlementPendingLastValidBlockHeight).toBe(100n); + expect(sends).toHaveLength(0); + expect(blockhashCalls).toBe(1); + + const restartedMethod = createMethod(durableStore); + phase = 'expired'; + await expect( + restartedMethod.verify({ credential: makeCred({ action: 'close', channelId }), request: {} as never }), + ).rejects.toThrow(/timed out/); + expect(sends).toEqual([pending?.settlementPendingWire]); + const recovered = await durableStore.getChannel(channelId); + expect(recovered?.sealed).toBe(false); + expect(recovered?.settlementPendingLastValidBlockHeight).toBeUndefined(); + expect(recovered?.settlementPendingSignature).toBeUndefined(); + expect(recovered?.settlementPendingWire).toBeUndefined(); + + phase = 'fresh'; + const receipt = await restartedMethod.verify({ + credential: makeCred({ action: 'close', channelId }), + request: {} as never, + }); + expect(receipt.status).toBe('success'); + expect(sends).toHaveLength(2); + expect(sends[0]).toBe(pending?.settlementPendingWire); + expect(sends[1]).not.toBe(pending?.settlementPendingWire); + expect(blockhashCalls).toBe(2); + const sealed = await durableStore.getChannel(channelId); + expect(sealed?.sealed).toBe(true); + expect(sealed?.settledSignature).toBe(signedWireSignature(sends[1]!)); + expect(sealed?.settlementPendingLastValidBlockHeight).toBeUndefined(); + expect(sealed?.settlementPendingSignature).toBeUndefined(); + expect(sealed?.settlementPendingWire).toBeUndefined(); + }); + + test('a definite on-chain failure clears the pending signature for a fresh retry', async () => { + const store = createMemorySessionStore(); + const signer = await generateKeyPairSigner(); + const merchant = await generateKeyPairSigner(); + const channelId = '11111111111111111111111111111111'; + const failedSignature = 'FailedSig11111111111111111111111111111111111111111111111111111111'; + let retrySignature: string | undefined; + const sends: string[] = []; + const rpc = { + getBlockHeight: () => ({ send: async () => 0n }), + getLatestBlockhash: () => ({ + send: async () => ({ + value: { + blockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N', + lastValidBlockHeight: 0n, + }, + }), + }), + getSignatureStatuses: (sigs: readonly string[]) => ({ + send: async () => ({ + context: { slot: 42 }, + value: sigs.map(signature => + signature === failedSignature + ? { confirmationStatus: 'confirmed', err: { InstructionError: [0, 'Custom'] } } + : { confirmationStatus: 'confirmed', err: null }, + ), + }), + }), + sendTransaction: (wire: string) => ({ + send: async () => { + sends.push(wire); + retrySignature = signedWireSignature(wire); + return retrySignature; + }, + }), + }; + const method = session({ + cap: 1_000_000n, + currency: 'USDC', + decimals: 6, + network: 'localnet', + operator: OPERATOR, + pricing: {}, + recipient: RECIPIENT, + rpc: rpc as never, + signer: merchant, + store, + }); + await store.updateChannel(channelId, () => ({ + authorizedSigner: signer.address, + channelId, + closeRequestedAt: 1n, + committedDeliveries: [], + cumulative: 0n, + deposit: 1_000n, + nextDeliverySequence: 0n, + operator: signer.address, + pendingDeliveries: [], + sealed: false, + settlementPendingSignature: failedSignature, + })); + + await expect( + method.verify({ credential: makeCred({ action: 'close', channelId }), request: {} as never }), + ).rejects.toThrow(/failed on-chain/); + let state = await store.getChannel(channelId); + expect(state?.settlementPendingSignature).toBeUndefined(); + expect(state?.settling).toBe(false); + expect(state?.sealed).toBe(false); + expect(sends).toHaveLength(0); + + const receipt = await method.verify({ + credential: makeCred({ action: 'close', channelId }), + request: {} as never, + }); + expect(receipt.status).toBe('success'); + state = await store.getChannel(channelId); + expect(state?.sealed).toBe(true); + expect(state?.settledSignature).toBe(retrySignature); + expect(sends).toHaveLength(1); + }); + + test('restart recovery re-confirms pending signatures and only takes over stale claim-only leases', async () => { + const store = createMemorySessionStore(); + const signer = await generateKeyPairSigner(); + const merchant = await generateKeyPairSigner(); + const pendingChannelId = '11111111111111111111111111111111'; + const claimOnlyChannelId = OPERATOR; + const pendingSignature = 'PendingSig1111111111111111111111111111111111111111111111111111111'; + const sends: string[] = []; + const rpc = { + getBlockHeight: () => ({ send: async () => 0n }), + getLatestBlockhash: () => ({ + send: async () => ({ + value: { + blockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N', + lastValidBlockHeight: 0n, + }, + }), + }), + getSignatureStatuses: (sigs: readonly string[]) => ({ + send: async () => ({ + context: { slot: 42 }, + value: sigs.map(() => ({ confirmationStatus: 'confirmed', err: null })), + }), + }), + sendTransaction: (wire: string) => ({ + send: async () => { + sends.push(wire); + return signedWireSignature(wire); + }, + }), + }; + const method = session({ + cap: 1_000_000n, + currency: 'USDC', + decimals: 6, + network: 'localnet', + operator: OPERATOR, + pricing: {}, + recipient: RECIPIENT, + rpc: rpc as never, + signer: merchant, + store, + }); + const restartedState = (channelId: string): ChannelState => ({ + authorizedSigner: signer.address, + channelId, + closeRequestedAt: 1n, + committedDeliveries: [], + cumulative: 0n, + deposit: 1_000n, + nextDeliverySequence: 0n, + operator: signer.address, + pendingDeliveries: [], + sealed: false, + settlementClaimExpiresAt: BigInt(Date.now() + 60_000), + settlementClaimOwner: 'crashed-instance', + settling: true, + }); + await store.updateChannel(pendingChannelId, () => ({ + ...restartedState(pendingChannelId), + settlementPendingSignature: pendingSignature, + })); + await store.updateChannel(claimOnlyChannelId, () => restartedState(claimOnlyChannelId)); + + const recovered = await method.verify({ + credential: makeCred({ action: 'close', channelId: pendingChannelId }), + request: {} as never, + }); + expect(recovered.status).toBe('success'); + expect((await store.getChannel(pendingChannelId))?.settledSignature).toBe(pendingSignature); + expect(sends).toHaveLength(0); + + const blocked = await method.verify({ + credential: makeCred({ action: 'close', channelId: claimOnlyChannelId }), + request: {} as never, + }); + expect(blocked.status).toBe('success'); + expect((await store.getChannel(claimOnlyChannelId))?.sealed).toBe(false); + expect(sends).toHaveLength(0); + + await store.updateChannel(claimOnlyChannelId, current => ({ + ...current!, + settlementClaimExpiresAt: 0n, + })); + const takenOver = await method.verify({ + credential: makeCred({ action: 'close', channelId: claimOnlyChannelId }), + request: {} as never, + }); + expect(takenOver.status).toBe('success'); + expect((await store.getChannel(claimOnlyChannelId))?.sealed).toBe(true); + expect(sends).toHaveLength(1); + }); + + test('concurrent closes broadcast once and seal only after confirmation', async () => { + const store = createMemorySessionStore(); + const signer = await generateKeyPairSigner(); + const merchant = await generateKeyPairSigner(); + const channelId = '11111111111111111111111111111111'; + let settleSignature: string | undefined; + const sends: string[] = []; + let releaseConfirmation!: () => void; + let secondStatusRequested!: () => void; + let statusRequested!: () => void; + let statusRequests = 0; + const confirmationGate = new Promise(resolve => { + releaseConfirmation = resolve; + }); + const statusRequest = new Promise(resolve => { + statusRequested = resolve; + }); + const secondStatusRequest = new Promise(resolve => { + secondStatusRequested = resolve; + }); + const rpc = { + getBlockHeight: () => ({ send: async () => 0n }), + getLatestBlockhash: () => ({ + send: async () => ({ + value: { + blockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N', + lastValidBlockHeight: 0n, + }, + }), + }), + getSignatureStatuses: (sigs: readonly string[]) => ({ + send: async () => { + if (settleSignature !== undefined && sigs.includes(settleSignature)) { + statusRequested(); + statusRequests += 1; + if (statusRequests === 2) secondStatusRequested(); + await confirmationGate; + } + return { + context: { slot: 42 }, + value: sigs.map(() => ({ confirmationStatus: 'confirmed', err: null })), + }; + }, + }), + sendTransaction: (wire: string) => ({ + send: async () => { + sends.push(wire); + settleSignature = signedWireSignature(wire); + return settleSignature; + }, + }), + }; + const method = session({ + cap: 1_000_000n, + currency: 'USDC', + decimals: 6, + network: 'localnet', + operator: OPERATOR, + pricing: {}, + recipient: RECIPIENT, + rpc: rpc as never, + signer: merchant, + store, + }); + + await method.verify({ + credential: makeCred({ + action: 'open', + authorizedSigner: signer.address, + channelId, + deposit: '1000', + mode: 'push', + payer: signer.address, + signature: 'open-sig', + }), + request: {} as never, + }); + + const requestAbort = new AbortController(); + const firstClose = method.verify({ + credential: makeCred({ action: 'close', channelId }), + request: new Request('https://api.test/session/close', { signal: requestAbort.signal }) as never, + }); + await statusRequest; + + let state = await store.getChannel(channelId); + expect(state?.settlementPendingSignature).toBe(settleSignature); + expect(state?.settlementPendingWire).toBe(sends[0]); + expect(state?.settling).toBe(true); + expect(state?.sealed).toBe(false); + expect(state?.settledSignature).toBeUndefined(); + requestAbort.abort(); + + const secondClose = method.verify({ + credential: makeCred({ action: 'close', channelId }), + request: {} as never, + }); + await secondStatusRequest; + expect(sends).toHaveLength(2); + expect(new Set(sends).size).toBe(1); + + releaseConfirmation(); + const [firstReceipt, secondReceipt] = await Promise.all([firstClose, secondClose]); + expect(firstReceipt.status).toBe('success'); + expect(secondReceipt.status).toBe('success'); + state = await store.getChannel(channelId); + expect(state?.settlementPendingSignature).toBeUndefined(); + expect(state?.settling).toBe(false); + expect(state?.sealed).toBe(true); + expect(state?.settledSignature).toBe(settleSignature); + expect(sends).toHaveLength(2); + expect(new Set(sends).size).toBe(1); + }); + test('close refuses to settle when the channel payer (refund destination) was not recorded', async () => { const store = createMemorySessionStore(); const signer = await generateKeyPairSigner(); const merchant = await generateKeyPairSigner(); const channelId = '11111111111111111111111111111111'; const rpc = { + getBlockHeight: () => ({ send: async () => 0n }), getLatestBlockhash: () => ({ send: async () => ({ value: { blockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N', lastValidBlockHeight: 0n }, }), }), getSignatureStatuses: (sigs: readonly string[]) => ({ - send: async () => ({ value: sigs.map(() => ({ err: null })) }), + send: async () => ({ + context: { slot: 42 }, + value: sigs.map(() => ({ confirmationStatus: 'confirmed', err: null })), + }), }), sendTransaction: () => ({ send: async () => 'Sig' }), }; @@ -1410,7 +1990,7 @@ describe('session() verify() close retry', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1447,7 +2027,7 @@ describe('session() verify() commit replay', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1498,7 +2078,7 @@ describe('session() default store sharing', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, diff --git a/typescript/packages/mpp/src/__tests__/session-signature-only-open.test.ts b/typescript/packages/mpp/src/__tests__/session-signature-only-open.test.ts new file mode 100644 index 000000000..998a18456 --- /dev/null +++ b/typescript/packages/mpp/src/__tests__/session-signature-only-open.test.ts @@ -0,0 +1,286 @@ +import { + createKeyPairSignerFromPrivateKeyBytes, + getBase64Decoder, + getBase64Encoder, + getSignatureFromTransaction, + getTransactionDecoder, + type KeyPairSigner, +} from '@solana/kit'; +import { describe, expect, test } from 'vitest'; + +import { buildOpenPaymentChannelTransaction } from '../client/PaymentChannels.js'; +import type { SessionRequest } from '../client/Session.js'; +import { USDC } from '../constants.js'; +import { getChannelEncoder } from '../generated/payment-channels/accounts/channel.js'; +import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from '../generated/payment-channels/programs/paymentChannels.js'; +import { session } from '../server/Session.js'; +import { createMemorySessionStore } from '../server/session/store.js'; + +const EMPTY_DISTRIBUTION_HASH = [ + 0xdf, 0x3f, 0x61, 0x98, 0x04, 0xa9, 0x2f, 0xdb, 0x40, 0x57, 0x19, 0x2d, 0xc4, 0x3d, 0xd7, 0x48, 0xea, 0x77, 0x8a, + 0xdc, 0x52, 0xbc, 0x49, 0x8c, 0xe8, 0x05, 0x24, 0xc0, 0x14, 0xb8, 0x11, 0x19, +]; + +function seed(byte: number): Uint8Array { + const value = new Uint8Array(32); + value.fill(byte); + return value; +} + +async function loadSigners(): Promise<[KeyPairSigner, KeyPairSigner, KeyPairSigner]> { + return (await Promise.all([ + createKeyPairSignerFromPrivateKeyBytes(seed(0x21)), + createKeyPairSignerFromPrivateKeyBytes(seed(0x22)), + createKeyPairSignerFromPrivateKeyBytes(seed(0x23)), + ])) as [KeyPairSigner, KeyPairSigner, KeyPairSigner]; +} + +async function buildOpen( + payer: KeyPairSigner, + payee: KeyPairSigner, + authorizedSigner: KeyPairSigner, + salt: bigint, + deposit: bigint, +) { + const request: SessionRequest = { + cap: '5000000', + currency: USDC.mainnet!, + decimals: 6, + network: 'localnet', + operator: payer.address, + recentBlockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N' as never, + recentSlot: '4242', + recipient: payee.address, + }; + return await buildOpenPaymentChannelTransaction({ + authorizedSigner: authorizedSigner.address, + deposit, + gracePeriod: 900, + programAddress: PAYMENT_CHANNELS_PROGRAM_ADDRESS, + request, + salt, + signer: payer, + }); +} + +function encodeChannel( + open: { + readonly authorizedSigner: string; + readonly channelId: string; + readonly deposit: string; + readonly mint: string; + readonly openSlot: string; + readonly payee: string; + readonly payer: string; + }, + deposit = BigInt(open.deposit), +): string { + const bytes = getChannelEncoder().encode({ + authorizedSigner: open.authorizedSigner as never, + bump: 255, + closureStartedAt: 0n, + deposit, + discriminator: 1, + distributionHash: EMPTY_DISTRIBUTION_HASH, + gracePeriod: 900, + mint: open.mint as never, + openSlot: BigInt(open.openSlot), + payer: open.payer as never, + payee: open.payee as never, + payerWithdrawnAt: 0n, + rentPayer: open.payer as never, + salt: 7n, + settlement: { payoutWatermark: 0n, settled: 0n }, + status: 0, + version: 1, + }); + return getBase64Decoder().decode(bytes); +} + +function makeRpc(wire: string, channelData: string) { + const calls: string[] = []; + return { + calls, + getAccountInfo: () => ({ + send: async () => { + calls.push('account'); + return { + value: { + data: [channelData, 'base64'], + owner: PAYMENT_CHANNELS_PROGRAM_ADDRESS, + }, + }; + }, + }), + getSignatureStatuses: (signatures: readonly string[]) => ({ + send: async () => { + calls.push('status'); + return { + context: { slot: 5000 }, + value: signatures.map(() => ({ confirmationStatus: 'confirmed', err: null })), + }; + }, + }), + getTransaction: () => ({ + send: async () => { + calls.push('transaction'); + return { + meta: { err: null, loadedAddresses: { readonly: [], writable: [] } }, + transaction: [wire, 'base64'], + } as const; + }, + }), + }; +} + +function openCredential( + open: Awaited>, + payer: KeyPairSigner, + payee: KeyPairSigner, + authorizedSigner: KeyPairSigner, + signature: string, + deposit = open.deposit, +) { + return { + challenge: { + id: 'signature-only-open', + intent: 'session', + method: 'solana', + realm: 'api.test', + request: { + cap: '5000000', + currency: USDC.mainnet!, + network: 'localnet', + operator: payer.address, + recentSlot: '4242', + recipient: payee.address, + }, + }, + payload: { + action: 'open', + authorizedSigner: authorizedSigner.address, + channelId: open.channelId, + deposit: deposit.toString(), + mode: 'push', + recentSlot: '4242', + signature, + }, + } as never; +} + +async function makeMethod( + open: Awaited>, + payer: KeyPairSigner, + payee: KeyPairSigner, + authorizedSigner: KeyPairSigner, + rpc: ReturnType, +) { + return session({ + cap: 5_000_000n, + currency: USDC.mainnet!, + decimals: 6, + network: 'localnet', + operator: payer.address, + pricing: {}, + recipient: payee.address, + rpc: rpc as never, + store: createMemorySessionStore(), + }); +} + +describe('session() signature-only payment-channel opens', () => { + test('fetches and binds the confirmed transaction before Channel state', async () => { + const [payer, payee, authorizedSigner] = await loadSigners(); + const open = await buildOpen(payer, payee, authorizedSigner, 7n, 1_000_000n); + const rpc = makeRpc( + open.transaction, + encodeChannel({ + authorizedSigner: authorizedSigner.address, + channelId: open.channelId, + deposit: open.deposit, + mint: open.mint, + openSlot: open.openSlot, + payee: payee.address, + payer: payer.address, + }), + ); + const method = await makeMethod(open, payer, payee, authorizedSigner, rpc); + + const receipt = await method.verify({ + credential: openCredential( + open, + payer, + payee, + authorizedSigner, + getSignatureFromTransaction( + getTransactionDecoder().decode(getBase64Encoder().encode(open.transaction)), + ), + ), + request: {} as never, + }); + + expect(receipt.status).toBe('success'); + expect(rpc.calls.indexOf('transaction')).toBeLessThan(rpc.calls.indexOf('account')); + }); + + test('rejects a confirmed unrelated open before reading Channel state', async () => { + const [payer, payee, authorizedSigner] = await loadSigners(); + const open = await buildOpen(payer, payee, authorizedSigner, 7n, 1_000_000n); + const unrelated = await buildOpen(payer, payee, authorizedSigner, 8n, 1_000_000n); + const unrelatedSignature = getSignatureFromTransaction( + getTransactionDecoder().decode(getBase64Encoder().encode(unrelated.transaction)), + ); + const rpc = makeRpc( + unrelated.transaction, + encodeChannel({ + authorizedSigner: authorizedSigner.address, + channelId: open.channelId, + deposit: open.deposit, + mint: open.mint, + openSlot: open.openSlot, + payee: payee.address, + payer: payer.address, + }), + ); + const method = await makeMethod(open, payer, payee, authorizedSigner, rpc); + + await expect( + method.verify({ + credential: openCredential(open, payer, payee, authorizedSigner, unrelatedSignature), + request: {} as never, + }), + ).rejects.toThrow(/open channel/); + expect(rpc.calls).not.toContain('account'); + }); + + test('rejects an asserted deposit that differs from authoritative Channel.deposit', async () => { + const [payer, payee, authorizedSigner] = await loadSigners(); + const open = await buildOpen(payer, payee, authorizedSigner, 7n, 1_000_000n); + const rpc = makeRpc( + open.transaction, + encodeChannel( + { + authorizedSigner: authorizedSigner.address, + channelId: open.channelId, + deposit: open.deposit, + mint: open.mint, + openSlot: open.openSlot, + payee: payee.address, + payer: payer.address, + }, + 900_000n, + ), + ); + const method = await makeMethod(open, payer, payee, authorizedSigner, rpc); + const signature = getSignatureFromTransaction( + getTransactionDecoder().decode(getBase64Encoder().encode(open.transaction)), + ); + + await expect( + method.verify({ + credential: openCredential(open, payer, payee, authorizedSigner, signature), + request: {} as never, + }), + ).rejects.toThrow(/on-chain deposit 900000 != expected 1000000/); + }); +}); diff --git a/typescript/packages/mpp/src/__tests__/session-state-binding.test.ts b/typescript/packages/mpp/src/__tests__/session-state-binding.test.ts new file mode 100644 index 000000000..0b7fedf75 --- /dev/null +++ b/typescript/packages/mpp/src/__tests__/session-state-binding.test.ts @@ -0,0 +1,444 @@ +import { + address, + type Address, + getAddressEncoder, + getBase64Decoder, + getBase64Encoder, + getProgramDerivedAddress, + getU64Encoder, + getUtf8Encoder, +} from '@solana/kit'; +import { describe, expect, test } from 'vitest'; + +import { resolveStablecoinMint, TOKEN_PROGRAM } from '../constants.js'; +import { getChannelEncoder } from '../generated/payment-channels/accounts/channel.js'; +import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from '../generated/payment-channels/programs/paymentChannels.js'; +import { session } from '../server/Session.js'; +import { verifyChannelAccountState } from '../server/session/on-chain.js'; +import { createMemorySessionStore } from '../server/session/store.js'; + +const OPERATOR = '9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ'; +const RECIPIENT = '5fKb5cF22cFybZB1H4hLDydFhwoQy9JzKzRWaSbMkB6h'; +const SIGNER = 'CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY'; +const PAYER = '11111111111111111111111111111111'; +const CLAIMED_PAYER = 'SysvarRent111111111111111111111111111111111'; +const LOCALNET_USDC = resolveStablecoinMint('USDC', 'localnet')!; +const EMPTY_DISTRIBUTION_HASH = Uint8Array.from([ + 0xdf, 0x3f, 0x61, 0x98, 0x04, 0xa9, 0x2f, 0xdb, 0x40, 0x57, 0x19, 0x2d, 0xc4, 0x3d, 0xd7, 0x48, 0xea, 0x77, 0x8a, + 0xdc, 0x52, 0xbc, 0x49, 0x8c, 0xe8, 0x05, 0x24, 0xc0, 0x14, 0xb8, 0x11, 0x19, +]); + +async function channelId(): Promise { + const [derived] = await getProgramDerivedAddress({ + programAddress: address(PAYMENT_CHANNELS_PROGRAM_ADDRESS), + seeds: [ + getUtf8Encoder().encode('channel'), + getAddressEncoder().encode(address(PAYER)), + getAddressEncoder().encode(address(RECIPIENT)), + getAddressEncoder().encode(address(LOCALNET_USDC)), + getAddressEncoder().encode(address(SIGNER)), + getU64Encoder().encode(7n), + getU64Encoder().encode(42n), + ], + }); + return derived; +} + +function encodeChannel( + deposit: bigint, + status = 0, + gracePeriod = 900, + settlement: { readonly payoutWatermark: bigint; readonly settled: bigint } = { + payoutWatermark: 0n, + settled: 0n, + }, +): string { + const bytes = getChannelEncoder().encode({ + discriminator: 1, + version: 1, + bump: 255, + status, + salt: 7n, + deposit, + settlement, + closureStartedAt: 0n, + payerWithdrawnAt: 0n, + gracePeriod, + distributionHash: Array.from(EMPTY_DISTRIBUTION_HASH), + payer: address(PAYER), + payee: address(RECIPIENT), + authorizedSigner: address(SIGNER), + mint: address(LOCALNET_USDC), + rentPayer: address(OPERATOR), + openSlot: 42n, + }); + return getBase64Decoder().decode(bytes); +} + +function rpcWithChannel(data: string) { + return { + getAccountInfo: (_address: Address) => ({ + send: async () => ({ + value: { data: [data, 'base64'], owner: PAYMENT_CHANNELS_PROGRAM_ADDRESS }, + }), + }), + getSignatureStatuses: (signatures: readonly string[]) => ({ + send: async () => ({ + context: { slot: 42 }, + value: signatures.map(() => ({ err: null, confirmationStatus: 'confirmed' })), + }), + }), + }; +} + +function credential(payload: unknown, requestOverrides: Record = {}) { + return { + challenge: { + id: 'challenge-id', + intent: 'session', + method: 'solana', + realm: 'api.test', + request: { + cap: '10000', + currency: 'USDC', + operator: OPERATOR, + recipient: RECIPIENT, + ...requestOverrides, + }, + }, + payload, + } as never; +} + +function parameters(overrides: Record = {}) { + return { + cap: 10_000n, + currency: 'USDC', + decimals: 6, + network: 'localnet', + operator: OPERATOR, + pricing: {}, + recipient: RECIPIENT, + ...overrides, + } as Parameters[0]; +} + +describe('session Channel account state binding', () => { + test('bare push open fails closed when rpc cannot fetch its transaction', async () => { + const channel = await channelId(); + const store = createMemorySessionStore(); + const method = session(parameters({ rpc: rpcWithChannel(encodeChannel(4_000n)) as never, store })); + await expect( + method.verify({ + credential: credential({ + action: 'open', + authorizedSigner: SIGNER, + channelId: channel, + deposit: '4000', + mode: 'push', + payer: CLAIMED_PAYER, + signature: 'open-signature', + }), + request: {} as never, + }), + ).rejects.toThrow(/does not expose getTransaction/); + expect(await store.getChannel(channel)).toBeUndefined(); + }); + + test('binds signature-only opens to the challenge incarnation and configured grace period', async () => { + const channel = await channelId(); + const store = createMemorySessionStore(); + const method = session( + parameters({ + rpc: rpcWithChannel(encodeChannel(4_000n, 0, 600)) as never, + settlementWindowSeconds: 600n, + store, + }), + ); + + await expect( + method.verify({ + credential: credential( + { + action: 'open', + authorizedSigner: SIGNER, + channelId: channel, + deposit: '1000', + mode: 'push', + recentSlot: '41', + signature: 'open-signature', + }, + { recentSlot: '42' }, + ), + request: {} as never, + }), + ).rejects.toThrow(/does not match challenge recentSlot/); + + await expect( + method.verify({ + credential: credential( + { + action: 'open', + authorizedSigner: SIGNER, + channelId: channel, + deposit: '1000', + mode: 'push', + recentSlot: '43', + signature: 'open-signature', + }, + { recentSlot: '43' }, + ), + request: {} as never, + }), + ).rejects.toThrow(/does not expose getTransaction/); + }); + + test('topUp rejects a mismatched resulting deposit and non-open status', async () => { + const channel = await channelId(); + const store = createMemorySessionStore(); + await store.updateChannel(channel, () => ({ + authorizedSigner: SIGNER, + channelId: channel, + committedDeliveries: [], + cumulative: 0n, + deposit: 1_000n, + nextDeliverySequence: 0n, + operator: PAYER, + pendingDeliveries: [], + sealed: false, + })); + const payload = { + action: 'topUp', + channelId: channel, + newDeposit: '3000', + signature: 'topup-signature', + }; + const mismatch = session(parameters({ rpc: rpcWithChannel(encodeChannel(2_000n)) as never, store })); + await expect(mismatch.verify({ credential: credential(payload), request: {} as never })).rejects.toThrow( + /on-chain deposit 2000 != expected 3000/, + ); + expect((await store.getChannel(channel))?.deposit).toBe(1_000n); + + const wrongSigner = session(parameters({ rpc: rpcWithChannel(encodeChannel(3_000n)) as never, store })); + await store.updateChannel(channel, current => { + if (!current) throw new Error('missing test channel'); + return { ...current, authorizedSigner: CLAIMED_PAYER }; + }); + await expect(wrongSigner.verify({ credential: credential(payload), request: {} as never })).rejects.toThrow( + /authorizedSigner/, + ); + + const sealed = session(parameters({ rpc: rpcWithChannel(encodeChannel(3_000n, 1)) as never, store })); + await expect(sealed.verify({ credential: credential(payload), request: {} as never })).rejects.toThrow( + /not open on-chain/, + ); + }); + + test('topUp accepts an existing channel with a settlement watermark', async () => { + const channel = await channelId(); + const store = createMemorySessionStore(); + await store.updateChannel(channel, () => ({ + authorizedSigner: SIGNER, + channelId: channel, + committedDeliveries: [], + cumulative: 0n, + deposit: 1_000n, + nextDeliverySequence: 0n, + operator: PAYER, + pendingDeliveries: [], + sealed: false, + })); + const method = session( + parameters({ + rpc: rpcWithChannel( + encodeChannel(3_000n, 0, 900, { payoutWatermark: 1_000n, settled: 1_000n }), + ) as never, + store, + }), + ); + + await expect( + method.verify({ + credential: credential({ + action: 'topUp', + channelId: channel, + newDeposit: '3000', + signature: 'topup-signature', + }), + request: {} as never, + }), + ).resolves.toMatchObject({ status: 'success' }); + expect((await store.getChannel(channel))?.deposit).toBe(3_000n); + }); + + test('topUp fails closed without rpc off localnet', async () => { + const channel = await channelId(); + const store = createMemorySessionStore(); + await store.updateChannel(channel, () => ({ + authorizedSigner: SIGNER, + channelId: channel, + committedDeliveries: [], + cumulative: 0n, + deposit: 1_000n, + nextDeliverySequence: 0n, + operator: PAYER, + pendingDeliveries: [], + sealed: false, + })); + const durableStore = { ...store, sessionStoreDurability: 'durable-shared' as const }; + const method = session(parameters({ network: 'devnet', store: durableStore, tokenProgram: TOKEN_PROGRAM })); + await expect( + method.verify({ + credential: credential({ + action: 'topUp', + channelId: channel, + newDeposit: '3000', + signature: 'topup-signature', + }), + request: {} as never, + }), + ).rejects.toThrow(/requires an rpc client/); + }); + + test('rejects processed signatures and malformed Channel accounts', async () => { + const channel = await channelId(); + const valid = encodeChannel(2_000n); + const bytes = getBase64Encoder().encode(valid); + const expected = { + authorizedSigner: SIGNER, + mint: LOCALNET_USDC, + payee: RECIPIENT, + programId: PAYMENT_CHANNELS_PROGRAM_ADDRESS, + rentPayer: OPERATOR, + }; + for (const malformed of [ + Uint8Array.from([9, ...bytes.slice(1)]), + Uint8Array.from([bytes[0]!, 9, ...bytes.slice(2)]), + bytes.slice(0, -1), + ]) { + await expect( + verifyChannelAccountState({ + channelId: channel, + expected, + rpc: rpcWithChannel(getBase64Decoder().decode(malformed)), + }), + ).rejects.toThrow(); + } + + const processedRpc = { + ...rpcWithChannel(valid), + getSignatureStatuses: () => ({ + send: async () => ({ + context: { slot: 42 }, + value: [{ confirmationStatus: 'processed', err: null }], + }), + }), + }; + const durableStore = { + ...createMemorySessionStore(), + sessionStoreDurability: 'durable-shared' as const, + }; + const method = session(parameters({ network: 'devnet', rpc: processedRpc as never, store: durableStore })); + await expect( + method.verify({ + credential: credential({ + action: 'open', + authorizedSigner: SIGNER, + channelId: channel, + deposit: '1000', + mode: 'push', + signature: 'processed-signature', + }), + request: {} as never, + }), + ).rejects.toThrow(/only processed/); + }); + + test.each([ + ['missing context', undefined], + ['missing slot', {}], + ['negative slot', { slot: -1 }], + ['fractional slot', { slot: 1.5 }], + ['non-numeric slot', { slot: '42' }], + ])('rejects a confirmed signature with %s', async (_label, context) => { + const channel = await channelId(); + let accountInfoRequested = false; + const rpc = { + ...rpcWithChannel(encodeChannel(1_000n)), + getAccountInfo: (_address: Address) => ({ + send: async () => { + accountInfoRequested = true; + return { + value: { data: [encodeChannel(1_000n), 'base64'], owner: PAYMENT_CHANNELS_PROGRAM_ADDRESS }, + }; + }, + }), + getSignatureStatuses: () => ({ + send: async () => ({ + context, + value: [{ confirmationStatus: 'confirmed', err: null }], + }), + }), + }; + const method = session(parameters({ rpc: rpc as never })); + + await expect( + method.verify({ + credential: credential({ + action: 'open', + authorizedSigner: SIGNER, + channelId: channel, + deposit: '1000', + mode: 'push', + signature: 'confirmed-signature', + }), + request: {} as never, + }), + ).rejects.toThrow(/confirmation response has an invalid context slot/); + expect(accountInfoRequested).toBe(false); + }); + + test('rejects an account whose grace period or distribution hash differs from the challenge', async () => { + const channel = await channelId(); + const expected = { + authorizedSigner: SIGNER, + mint: LOCALNET_USDC, + payee: RECIPIENT, + programId: PAYMENT_CHANNELS_PROGRAM_ADDRESS, + rentPayer: OPERATOR, + }; + const rpc = rpcWithChannel(encodeChannel(2_000n)); + + await expect( + verifyChannelAccountState({ + channelId: channel, + expected: { ...expected, gracePeriod: 899 }, + rpc, + }), + ).rejects.toThrow(/gracePeriod/); + await expect( + verifyChannelAccountState({ + channelId: channel, + expected: { ...expected, splits: [{ bps: 100, recipient: RECIPIENT }] }, + rpc, + }), + ).rejects.toThrow(/distributionHash/); + }); + + test('keeps fresh-channel state binding strict about settlement watermarks', async () => { + const channel = await channelId(); + await expect( + verifyChannelAccountState({ + channelId: channel, + expected: { + authorizedSigner: SIGNER, + mint: LOCALNET_USDC, + payee: RECIPIENT, + programId: PAYMENT_CHANNELS_PROGRAM_ADDRESS, + rentPayer: OPERATOR, + }, + rpc: rpcWithChannel(encodeChannel(2_000n, 0, 900, { payoutWatermark: 1_000n, settled: 1_000n })), + }), + ).rejects.toThrow(/nonzero settlement watermarks/); + }); +}); diff --git a/typescript/packages/mpp/src/__tests__/session-store.test.ts b/typescript/packages/mpp/src/__tests__/session-store.test.ts index 6c6f200f4..56c1a676c 100644 --- a/typescript/packages/mpp/src/__tests__/session-store.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-store.test.ts @@ -80,6 +80,62 @@ describe('createMemorySessionStore', () => { expect((await store.getChannel('c1'))?.cumulative).toBe(50n); }); + test('updateChannel grants one concurrent settlement claim and preserves its pending signature', async () => { + const store = createMemorySessionStore(); + await store.updateChannel('c1', () => makeState({ channelId: 'c1' })); + const now = BigInt(Date.now()); + let claims = 0; + + await Promise.all( + Array.from({ length: 20 }, (_, index) => + store.updateChannel('c1', current => { + if ( + current?.settling && + current.settlementClaimExpiresAt !== undefined && + current.settlementClaimExpiresAt > now + ) { + return current; + } + claims += 1; + return { + ...current!, + settlementClaimExpiresAt: now + 30_000n, + settlementClaimOwner: `owner-${index}`, + settling: true, + }; + }), + ), + ); + expect(claims).toBe(1); + + const pending = await store.updateChannel('c1', current => ({ + ...current!, + settlementPendingLastValidBlockHeight: 123n, + settlementPendingSignature: 'settle-signature', + settlementPendingWire: 'signed-wire', + settling: false, + })); + expect(pending.settlementPendingSignature).toBe('settle-signature'); + expect(pending.settlementPendingLastValidBlockHeight).toBe(123n); + expect(pending.settlementPendingWire).toBe('signed-wire'); + expect(pending.settling).toBe(false); + + await store.updateChannel('c1', current => ({ + ...current!, + settlementClaimExpiresAt: 0n, + settlementPendingLastValidBlockHeight: undefined, + settlementPendingSignature: undefined, + settlementPendingWire: undefined, + settling: true, + })); + const recovered = await store.updateChannel('c1', current => ({ + ...current!, + settlementClaimExpiresAt: now + 30_000n, + settlementClaimOwner: 'recovery-owner', + })); + expect(recovered.settlementClaimOwner).toBe('recovery-owner'); + }); + test('updateChannel error does not poison subsequent updates', async () => { const store = createMemorySessionStore(); await store.updateChannel('c1', () => makeState({ channelId: 'c1', cumulative: 7n })); diff --git a/typescript/packages/mpp/src/server/Session.ts b/typescript/packages/mpp/src/server/Session.ts index 0b16b40a3..6fe2df7d2 100644 --- a/typescript/packages/mpp/src/server/Session.ts +++ b/typescript/packages/mpp/src/server/Session.ts @@ -1,6 +1,9 @@ import { type Address, createSolanaRpc, + getBase64Codec, + getSignatureFromTransaction, + getTransactionDecoder, isTransactionPartialSigner, type Signature, type TransactionPartialSigner, @@ -28,15 +31,24 @@ import type { import { normalizeSignedVoucher, verifyVoucherSignature } from '../shared/voucher.js'; import { createLifecycle, type Lifecycle } from './session/lifecycle.js'; import { + type ConfirmSignatureOptions, + type GetAccountInfoRpc, + isGetAccountInfoRpc, + isOpenTransactionRpc, type MultiDelegateSubmitRpc, + type OpenTransactionRpc, PAYMENT_CHANNELS_PROGRAM_ID, + SignatureConfirmationError, submitInitMultiDelegateTxIfMissing, submitOpenTx, - submitSettleAndDistribute, type SubmitSettleAndDistributeResult, + submitSettleAndDistributeWithPreBroadcastPersistence, type TopUpTransactionRpc, + verifyChannelAccountState, verifyOpenTx, + verifySignatureOnlyOpenTransaction, verifyTopUpTransaction, + waitForSignatureConfirmation, } from './session/on-chain.js'; import { type ChannelState, @@ -46,11 +58,12 @@ import { type SessionStore, } from './session/store.js'; import { verifyVoucherForChannel, type VoucherVerifyResult } from './session/voucher.js'; -import { buildAndSignWireTransaction } from './session/wire-tx.js'; +import { buildAndSignWireTransactionWithLifetime } from './session/wire-tx.js'; // The Rust mirror keeps this default literal in // `crate::protocol::intents::session::DEFAULT_SESSION_EXPIRES_AT` (year 2100). const DEFAULT_DIRECTIVE_EXPIRES_AT = 4_102_444_800; +const SETTLEMENT_CLAIM_LEASE_MS = 30_000n; // Lazily-created default store shared by `session()` and `session.routes()` // when both are built from the same parameters object — otherwise each call @@ -176,6 +189,7 @@ export function session(parameters: session.Parameters) { minVoucherDelta, openTxSubmitter = 'client', paymentChannelPayerSigner, + settlementConfirmation, settlementWindowSeconds, } = parameters; @@ -185,6 +199,9 @@ export function session(parameters: session.Parameters) { if (signer && !isTransactionPartialSigner(signer)) { throw new Error('signer must implement signTransactions()'); } + if (signer && rpc) { + requireSettlementRpc(rpc); + } if (paymentChannelPayerSigner && !isTransactionPartialSigner(paymentChannelPayerSigner)) { throw new Error('paymentChannelPayerSigner must implement signTransactions()'); } @@ -207,6 +224,7 @@ export function session(parameters: session.Parameters) { const resolvedProgramId = (programId ?? PAYMENT_CHANNELS_PROGRAM_ID) as Address; const resolvedMint = resolveStablecoinMint(currency, network) ?? currency; const tokenProgram = parameters.tokenProgram ?? defaultTokenProgramForCurrency(currency, network); + const sessionGracePeriod = expectedSessionGracePeriod(settlementWindowSeconds); const lifecycleRef: { value: Lifecycle | undefined } = { value: undefined }; // Note: lifecycle's closeOnIdle would normally drive an on-chain settle. @@ -229,6 +247,7 @@ export function session(parameters: session.Parameters) { programId: resolvedProgramId, recipient, rpc, + settlementConfirmation, splits, store, tokenProgram, @@ -326,6 +345,7 @@ export function session(parameters: session.Parameters) { challengeOpenSlot: parseOptionalU64(cred.challenge.request.recentSlot, 'recentSlot'), currency, externalId: cred.challenge.request.externalId, + gracePeriod: sessionGracePeriod, lifecycle: lifecycleRef.value, merchantSigner: signer, mint: resolvedMint, @@ -341,6 +361,7 @@ export function session(parameters: session.Parameters) { rpc, splits, store, + tokenProgram, }); case 'voucher': return await handleVoucher({ @@ -366,10 +387,16 @@ export function session(parameters: session.Parameters) { cap, challengeId: cred.challenge.id, externalId: cred.challenge.request.externalId, + gracePeriod: sessionGracePeriod, lifecycle: lifecycleRef.value, + mint: resolvedMint, + network, + operator, payload: cred.payload, programId: resolvedProgramId, + recipient, rpc, + splits, store, }); case 'close': @@ -387,6 +414,7 @@ export function session(parameters: session.Parameters) { programId: resolvedProgramId, recipient, rpc, + settlementConfirmation, settlementWindow: settlementWindowSeconds, splits, store, @@ -477,6 +505,7 @@ interface HandleOpenArgs { readonly challengeOpenSlot: bigint | undefined; readonly currency: string; readonly externalId: string | undefined; + readonly gracePeriod: number; readonly lifecycle: Lifecycle | undefined; readonly merchantSigner: TransactionPartialSigner | undefined; readonly mint: string; @@ -493,6 +522,7 @@ interface HandleOpenArgs { readonly rpc: RpcLike | undefined; readonly splits: readonly SessionSplit[] | undefined; readonly store: SessionStore; + readonly tokenProgram: string; } async function handleOpen(args: HandleOpenArgs): Promise { @@ -516,6 +546,8 @@ async function handleOpen(args: HandleOpenArgs): Promise { // open transaction when present; otherwise from the client payload's // `recentSlot` echo. let openSlot: bigint | undefined = parseOptionalU64(payload.recentSlot, 'recentSlot'); + let salt: bigint | undefined = parseOptionalU64(payload.salt, 'salt'); + const paymentChannelBacked = payload.transaction !== undefined || mode === 'push'; if (mode === 'push' && !payload.transaction && !payload.channelId) { throw new Error('open payload missing transaction or channelId'); @@ -533,6 +565,7 @@ async function handleOpen(args: HandleOpenArgs): Promise { const expected = { authorizedSigner: payload.authorizedSigner, currency: args.currency, + gracePeriod: args.gracePeriod, maxCap: args.cap, mint: args.mint, network: args.network, @@ -540,7 +573,8 @@ async function handleOpen(args: HandleOpenArgs): Promise { operator: args.operator, programId: args.programId.toString(), recipient: args.recipient, - splits: args.splits ?? [], + splits: args.splits, + tokenProgram: args.tokenProgram, }; if (args.openTxSubmitter === 'server') { @@ -550,11 +584,17 @@ async function handleOpen(args: HandleOpenArgs): Promise { const preVerified = await verifyOpenTx({ expected, openPayload: payload }); const existing = await args.store.getChannel(preVerified.channelId); if (existing) { + if (!existing.openSignature) { + throw new Error( + `server-submitted open ${preVerified.channelId} is missing its persisted broadcast signature`, + ); + } channelId = preVerified.channelId; deposit = preVerified.deposit; channelPayer = preVerified.payer; openSlot = preVerified.openSlot; - signature = payload.signature; + salt = preVerified.salt; + signature = existing.openSignature; } else { const submitted = await submitOpenTx({ expected, @@ -566,6 +606,7 @@ async function handleOpen(args: HandleOpenArgs): Promise { deposit = submitted.deposit; channelPayer = submitted.payer; openSlot = submitted.openSlot; + salt = submitted.salt; signature = submitted.signature as unknown as string; } } else { @@ -578,17 +619,12 @@ async function handleOpen(args: HandleOpenArgs): Promise { deposit = verified.deposit; channelPayer = verified.payer; openSlot = verified.openSlot; + salt = verified.salt; signature = payload.signature; } } else if (mode === 'push') { - // No transaction in payload: the client asserts a previously - // broadcast open. When an RPC client is configured the open - // signature is confirmed on-chain before persisting (mirrors - // Rust `process_open`); without one the channelId/deposit - // fields are trusted as-is, matching Rust with `rpc_url` - // unset. The generated payment-channels client has no Channel - // account decoder yet, so the on-chain channel fields - // (payee/mint/authorizedSigner/deposit) are not re-checked. + // No transaction in payload: confirm the asserted signature, then bind + // the state below to the authoritative Channel account. channelId = expectString(payload.channelId, 'channelId'); deposit = parseU64String(expectString(payload.deposit, 'deposit'), 'deposit'); signature = payload.signature; @@ -622,6 +658,88 @@ async function handleOpen(args: HandleOpenArgs): Promise { } } + if (!payload.transaction && mode === 'push' && args.challengeOpenSlot !== undefined) { + if (openSlot === undefined) { + throw new Error('open payload missing recentSlot for challenge-bound push open'); + } + if (openSlot !== args.challengeOpenSlot) { + throw new Error( + `open payload recentSlot ${openSlot} does not match challenge recentSlot ${args.challengeOpenSlot}`, + ); + } + } + + if (paymentChannelBacked) { + if (!args.rpc) { + if (args.network !== 'localnet') { + throw new Error( + 'payment-channel open requires an rpc client to bind the on-chain channel off localnet', + ); + } + } else if (!isGetAccountInfoRpc(args.rpc)) { + if (args.network !== 'localnet') { + throw new Error( + 'open: configured rpc does not expose getAccountInfo; cannot bind the open to the on-chain Channel account', + ); + } + } else { + const confirmedSlot = await assertSignatureSucceeded( + args.rpc as unknown as VerifyOpenRpc, + signature, + 'open', + ); + if (!payload.transaction) { + if (!isOpenTransactionRpc(args.rpc)) { + throw new Error( + 'open: configured rpc does not expose getTransaction; cannot bind the signature-only open to its canonical transaction', + ); + } + await verifySignatureOnlyOpenTransaction({ + channelId, + deposit, + expected: { + authorizedSigner: payload.authorizedSigner, + currency: args.currency, + gracePeriod: args.gracePeriod, + maxCap: args.cap, + mint: args.mint, + network: args.network, + openSlot, + operator: args.operator, + programId: args.programId.toString(), + recipient: args.recipient, + splits: args.splits, + tokenProgram: args.tokenProgram, + }, + openSlot, + rpc: args.rpc as OpenTransactionRpc, + signature: expectString(signature, 'signature') as Signature, + }); + } + const channel = await verifyChannelAccountState({ + channelId, + expected: { + authorizedSigner: payload.authorizedSigner, + ...(payload.transaction ? {} : { deposit }), + gracePeriod: args.gracePeriod, + mint: args.mint, + openSlot: args.challengeOpenSlot, + payee: args.recipient, + payer: payload.transaction ? channelPayer : undefined, + programId: args.programId.toString(), + rentPayer: args.operator, + splits: args.splits, + }, + minContextSlot: confirmedSlot, + rpc: args.rpc, + }); + deposit = channel.deposit; + channelPayer = channel.payer; + openSlot = channel.openSlot; + salt = channel.salt; + } + } + if (deposit === 0n) throw new Error('deposit must be greater than zero'); if (deposit > args.cap) throw new Error(`deposit ${deposit} exceeds cap ${args.cap}`); @@ -636,6 +754,8 @@ async function handleOpen(args: HandleOpenArgs): Promise { highestVoucherSignature: undefined, nextDeliverySequence: 0n, openSlot, + salt, + ...(args.openTxSubmitter === 'server' ? { openSignature: signature } : {}), // Prefer the payer read from the verified open transaction (account 0, // what the channel actually records) over the client-supplied payload // fields, which could be stale/wrong. Fall back to the payload only for @@ -772,7 +892,11 @@ interface HandleTopUpArgs { readonly cap: bigint; readonly challengeId: string | undefined; readonly externalId: string | undefined; + readonly gracePeriod: number; readonly lifecycle: Lifecycle | undefined; + readonly mint: string; + readonly network: string; + readonly operator: string; readonly payload: { readonly action: 'topUp'; readonly channelId: string; @@ -780,7 +904,9 @@ interface HandleTopUpArgs { readonly signature: string; }; readonly programId: Address; + readonly recipient: string; readonly rpc: RpcLike | undefined; + readonly splits: readonly SessionSplit[] | undefined; readonly store: SessionStore; } @@ -798,20 +924,57 @@ async function handleTopUp(args: HandleTopUpArgs): Promise { throw new Error('Channel close is pending — no further top-ups accepted'); } - // Confirm the top-up transaction on-chain before raising the deposit - // (parity with the open-signature verification). + // Confirm the signature and bind the resulting account state before + // raising the local deposit. if (args.rpc) { - if (!isTopUpTransactionRpc(args.rpc)) { + const hasTransactionRpc = isTopUpTransactionRpc(args.rpc); + const hasAccountInfoRpc = isGetAccountInfoRpc(args.rpc); + if (!hasTransactionRpc && !hasAccountInfoRpc) { throw new Error('topUp requires an rpc client with getTransaction to bind the deposit delta'); } - await assertSignatureSucceeded(args.rpc as VerifyOpenRpc, args.payload.signature, 'topUp'); - await verifyTopUpTransaction({ - amount: newDeposit - existing.deposit, - channelId: args.payload.channelId, - programId: args.programId, - rpc: args.rpc, - signature: args.payload.signature as Signature, - }); + const confirmedSlot = await assertSignatureSucceeded( + args.rpc as VerifyOpenRpc, + args.payload.signature, + 'topUp', + ); + if (hasTransactionRpc) { + await verifyTopUpTransaction({ + amount: newDeposit - existing.deposit, + channelId: args.payload.channelId, + programId: args.programId.toString(), + rpc: args.rpc, + signature: args.payload.signature as Signature, + }); + } + if (!hasAccountInfoRpc) { + if (args.network !== 'localnet') { + throw new Error( + 'topUp: configured rpc does not expose getAccountInfo; cannot bind the raised deposit to the on-chain Channel account', + ); + } + } else { + await verifyChannelAccountState({ + channelId: args.payload.channelId, + expected: { + authorizedSigner: existing.authorizedSigner, + deposit: newDeposit, + gracePeriod: args.gracePeriod, + mint: args.mint, + payee: args.recipient, + payer: existing.operator, + programId: args.programId.toString(), + rentPayer: args.operator, + // An existing channel may have advanced its settlement + // watermark; freshness is required only when opening it. + requireFresh: false, + splits: args.splits, + }, + minContextSlot: confirmedSlot, + rpc: args.rpc as GetAccountInfoRpc, + }); + } + } else if (args.network !== 'localnet') { + throw new Error('payment-channel top-up requires an rpc client to bind the on-chain channel off localnet'); } const result = await args.store.updateChannel(args.payload.channelId, current => { @@ -860,6 +1023,7 @@ interface HandleCloseArgs { readonly programId: Address; readonly recipient: string; readonly rpc: RpcLike | undefined; + readonly settlementConfirmation: ConfirmSignatureOptions | undefined; readonly settlementWindow: bigint | undefined; readonly splits: readonly SessionSplit[] | undefined; readonly store: SessionStore; @@ -934,6 +1098,7 @@ async function handleClose(args: HandleCloseArgs): Promise { programId: args.programId, recipient: args.recipient, rpc: args.rpc, + settlementConfirmation: args.settlementConfirmation, splits: args.splits, store: args.store, tokenProgram: args.tokenProgram, @@ -1120,6 +1285,7 @@ interface CloseAndSettleArgs { readonly programId: Address; readonly recipient: string; readonly rpc: RpcLike; + readonly settlementConfirmation: ConfirmSignatureOptions | undefined; readonly splits: readonly SessionSplit[] | undefined; readonly store: SessionStore; readonly tokenProgram: string; @@ -1133,68 +1299,198 @@ interface CloseAndSettleArgs { * Returns `undefined` when the channel cannot be settled (e.g. no * highest voucher recorded — nothing to settle). */ -async function closeAndSettleChannel(args: CloseAndSettleArgs): Promise { - const state = await args.store.getChannel(args.channelId); - if (!state) return undefined; - - // The distribute refund goes to the channel payer (the program enforces - // `payer == channel.payer`). It is recorded as `state.operator` at open. - // Never fall back to the recipient: refunding the merchant would derive the - // wrong refund token account and the settlement would fail on-chain. - if (!state.operator) { - throw new Error( - `cannot settle channel ${args.channelId}: the channel payer (refund destination) was not recorded at open`, - ); - } +async function closeAndSettleChannel( + args: CloseAndSettleArgs, +): Promise | undefined> { + requireSettlementRpc(args.rpc); + const claimOwner = crypto.randomUUID(); + const claimNow = BigInt(Date.now()); + let claimed = false; + const state = await args.store.updateChannel(args.channelId, current => { + if (!current) throw new Error(`Channel ${args.channelId} not found`); + if (current.sealed || current.settledSignature !== undefined) return current; + const hasPendingSignature = current.settlementPendingSignature !== undefined; + const claimIsFresh = + current.settling && + current.settlementClaimExpiresAt !== undefined && + current.settlementClaimExpiresAt > claimNow; + if (!hasPendingSignature && claimIsFresh) return current; + claimed = true; + return { + ...current, + settlementClaimExpiresAt: claimNow + SETTLEMENT_CLAIM_LEASE_MS, + settlementClaimOwner: claimOwner, + settling: true, + }; + }); + if (!claimed) return undefined; - let voucher: { authorizedSigner: string; signed: SignedVoucher } | undefined; - if (state.highestVoucherSignature && state.highestVoucherExpiresAt !== undefined && state.cumulative > 0n) { - voucher = { - authorizedSigner: state.authorizedSigner, - signed: { - data: { + let pendingSignature = state.settlementPendingSignature as Signature | undefined; + let pendingLastValidBlockHeight = state.settlementPendingLastValidBlockHeight; + let pendingWire = state.settlementPendingWire; + try { + // The distribute refund goes to the channel payer (the program enforces + // `payer == channel.payer`). It is recorded as `state.operator` at open. + // Never fall back to the recipient: refunding the merchant would derive the + // wrong refund token account and the settlement would fail on-chain. + if (!state.operator) { + throw new Error( + `cannot settle channel ${args.channelId}: the channel payer (refund destination) was not recorded at open`, + ); + } + + if (!pendingSignature) { + let voucher: { authorizedSigner: string; signed: SignedVoucher } | undefined; + if (state.highestVoucherSignature && state.highestVoucherExpiresAt !== undefined && state.cumulative > 0n) { + voucher = { + authorizedSigner: state.authorizedSigner, + signed: { + data: { + channelId: args.channelId, + cumulativeAmount: state.cumulative.toString(), + expiresAt: Number(state.highestVoucherExpiresAt), + }, + signature: state.highestVoucherSignature, + }, + }; + } + + const result = await submitSettleAndDistributeWithPreBroadcastPersistence( + { + buildAndSignWireTransaction: async instructions => { + const prepared = await buildAndSignWireTransactionWithLifetime( + args.rpc as unknown as Parameters[0], + args.merchantSigner as unknown as TransactionSigner, + instructions, + ); + pendingLastValidBlockHeight = prepared.lastValidBlockHeight; + return prepared.wire; + }, channelId: args.channelId, - cumulativeAmount: state.cumulative.toString(), - expiresAt: Number(state.highestVoucherExpiresAt), + currency: args.currency, + mint: args.mint, + network: args.network, + payee: args.recipient, + payer: state.operator, + + programId: args.programId, + // rentPayer reclaims the channel/escrow rent at distribute; it is the + // operator recorded as the channel rentPayer at open (the fee payer), + // not the refund payer carried in state.operator. + rentPayer: args.operator, + rpc: args.rpc as unknown as { + sendTransaction: (wire: string, config?: unknown) => { send: () => Promise }; + }, + signer: args.merchantSigner as unknown as TransactionSigner, + splits: args.splits ?? [], + tokenProgram: args.tokenProgram, + voucher, }, - signature: state.highestVoucherSignature, - }, - }; - } - - const result = await submitSettleAndDistribute({ - buildAndSignWireTransaction: instructions => - buildAndSignWireTransaction( - args.rpc as unknown as Parameters[0], - args.merchantSigner as unknown as TransactionSigner, - instructions, - ), - channelId: args.channelId, - currency: args.currency, - mint: args.mint, - network: args.network, - payee: args.recipient, - payer: state.operator, - - programId: args.programId, - // rentPayer reclaims the channel/escrow rent at distribute; it is the - // operator recorded as the channel rentPayer at open (the fee payer), - // not the refund payer carried in state.operator. - rentPayer: args.operator, - rpc: args.rpc as unknown as { - sendTransaction: (wire: string, config?: unknown) => { send: () => Promise }; - }, - signer: args.merchantSigner as unknown as TransactionSigner, - splits: args.splits ?? [], - tokenProgram: args.tokenProgram, - voucher, - }); + async prepared => { + pendingSignature = prepared.signature; + pendingWire = prepared.wire; + if (pendingLastValidBlockHeight === undefined) { + throw new Error(`Channel ${args.channelId} settlement transaction lifetime was not captured`); + } + await args.store.updateChannel(args.channelId, current => { + if (!current) { + throw new Error(`Channel ${args.channelId} disappeared before settlement broadcast`); + } + if (current.settlementClaimOwner !== claimOwner) { + throw new Error(`Channel ${args.channelId} lost its settlement claim`); + } + return { + ...current, + settlementPendingLastValidBlockHeight: pendingLastValidBlockHeight, + settlementPendingSignature: prepared.signature as unknown as string, + settlementPendingWire: prepared.wire, + }; + }); + }, + ); + pendingSignature = result.signature; + } else if (pendingWire) { + const wireSignature = getSignatureFromTransaction( + getTransactionDecoder().decode(getBase64Codec().encode(pendingWire)), + ); + if (wireSignature !== pendingSignature) { + throw new Error(`Channel ${args.channelId} pending settlement wire does not match its signature`); + } + try { + await ( + args.rpc as unknown as { + sendTransaction: (wire: string, config?: unknown) => { send: () => Promise }; + } + ) + .sendTransaction(pendingWire, { encoding: 'base64' }) + .send(); + } catch { + // The RPC can report an error after accepting the transaction. + // Always reconcile the persisted signature before deciding. + } + } - await args.store.updateChannel(args.channelId, current => { - if (!current) throw new Error(`Channel ${args.channelId} disappeared during settle`); - return { ...current, sealed: true, settledSignature: result.signature as unknown as string }; - }); - return result; + await waitForSignatureConfirmation({ + context: `settle channel ${args.channelId}`, + options: args.settlementConfirmation, + rpc: args.rpc, + signature: pendingSignature, + }); + await args.store.updateChannel(args.channelId, current => { + if (!current) throw new Error(`Channel ${args.channelId} disappeared during settle`); + if (current.sealed && current.settledSignature === pendingSignature) return current; + if (current.settlementPendingSignature !== pendingSignature) { + throw new Error(`Channel ${args.channelId} settlement signature changed during confirmation`); + } + return { + ...current, + sealed: true, + settledSignature: pendingSignature as unknown as string, + settlementClaimExpiresAt: undefined, + settlementClaimOwner: undefined, + settlementPendingLastValidBlockHeight: undefined, + settlementPendingSignature: undefined, + settlementPendingWire: undefined, + settling: false, + }; + }); + return { signature: pendingSignature }; + } catch (error) { + const definiteFailure = error instanceof SignatureConfirmationError && error.outcome === 'definite-failure'; + let expired = false; + if ( + error instanceof SignatureConfirmationError && + error.reason === 'timeout' && + pendingLastValidBlockHeight !== undefined + ) { + try { + const blockHeight = await args.rpc.getBlockHeight({ commitment: 'confirmed' }).send(); + expired = BigInt(blockHeight) > pendingLastValidBlockHeight; + } catch { + // Failure to prove expiry is uncertainty; retain the outbox. + } + } + const retireOutbox = definiteFailure || expired; + await args.store.updateChannel(args.channelId, current => { + if (!current) throw new Error(`Channel ${args.channelId} disappeared during settle`); + if (current.sealed) return current; + if (current.settlementClaimOwner !== claimOwner) return current; + return { + ...current, + settlementClaimExpiresAt: undefined, + settlementClaimOwner: undefined, + ...(retireOutbox && current.settlementPendingSignature === pendingSignature + ? { + settlementPendingLastValidBlockHeight: undefined, + settlementPendingSignature: undefined, + settlementPendingWire: undefined, + } + : {}), + settling: false, + }; + }); + throw error; + } } // ───────────────────────────────────────────────────────────────────── @@ -1208,14 +1504,31 @@ function rejectIfVoucherRejected(result: VoucherVerifyResult): void { } /** Throw unless `signature` exists on-chain and executed without error. */ -async function assertSignatureSucceeded(rpc: VerifyOpenRpc, signature: string, context: string): Promise { - const [status] = (await rpc.getSignatureStatuses([signature as Signature]).send()).value; +async function assertSignatureSucceeded( + rpc: VerifyOpenRpc, + signature: string, + context: string, +): Promise { + const response = await rpc.getSignatureStatuses([signature as Signature]).send(); + const [status] = response.value; if (!status) { throw new Error(`${context}: tx ${signature} not found on-chain`); } if (status.err) { throw new Error(`${context}: tx ${signature} failed on-chain: ${JSON.stringify(status.err)}`); } + if (status.confirmationStatus !== 'confirmed' && status.confirmationStatus !== 'finalized') { + throw new Error(`${context}: tx ${signature} is only ${String(status.confirmationStatus)}; confirmed required`); + } + const slot = response.context?.slot; + if ( + (typeof slot !== 'number' && typeof slot !== 'bigint') || + slot < 0 || + (typeof slot === 'number' && !Number.isSafeInteger(slot)) + ) { + throw new Error(`${context}: tx ${signature} confirmation response has an invalid context slot`); + } + return slot; } /** Throw unless the voucher's Ed25519 signature verifies against `authorizedSigner`. */ @@ -1249,6 +1562,12 @@ function isTopUpTransactionRpc(rpc: RpcLike | undefined): rpc is RpcLike & TopUp return typeof (rpc as { getTransaction?: unknown } | undefined)?.getTransaction === 'function'; } +function requireSettlementRpc(rpc: RpcLike): asserts rpc is SettlementRpc { + if (typeof (rpc as { getBlockHeight?: unknown }).getBlockHeight !== 'function') { + throw new Error('session settlement requires rpc.getBlockHeight() for outbox expiry recovery'); + } +} + function isPlaceholderSignature(signature: string | undefined): boolean { return !signature || /^1+$/.test(signature); } @@ -1271,6 +1590,14 @@ function parseOptionalU64(value: number | string | undefined, name: string): big return parseU64String(String(value), name); } +function expectedSessionGracePeriod(settlementWindowSeconds: bigint | undefined): number { + if (settlementWindowSeconds === undefined || settlementWindowSeconds <= 0n) return 900; + if (settlementWindowSeconds > 0xffff_ffffn) { + throw new Error('settlementWindowSeconds must fit in a u32 channel grace period'); + } + return Number(settlementWindowSeconds); +} + function errorMessage(error: unknown): string { if (error instanceof Error) return error.message; return String(error); @@ -1294,7 +1621,10 @@ export type RpcLike = ReturnType | SubmitOpenRpc; /** Minimal RPC subset used to verify open transactions. */ export type VerifyOpenRpc = { getSignatureStatuses(signatures: readonly Signature[]): { - send(): Promise<{ value: ReadonlyArray<{ err: unknown } | null> }>; + send(): Promise<{ + context?: { slot?: bigint | number }; + value: ReadonlyArray<{ confirmationStatus?: string | null; err: unknown } | null>; + }>; }; }; @@ -1303,6 +1633,13 @@ export type SubmitOpenRpc = VerifyOpenRpc & { sendTransaction(wire: string, config?: unknown): { send(): Promise }; }; +/** RPC contract required before a session settlement outbox may be created. */ +export type SettlementRpc = RpcLike & { + getBlockHeight(config?: { commitment?: 'confirmed' | 'finalized' | 'processed' }): { + send(): Promise; + }; +}; + type CredentialPayload = { challenge: { id?: string; @@ -1391,6 +1728,8 @@ export declare namespace session { readonly rpc?: RpcLike; /** RPC URL for blockhash prefetch. Defaults from `network`. */ readonly rpcUrl?: string; + /** Settlement confirmation timing and cancellation overrides. */ + readonly settlementConfirmation?: ConfirmSignatureOptions; /** * Settlement window in seconds — the forced-close grace period a * non-zero voucher `expiresAt` must outlast. When set, a voucher diff --git a/typescript/packages/mpp/src/server/index.ts b/typescript/packages/mpp/src/server/index.ts index f5eed800b..ba0a234d4 100644 --- a/typescript/packages/mpp/src/server/index.ts +++ b/typescript/packages/mpp/src/server/index.ts @@ -1,7 +1,14 @@ export * from '../constants.js'; export { type ChallengeRequest, charge, verifyChargeTransaction } from './Charge.js'; export { solana } from './Methods.js'; -export { ConfigurationError, type RpcLike, session, type SubmitOpenRpc, type VerifyOpenRpc } from './Session.js'; +export { + ConfigurationError, + type RpcLike, + session, + type SettlementRpc, + type SubmitOpenRpc, + type VerifyOpenRpc, +} from './Session.js'; export { type ChannelMutator, type ChannelState, diff --git a/typescript/packages/mpp/src/server/session/on-chain.ts b/typescript/packages/mpp/src/server/session/on-chain.ts index fa73994d4..158e517f0 100644 --- a/typescript/packages/mpp/src/server/session/on-chain.ts +++ b/typescript/packages/mpp/src/server/session/on-chain.ts @@ -26,6 +26,7 @@ import { getCompiledTransactionMessageDecoder, getI64Encoder, getProgramDerivedAddress, + getSignatureFromTransaction, getTransactionDecoder, getU64Encoder, getUtf8Encoder, @@ -39,9 +40,19 @@ import { } from '@solana/kit'; import { findAssociatedTokenPda } from '@solana-program/token'; -import { ASSOCIATED_TOKEN_PROGRAM, defaultTokenProgramForCurrency, resolveStablecoinMint } from '../../constants.js'; +import { + ASSOCIATED_TOKEN_PROGRAM, + defaultTokenProgramForCurrency, + resolveStablecoinMint, + SYSTEM_PROGRAM, +} from '../../constants.js'; +import { type Channel, getChannelDecoder } from '../../generated/payment-channels/accounts/channel.js'; import { getDistributeInstruction } from '../../generated/payment-channels/instructions/distribute.js'; -import { getOpenInstructionDataDecoder } from '../../generated/payment-channels/instructions/open.js'; +import type { OpenInstructionData } from '../../generated/payment-channels/instructions/open.js'; +import { + getOpenInstructionDataDecoder, + getOpenInstructionDataEncoder, +} from '../../generated/payment-channels/instructions/open.js'; import { getReclaimInstruction } from '../../generated/payment-channels/instructions/reclaim.js'; import { getSettleAndSealInstruction } from '../../generated/payment-channels/instructions/settleAndSeal.js'; import { @@ -51,7 +62,6 @@ import { } from '../../generated/payment-channels/instructions/topUp.js'; import { findEventAuthorityPda } from '../../generated/payment-channels/pdas/eventAuthority.js'; import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from '../../generated/payment-channels/programs/paymentChannels.js'; -import type { OpenArgs } from '../../generated/payment-channels/types/openArgs.js'; import type { OpenPayload, SignedVoucher } from '../../shared/session-types.js'; import { VOUCHER_MAGIC } from '../../shared/voucher.js'; import { coSignBase64Transaction } from '../../utils/transactions.js'; @@ -98,6 +108,7 @@ const TREASURY_OWNER_BYTES = new Uint8Array([ /** Payment-channel open instruction discriminator. */ const OPEN_DISCRIMINATOR = 1; +const RENT_SYSVAR_ADDRESS = 'SysvarRent111111111111111111111111111111111' as Address; const U16_LE = (n: number) => new Uint8Array([n & 0xff, (n >> 8) & 0xff]); @@ -430,6 +441,8 @@ export function buildReclaimInstruction(args: ReclaimBuildArgs): ServerInstructi export interface VerifyOpenTxExpected { readonly authorizedSigner: string; readonly currency: string; + /** Expected channel close grace period, defaulting to the program default. */ + readonly gracePeriod?: number | undefined; readonly maxCap: bigint; /** Optional override for the SPL mint (otherwise resolved from currency/network). */ readonly mint?: string | undefined; @@ -466,10 +479,43 @@ export interface SignatureStatus { /** Minimal RPC shape needed to check transaction signature statuses. */ export interface SignatureStatusRpc { getSignatureStatuses(signatures: readonly Signature[]): { - send(): Promise<{ value: ReadonlyArray }>; + send(): Promise<{ + context?: { slot?: bigint | number }; + value: ReadonlyArray; + }>; + }; +} + +/** Minimal RPC shape needed to fetch and inspect a confirmed open transaction. */ +export interface OpenTransactionRpc extends SignatureStatusRpc { + getTransaction( + signature: Signature, + config: { + readonly commitment: 'confirmed'; + readonly encoding: 'base64'; + readonly maxSupportedTransactionVersion: 0; + }, + ): { + send(): Promise<{ + readonly meta: { + readonly err: unknown; + readonly loadedAddresses?: + | { + readonly readonly: readonly string[]; + readonly writable: readonly string[]; + } + | null + | undefined; + } | null; + readonly transaction: readonly [string, string]; + } | null>; }; } +export function isOpenTransactionRpc(rpc: unknown): rpc is OpenTransactionRpc { + return typeof rpc === 'object' && rpc !== null && typeof (rpc as OpenTransactionRpc).getTransaction === 'function'; +} + /** Arguments to {@link verifyOpenTx}. */ export interface VerifyOpenTxArgs { /** Expected values from the challenge. */ @@ -530,6 +576,11 @@ export async function verifyOpenTx(args: VerifyOpenTxArgs): Promise expected.maxCap) { throw new Error(`verifyOpenTx: deposit ${deposit} exceeds maxCap ${expected.maxCap}`); } + const expectedGracePeriod = expected.gracePeriod ?? 900; + if (gracePeriod !== expectedGracePeriod) { + throw new Error(`verifyOpenTx: gracePeriod ${gracePeriod} != expected ${expectedGracePeriod}`); + } if (expected.openSlot !== undefined && openSlot !== expected.openSlot) { throw new Error(`verifyOpenTx: openSlot ${openSlot} != challenge-issued openSlot ${expected.openSlot}`); } - if (expected.splits !== undefined) { - if (recipients.length !== expected.splits.length) { - throw new Error( - `verifyOpenTx: recipient split count ${recipients.length} != expected ${expected.splits.length}`, - ); - } - for (let index = 0; index < recipients.length; index += 1) { - const actual = recipients[index]; - const expectedSplit = expected.splits[index]; - if ( - !actual || - !expectedSplit || - actual.recipient !== expectedSplit.recipient || - actual.bps !== expectedSplit.bps - ) { - throw new Error(`verifyOpenTx: recipient split at index ${index} does not match the challenge`); - } + const expectedSplits = expected.splits ?? []; + if (recipients.length !== expectedSplits.length) { + throw new Error( + `verifyOpenTx: open recipients length ${recipients.length} != expected splits length ${expectedSplits.length}`, + ); + } + for (const [index, recipient] of recipients.entries()) { + const expectedSplit = expectedSplits[index]; + if (!expectedSplit || recipient.recipient !== expectedSplit.recipient || recipient.bps !== expectedSplit.bps) { + throw new Error(`verifyOpenTx: open recipient[${index}] does not match expected split`); } } + const canonicalData = getOpenInstructionDataEncoder().encode({ + openArgs: { + deposit, + gracePeriod, + openSlot, + recipients: expectedSplits.map(split => ({ bps: split.bps, recipient: address(split.recipient) })), + salt, + }, + }); + if (!bytesEqual(openIx.data, canonicalData)) { + throw new Error('verifyOpenTx: open instruction data is not canonical'); + } + // Re-derive the channel PDA and assert it matches. const programAddress = programIdStr as Address; const [derivedChannel] = await getProgramDerivedAddress({ @@ -690,6 +765,57 @@ export async function verifyOpenTx(args: VerifyOpenTxArgs): Promise; + }; +} + export interface TopUpTransactionRpc { getTransaction( signature: Signature, - config: { readonly encoding: 'base64'; readonly maxSupportedTransactionVersion: 0 }, + config: { + readonly commitment?: 'confirmed'; + readonly encoding: 'base64'; + readonly maxSupportedTransactionVersion: 0; + }, ): { send(): Promise<{ - meta: { err: unknown } | null; + meta: { + err: unknown; + loadedAddresses?: + | { + readonly readonly: readonly string[]; + readonly writable: readonly string[]; + } + | null + | undefined; + } | null; transaction: readonly [string, string]; } | null>; }; } +export function isGetAccountInfoRpc(rpc: unknown): rpc is GetAccountInfoRpc { + return typeof rpc === 'object' && rpc !== null && typeof (rpc as GetAccountInfoRpc).getAccountInfo === 'function'; +} + +export interface VerifyChannelStateExpected { + readonly authorizedSigner: string; + readonly deposit?: bigint | undefined; + readonly gracePeriod?: number | undefined; + readonly mint: string; + readonly openSlot?: bigint | undefined; + readonly payee: string; + readonly payer?: string | undefined; + readonly programId?: string | undefined; + readonly rentPayer: string; + readonly requireFresh?: boolean | undefined; + readonly splits?: readonly { readonly bps: number; readonly recipient: string }[] | undefined; +} + +async function sessionDistributionHash( + splits: readonly { readonly bps: number; readonly recipient: string }[], +): Promise { + const preimage = new Uint8Array(4 + splits.length * 34); + new DataView(preimage.buffer).setUint32(0, splits.length, true); + let offset = 4; + for (const split of splits) { + preimage.set(getAddressEncoder().encode(address(split.recipient)), offset); + offset += 32; + new DataView(preimage.buffer).setUint16(offset, split.bps, true); + offset += 2; + } + return new Uint8Array(await crypto.subtle.digest('SHA-256', preimage)); +} + +/** Fetch and bind the authoritative on-chain payment-channel state. */ +export async function verifyChannelAccountState(args: { + readonly channelId: string; + readonly expected: VerifyChannelStateExpected; + readonly minContextSlot?: bigint | number | undefined; + readonly rpc: GetAccountInfoRpc; +}): Promise { + const info = await args.rpc + .getAccountInfo(address(args.channelId), { + commitment: 'confirmed', + encoding: 'base64', + ...(args.minContextSlot !== undefined ? { minContextSlot: args.minContextSlot } : {}), + }) + .send(); + const value = info.value; + if (!value) { + throw new Error(`verifyChannelAccountState: channel ${args.channelId} not found on-chain`); + } + const programId = args.expected.programId ?? PAYMENT_CHANNELS_PROGRAM_ID; + if (value.owner !== programId) { + throw new Error( + `verifyChannelAccountState: channel ${args.channelId} is owned by ${String(value.owner)} != payment-channels program ${programId}`, + ); + } + const raw = value.data; + const dataBase64 = Array.isArray(raw) ? (raw[0] as unknown) : raw; + if (typeof dataBase64 !== 'string' || dataBase64 === '') { + throw new Error('verifyChannelAccountState: unsupported getAccountInfo encoding (expected base64)'); + } + const bytes = getBase64Codec().encode(dataBase64); + if (bytes.length !== 256) { + throw new Error(`verifyChannelAccountState: invalid Channel account length ${bytes.length}`); + } + const channel = getChannelDecoder().decode(bytes); + if (channel.discriminator !== 1) { + throw new Error(`verifyChannelAccountState: invalid Channel discriminator ${channel.discriminator}`); + } + if (channel.version !== 1) { + throw new Error(`verifyChannelAccountState: unsupported Channel version ${channel.version}`); + } + if (channel.status !== 0) { + throw new Error( + `verifyChannelAccountState: channel ${args.channelId} is not open on-chain (status ${channel.status})`, + ); + } + if (channel.mint !== args.expected.mint) { + throw new Error(`verifyChannelAccountState: on-chain mint ${channel.mint} != expected ${args.expected.mint}`); + } + if (channel.payee !== args.expected.payee) { + throw new Error( + `verifyChannelAccountState: on-chain payee ${channel.payee} != expected ${args.expected.payee}`, + ); + } + if (channel.rentPayer !== args.expected.rentPayer) { + throw new Error( + `verifyChannelAccountState: on-chain rentPayer ${channel.rentPayer} != expected operator ${args.expected.rentPayer}`, + ); + } + if (args.expected.openSlot !== undefined && channel.openSlot !== args.expected.openSlot) { + throw new Error( + `verifyChannelAccountState: on-chain openSlot ${channel.openSlot} != expected ${args.expected.openSlot}`, + ); + } + if ( + args.expected.requireFresh !== false && + (channel.settlement.settled !== 0n || channel.settlement.payoutWatermark !== 0n) + ) { + throw new Error(`verifyChannelAccountState: channel ${args.channelId} has nonzero settlement watermarks`); + } + const expectedGracePeriod = args.expected.gracePeriod ?? 900; + if (channel.gracePeriod !== expectedGracePeriod) { + throw new Error( + `verifyChannelAccountState: on-chain gracePeriod ${channel.gracePeriod} != expected ${expectedGracePeriod}`, + ); + } + const expectedDistributionHash = await sessionDistributionHash(args.expected.splits ?? []); + if (!channel.distributionHash.every((byte, index) => byte === expectedDistributionHash[index])) { + throw new Error('verifyChannelAccountState: on-chain distributionHash does not match session splits'); + } + if (channel.authorizedSigner !== args.expected.authorizedSigner) { + throw new Error( + `verifyChannelAccountState: on-chain authorizedSigner ${channel.authorizedSigner} != expected ${args.expected.authorizedSigner}`, + ); + } + if (args.expected.payer !== undefined && channel.payer !== args.expected.payer) { + throw new Error( + `verifyChannelAccountState: on-chain payer ${channel.payer} != expected ${args.expected.payer}`, + ); + } + if (args.expected.deposit !== undefined && channel.deposit !== args.expected.deposit) { + throw new Error( + `verifyChannelAccountState: on-chain deposit ${channel.deposit} != expected ${args.expected.deposit}`, + ); + } + const [derivedChannel] = await getProgramDerivedAddress({ + programAddress: address(programId), + seeds: [ + getUtf8Encoder().encode('channel'), + getAddressEncoder().encode(channel.payer), + getAddressEncoder().encode(channel.payee), + getAddressEncoder().encode(channel.mint), + getAddressEncoder().encode(channel.authorizedSigner), + getU64Encoder().encode(channel.salt), + getU64Encoder().encode(channel.openSlot), + ], + }); + if (derivedChannel !== args.channelId) { + throw new Error( + `verifyChannelAccountState: channel account ${args.channelId} != PDA derived from authoritative state ${derivedChannel}`, + ); + } + return channel; +} + +/** + * Fetch and bind a signature-only payment-channel open to its canonical open + * instruction. This is deliberately separate from verifyOpenTx: attached + * transaction opens remain authoritative over their decoded transaction, while + * compact opens must derive their authorization from the confirmed wire tx. + */ +export async function verifySignatureOnlyOpenTransaction(args: { + readonly channelId: string; + readonly deposit: bigint; + readonly expected: VerifyOpenTxExpected; + readonly openSlot?: bigint | undefined; + readonly rpc: OpenTransactionRpc; + readonly signature: Signature; +}): Promise { + const fetched = await args.rpc + .getTransaction(args.signature, { + commitment: 'confirmed', + encoding: 'base64', + maxSupportedTransactionVersion: 0, + }) + .send(); + if (!fetched) { + throw new Error(`verifySignatureOnlyOpenTransaction: tx ${args.signature} not found on-chain`); + } + if (fetched.meta?.err) { + throw new Error( + `verifySignatureOnlyOpenTransaction: tx ${args.signature} failed on-chain: ${JSON.stringify(fetched.meta.err)}`, + ); + } + + const [wire, encoding] = fetched.transaction; + if (encoding !== 'base64') { + throw new Error(`verifySignatureOnlyOpenTransaction: expected base64, got ${encoding}`); + } + const decoded = getTransactionDecoder().decode(getBase64Codec().encode(wire)); + if (getSignatureFromTransaction(decoded) !== args.signature) { + throw new Error( + `verifySignatureOnlyOpenTransaction: fetched transaction signature does not match ${args.signature}`, + ); + } + const message = getCompiledTransactionMessageDecoder().decode(decoded.messageBytes) as unknown as { + readonly instructions: readonly { + readonly accountIndices?: readonly number[]; + readonly data?: Uint8Array | undefined; + readonly programAddressIndex: number; + }[]; + readonly staticAccounts: readonly string[]; + }; + const loadedAddresses = fetched.meta?.loadedAddresses; + const accounts = [ + ...message.staticAccounts, + ...(loadedAddresses?.writable ?? []), + ...(loadedAddresses?.readonly ?? []), + ]; + const accountAtIndex = (index: number, context: string): string => { + const value = accounts[index]; + if (!value) { + throw new Error(`verifySignatureOnlyOpenTransaction: ${context} account index ${index} is out of range`); + } + return value; + }; + + const programId = args.expected.programId ?? PAYMENT_CHANNELS_PROGRAM_ID; + const expectedMint = + args.expected.mint ?? + resolveStablecoinMint(args.expected.currency, args.expected.network ?? 'mainnet') ?? + args.expected.currency; + if (!expectedMint) { + throw new Error('verifySignatureOnlyOpenTransaction: could not resolve mint from currency/network'); + } + if (!args.expected.operator) { + throw new Error('verifySignatureOnlyOpenTransaction: expected.operator is required'); + } + + const openInstructions = [] as { + readonly accountIndices: readonly number[]; + readonly data: Uint8Array; + }[]; + for (const [instructionIndex, instruction] of message.instructions.entries()) { + const instructionProgram = accountAtIndex( + instruction.programAddressIndex, + `instruction[${instructionIndex}] program`, + ); + if (instructionProgram !== programId) continue; + if (!instruction.data || instruction.data[0] !== OPEN_DISCRIMINATOR) continue; + openInstructions.push({ accountIndices: instruction.accountIndices ?? [], data: instruction.data }); + } + if (openInstructions.length !== 1) { + throw new Error( + `verifySignatureOnlyOpenTransaction: expected exactly one canonical payment-channel open, found ${openInstructions.length}`, + ); + } + + const openInstruction = openInstructions[0]; + if (!openInstruction) throw new Error('verifySignatureOnlyOpenTransaction: missing open instruction'); + if (openInstruction.accountIndices.length !== 14) { + throw new Error( + `verifySignatureOnlyOpenTransaction: open instruction account count ${openInstruction.accountIndices.length} != canonical count 14`, + ); + } + const instructionAccounts = openInstruction.accountIndices.map((index, slot) => + accountAtIndex(index, `open instruction account[${slot}]`), + ); + const [payerAddr, rentPayerAddr, payeeAddr, mintAddr, authorizedSignerAddr, channelAddr] = instructionAccounts; + if (!payerAddr || !rentPayerAddr || !payeeAddr || !mintAddr || !authorizedSignerAddr || !channelAddr) { + throw new Error('verifySignatureOnlyOpenTransaction: open instruction is missing canonical accounts'); + } + if (channelAddr !== args.channelId) { + throw new Error( + `verifySignatureOnlyOpenTransaction: open channel ${channelAddr} != asserted channel ${args.channelId}`, + ); + } + if (rentPayerAddr !== args.expected.operator) { + throw new Error( + `verifySignatureOnlyOpenTransaction: rentPayer ${rentPayerAddr} != expected operator ${args.expected.operator}`, + ); + } + if (payeeAddr !== args.expected.recipient) { + throw new Error( + `verifySignatureOnlyOpenTransaction: payee ${payeeAddr} != expected recipient ${args.expected.recipient}`, + ); + } + if (mintAddr !== expectedMint) { + throw new Error(`verifySignatureOnlyOpenTransaction: mint ${mintAddr} != expected mint ${expectedMint}`); + } + if (authorizedSignerAddr !== args.expected.authorizedSigner) { + throw new Error( + `verifySignatureOnlyOpenTransaction: authorizedSigner ${authorizedSignerAddr} != expected ${args.expected.authorizedSigner}`, + ); + } + + let decodedOpenData: OpenInstructionData; + try { + decodedOpenData = getOpenInstructionDataDecoder().decode(openInstruction.data); + } catch (error) { + throw new Error(`verifySignatureOnlyOpenTransaction: malformed open instruction data: ${String(error)}`); + } + if (decodedOpenData.discriminator !== OPEN_DISCRIMINATOR) { + throw new Error( + `verifySignatureOnlyOpenTransaction: invalid open instruction discriminator ${decodedOpenData.discriminator}`, + ); + } + const { deposit, gracePeriod, openSlot, recipients, salt } = decodedOpenData.openArgs; + if (deposit !== args.deposit) { + throw new Error( + `verifySignatureOnlyOpenTransaction: on-chain open deposit ${deposit} != asserted deposit ${args.deposit}`, + ); + } + const expectedGracePeriod = args.expected.gracePeriod ?? 900; + if (gracePeriod !== expectedGracePeriod) { + throw new Error( + `verifySignatureOnlyOpenTransaction: gracePeriod ${gracePeriod} != expected ${expectedGracePeriod}`, + ); + } + if (args.openSlot !== undefined && openSlot !== args.openSlot) { + throw new Error(`verifySignatureOnlyOpenTransaction: openSlot ${openSlot} != expected ${args.openSlot}`); + } + if (deposit === 0n || deposit > args.expected.maxCap) { + throw new Error(`verifySignatureOnlyOpenTransaction: deposit ${deposit} is outside the permitted range`); + } + + const expectedSplits = args.expected.splits ?? []; + if ( + recipients.length !== expectedSplits.length || + recipients.some( + (recipient, index) => + recipient.recipient !== expectedSplits[index]?.recipient || + recipient.bps !== expectedSplits[index]?.bps, + ) + ) { + throw new Error('verifySignatureOnlyOpenTransaction: open recipients do not match expected session splits'); + } + + const tokenProgram = address( + args.expected.tokenProgram ?? + defaultTokenProgramForCurrency(args.expected.currency, args.expected.network ?? 'mainnet'), + ); + const [payerTokenAccount] = await findAssociatedTokenPda({ + mint: address(mintAddr), + owner: address(payerAddr), + tokenProgram, + }); + const [channelTokenAccount] = await findAssociatedTokenPda({ + mint: address(mintAddr), + owner: address(channelAddr), + tokenProgram, + }); + const [eventAuthority] = await findEventAuthorityPda({ programAddress: address(programId) }); + const canonicalAccounts = [ + payerAddr, + args.expected.operator, + args.expected.recipient, + expectedMint, + args.expected.authorizedSigner, + channelAddr, + payerTokenAccount, + channelTokenAccount, + tokenProgram, + SYSTEM_PROGRAM, + RENT_SYSVAR_ADDRESS, + ASSOCIATED_TOKEN_PROGRAM, + eventAuthority, + programId, + ]; + for (const [slot, expectedAccount] of canonicalAccounts.entries()) { + const actualAccount = instructionAccounts[slot]; + if (actualAccount !== expectedAccount) { + throw new Error( + `verifySignatureOnlyOpenTransaction: open instruction account[${slot}] ${String(actualAccount)} != canonical account ${expectedAccount}`, + ); + } + } + + const canonicalData = getOpenInstructionDataEncoder().encode({ + openArgs: { + deposit, + gracePeriod, + openSlot, + recipients: expectedSplits.map(split => ({ bps: split.bps, recipient: address(split.recipient) })), + salt, + }, + }); + if (!bytesEqual(openInstruction.data, canonicalData)) { + throw new Error('verifySignatureOnlyOpenTransaction: open instruction data is not canonical'); + } + + const [derivedChannel] = await getProgramDerivedAddress({ + programAddress: address(programId), + seeds: [ + getUtf8Encoder().encode('channel'), + getAddressEncoder().encode(address(payerAddr)), + getAddressEncoder().encode(address(payeeAddr)), + getAddressEncoder().encode(address(mintAddr)), + getAddressEncoder().encode(address(authorizedSignerAddr)), + getU64Encoder().encode(salt), + getU64Encoder().encode(openSlot), + ], + }); + if (derivedChannel !== args.channelId) { + throw new Error( + `verifySignatureOnlyOpenTransaction: channel PDA ${args.channelId} != derived ${derivedChannel}`, + ); + } +} + /** * Confirms that a landed transaction contains exactly the required payment-channel * top-up: same program, same channel account, and the precise deposit delta. @@ -730,7 +1274,11 @@ export async function verifyTopUpTransaction(args: { readonly signature: Signature; }): Promise { const fetched = await args.rpc - .getTransaction(args.signature, { encoding: 'base64', maxSupportedTransactionVersion: 0 }) + .getTransaction(args.signature, { + commitment: 'confirmed', + encoding: 'base64', + maxSupportedTransactionVersion: 0, + }) .send(); if (!fetched) { throw new Error(`verifyTopUpTransaction: tx ${args.signature} not found on-chain`); @@ -760,34 +1308,40 @@ export async function verifyTopUpTransaction(args: { throw new Error(`verifyTopUpTransaction: invalid transaction data: ${errorMessage(error)}`); } - let topUpCount = 0; - let topUpTotal = 0n; + const loadedAddresses = fetched.meta?.loadedAddresses; + const accounts = [ + ...message.staticAccounts, + ...(loadedAddresses?.writable ?? []), + ...(loadedAddresses?.readonly ?? []), + ]; + let count = 0; + let total = 0n; for (const instruction of message.instructions) { - if (message.staticAccounts[instruction.programAddressIndex] !== args.programId) continue; + if (accounts[instruction.programAddressIndex] !== args.programId) continue; if (!instruction.data || instruction.data[0] !== TOP_UP_DISCRIMINATOR) continue; const channelIndex = instruction.accountIndices?.[1]; - if (channelIndex === undefined || message.staticAccounts[channelIndex] !== args.channelId) continue; - + if (channelIndex === undefined || accounts[channelIndex] !== args.channelId) continue; try { - topUpCount += 1; - topUpTotal += getTopUpInstructionDataDecoder().decode(instruction.data).topUpArgs.amount; + count += 1; + total += getTopUpInstructionDataDecoder().decode(instruction.data).topUpArgs.amount; } catch (error) { throw new Error(`verifyTopUpTransaction: invalid top-up instruction: ${errorMessage(error)}`); } } - - if (topUpCount === 0) { + if (count === 0) { throw new Error(`verifyTopUpTransaction: no top-up for channel ${args.channelId} found in ${args.signature}`); } - if (topUpTotal !== args.amount) { - throw new Error(`verifyTopUpTransaction: on-chain top-up total ${topUpTotal} != expected delta ${args.amount}`); + if (count !== 1) { + throw new Error(`verifyTopUpTransaction: expected exactly one top-up, found ${count}`); + } + if (total !== args.amount) { + throw new Error(`verifyTopUpTransaction: on-chain top-up total ${total} != expected delta ${args.amount}`); } } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } - /** Tuning knobs for {@link waitForSignatureConfirmation}. */ export interface ConfirmSignatureOptions { /** Delay between status polls in ms. Defaults to 1_000. */ @@ -798,6 +1352,19 @@ export interface ConfirmSignatureOptions { readonly timeoutMs?: number | undefined; } +/** Confirmation failure with an explicit retry-safety classification. */ +export class SignatureConfirmationError extends Error { + readonly outcome: 'definite-failure' | 'uncertain'; + readonly reason: 'aborted' | 'failed' | 'timeout'; + + constructor(message: string, outcome: 'definite-failure' | 'uncertain', reason: 'aborted' | 'failed' | 'timeout') { + super(message); + this.name = 'SignatureConfirmationError'; + this.outcome = outcome; + this.reason = reason; + } +} + /** * Poll `getSignatureStatuses` until `signature` reaches at least * 'confirmed' commitment. Throws if the transaction failed on-chain, the @@ -816,22 +1383,32 @@ export async function waitForSignatureConfirmation(args: { for (;;) { if (args.options?.signal?.aborted) { - throw new Error(`${context}: aborted while waiting for tx ${args.signature} confirmation`); + throw new SignatureConfirmationError( + `${context}: aborted while waiting for tx ${args.signature} confirmation`, + 'uncertain', + 'aborted', + ); } const [status] = (await args.rpc.getSignatureStatuses([args.signature]).send()).value; if (status) { if (status.err) { - throw new Error(`${context}: tx ${args.signature} failed on-chain: ${JSON.stringify(status.err)}`); + throw new SignatureConfirmationError( + `${context}: tx ${args.signature} failed on-chain: ${JSON.stringify(status.err)}`, + 'definite-failure', + 'failed', + ); } const level = status.confirmationStatus; - // RPC endpoints that omit confirmationStatus only report a - // status once the tx landed — treat that as confirmed. - if (level === undefined || level === null || level === 'confirmed' || level === 'finalized') { + if (level === 'confirmed' || level === 'finalized') { return; } } if (Date.now() >= deadline) { - throw new Error(`${context}: timed out waiting for tx ${args.signature} confirmation`); + throw new SignatureConfirmationError( + `${context}: timed out waiting for tx ${args.signature} confirmation`, + 'uncertain', + 'timeout', + ); } await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); } @@ -935,7 +1512,7 @@ export interface SubmitSettleAndDistributeArgs { export interface SubmitSettleAndDistributeResult { /** Instructions that were composed into the transaction. */ readonly instructions: readonly ServerInstruction[]; - /** Signature of the broadcast transaction. */ + /** Signature derived from the signed transaction. */ readonly signature: Signature; } @@ -947,6 +1524,22 @@ export interface SubmitSettleAndDistributeResult { */ export async function submitSettleAndDistribute( args: SubmitSettleAndDistributeArgs, +): Promise { + return await submitSettleAndDistributeInternal(args); +} + +/** Session-internal variant that persists signed identity before broadcast. */ +export async function submitSettleAndDistributeWithPreBroadcastPersistence( + args: SubmitSettleAndDistributeArgs, + beforeBroadcast: (prepared: { readonly signature: Signature; readonly wire: string }) => Promise, +): Promise { + return await submitSettleAndDistributeInternal(args, beforeBroadcast, true); +} + +async function submitSettleAndDistributeInternal( + args: SubmitSettleAndDistributeArgs, + beforeBroadcast?: (prepared: { readonly signature: Signature; readonly wire: string }) => Promise, + reconcileBroadcastError = false, ): Promise { const tokenProgram = args.tokenProgram ?? (args.currency ? defaultTokenProgramForCurrency(args.currency, args.network) : undefined); @@ -973,7 +1566,14 @@ export async function submitSettleAndDistribute( const instructions: ServerInstruction[] = [...settle.instructions, distribute]; const wire = await args.buildAndSignWireTransaction(instructions); - const signature = await args.rpc.sendTransaction(wire, { encoding: 'base64' }).send(); + const transaction = getTransactionDecoder().decode(getBase64Codec().encode(wire)); + const signature = getSignatureFromTransaction(transaction); + await beforeBroadcast?.({ signature, wire }); + try { + await args.rpc.sendTransaction(wire, { encoding: 'base64' }).send(); + } catch (error) { + if (!reconcileBroadcastError) throw error; + } return { instructions, signature }; } @@ -1165,6 +1765,33 @@ export async function buildMultiDelegatorUpdateInstruction(args: MultiDelegatorU // internals // ───────────────────────────────────────────────────────────────────── +function bytesEqual(left: ReadonlyUint8Array, right: ReadonlyUint8Array): boolean { + return left.length === right.length && left.every((byte, index) => byte === right[index]); +} + +function compiledAccountRole( + message: { + readonly header: { + readonly numReadonlyNonSignerAccounts: number; + readonly numReadonlySignerAccounts: number; + readonly numSignerAccounts: number; + }; + readonly staticAccounts: readonly unknown[]; + }, + index: number, +): AccountRole { + if (index < 0 || index >= message.staticAccounts.length) { + throw new Error(`verifyOpenTx: account index ${index} is outside static accounts`); + } + const { header } = message; + const writableSignerCount = header.numSignerAccounts - header.numReadonlySignerAccounts; + const writableNonSignerEnd = message.staticAccounts.length - header.numReadonlyNonSignerAccounts; + if (index < header.numSignerAccounts) { + return index < writableSignerCount ? AccountRole.WRITABLE_SIGNER : AccountRole.READONLY_SIGNER; + } + return index < writableNonSignerEnd ? AccountRole.WRITABLE : AccountRole.READONLY; +} + const SYSTEM_PROGRAM_ADDRESS = '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>; function isPlaceholderSignature(sig: string): boolean { diff --git a/typescript/packages/mpp/src/server/session/store.ts b/typescript/packages/mpp/src/server/session/store.ts index b49db8975..1d4013c4a 100644 --- a/typescript/packages/mpp/src/server/session/store.ts +++ b/typescript/packages/mpp/src/server/session/store.ts @@ -62,6 +62,11 @@ export interface ChannelState { readonly highestVoucherSignature?: string | undefined; /** Next server-side metered delivery sequence. */ readonly nextDeliverySequence: bigint; + /** + * Confirmed signature of a server-broadcast open transaction. Replays use + * this instead of trusting the client payload's pre-signing placeholder. + */ + readonly openSignature?: string | undefined; /** * Slot the channel was opened at (a channel PDA seed). Needed to * re-derive the PDA and to gate reclaim (`slot > openSlot + 1500`). @@ -72,10 +77,24 @@ export interface ChannelState { readonly operator?: string | undefined; /** Deliveries reserved but not yet committed. */ readonly pendingDeliveries: readonly PendingDelivery[]; + /** Authoritative channel PDA salt read from on-chain state. */ + readonly salt?: bigint | undefined; /** True once the channel has been sealed on-chain. */ readonly sealed: boolean; - /** On-chain settle_and_seal transaction signature (base58), once submitted. */ + /** Confirmed on-chain settle_and_seal transaction signature (base58). */ readonly settledSignature?: string | undefined; + /** Unix milliseconds when the current signature-less settlement claim expires. */ + readonly settlementClaimExpiresAt?: bigint | undefined; + /** Opaque owner token for the current settlement claim. */ + readonly settlementClaimOwner?: string | undefined; + /** Last valid block height of the signed settlement outbox transaction. */ + readonly settlementPendingLastValidBlockHeight?: bigint | undefined; + /** Broadcast settlement awaiting a definite confirmed/failed outcome. */ + readonly settlementPendingSignature?: string | undefined; + /** Exact signed base64 transaction paired with `settlementPendingSignature`. */ + readonly settlementPendingWire?: string | undefined; + /** True while one server instance owns the on-chain settlement broadcast. */ + readonly settling?: boolean | undefined; /** * Top-up transaction signatures already applied to this channel. * Kept in the channel record so replay rejection and the deposit increase @@ -179,7 +198,6 @@ export function createMemorySessionStore(): SessionStore { data.delete(channelId); return Promise.resolve(); }, - getChannel(channelId) { return Promise.resolve(data.get(channelId)); }, diff --git a/typescript/packages/mpp/src/server/session/wire-tx.ts b/typescript/packages/mpp/src/server/session/wire-tx.ts index 6a3b28163..06aa99499 100644 --- a/typescript/packages/mpp/src/server/session/wire-tx.ts +++ b/typescript/packages/mpp/src/server/session/wire-tx.ts @@ -34,6 +34,15 @@ export async function buildAndSignWireTransaction( signer: TransactionSigner, instructions: readonly ServerInstruction[], ): Promise { + return (await buildAndSignWireTransactionWithLifetime(rpc, signer, instructions)).wire; +} + +/** Session-internal builder that also returns the signed blockhash lifetime. */ +export async function buildAndSignWireTransactionWithLifetime( + rpc: RpcWithBlockhash, + signer: TransactionSigner, + instructions: readonly ServerInstruction[], +): Promise<{ readonly lastValidBlockHeight: bigint; readonly wire: string }> { const { value: latestBlockhash } = await rpc.getLatestBlockhash({ commitment: 'confirmed' }).send(); const message = pipe( createTransactionMessage({ version: 0 }), @@ -42,5 +51,8 @@ export async function buildAndSignWireTransaction( m => appendTransactionMessageInstructions(instructions, m), ); const signed = await signTransactionMessageWithSigners(message); - return getBase64EncodedWireTransaction(signed); + return { + lastValidBlockHeight: latestBlockhash.lastValidBlockHeight, + wire: getBase64EncodedWireTransaction(signed), + }; } diff --git a/typescript/packages/pay-kit/src/__tests__/mpp-session-store.test.ts b/typescript/packages/pay-kit/src/__tests__/mpp-session-store.test.ts new file mode 100644 index 000000000..0ac7c7f59 --- /dev/null +++ b/typescript/packages/pay-kit/src/__tests__/mpp-session-store.test.ts @@ -0,0 +1,67 @@ +import { createMemorySessionStore } from '@solana/mpp/server'; +import { Store } from 'mppx'; +import { describe, expect, it } from 'vitest'; + +import { createSessionEngine } from '../adapters/mpp-session.js'; +import { configure } from '../config.js'; +import { Gate } from '../gate.js'; +import { usd } from '../price.js'; +import { Signer } from '../signer.js'; + +const RECIPIENT = 'AyNAa2VPe2t5pgg8M61iE6kqMudkV98zsT4rkAZuU6tj'; +type SessionStoreWithCapability = ReturnType & { + readonly sessionStoreDurability?: 'durable-shared' | 'ephemeral'; +}; + +function sessionGate() { + return Gate.create( + { + amount: usd('1.00'), + kind: 'session', + name: 'stream', + payTo: RECIPIENT, + session: { unitPrice: 100n }, + }, + { accept: ['mpp'], payTo: RECIPIENT }, + ); +} + +async function configWithStore(sessionStore?: SessionStoreWithCapability) { + return await configure({ + mpp: { challengeBindingSecret: 'session-store-test-secret', sessionStore }, + network: 'solana_devnet', + operator: { recipient: RECIPIENT, signer: await Signer.generate() }, + replayStore: Store.memory(), + }); +} + +describe('MPP session store construction', () => { + it('fails closed outside localnet without an injected store', async () => { + const config = await configWithStore(); + expect(() => createSessionEngine(config, sessionGate())).toThrow( + /mpp\.sessionStore is required outside localnet/, + ); + }); + + it('rejects an unmarked injected store outside localnet', async () => { + const { sessionStoreDurability: _ignored, ...store } = createMemorySessionStore() as SessionStoreWithCapability; + const config = await configWithStore(store); + expect(() => createSessionEngine(config, sessionGate())).toThrow(/explicitly declare durable shared/); + }); + + it('accepts an explicitly durable shared store outside localnet', async () => { + const store = { ...createMemorySessionStore(), sessionStoreDurability: 'durable-shared' as const }; + const engine = createSessionEngine(await configWithStore(store), sessionGate()); + await expect(engine.receipt('unknown-channel')).resolves.toBeUndefined(); + }); + + it('keeps the explicit localnet development fallback', async () => { + const config = await configure({ + mpp: { challengeBindingSecret: 'session-store-test-secret' }, + network: 'solana_localnet', + operator: { recipient: RECIPIENT, signer: await Signer.generate() }, + }); + const engine = createSessionEngine(config, sessionGate()); + await expect(engine.receipt('unknown-channel')).resolves.toBeUndefined(); + }); +}); diff --git a/typescript/packages/pay-kit/src/adapters/mpp-session.ts b/typescript/packages/pay-kit/src/adapters/mpp-session.ts index cdb169a78..0aaff8ab9 100644 --- a/typescript/packages/pay-kit/src/adapters/mpp-session.ts +++ b/typescript/packages/pay-kit/src/adapters/mpp-session.ts @@ -8,7 +8,8 @@ * * pay-kit does NOT auto-mount the side-channel routes (mppx-consistent): the * instance exposes `handler` / `deliveries` / `commit` / `receipt` and the app - * mounts them. All four share the configured session store per gate. + * mounts them. All four share the injected session store (or an explicitly + * localnet-only in-memory store) per gate. */ import { createSolanaRpc } from '@solana/kit'; import { resolveStablecoinMint } from '@solana/mpp'; @@ -68,6 +69,8 @@ export function createSessionEngine(config: PayKitConfig, gate: Gate): SessionEn const signer = config.operator.signer.signer; const store = resolveSessionStore(config); + const allowUnsafeEphemeralStoreOffLocalnet = + config.network !== 'solana_localnet' && process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE === '1'; const params = { cap: gate.amount.baseUnits(), ...(gate.session.closeDelayMs !== undefined ? { closeDelayMs: gate.session.closeDelayMs } : {}), @@ -85,6 +88,7 @@ export function createSessionEngine(config: PayKitConfig, gate: Gate): SessionEn rpcUrl: config.rpcUrl, signer, store, + ...(allowUnsafeEphemeralStoreOffLocalnet ? { allowUnsafeEphemeralStoreOffLocalnet: true } : {}), }; const mppx = Mppx.create({ diff --git a/typescript/packages/pay-kit/src/config.ts b/typescript/packages/pay-kit/src/config.ts index b67b94775..58962740c 100644 --- a/typescript/packages/pay-kit/src/config.ts +++ b/typescript/packages/pay-kit/src/config.ts @@ -34,8 +34,8 @@ export type MppOptions = { readonly html?: boolean; readonly realm?: string; /** - * Storage for MPP session channels. Provide a durable, shared store in - * production because it records voucher and delivery state. + * Shared store for MPP session channel state. Required for session gates + * outside localnet; localnet may use the adapter's ephemeral memory store. */ readonly sessionStore?: SessionStore; };