From adfa67930045c0f19a9d357bc5c8d505874fb4ec Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:11:06 +0300 Subject: [PATCH 01/39] fix(mpp): bind session state to confirmed channels --- go/internal/testutil/fakes.go | 33 +- go/protocols/mpp/server/session.go | 79 +- go/protocols/mpp/server/session_method.go | 129 +- .../mpp/server/session_method_test.go | 160 +- go/protocols/mpp/server/session_onchain.go | 322 +++- .../mpp/server/session_onchain_test.go | 32 +- .../mpp/server/session_state_binding_test.go | 314 ++++ go/protocols/mpp/server/session_store.go | 47 + python/examples/playground_api/sessions.py | 4 + python/src/solana_pay_kit/_paycore/rpc.py | 9 +- .../protocols/mpp/server/session.py | 55 +- .../protocols/mpp/server/session_method.py | 122 +- .../protocols/mpp/server/session_onchain.py | 396 ++++- .../protocols/mpp/server/session_store.py | 37 + python/tests/test_session_method.py | 150 +- python/tests/test_session_onchain.py | 73 +- python/tests/test_session_state_binding.py | 347 +++++ python/tests/test_session_store.py | 15 + rust/crates/kit/src/core/session.rs | 4 + rust/crates/kit/src/core/store.rs | 33 + rust/crates/kit/src/mpp/mod.rs | 2 +- rust/crates/kit/src/mpp/server/session.rs | 1310 ++++++++++++++++- .../kit/src/x402/server/batch_settlement.rs | 4 + .../src/__tests__/session-on-chain.test.ts | 2 +- .../__tests__/session-server-on-chain.test.ts | 7 +- .../mpp/src/__tests__/session-server.test.ts | 81 +- .../__tests__/session-state-binding.test.ts | 303 ++++ typescript/packages/mpp/src/server/Session.ts | 174 ++- .../mpp/src/server/session/on-chain.ts | 220 ++- .../packages/mpp/src/server/session/store.ts | 15 +- .../src/__tests__/mpp-session-store.test.ts | 65 + .../pay-kit/src/adapters/mpp-session.ts | 31 +- typescript/packages/pay-kit/src/config.ts | 8 + 33 files changed, 4342 insertions(+), 241 deletions(-) create mode 100644 go/protocols/mpp/server/session_state_binding_test.go create mode 100644 python/tests/test_session_state_binding.py create mode 100644 typescript/packages/mpp/src/__tests__/session-state-binding.test.ts create mode 100644 typescript/packages/pay-kit/src/__tests__/mpp-session-store.test.ts diff --git a/go/internal/testutil/fakes.go b/go/internal/testutil/fakes.go index 27b3a3f6d..c8ae9ab9b 100644 --- a/go/internal/testutil/fakes.go +++ b/go/internal/testutil/fakes.go @@ -32,10 +32,12 @@ 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 + 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 +53,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 @@ -94,6 +110,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 7b70f1f12..f6b3e412e 100644 --- a/go/protocols/mpp/server/session.go +++ b/go/protocols/mpp/server/session.go @@ -57,6 +57,10 @@ type Split struct { // payload's owner/payer fields. type SessionTxVerifier[P any] func(ctx context.Context, payload *P) (string, 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. @@ -111,9 +115,17 @@ type SessionConfig struct { // mode) before ProcessOpen persists channel state. See SessionTxVerifier. VerifyOpenTx SessionTxVerifier[intents.OpenPayload] - // VerifyTopUpTx, when set, confirms the top-up transaction on-chain - // before ProcessTopUp raises the deposit. See SessionTxVerifier. + // VerifyTopUpTx is the legacy payload-only hook retained for API + // compatibility. New integrations should use VerifyTopUpStateTx. VerifyTopUpTx SessionTxVerifier[intents.TopUpPayload] + + // VerifyTopUpStateTx binds the top-up to the stored channel identity and + // resulting on-chain account state. + 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 @@ -239,6 +251,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) } @@ -258,11 +273,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). + paymentChannelBacked := payload.Mode == intents.SessionModePush || payload.Transaction != nil + if paymentChannelBacked && s.config.Network != "localnet" && s.config.VerifyOpenTx == nil { + return ChannelState{}, fmt.Errorf("payment-channel open requires an on-chain verifier off localnet") + } + + // Transaction-backed pull opens are payment-channel opens too and must pass + // the same verifier as push opens. var verifiedPayer string - if payload.Mode == intents.SessionModePush && s.config.VerifyOpenTx != nil { + if paymentChannelBacked && s.config.VerifyOpenTx != nil { var err error verifiedPayer, err = s.config.VerifyOpenTx(ctx, payload) if err != nil { @@ -374,6 +393,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 @@ -462,18 +484,37 @@ 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) } - // On-chain verification seam (same shape as ProcessOpen). A top-up never - // establishes the channel payer, so the returned payer is ignored. + current, err := s.store.GetChannel(ctx, payload.ChannelID) + if err != nil { + return ChannelState{}, err + } + if current == nil { + return ChannelState{}, fmt.Errorf("channel %s not found", payload.ChannelID) + } + + // Bind the resulting on-chain state to the identity already persisted for + // this channel before raising its local deposit. if s.config.VerifyTopUpTx != nil { if _, err := s.config.VerifyTopUpTx(ctx, payload); err != nil { 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 channelID := payload.ChannelID @@ -499,6 +540,16 @@ func (s *SessionServer) ProcessTopUp(ctx context.Context, payload *intents.TopUp }) } +func (s *SessionServer) requireProductionSessionSafety() error { + if s.config.Network == "localnet" || s.config.AllowUnsafeEphemeralStoreOffLocalnet { + 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. // @@ -506,6 +557,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") } @@ -604,6 +658,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 { @@ -755,6 +812,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 @@ -819,6 +879,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 21eca165e..b7e7ccf83 100644 --- a/go/protocols/mpp/server/session_method.go +++ b/go/protocols/mpp/server/session_method.go @@ -25,6 +25,7 @@ import ( solana "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/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" @@ -107,9 +108,15 @@ type SessionOptions struct { // server broadcasts a client-built open (OpenTxSubmitterServer). PaymentChannelPayerSigner solanatx.Signer - // Store is the pluggable channel store. Defaults to in-memory. + // Store is the pluggable channel store. It is required off localnet so + // session state is not silently process-local in production. Localnet + // defaults to an in-memory store for development. Store ChannelStore + // AllowUnsafeEphemeralStoreOffLocalnet permits the built-in process-local + // memory store outside localnet. Unsafe; intended only for explicit dev use. + AllowUnsafeEphemeralStoreOffLocalnet bool + // RPC is the optional RPC client used for on-chain checks, the // recentBlockhash prefetch, and settlement broadcasts. Nil skips every // on-chain check and trusts payload claims as provided. @@ -217,22 +224,35 @@ func NewSession(options SessionOptions) (*Session, error) { } store := options.Store if store == nil { + if options.Network != "localnet" { + return nil, core.NewError(core.ErrCodeInvalidConfig, + "session store is required off localnet; inject a durable shared ChannelStore") + } store = NewMemoryChannelStore() } + if options.Network != "localnet" && !options.AllowUnsafeEphemeralStoreOffLocalnet && !isDurableSharedSessionStore(store) { + return nil, core.NewError(core.ErrCodeInvalidConfig, + sessionStoreSafetyMessage(store)) + } config := SessionConfig{ - Operator: options.Operator, - Recipient: options.Recipient, - Splits: options.Splits, - MaxCap: options.Cap, - Currency: options.Currency, - Decimals: options.Decimals, - Network: options.Network, - ProgramID: options.ProgramID, - MinVoucherDelta: options.MinVoucherDelta, - Modes: options.Modes, - PullVoucherStrategy: options.PullVoucherStrategy, - } + Operator: options.Operator, + Recipient: options.Recipient, + Splits: options.Splits, + MaxCap: options.Cap, + Currency: options.Currency, + Decimals: options.Decimals, + Network: options.Network, + ProgramID: options.ProgramID, + MinVoucherDelta: options.MinVoucherDelta, + Modes: options.Modes, + PullVoucherStrategy: options.PullVoucherStrategy, + AllowUnsafeEphemeralStoreOffLocalnet: options.AllowUnsafeEphemeralStoreOffLocalnet, + } + if options.RPC != nil { + config.VerifyOpenTx = NewOpenTxVerifier(config, options.RPC) + } + config.VerifyTopUpStateTx = NewTopUpStateTxVerifier(config, options.RPC) session := &Session{ core: NewSessionServer(config, store), secretKey: options.SecretKey, @@ -494,6 +514,8 @@ 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 switch { case hasTransaction: @@ -524,10 +546,16 @@ 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 } else { submitted, err := SubmitOpenTx(ctx, expected, payload, s.payerSigner, s.rpc) if err != nil { @@ -537,7 +565,9 @@ 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 } } else { verified, err := VerifyOpenTx(ctx, expected, payload, s.rpc) @@ -548,6 +578,42 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload) 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 + } + deposit = bound.Deposit + channelPayer = bound.Payer + openSlot = bound.OpenSlot + salt = bound.Salt } case mode == intents.SessionModePush: // No transaction in the payload: the client asserts a previously @@ -561,9 +627,37 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload) return "", err } if s.rpc != nil { - if err := confirmTransactionSignature(ctx, s.rpc, signature, "open"); err != nil { + 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 + } + deposit = bound.Deposit + channelPayer = bound.Payer + openSlot = bound.OpenSlot + salt = bound.Salt + } else if s.network != "localnet" { + return "", fmt.Errorf("payment-channel push open requires an rpc client to bind the on-chain channel off localnet") } default: // Pull mode without a channel transaction: trust the @@ -600,6 +694,8 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload) AuthorizedSigner: payload.AuthorizedSigner, Deposit: deposit, OpenSlot: openSlot, + Salt: salt, + OpenSignature: openSignature, Operator: operator, } @@ -679,11 +775,6 @@ func (s *Session) handleTopUp(ctx context.Context, payload *intents.TopUpPayload if existing.CloseRequestedAt != nil { return "", fmt.Errorf("channel %s close is pending; no further top-ups accepted", payload.ChannelID) } - if s.rpc != nil { - if err := confirmTransactionSignature(ctx, s.rpc, payload.Signature, "topUp"); err != nil { - return "", err - } - } if _, err := s.core.ProcessTopUp(ctx, payload); err != nil { return "", err } diff --git a/go/protocols/mpp/server/session_method_test.go b/go/protocols/mpp/server/session_method_test.go index 3da0a4ce6..b4511c4d3 100644 --- a/go/protocols/mpp/server/session_method_test.go +++ b/go/protocols/mpp/server/session_method_test.go @@ -18,14 +18,17 @@ import ( "testing" "time" + bin "github.com/gagliardetto/binary" solana "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/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" 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" @@ -91,11 +94,11 @@ func openTrustedChannel(t *testing.T, session *Session, deposit uint64) (testVou 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 @@ -103,11 +106,81 @@ func openSessionChannel(t *testing.T, session *Session, channelID string, deposi // without it the settle path now refuses rather than refunding the merchant. payer := solana.NewWallet().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, + ) + } + } receipt, err := verifySessionAction(t, session, intents.NewOpenAction(payload)) if err != nil { t.Fatalf("open: %v", err) } - return receipt + return receipt, channelID +} + +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 { @@ -183,6 +256,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) @@ -195,6 +269,31 @@ 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(), "session store is required") { + 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() + + options.Store = struct{ ChannelStore }{ChannelStore: NewMemoryChannelStore()} + if _, err := NewSession(options); 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) { @@ -392,7 +491,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) } @@ -478,7 +577,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) @@ -518,7 +617,7 @@ func TestSessionOpenVerifiesSignatureOnChain(t *testing.T) { signer := newTestVoucherSigner(t) channelID := solana.NewWallet().PublicKey().String() - receipt := openSessionChannel(t, session, channelID, 1_000, signer.Address(), okSig) + receipt, channelID := openSessionChannel(t, session, channelID, 1_000, signer.Address(), okSig) if receipt.Reference != okSig { t.Fatalf("reference = %q", receipt.Reference) } @@ -728,7 +827,11 @@ 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) + _, channelID = openSessionChannel(t, session, channelID, 1_000, signer.Address(), openSig) + state := mustGetChannel(t, session, channelID) + seedSessionAccountThroughSetter( + t, fake, session, channelID, 5_000, *state.Operator, signer.Address(), + ) receipt, err := verifySessionAction(t, session, intents.NewTopUpAction(intents.TopUpPayload{ ChannelID: channelID, NewDeposit: "5000", Signature: topupSig, @@ -879,7 +982,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) @@ -1141,6 +1244,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 { @@ -1182,6 +1290,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() @@ -1189,6 +1301,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 { @@ -1203,6 +1320,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 @@ -1369,8 +1496,23 @@ func TestSessionMiddlewareSkipsBlockhashPrefetchOnVerifyPath(t *testing.T) { } calls := fake.calls() signer := newTestVoucherSigner(t) + payer := solana.NewWallet().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()) credential, err := core.NewPaymentCredential(challenge.ToEcho(), intents.NewOpenAction( - intents.OpenPayloadPush(solana.NewWallet().PublicKey().String(), "1000", signer.Address(), confirmedSignature(0x88)))) + intents.OpenPayloadPush(channelID, "1000", signer.Address(), confirmedSignature(0x88)))) if err != nil { t.Fatalf("NewPaymentCredential: %v", err) } diff --git a/go/protocols/mpp/server/session_onchain.go b/go/protocols/mpp/server/session_onchain.go index cfb9395a7..34ec342b1 100644 --- a/go/protocols/mpp/server/session_onchain.go +++ b/go/protocols/mpp/server/session_onchain.go @@ -15,22 +15,185 @@ package server import ( "context" + "crypto/sha256" "encoding/binary" "fmt" + "strconv" "strings" + bin "github.com/gagliardetto/binary" solana "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" "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" ) +type boundChannel struct { + Deposit uint64 + Payer string + AuthorizedSigner string + Payee string + Mint string + 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, +) (*pcgen.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(pcgen.Channel) + if err := channel.UnmarshalWithDecoder(bin.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(pcgen.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(), + 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. -const openInstructionDiscriminator = 1 +const ( + openInstructionDiscriminator = 1 + topUpInstructionDiscriminator = 3 +) // VerifyOpenTxExpected carries the challenge-side values a client-submitted // open transaction is validated against. @@ -312,20 +475,107 @@ func NewOpenTxVerifier(config SessionConfig, rpcClient solanatx.RPCClient) Sessi // NewTopUpTxVerifier returns the on-chain top-up verifier to install on // SessionConfig.VerifyTopUpTx: it confirms the top-up transaction signature // on-chain via getSignatureStatuses. -// A nil rpcClient returns nil so the seam stays unset, and the new deposit is -// trusted as provided; suitable only for unit tests or deployments that -// verify transactions out of band. +// NewTopUpTxVerifier is the legacy payload-only callback factory. Keep this +// public shape for host integrations that install VerifyTopUpTx directly. +// Session construction uses NewTopUpStateTxVerifier below for account binding. func NewTopUpTxVerifier(rpcClient solanatx.RPCClient) SessionTxVerifier[intents.TopUpPayload] { if rpcClient == nil { return nil } return func(ctx context.Context, payload *intents.TopUpPayload) (string, error) { - // A top-up carries only a signature, not an open transaction, so it - // never establishes the channel payer. return "", confirmTransactionSignature(ctx, rpcClient, payload.Signature, "top-up") } } +// 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 + } + amount := binary.LittleEndian.Uint64(instruction.Data[1:]) + if ^uint64(0)-funded < amount { + return fmt.Errorf("top-up instruction amount overflows total") + } + funded += amount + matched++ + } + if matched == 0 { + return fmt.Errorf("no payment-channels top_up instruction found for channel %s", channelID) + } + 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 @@ -336,6 +586,9 @@ func NewTopUpTxVerifier(rpcClient solanatx.RPCClient) SessionTxVerifier[intents. // The mint and token program are resolved from the configured currency and // network (Token-2022 for PYUSD/USDG/CASH). 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 @@ -474,15 +727,32 @@ 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) + 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. + verified, err := VerifyOpenTx(ctx, expected, payload, nil) + 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) } // Complete the fee-payer signature when the client left the slot for the // server (the createServerOpenedPaymentChannelSessionOpener flow builds @@ -490,20 +760,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") + return VerifyOpenTxResult{}, nil, solana.Signature{}, 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 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 @@ -521,21 +784,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 0, fmt.Errorf("%s tx %q is only %s; confirmed or finalized required", label, signature, status) } - return nil + return out.Value[0].Slot, nil } // isPlaceholderSignature reports whether the signature is the pending diff --git a/go/protocols/mpp/server/session_onchain_test.go b/go/protocols/mpp/server/session_onchain_test.go index 18beeab3e..7b0a524c6 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 @@ -522,9 +523,22 @@ func TestNewTopUpTxVerifierConfirmsSignature(t *testing.T) { if err != nil { t.Fatalf("sign: %v", err) } - verifier := NewTopUpTxVerifier(testutil.NewFakeRPC()) - payload := &intents.TopUpPayload{ChannelID: "chan", NewDeposit: "2000000", Signature: signature.String()} - if _, err := verifier(context.Background(), payload); err != nil { + config := sessionTestConfig() + fake := testutil.NewFakeRPC() + channelID := solana.NewWallet().PublicKey() + payer := solana.NewWallet().PublicKey() + authorizedSigner := solana.NewWallet().PublicKey() + seedSessionChannelAccount( + t, fake, channelID, 2_000_000, payer, + solana.MustPublicKeyFromBase58(config.Recipient), authorizedSigner, + solana.MustPublicKeyFromBase58(paycore.ResolveMint(config.Currency, config.Network)), + pcgen.ChannelStatus_Open, + ) + verifier := NewTopUpStateTxVerifier(config, fake) + payload := &intents.TopUpPayload{ChannelID: channelID.String(), NewDeposit: "2000000", Signature: signature.String()} + storedPayer := payer.String() + current := ChannelState{AuthorizedSigner: authorizedSigner.String(), Operator: &storedPayer} + if err := verifier(context.Background(), payload, current); err != nil { t.Fatalf("verifier with confirmed signature: %v", err) } } @@ -537,18 +551,18 @@ func TestNewTopUpTxVerifierSurfacesFailureAndNotFound(t *testing.T) { } fakeRPC := testutil.NewFakeRPC() fakeRPC.Statuses[signature.String()] = &rpc.SignatureStatusesResult{Err: "InstructionError"} - verifier := NewTopUpTxVerifier(fakeRPC) + verifier := NewTopUpStateTxVerifier(sessionTestConfig(), fakeRPC) payload := &intents.TopUpPayload{ChannelID: "chan", NewDeposit: "2000000", Signature: signature.String()} - if _, err := verifier(context.Background(), payload); err == nil || !strings.Contains(err.Error(), "top-up") { + if err := verifier(context.Background(), payload, ChannelState{}); err == nil || !strings.Contains(err.Error(), "top-up") { t.Fatalf("err = %v, want top-up failure rejection", err) } fakeRPC.Statuses[signature.String()] = nil - if _, err := verifier(context.Background(), payload); err == nil || !strings.Contains(err.Error(), "not found") { + if err := verifier(context.Background(), payload, ChannelState{}); err == nil || !strings.Contains(err.Error(), "not found") { t.Fatalf("err = %v, want not-found rejection", err) } - if _, err := verifier(context.Background(), &intents.TopUpPayload{Signature: "not-base58!"}); err == nil || !strings.Contains(err.Error(), "invalid top-up tx signature") { + if err := verifier(context.Background(), &intents.TopUpPayload{Signature: "not-base58!"}, ChannelState{}); err == nil || !strings.Contains(err.Error(), "invalid top-up tx signature") { t.Fatalf("err = %v, want invalid-signature rejection", err) } } @@ -696,6 +710,10 @@ 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 + } server := newSessionTestServer(config) payer := testutil.NewPrivateKey().PublicKey() merchant := testutil.NewPrivateKey().PublicKey() 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..b8b3e340b --- /dev/null +++ b/go/protocols/mpp/server/session_state_binding_test.go @@ -0,0 +1,314 @@ +package server + +import ( + "bytes" + "context" + "strings" + "testing" + + bin "github.com/gagliardetto/binary" + solana "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/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() + payer := solana.NewWallet().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(), "1000", signer.String(), confirmedSignature(0x31)) + claimedPayer := solana.NewWallet().PublicKey().String() + payload.Payer = &claimedPayer + 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 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) (string, 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 9acbb03dc..8272b06b5 100644 --- a/go/protocols/mpp/server/session_store.go +++ b/go/protocols/mpp/server/session_store.go @@ -76,6 +76,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"` @@ -230,6 +237,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. @@ -254,6 +283,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() diff --git a/python/examples/playground_api/sessions.py b/python/examples/playground_api/sessions.py index c5358c833..44c880cef 100644 --- a/python/examples/playground_api/sessions.py +++ b/python/examples/playground_api/sessions.py @@ -28,6 +28,7 @@ from solana_pay_kit._paycore.solana import resolve_mint, stablecoin_decimals from solana_pay_kit.fastapi import RequireSession from solana_pay_kit.protocols.mpp.server import ( + MemoryChannelStore, SessionChallengeOptions, SessionOptions, new_session, @@ -58,6 +59,9 @@ rpc=SolanaRpc(_cfg.effective_rpc_url()), open_tx_submitter="server", close_delay=2.0, + store=MemoryChannelStore(), + # Playground-only process state; production must inject a durable store. + allow_unsafe_ephemeral_store_off_localnet=True, ) ) diff --git a/python/src/solana_pay_kit/_paycore/rpc.py b/python/src/solana_pay_kit/_paycore/rpc.py index 6a1a39007..d8bee74fb 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 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 0381f621f..158c1eca4 100644 --- a/python/src/solana_pay_kit/protocols/mpp/server/session.py +++ b/python/src/solana_pay_kit/protocols/mpp/server/session.py @@ -29,6 +29,7 @@ from dataclasses import dataclass, field from typing import TypeVar +from solana_pay_kit._paycore.errors import PaymentError from solana_pay_kit.protocols.mpp.intents.session import ( DEFAULT_SESSION_EXPIRES_AT, ClosePayload, @@ -49,6 +50,10 @@ ChannelStore, CommittedDelivery, PendingDelivery, + SessionStoreDurability, +) +from solana_pay_kit.protocols.mpp.server.session_store import ( + session_store_safety_message as _session_store_safety_message, ) from solana_pay_kit.protocols.mpp.server.session_voucher import ( ChannelState as VoucherChannelState, @@ -78,6 +83,7 @@ # signature on-chain. This is the seam the on-chain layer plugs into; ``None`` # skips verification. Raising signals a verification failure. SessionTxVerifier = Callable[[_P], Awaitable[None]] +SessionTopUpTxVerifier = Callable[[TopUpPayload, ChannelState], Awaitable[None]] @dataclass @@ -150,10 +156,14 @@ class SessionConfig: # mode) before process_open persists channel state. verify_open_tx: SessionTxVerifier[OpenPayload] | None = None - # VerifyTopUpTx, when set, confirms the top-up transaction on-chain before - # process_top_up raises the deposit. + # Legacy payload-only callback retained for API compatibility. verify_top_up_tx: SessionTxVerifier[TopUpPayload] | None = None + # State-aware hook for identity and resulting-state binding. + verify_top_up_state_tx: SessionTopUpTxVerifier | None = None + + allow_unsafe_ephemeral_store_off_localnet: bool = False + @dataclass class DeliveryRequest: @@ -319,6 +329,7 @@ async def process_open(self, payload: OpenPayload) -> ChannelState: when the channel is sealed or when the payload's authorized signer differs from the stored one. """ + self._require_production_session_safety() if not self._supports_mode(payload.mode): raise ValueError(f"session mode {payload.mode!r} is not supported by this challenge") @@ -329,10 +340,10 @@ async def process_open(self, payload: OpenPayload) -> ChannelState: if deposit > self._config.max_cap: raise ValueError(f"deposit {deposit} exceeds max cap {self._config.max_cap}") - # On-chain verification seam (push mode only; pull-mode host - # integrations submit server-broadcast transactions or validate - # delegated-token state before invoking this lower-level store method). - if payload.mode == "push" and self._config.verify_open_tx is not None: + payment_channel_backed = payload.mode == "push" or payload.transaction is not None + if payment_channel_backed and self._config.network != "localnet" and self._config.verify_open_tx is None: + raise ValueError("payment-channel open requires an on-chain verifier off localnet") + if payment_channel_backed and self._config.verify_open_tx is not None: try: await self._config.verify_open_tx(payload) except Exception as exc: @@ -351,6 +362,8 @@ async def process_open(self, payload: OpenPayload) -> ChannelState: # the rent reclaimed later. Zero when the payload does not carry # one (pull opens, trusted opens). open_slot=payload.recent_slot or 0, + salt=payload.salt or 0, + open_signature=payload.signature or None, ) def mutator(existing: ChannelState | None) -> ChannelState: @@ -378,6 +391,7 @@ async def verify_voucher(self, payload: VoucherPayload) -> int: checks are re-applied inside the atomic mutator before the watermark is persisted. """ + self._require_production_session_safety() voucher = payload.voucher channel_id = voucher.data.channel_id @@ -442,15 +456,32 @@ async def process_top_up(self, payload: TopUpPayload) -> ChannelState: configured max cap. Top-ups are rejected once the channel is sealed or a close has been requested. """ + self._require_production_session_safety() + if self._config.network != "localnet" and self._config.verify_top_up_state_tx is None: + raise ValueError("payment-channel top-up requires a state-aware on-chain verifier off localnet") try: new_deposit = _parse_u64(payload.new_deposit) except ValueError as exc: raise ValueError(f"invalid newDeposit: {payload.new_deposit}") from exc - # On-chain verification seam (same shape as process_open). + current = await self._store.get_channel(payload.channel_id) + if current is None: + raise ValueError(f"channel {payload.channel_id} not found") + if self._config.verify_top_up_tx is not None: try: await self._config.verify_top_up_tx(payload) + except PaymentError: + raise + except Exception as exc: + raise _wrap("top-up tx verification failed", exc) from exc + + # Bind the resulting account to the stored channel identity. + if self._config.verify_top_up_state_tx is not None: + try: + await self._config.verify_top_up_state_tx(payload, current) + except PaymentError: + raise except Exception as exc: raise _wrap("top-up tx verification failed", exc) from exc @@ -474,6 +505,12 @@ def mutator(current: ChannelState | None) -> ChannelState: return await self._store.update_channel(channel_id, mutator) + def _require_production_session_safety(self) -> None: + if self._config.network == "localnet" or self._config.allow_unsafe_ephemeral_store_off_localnet: + return + if self._store.session_store_durability != SessionStoreDurability.DURABLE_SHARED: + raise ValueError(_session_store_safety_message(self._store)) + async def begin_delivery(self, request: DeliveryRequest) -> MeteringDirective: """Reserve capacity for a delivered message/response and return the metering directive the client must commit after processing it. @@ -482,6 +519,7 @@ async def begin_delivery(self, request: DeliveryRequest) -> MeteringDirective: assigns the next sequence, and defaults the delivery id to ":". """ + self._require_production_session_safety() if request.amount == 0: raise ValueError("delivery amount must be greater than zero") @@ -554,6 +592,7 @@ async def process_commit(self, payload: CommitPayload) -> CommitReceipt: and same signature) returns the cached receipt with status replayed after re-verifying the voucher signature. """ + self._require_production_session_safety() channel_id = payload.voucher.data.channel_id try: new_cumulative = _parse_u64(payload.voucher.data.cumulative) @@ -655,6 +694,7 @@ async def process_close(self, payload: ClosePayload) -> ChannelState: (unless it is an idempotent replay of the current highest voucher) and leaves the state unchanged. """ + self._require_production_session_safety() now = int(time.time()) channel_id = payload.channel_id voucher = payload.voucher @@ -711,6 +751,7 @@ def mutator(current: ChannelState | None) -> ChannelState: async def mark_sealed(self, channel_id: str) -> None: """Mark a channel as sealed. Call after the on-chain seal transaction confirms.""" + self._require_production_session_safety() await self._store.mark_sealed(channel_id) 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 ad55a1d39..a4f8525bd 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 @@ -45,7 +45,7 @@ PaymentError, payment_required_response, ) -from solana_pay_kit._paycore.solana import MAX_SPLITS +from solana_pay_kit._paycore.solana import MAX_SPLITS, resolve_mint from solana_pay_kit.protocols.mpp.core.expires import minutes from solana_pay_kit.protocols.mpp.core.headers import ( PAYMENT_RECEIPT_HEADER, @@ -72,10 +72,18 @@ VerifyOpenTxExpected, confirm_transaction_signature, cosign_and_broadcast_open, + fetch_and_bind_channel_account, settle_and_seal_channel, verify_open_tx, ) -from solana_pay_kit.protocols.mpp.server.session_store import ChannelStore, MemoryChannelStore +from solana_pay_kit.protocols.mpp.server.session_store import ( + ChannelStore, + MemoryChannelStore, + SessionStoreDurability, +) +from solana_pay_kit.protocols.mpp.server.session_store import ( + session_store_safety_message as _session_store_safety_message, +) from solana_pay_kit.signer import LocalSigner logger = logging.getLogger(__name__) @@ -160,8 +168,11 @@ class SessionOptions: # OpenTxSubmitter selects who broadcasts push-mode open transactions. # Default "client". open_tx_submitter: OpenTxSubmitter = "" - # Store is the pluggable channel store. Defaults to in-memory. + # Store is required off localnet so production session state cannot become + # silently process-local. Localnet defaults to in-memory for development. store: ChannelStore | None = None + # Unsafe explicit development escape hatch for stores not marked durable/shared. + allow_unsafe_ephemeral_store_off_localnet: bool = False # RPC is the optional RPC client used for on-chain checks. None skips every # on-chain check and trusts payload claims as provided. rpc: RpcClient | None = None @@ -633,6 +644,14 @@ async def _handle_open(self, payload: OpenPayload, challenge_recent_slot: int | f"channel {session_id} already exists with a different authorized signer", code="invalid-payload", ) + if existing.open_signature is None: + raise PaymentError( + f"server-submitted open {session_id} is missing its persisted broadcast signature", + code="invalid-payload", + ) + payload.signature = existing.open_signature + payload.salt = existing.salt + payload.recent_slot = existing.open_slot else: # Built lazily: only the transaction-carrying paths verify # the open on-chain, so the on-chain expected facts (and the @@ -647,6 +666,7 @@ async def _handle_open(self, payload: OpenPayload, challenge_recent_slot: int | # openSlot from the verified transaction is authoritative; # persist it so the channel PDA stays re-derivable. payload.recent_slot = verified.open_slot + payload.salt = verified.salt payload.signature = await cosign_and_broadcast_open( payload, fee_payer=self._signer.keypair, rpc=self._rpc ) @@ -671,19 +691,81 @@ async def _handle_open(self, payload: OpenPayload, challenge_recent_slot: int | payload.payer = verified.payer payload.deposit = str(verified.deposit) payload.recent_slot = verified.open_slot + payload.salt = verified.salt elif mode == "push" and self._signer is not None and self._rpc is not None and not payload.payer: raise PaymentError( "push open requires payer or transaction when settle-at-close is configured", code="invalid-payload", ) elif mode == "push" and self._rpc is not None: - await confirm_transaction_signature(self._rpc, payload.signature, "open") + confirmed_slot = await confirm_transaction_signature(self._rpc, payload.signature, "open") + expected_mint = resolve_mint(self._currency, self._network) + if not expected_mint: + raise PaymentError( + f"payment-channel push open requires an SPL token, got currency {self._currency!r}", + code="invalid-config", + ) + bound = await fetch_and_bind_channel_account( + self._rpc, + payload.session_id(), + program_id=self._core.config.program_id, + max_cap=self._core.config.max_cap, + expected_authorized_signer=payload.authorized_signer, + expected_payee=self._recipient, + expected_mint=expected_mint, + expected_operator=self._core.config.operator, + min_context_slot=confirmed_slot, + expected_grace_period=(self._core.config.settlement_window or 900), + expected_splits=self._core.config.splits, + ) + payload.deposit = str(bound.deposit) + payload.payer = bound.payer + payload.recent_slot = bound.open_slot + payload.salt = bound.salt + elif mode == "push" and self._network != "localnet": + raise PaymentError( + "payment-channel push open requires an rpc client to bind the on-chain channel off localnet", + code="invalid-config", + ) # else: no transaction is attached. Reachable by a pull open (the channel # id / token account and deposit are trusted as provided, mirroring the TS # `else` branch) or by a push open with a channel id and no RPC (trusted # as previously broadcast). The server-broadcast path is skipped even when # openTxSubmitter=server is configured. + if has_transaction: + if self._rpc is None: + if self._network != "localnet": + raise PaymentError( + "payment-channel open requires an rpc client to bind the on-chain channel off localnet", + code="invalid-config", + ) + else: + confirmed_slot = await confirm_transaction_signature(self._rpc, payload.signature, "open") + expected_mint = resolve_mint(self._currency, self._network) + if not expected_mint: + raise PaymentError( + f"payment-channel open requires an SPL token, got currency {self._currency!r}", + code="invalid-config", + ) + bound = await fetch_and_bind_channel_account( + self._rpc, + payload.session_id(), + program_id=self._core.config.program_id, + max_cap=self._core.config.max_cap, + expected_authorized_signer=payload.authorized_signer, + expected_payee=self._recipient, + expected_mint=expected_mint, + expected_operator=self._core.config.operator, + min_context_slot=confirmed_slot, + expected_grace_period=(self._core.config.settlement_window or 900), + expected_splits=self._core.config.splits, + ) + payload.deposit = str(bound.deposit) + payload.payer = bound.payer + payload.recent_slot = bound.open_slot + payload.salt = bound.salt + try: state = await self._core.process_open(payload) except ValueError as exc: @@ -736,10 +818,10 @@ async def _handle_top_up(self, payload: TopUpPayload) -> str: f"channel {payload.channel_id} close is pending; no further top-ups accepted", code="invalid-payload", ) - if self._rpc is not None: - await confirm_transaction_signature(self._rpc, payload.signature, "topUp") try: await self._core.process_top_up(payload) + except PaymentError: + raise except ValueError as exc: raise PaymentError(str(exc), code="invalid-payload") from exc self._touch(payload.channel_id) @@ -979,7 +1061,21 @@ def new_session(options: SessionOptions) -> Session: code="invalid-config", ) + if options.store is None and network != "localnet": + raise PaymentError( + "session store is required off localnet; inject a durable shared ChannelStore", + code="invalid-config", + ) store = options.store if options.store is not None else MemoryChannelStore() + if ( + network != "localnet" + and not options.allow_unsafe_ephemeral_store_off_localnet + and store.session_store_durability != SessionStoreDurability.DURABLE_SHARED + ): + raise PaymentError( + _session_store_safety_message(store), + code="invalid-config", + ) config = SessionConfig( operator=options.operator, @@ -994,11 +1090,15 @@ def new_session(options: SessionOptions) -> Session: modes=options.modes, pull_voucher_strategy=options.pull_voucher_strategy, ) - # The method layer performs the optional on-chain liveness confirm inline in - # its open / topUp handlers, leaving the core SessionConfig verifier seams - # unset and confirming in the method, so the core is left to trust payload - # claims; the seam stays available for hosts that drive the lower-level - # SessionServer directly. + from solana_pay_kit.protocols.mpp.server.session_onchain import ( + new_open_tx_verifier, + new_top_up_state_tx_verifier, + ) + + config.allow_unsafe_ephemeral_store_off_localnet = options.allow_unsafe_ephemeral_store_off_localnet + if options.rpc is not None: + config.verify_open_tx = new_open_tx_verifier(config, options.rpc) + config.verify_top_up_state_tx = new_top_up_state_tx_verifier(config, options.rpc) core = SessionServer(config, store) session = Session( core=core, 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 a23b024c4..a05cba52e 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 @@ -19,11 +19,12 @@ import asyncio import base64 +import hashlib import struct import time from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol, cast from solders.hash import Hash # type: ignore[import-untyped] from solders.keypair import Keypair # type: ignore[import-untyped] @@ -41,26 +42,34 @@ find_channel_pda, ) from solana_pay_kit.protocols.mpp.intents.session import OpenPayload, TopUpPayload +from solana_pay_kit.protocols.mpp.server._tx_decode import _transaction_dict +from solana_pay_kit.protocols.programs.paymentchannels.types.topUpArgs import TopUpArgs if TYPE_CHECKING: from solana_pay_kit.protocols.mpp.server.session import SessionConfig from solana_pay_kit.protocols.mpp.server.session_store import ChannelState __all__ = [ + "BoundChannel", "VerifyOpenTxExpected", "VerifyOpenTxResult", "cosign_and_broadcast_open", "settle_and_seal_channel", "verify_open_tx", "new_open_tx_verifier", + "new_top_up_state_tx_verifier", "new_top_up_tx_verifier", "confirm_transaction_signature", + "fetch_and_bind_channel_account", "is_placeholder_signature", ] # Payment-channel open instruction discriminator (single-byte Anchor-numeric # form, not the 8-byte sha256 convention). _OPEN_INSTRUCTION_DISCRIMINATOR = 1 +_CHANNEL_STATUS_OPEN = 0 +_TOP_UP_INSTRUCTION_DISCRIMINATOR = 3 +_BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" class RpcClient(Protocol): @@ -78,10 +87,17 @@ async def get_latest_blockhash(self, commitment: str = ...) -> Any: ... async def send_raw_transaction(self, raw_tx: bytes) -> Any: ... +class AccountInfoRpc(Protocol): + async def get_account_info( + self, address: str, commitment: str = ..., min_context_slot: int | None = ... + ) -> tuple[bytes, str] | None: ... + + #: A verifier seam installed on the session config: validates a payload (open #: or top-up) and raises on rejection. OpenTxVerifier = Callable[[OpenPayload], Awaitable[None]] TopUpTxVerifier = Callable[[TopUpPayload], Awaitable[None]] +TopUpStateTxVerifier = Callable[[TopUpPayload, "ChannelState"], Awaitable[None]] class OpenVerifierConfig(Protocol): @@ -94,7 +110,9 @@ class OpenVerifierConfig(Protocol): recipient: str max_cap: int operator: str - program_id: Pubkey | None + + @property + def program_id(self) -> Pubkey | str | None: ... @dataclass @@ -141,6 +159,19 @@ class VerifyOpenTxResult: payer: 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'`` @@ -182,7 +213,7 @@ def _decode_transaction(transaction_b64: str) -> tuple[list[str], list, list[str open verifier only sees the static account keys, so an ALT could hide the accounts it validates. See :func:`_reject_address_lookup_tables`. """ - from solders.transaction import Transaction, VersionedTransaction + from solders.transaction import Transaction, VersionedTransaction # type: ignore[import-untyped] from solana_pay_kit._paycore.transaction import is_v0_wire_bytes @@ -399,7 +430,9 @@ async def verifier(payload: OpenPayload) -> None: max_cap=config.max_cap, network=config.network, operator=config.operator, - program_id=config.program_id, + program_id=( + Pubkey.from_string(config.program_id) if isinstance(config.program_id, str) else config.program_id + ), recipient=config.recipient, ) await verify_open_tx(expected, payload, rpc_client) @@ -414,14 +447,189 @@ async def verifier(payload: OpenPayload) -> None: return verifier +def _require_account_info_rpc(rpc_client: RpcClient) -> AccountInfoRpc: + if not callable(getattr(rpc_client, "get_account_info", None)): + raise PaymentError( + "payment-channel account binding requires an RPC client with get_account_info", + code="invalid-config", + ) + return cast(AccountInfoRpc, rpc_client) + + +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, +) -> 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 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 TopUpVerifierConfig(Protocol): + currency: str + network: str + recipient: str + operator: str + settlement_window: int + splits: list[Any] + + @property + def program_id(self) -> Pubkey | str | None: ... + + def new_top_up_tx_verifier(rpc_client: RpcClient | None) -> TopUpTxVerifier | None: - """Return the on-chain top-up verifier to install on the session config: it - confirms the top-up transaction signature on-chain via - ``getSignatureStatuses``. + """Return the legacy payload-only top-up confirmation callback. - A ``None`` ``rpc_client`` returns ``None`` so the seam stays unset, and the - new deposit is trusted as provided; suitable only for unit tests or - deployments that verify transactions out of band. + This factory remains compatible with integrations that install + ``SessionConfig.verify_top_up_tx`` themselves. The session method uses + :func:`new_top_up_state_tx_verifier` for account-state binding. """ if rpc_client is None: return None @@ -432,6 +640,135 @@ async def verifier(payload: TopUpPayload) -> None: return verifier +def new_top_up_state_tx_verifier( + config: TopUpVerifierConfig, rpc_client: RpcClient | None +) -> TopUpStateTxVerifier | None: + """Confirm and bind a top-up to the resulting on-chain Channel state.""" + if rpc_client is None: + if config.network == "localnet": + return None + + async def fail_closed(_payload: TopUpPayload, _current: ChannelState) -> None: + raise PaymentError( + "payment-channel top-up requires an rpc client to bind the on-chain channel off localnet", + code="invalid-config", + ) + + return fail_closed + + async def verifier(payload: TopUpPayload, current: ChannelState) -> None: + try: + new_deposit = int(payload.new_deposit) + except (TypeError, ValueError) as exc: + raise PaymentError(f"invalid newDeposit: {payload.new_deposit}", code="invalid-payload") from exc + expected_mint = resolve_mint(config.currency, config.network) + if not expected_mint: + raise PaymentError( + f"payment-channel top-up requires an SPL token, got currency {config.currency!r}", + code="invalid-config", + ) + confirmed_slot = await confirm_transaction_signature(rpc_client, payload.signature, "top-up") + get_transaction: Any = getattr(rpc_client, "get_transaction", None) + if config.network != "localnet": + if not callable(get_transaction): + raise PaymentError( + "top-up verification requires an RPC client with get_transaction", + code="invalid-config", + ) + pending: Any = get_transaction( + payload.signature, + encoding="jsonParsed", + max_supported_transaction_version=0, + ) + response: Any = await pending + transaction = _transaction_dict(response) + if transaction is None: + raise PaymentError("top-up transaction not found or not yet confirmed", code="transaction-not-found") + _verify_confirmed_top_up(transaction, payload, current, config.program_id) + channel = await _fetch_and_validate_channel( + rpc_client, + payload.channel_id, + program_id=config.program_id, + expected_payee=config.recipient, + expected_mint=expected_mint, + expected_operator=config.operator, + expected_grace_period=_expected_session_grace_period(getattr(config, "settlement_window", None)), + expected_distribution_hash=_session_distribution_hash(getattr(config, "splits", [])), + require_fresh=False, + min_context_slot=confirmed_slot, + ) + if str(channel.authorizedSigner) != current.authorized_signer: + raise PaymentError( + "on-chain channel authorized signer does not match stored channel", code="invalid-payload" + ) + if current.operator is None or str(channel.payer) != current.operator: + raise PaymentError("on-chain channel payer does not match stored channel", code="invalid-payload") + if int(channel.deposit) != new_deposit: + raise PaymentError( + f"on-chain channel deposit {channel.deposit} != asserted newDeposit {new_deposit}", + code="invalid-payload", + ) + + return verifier + + +def _verify_confirmed_top_up( + transaction: dict[str, Any], + payload: TopUpPayload, + state: ChannelState, + configured_program_id: Pubkey | str | None, +) -> None: + program_id = str(PROGRAM_ID if configured_program_id is None else configured_program_id) + meta = transaction.get("meta") + if not isinstance(meta, dict) or meta.get("err") is not None: + raise PaymentError("top-up transaction failed on-chain", code="transaction-failed") + message = (transaction.get("transaction") or {}).get("message") + instructions = message.get("instructions") if isinstance(message, dict) else None + if not isinstance(instructions, list): + raise PaymentError("confirmed top-up transaction has no instructions", code="invalid-payload") + matches: list[dict[str, Any]] = [] + for instruction in instructions: + if not isinstance(instruction, dict) or instruction.get("programId") != program_id: + continue + raw = instruction.get("data") + if not isinstance(raw, str): + continue + decoded = _base58_decode(raw) + if decoded and decoded[0] == _TOP_UP_INSTRUCTION_DISCRIMINATOR: + matches.append(instruction) + if len(matches) != 1: + raise PaymentError( + f"confirmed transaction must contain exactly one configured topUp instruction, found {len(matches)}", + code="invalid-payload", + ) + instruction = matches[0] + accounts = instruction.get("accounts") + if not isinstance(accounts, list) or len(accounts) < 2 or accounts[1] != payload.channel_id: + raise PaymentError("top-up instruction channel does not match the session", code="invalid-payload") + raw_data = _base58_decode(instruction["data"]) + decoded_args = TopUpArgs.from_decoded(TopUpArgs.layout.parse(raw_data[1:])) + if TopUpArgs.layout.build(decoded_args.to_encodable()) != raw_data[1:]: + raise PaymentError("top-up instruction has trailing data", code="invalid-payload") + new_deposit = int(payload.new_deposit) + if new_deposit <= state.deposit or decoded_args.amount != new_deposit - state.deposit: + raise PaymentError( + f"top-up amount {decoded_args.amount} != newDeposit delta {new_deposit - state.deposit}", + code="invalid-payload", + ) + + +def _base58_decode(value: str) -> bytes: + number = 0 + for char in value: + digit = _BASE58_ALPHABET.find(char) + if digit < 0: + raise PaymentError("invalid base58 instruction data", code="invalid-payload") + number = number * 58 + digit + leading_zeros = len(value) - len(value.lstrip("1")) + encoded = b"" if number == 0 else number.to_bytes((number.bit_length() + 7) // 8, "big") + return b"\0" * leading_zeros + encoded + + async def confirm_transaction_signature( rpc_client: RpcClient, signature: str, @@ -439,7 +776,7 @@ async def confirm_transaction_signature( *, timeout_seconds: float = 30.0, poll_interval_seconds: float = 1.0, -) -> None: +) -> int: """Poll ``getSignatureStatuses`` until ``signature`` reaches at least ``confirmed`` commitment, or raise. @@ -478,11 +815,9 @@ async def confirm_transaction_signature( f"{label} tx {signature!r} failed on-chain: {status['err']}", code="transaction-failed" ) level = status.get("confirmationStatus") - # RPC endpoints that omit ``confirmationStatus`` only report a - # status once the transaction has landed; treat that as - # confirmed, mirroring the TS helper. - if level is None or level in ("confirmed", "finalized"): - return + if level in ("confirmed", "finalized"): + slot = status.get("slot") + return slot if isinstance(slot, int) and slot >= 0 else 0 now = time.monotonic() if now >= deadline: @@ -592,6 +927,22 @@ async def cosign_and_broadcast_open(payload: OpenPayload, *, fee_payer: Any, rpc signature, broadcasts, and confirms. Returns the confirmed open signature. Mirrors Go SubmitOpenTx (and reuses the charge fee-payer co-sign). """ + wire, expected_signature = _complete_open_transaction(payload, fee_payer) + sent = await rpc.send_raw_transaction(wire) + signature = str(sent.value) + if signature != expected_signature: + raise PaymentError( + f"broadcast open signature {signature} != completed transaction signature {expected_signature}", + code="invalid-payload", + ) + await confirm_transaction_signature(rpc, signature, "open") + return signature + + +def _complete_open_transaction(payload: OpenPayload, fee_payer: Any) -> tuple[bytes, str]: + """Complete the fee-payer signature without broadcasting the transaction.""" + from solders.transaction import VersionedTransaction # type: ignore[import-untyped] + from solana_pay_kit.protocols.mpp.server._verify import _co_sign_with_fee_payer if not payload.transaction: @@ -599,8 +950,11 @@ async def cosign_and_broadcast_open(payload: OpenPayload, *, fee_payer: Any, rpc "openTxSubmitter=server requires the client-built open transaction in the payload", code="invalid-payload", ) - cosigned = _co_sign_with_fee_payer(payload.transaction, fee_payer) - sent = await rpc.send_raw_transaction(base64.b64decode(cosigned)) - signature = str(sent.value) - await confirm_transaction_signature(rpc, signature, "open") - return signature + wire = base64.b64decode(_co_sign_with_fee_payer(payload.transaction, fee_payer)) + try: + signatures = Transaction.from_bytes(wire).signatures + except Exception: + signatures = VersionedTransaction.from_bytes(wire).signatures + if not signatures: + raise PaymentError("open transaction is missing the fee-payer signature", code="invalid-payload") + return wire, str(signatures[0]) 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 443dc1236..227de3ad6 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 @@ -22,6 +22,7 @@ import asyncio from collections.abc import Callable from dataclasses import dataclass, field, replace +from enum import StrEnum from typing import Any __all__ = [ @@ -30,11 +31,29 @@ "ChannelState", "ListChannelsFilter", "ChannelMutator", + "SessionStoreDurability", "ChannelStore", "MemoryChannelStore", ] +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 @@ -140,6 +159,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 @@ -210,6 +236,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, @@ -223,6 +250,8 @@ def to_dict(self) -> dict[str, Any]: # settled_signature is omitted from the wire form when unset. if self.settled_signature is not None: d["settled_signature"] = self.settled_signature + if self.open_signature is not None: + d["open_signature"] = self.open_signature return d @classmethod @@ -244,6 +273,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"]) @@ -293,6 +324,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 @@ -325,6 +360,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_session_method.py b/python/tests/test_session_method.py index dd983031d..14bbe0b9a 100644 --- a/python/tests/test_session_method.py +++ b/python/tests/test_session_method.py @@ -19,11 +19,18 @@ from __future__ import annotations +import hashlib +import struct + import pytest from solders.keypair import Keypair # type: ignore[import-untyped] +from solders.pubkey import Pubkey # type: ignore[import-untyped] from solders.signature import Signature # type: ignore[import-untyped] from 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 resolve_mint +from solana_pay_kit.protocols.mpp._paymentchannels import find_channel_pda from solana_pay_kit.protocols.mpp.core.types import PaymentChallenge, PaymentCredential from solana_pay_kit.protocols.mpp.intents.session import ( ClosePayload, @@ -42,6 +49,7 @@ SessionOptions, new_session, ) +from solana_pay_kit.protocols.mpp.server.session_store import MemoryChannelStore, SessionStoreDurability from solana_pay_kit.signer import LocalSigner SESSION_METHOD_SECRET = "session-method-secret" @@ -78,6 +86,41 @@ def _confirmed_signature(fill: int) -> str: return str(Signature.from_bytes(bytes([fill] * 64))) +def _channel_account( + deposit: int, + payer: str, + payee: str, + signer: str, + mint: str, + rent_payer: str | None = None, + salt: int = 0, + open_slot: int = 0, +) -> tuple[bytes, str]: + from solana_pay_kit.protocols.programs.paymentchannels.accounts.channel import Channel + + body = Channel.layout.build( + { + "version": 1, + "bump": 255, + "status": 0, + "salt": salt, + "deposit": deposit, + "settlement": {"settled": 0, "payoutWatermark": 0}, + "closureStartedAt": 0, + "payerWithdrawnAt": 0, + "gracePeriod": 900, + "distributionHash": list(hashlib.sha256(struct.pack(" None: self.statuses: dict[str, dict | None] = {} self.blockhash = blockhash + self.accounts: dict[str, tuple[bytes, str] | None] = {} + + def seed_channel( + self, + channel_id: str, + deposit: int, + payer: str, + payee: str, + signer: str, + mint: str, + rent_payer: str | None = None, + salt: int = 0, + open_slot: int = 0, + ) -> None: + self.accounts[channel_id] = _channel_account(deposit, payer, payee, signer, mint, rent_payer, salt, open_slot) + + async def get_account_info( + self, address: str, commitment: str = "confirmed", min_context_slot: int | None = None + ) -> tuple[bytes, str] | None: + return self.accounts.get(address) async def get_signature_statuses(self, signatures: list[str]) -> list[dict | None]: out: list[dict | None] = [] @@ -92,7 +155,7 @@ async def get_signature_statuses(self, signatures: list[str]) -> list[dict | Non if signature in self.statuses: out.append(self.statuses[signature]) else: - out.append({"err": None, "confirmationStatus": "confirmed"}) + out.append({"err": None, "confirmationStatus": "confirmed", "slot": 42}) return out async def get_latest_blockhash(self, commitment: str = "confirmed"): @@ -107,6 +170,20 @@ def __init__(self, blockhash: str) -> None: return _Resp(self.blockhash) +def _derived_channel_id(payer: str, payee: str, signer: str, mint: str, salt: int = 0, open_slot: int = 0) -> str: + return str( + find_channel_pda( + Pubkey.from_string(payer), + Pubkey.from_string(payee), + Pubkey.from_string(mint), + Pubkey.from_string(signer), + salt, + open_slot, + Pubkey.from_string(PAYMENT_CHANNELS_PROGRAM_ID), + )[0] + ) + + def _new_test_session(**overrides) -> Session: options = SessionOptions( operator=SESSION_TEST_RECIPIENT, @@ -138,6 +215,10 @@ async def _open_session_channel( session: Session, channel_id: str, deposit: int, authorized_signer: str, signature: str ): payload = OpenPayload.push(channel_id, str(deposit), authorized_signer, signature) + if isinstance(session._rpc, _FakeRpc): + mint = resolve_mint(session._currency, session._network) + assert mint is not None + session._rpc.seed_channel(channel_id, deposit, _new_wallet(), session._recipient, authorized_signer, mint) return await _verify_session_action(session, SessionAction.open_action(payload)) @@ -217,13 +298,38 @@ def test_new_session_validation_missing_secret(monkeypatch: pytest.MonkeyPatch) def test_new_session_defaults() -> None: - session = _new_test_session(currency="", decimals=0, network="", open_tx_submitter="") + store = MemoryChannelStore() + store.session_store_durability = SessionStoreDurability.DURABLE_SHARED + session = _new_test_session(currency="", decimals=0, network="", open_tx_submitter="", store=store) assert session._currency == "USDC" assert session._network == "mainnet" assert session._open_tx_submitter == "client" assert session.core().config.decimals == 6 +def test_new_session_requires_injected_store_off_localnet() -> None: + options = SessionOptions( + operator=SESSION_TEST_RECIPIENT, + recipient=SESSION_TEST_RECIPIENT, + cap=1_000, + network="devnet", + secret_key=SESSION_METHOD_SECRET, + ) + with pytest.raises(PaymentError, match="session store is required"): + new_session(options) + + options.store = MemoryChannelStore() + options.store.session_store_durability = SessionStoreDurability.DURABLE_SHARED + session = new_session(options) + assert session.core().store() is options.store + + unmarked = MemoryChannelStore() + unmarked.session_store_durability = None + options.store = unmarked + with pytest.raises(PaymentError, match="explicitly declare durable shared"): + new_session(options) + + # ── challenge ── @@ -484,8 +590,14 @@ async def test_session_open_verifies_signature_on_chain() -> None: session = _new_test_session(rpc=fake) signer = _TestVoucherSigner(1) - channel_id = _new_wallet() - receipt = await _open_session_channel(session, channel_id, 1_000, signer.address(), ok_sig) + mint = resolve_mint("USDC", "localnet") + assert mint is not None + payer = _new_wallet() + channel_id = _derived_channel_id(payer, SESSION_TEST_RECIPIENT, signer.address(), mint) + fake.seed_channel(channel_id, 1_000, payer, SESSION_TEST_RECIPIENT, signer.address(), mint) + receipt = await _verify_session_action( + session, SessionAction.open_action(OpenPayload.push(channel_id, "1000", signer.address(), ok_sig)) + ) assert receipt.reference == ok_sig ghost_channel = _new_wallet() @@ -660,8 +772,17 @@ async def test_session_top_up_verifies_signature_on_chain() -> None: session = _new_test_session(rpc=fake) signer = _TestVoucherSigner(1) - channel_id = _new_wallet() - await _open_session_channel(session, channel_id, 1_000, signer.address(), open_sig) + mint = resolve_mint("USDC", "localnet") + assert mint is not None + payer = _new_wallet() + channel_id = _derived_channel_id(payer, SESSION_TEST_RECIPIENT, signer.address(), mint) + fake.seed_channel(channel_id, 1_000, payer, SESSION_TEST_RECIPIENT, signer.address(), mint) + await _verify_session_action( + session, SessionAction.open_action(OpenPayload.push(channel_id, "1000", signer.address(), open_sig)) + ) + opened = await _get_channel(session, channel_id) + assert opened is not None and opened.operator is not None + fake.seed_channel(channel_id, 5_000, opened.operator, SESSION_TEST_RECIPIENT, signer.address(), mint) receipt = await _verify_session_action( session, @@ -821,8 +942,8 @@ async def test_session_push_open_requires_payer_or_transaction_for_settlement() signer=LocalSigner.from_keypair(operator), rpc=_FakeRpc(), ) - channel_id = _new_wallet() signer = _TestVoucherSigner(0x30) + channel_id = _new_wallet() with pytest.raises(PaymentError, match="requires payer or transaction"): await _verify_session_action( @@ -833,6 +954,21 @@ async def test_session_push_open_requires_payer_or_transaction_for_settlement() ) payer = _new_wallet() + mint = resolve_mint("USDC", "localnet") + assert mint is not None + channel_id = _derived_channel_id(payer, SESSION_TEST_RECIPIENT, signer.address(), mint, 1, 777) + assert isinstance(session._rpc, _FakeRpc) + session._rpc.seed_channel( + channel_id, + 1_000, + payer, + SESSION_TEST_RECIPIENT, + signer.address(), + mint, + str(operator.pubkey()), + 1, + 777, + ) await _verify_session_action( session, SessionAction.open_action( diff --git a/python/tests/test_session_onchain.py b/python/tests/test_session_onchain.py index 279f2bf66..30e5af319 100644 --- a/python/tests/test_session_onchain.py +++ b/python/tests/test_session_onchain.py @@ -12,7 +12,10 @@ from __future__ import annotations import base64 -from dataclasses import dataclass, replace +import hashlib +import struct +from dataclasses import dataclass, field, replace +from typing import Any import pytest from solders.hash import Hash # type: ignore[import-untyped] @@ -39,9 +42,11 @@ VerifyOpenTxExpected, is_placeholder_signature, new_open_tx_verifier, + new_top_up_state_tx_verifier, new_top_up_tx_verifier, verify_open_tx, ) +from solana_pay_kit.protocols.mpp.server.session_store import ChannelState USDC_MAINNET_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" @@ -73,6 +78,12 @@ class _FakeRpc: def __init__(self) -> None: self.statuses: dict[str, dict | None] = {} + self.accounts: dict[str, 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: + return self.accounts.get(address) async def get_signature_statuses(self, signatures: list[str]) -> list[dict | None]: out: list[dict | None] = [] @@ -80,7 +91,7 @@ async def get_signature_statuses(self, signatures: list[str]) -> list[dict | Non if signature in self.statuses: out.append(self.statuses[signature]) else: - out.append({"err": None, "confirmationStatus": "confirmed"}) + out.append({"err": None, "confirmationStatus": "confirmed", "slot": 42}) return out async def get_latest_blockhash(self, commitment: str = "confirmed"): # noqa: ANN201 (RPC seam stub) @@ -94,6 +105,32 @@ def _kp(seed: int) -> Keypair: return Keypair.from_seed(bytes([seed] * 32)) +def _channel_account(fixture: OpenTxFixture, deposit: int) -> tuple[bytes, str]: + from solana_pay_kit.protocols.programs.paymentchannels.accounts.channel import Channel + + body = Channel.layout.build( + { + "version": 1, + "bump": 255, + "status": 0, + "salt": OPEN_FIXTURE_SALT, + "deposit": deposit, + "settlement": {"settled": 0, "payoutWatermark": 0}, + "closureStartedAt": 0, + "payerWithdrawnAt": 0, + "gracePeriod": OPEN_FIXTURE_GRACE, + "distributionHash": list(hashlib.sha256(struct.pack(" tuple[str, OpenPayload]: blockhash = Hash.from_string("EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N") payer_pubkey = fixture.payer.pubkey() @@ -534,6 +571,8 @@ class _OpenConfig: max_cap: int operator: str = "" program_id: Pubkey | None = None + settlement_window: int = 900 + splits: list[Any] = field(default_factory=list) def _open_session_config(fixture: OpenTxFixture) -> _OpenConfig: @@ -587,6 +626,15 @@ async def test_new_open_tx_verifier_without_transaction_confirms_signature() -> # -- new_top_up_tx_verifier --------------------------------------------------- +def _stored_channel(fixture: OpenTxFixture) -> ChannelState: + return ChannelState( + channel_id=str(fixture.channel), + authorized_signer=str(fixture.authorized), + deposit=OPEN_FIXTURE_DEPOSIT, + operator=str(fixture.payer.pubkey()), + ) + + def test_new_top_up_tx_verifier_none_rpc_disables_the_seam() -> None: """Mirrors TestNewTopUpTxVerifierNilRPCDisablesTheSeam.""" assert new_top_up_tx_verifier(None) is None @@ -595,10 +643,13 @@ def test_new_top_up_tx_verifier_none_rpc_disables_the_seam() -> None: async def test_new_top_up_tx_verifier_confirms_signature() -> None: """Mirrors TestNewTopUpTxVerifierConfirmsSignature.""" signature = _kp(20).sign_message(b"top-up") - verifier = new_top_up_tx_verifier(_FakeRpc()) + fixture = build_open_tx_fixture(v0=False) + fake = _FakeRpc() + fake.accounts[str(fixture.channel)] = _channel_account(fixture, 2_000_000) + verifier = new_top_up_state_tx_verifier(_open_session_config(fixture), fake) assert verifier is not None - payload = TopUpPayload(channel_id="chan", new_deposit="2000000", signature=str(signature)) - await verifier(payload) + payload = TopUpPayload(channel_id=str(fixture.channel), new_deposit="2000000", signature=str(signature)) + await verifier(payload, _stored_channel(fixture)) async def test_new_top_up_tx_verifier_surfaces_failure_and_not_found() -> None: @@ -606,15 +657,19 @@ async def test_new_top_up_tx_verifier_surfaces_failure_and_not_found() -> None: signature = str(_kp(21).sign_message(b"top-up")) fake_rpc = _FakeRpc() fake_rpc.statuses[signature] = {"err": "InstructionError"} - verifier = new_top_up_tx_verifier(fake_rpc) + fixture = build_open_tx_fixture(v0=False) + verifier = new_top_up_state_tx_verifier(_open_session_config(fixture), fake_rpc) assert verifier is not None payload = TopUpPayload(channel_id="chan", new_deposit="2000000", signature=signature) with pytest.raises(PaymentError, match="top-up"): - await verifier(payload) + await verifier(payload, _stored_channel(fixture)) fake_rpc.statuses[signature] = None with pytest.raises(PaymentError, match="not found"): - await verifier(payload) + await verifier(payload, _stored_channel(fixture)) with pytest.raises(PaymentError, match="invalid top-up tx signature"): - await verifier(TopUpPayload(channel_id="", new_deposit="", signature="not-base58!")) + await verifier( + TopUpPayload(channel_id="", new_deposit="1", signature="not-base58!"), + _stored_channel(fixture), + ) diff --git a/python/tests/test_session_state_binding.py b/python/tests/test_session_state_binding.py new file mode 100644 index 000000000..66952f179 --- /dev/null +++ b/python/tests/test_session_state_binding.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +import hashlib +import struct +from typing import Any + +import pytest +from solders.keypair import Keypair # type: ignore[import-untyped] +from solders.pubkey import Pubkey # type: ignore[import-untyped] +from solders.signature import Signature # type: ignore[import-untyped] + +from 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 resolve_mint +from solana_pay_kit.protocols.mpp._paymentchannels import find_channel_pda +from solana_pay_kit.protocols.mpp.intents.session import OpenPayload, TopUpPayload +from solana_pay_kit.protocols.mpp.server.session import DeliveryRequest, 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, + new_top_up_state_tx_verifier, +) +from solana_pay_kit.protocols.mpp.server.session_store import ( + ChannelState, + MemoryChannelStore, + SessionStoreDurability, +) +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))) + + +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(" 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]) -> 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, + ) + store = MemoryChannelStore() + store.session_store_durability = SessionStoreDurability.DURABLE_SHARED + 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(6) + ) + payload.transaction = None + await session._handle_open(payload) + 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_top_up_binds_resulting_deposit_and_open_status() -> None: + rpc = _Rpc() + recipient = _wallet(11) + channel_id = _wallet(12) + signer = _wallet(13) + payer = _wallet(14) + mint = resolve_mint("USDC", "localnet") + assert mint is not None + config = SessionConfig( + recipient=recipient, + max_cap=10_000, + currency="USDC", + network="localnet", + operator=recipient, + ) + verifier = new_top_up_state_tx_verifier(config, rpc) + assert verifier is not None + payload = TopUpPayload(channel_id=channel_id, new_deposit="3000", signature=_signature(15)) + current = ChannelState(channel_id=channel_id, authorized_signer=signer, deposit=1_000, operator=payer) + + rpc.accounts[channel_id] = ( + _channel_bytes(deposit=2_000, payer=payer, payee=recipient, signer=signer, mint=mint), + PAYMENT_CHANNELS_PROGRAM_ID, + ) + current.authorized_signer = _wallet(16) + with pytest.raises(PaymentError, match="authorized signer does not match stored"): + await verifier(payload, current) + current.authorized_signer = signer + with pytest.raises(PaymentError, match="!= asserted newDeposit 3000"): + await verifier(payload, current) + + rpc.accounts[channel_id] = ( + _channel_bytes(deposit=3_000, payer=payer, payee=recipient, signer=signer, mint=mint, status=1), + PAYMENT_CHANNELS_PROGRAM_ID, + ) + with pytest.raises(PaymentError, match="not open on-chain"): + await verifier(payload, current) + + +async def test_top_up_fails_closed_without_rpc_off_localnet() -> None: + config = SessionConfig(recipient=_wallet(21), currency="USDC", network="mainnet") + verifier = new_top_up_state_tx_verifier(config, None) + assert verifier is not None + with pytest.raises(PaymentError, match="requires an rpc client") as error: + await verifier( + TopUpPayload(channel_id=_wallet(22), new_deposit="2", signature=_signature(23)), + ChannelState(channel_id=_wallet(22), authorized_signer=_wallet(24), operator=_wallet(25)), + ) + assert error.value.code == "invalid-config" + + +async def test_core_direct_construction_rejects_nonlocalnet_bypasses() -> None: + recipient = _wallet(31) + payload = OpenPayload.push(_wallet(32), "1000", _wallet(33), _signature(34)) + memory = MemoryChannelStore() + config = SessionConfig(recipient=recipient, max_cap=10_000, currency="USDC", network="mainnet") + with pytest.raises(ValueError, match="ephemeral"): + await SessionServer(config, memory).process_open(payload) + + memory.session_store_durability = SessionStoreDurability.DURABLE_SHARED + with pytest.raises(ValueError, match="requires an on-chain verifier"): + await SessionServer(config, memory).process_open(payload) + await memory.update_channel( + "topup", + lambda _: ChannelState(channel_id="topup", authorized_signer=_wallet(34), deposit=1_000, operator=_wallet(35)), + ) + + async def legacy_top_up_only(_payload: TopUpPayload) -> None: + return None + + config.verify_top_up_tx = legacy_top_up_only + with pytest.raises(ValueError, match="state-aware on-chain verifier"): + await SessionServer(config, memory).process_top_up( + TopUpPayload(channel_id="topup", new_deposit="2000", signature=_signature(36)) + ) + + class UnmarkedStore(MemoryChannelStore): + session_store_durability = None + + with pytest.raises(ValueError, match="explicitly declare durable shared"): + await SessionServer(config, UnmarkedStore()).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") + server = SessionServer(config, MemoryChannelStore()) + with pytest.raises(ValueError, match="ephemeral session store"): + await server.begin_delivery(DeliveryRequest(session_id="preloaded", amount=1)) + with pytest.raises(ValueError, match="ephemeral session store"): + await server.mark_sealed("preloaded") + + +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) + + 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 47322ce40..0f50cea80 100644 --- a/python/tests/test_session_store.py +++ b/python/tests/test_session_store.py @@ -273,6 +273,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/server/session.rs b/rust/crates/kit/src/mpp/server/session.rs index 0b0e3c6cb..d09ceae7b 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,141 @@ 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(); + if payment_channel_backed { + match self.config.rpc_url.as_deref() { + Some(rpc_url) => { + 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 + })?; + 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 +661,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 +719,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 +759,111 @@ 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.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 +905,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 +964,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 +1044,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 +1219,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 +1312,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 +1362,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 +1390,32 @@ enum VerifiedTx { TopUp, } +#[cfg(feature = "server")] +fn bound_client_open_signature(payload: &OpenPayload) -> Result<&str> { + let transaction = payload + .transaction + .as_deref() + .ok_or_else(|| Error::Other("payment-channel open missing transaction".to_string()))?; + 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 +1433,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 +1443,185 @@ 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_signature::Signature; + 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(&signature, UiTransactionEncoding::JsonParsed) + .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() { - 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( + "top-up transaction failed on-chain".to_string(), + )); } + let value = serde_json::to_value(&transaction) + .map_err(|e| Error::Other(format!("serialize top-up transaction: {e}")))?; + let instructions = value + .pointer("/transaction/transaction/message/instructions") + .and_then(|value| value.as_array()) + .ok_or_else(|| Error::Other("top-up transaction has no parsed instructions".to_string()))?; + let mut matches = 0usize; + let mut total = 0u64; + for instruction in instructions { + if instruction + .get("programId") + .and_then(|value| value.as_str()) + != Some(&program_id.to_string()) + { + continue; + } + let Some(data) = instruction.get("data").and_then(|value| value.as_str()) else { + continue; + }; + let decoded = bs58::decode(data) + .into_vec() + .map_err(|e| Error::Other(format!("invalid top-up instruction data: {e}")))?; + if decoded.first().copied() != Some(3) { + continue; + } + let accounts = instruction + .get("accounts") + .and_then(|value| value.as_array()) + .ok_or_else(|| Error::Other("top-up instruction has invalid accounts".to_string()))?; + if accounts.get(1).and_then(|value| value.as_str()) != Some(channel_id) { + continue; + } + let amount_bytes: [u8; 8] = decoded + .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 decoded.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(()) } fn parse_pubkey(s: &str) -> Result { @@ -1282,6 +1727,95 @@ mod tests { }; 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 +1834,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 +1856,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 +2457,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 +2481,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 +2511,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 +2869,673 @@ 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 { + 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 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 + } + }), + _ => 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")] + #[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 rpc = SessionAccountRpc::start( + encoded_channel(CHANNEL_STATUS_OPEN, 4_000, payer, payee, signer, mint), + params.program_id, + ); + 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; + let mut transaction = solana_transaction::Transaction::new_unsigned( + solana_message::Message::new(&[], Some(&payer)), + ); + transaction.signatures[0] = solana_signature::Signature::from([9u8; 64]); + let transaction = solana_transaction::versioned::VersionedTransaction::from(transaction); + payload.transaction = Some(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + bincode::serialize(&transaction).unwrap(), + )); + 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(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")] + #[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")] + #[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(), + ); + + let cases: Vec<(&str, Box)> = 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 +3745,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 59dbf5b0b..6cf5ef6fd 100644 --- a/typescript/packages/mpp/src/__tests__/session-on-chain.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-on-chain.test.ts @@ -549,7 +549,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 }] }; }, }), }; 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..5163e5efa 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 @@ -213,10 +213,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 +235,7 @@ function makeSubmitRpc(statusSequence: (MockStatus | null)[]) { }), sends, statusCallCount: () => statusCalls, + statusSignatures, }; } @@ -381,6 +384,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); }); }); diff --git a/typescript/packages/mpp/src/__tests__/session-server.test.ts b/typescript/packages/mpp/src/__tests__/session-server.test.ts index 477fa1782..cf71c1948 100644 --- a/typescript/packages/mpp/src/__tests__/session-server.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-server.test.ts @@ -30,7 +30,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; + }), + }; }, }), }; @@ -109,6 +115,7 @@ describe('session() request()', () => { test('builds a SessionRequest that satisfies the canonical schema', async () => { const method = session({ + allowUnsafeEphemeralStoreOffLocalnet: true, cap: 10_000_000n, currency: 'USDC', decimals: 6, @@ -137,6 +144,7 @@ describe('session() request()', () => { test('clamps requested cap to the server max', async () => { const method = session({ + allowUnsafeEphemeralStoreOffLocalnet: true, cap: 1_000_000n, currency: 'USDC', decimals: 6, @@ -154,6 +162,7 @@ describe('session() request()', () => { test('includes modes + pullVoucherStrategy when pull is advertised', async () => { const method = session({ + allowUnsafeEphemeralStoreOffLocalnet: true, cap: 1_000_000n, currency: 'USDC', decimals: 6, @@ -175,6 +184,7 @@ describe('session() request()', () => { test('skips blockhash/slot prefetch when a credential is present', async () => { const method = session({ + allowUnsafeEphemeralStoreOffLocalnet: true, cap: 1_000_000n, currency: 'USDC', decimals: 6, @@ -202,7 +212,7 @@ describe('session() verify() open', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -235,7 +245,7 @@ describe('session() verify() open', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -261,7 +271,7 @@ describe('session() verify() open', () => { cap: 1_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -293,7 +303,7 @@ describe('session() verify() voucher', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -330,7 +340,7 @@ describe('session() verify() voucher', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -352,7 +362,7 @@ describe('session() verify() voucher', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -395,7 +405,7 @@ describe('session() verify() topUp', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -436,7 +446,7 @@ describe('session() verify() topUp', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -476,7 +486,7 @@ describe('session() verify() close', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -514,7 +524,7 @@ describe('session() verify() close', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -553,7 +563,7 @@ describe('session() verify() commit', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -575,6 +585,7 @@ describe('session() verify() commit', () => { const routes = session.routes({ cap: 1_000_000n, currency: 'USDC', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -610,6 +621,7 @@ describe('session.routes()', () => { const routes = session.routes({ cap: 1_000n, currency: 'USDC', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -631,7 +643,7 @@ describe('session.routes()', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -653,6 +665,7 @@ describe('session.routes()', () => { const routes = session.routes({ cap: 1_000_000n, currency: 'USDC', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -701,7 +714,7 @@ describe('session() verify() open replay', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -779,7 +792,7 @@ describe('session() verify() open signature verification', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -811,7 +824,7 @@ describe('session() verify() open signature verification', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -843,7 +856,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 +891,7 @@ describe('session() verify() pull open keying', () => { currency: 'USDC', decimals: 6, modes: ['pull'], - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, pullVoucherStrategy: 'clientVoucher', @@ -916,7 +929,7 @@ describe('session() verify() voucher wire compatibility', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -994,7 +1007,7 @@ describe('session() verify() voucher wire compatibility', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1052,7 +1065,7 @@ describe('session() verify() topUp hardening', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1076,7 +1089,7 @@ describe('session() verify() topUp hardening', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1101,7 +1114,7 @@ describe('session() verify() topUp hardening', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1127,7 +1140,7 @@ describe('session() verify() topUp hardening', () => { cap: 5_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1163,7 +1176,7 @@ describe('session() verify() close monotonicity', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1237,7 +1250,10 @@ describe('session() verify() close retry', () => { }), }), getSignatureStatuses: (sigs: readonly string[]) => ({ - send: async () => ({ value: sigs.map(() => ({ err: null })) }), + send: async () => ({ + context: { slot: 42 }, + value: sigs.map(() => ({ confirmationStatus: 'confirmed', err: null })), + }), }), sendTransaction: (wire: string) => ({ send: async () => { @@ -1255,7 +1271,7 @@ describe('session() verify() close retry', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1315,7 +1331,10 @@ describe('session() verify() close retry', () => { }), }), 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' }), }; @@ -1323,7 +1342,7 @@ describe('session() verify() close retry', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1360,7 +1379,7 @@ describe('session() verify() commit replay', () => { cap: 1_000_000n, currency: 'USDC', decimals: 6, - network: 'devnet', + network: 'localnet', operator: OPERATOR, pricing: {}, recipient: RECIPIENT, @@ -1411,7 +1430,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-state-binding.test.ts b/typescript/packages/mpp/src/__tests__/session-state-binding.test.ts new file mode 100644 index 000000000..7fdeed0e4 --- /dev/null +++ b/typescript/packages/mpp/src/__tests__/session-state-binding.test.ts @@ -0,0 +1,303 @@ +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): string { + const bytes = getChannelEncoder().encode({ + discriminator: 1, + version: 1, + bump: 255, + status, + salt: 7n, + deposit, + settlement: { settled: 0n, payoutWatermark: 0n }, + closureStartedAt: 0n, + payerWithdrawnAt: 0n, + gracePeriod: 900, + 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) { + return { + challenge: { + id: 'challenge-id', + intent: 'session', + method: 'solana', + realm: 'api.test', + request: { cap: '10000', currency: 'USDC', operator: OPERATOR, recipient: RECIPIENT }, + }, + 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('rejects a marked memory store outside localnet', () => { + expect(() => session(parameters({ network: 'devnet', store: createMemorySessionStore() }))).toThrow( + /ephemeral session store/, + ); + }); + + test('rejects an unmarked wrapper outside localnet', () => { + const { sessionStoreDurability: _ignored, ...store } = createMemorySessionStore(); + expect(() => session(parameters({ network: 'devnet', store }))).toThrow(/explicitly declare durable shared/); + }); + + test('side-channel routes reject an ephemeral store outside localnet', () => { + expect(() => session.routes(parameters({ network: 'devnet', store: createMemorySessionStore() }))).toThrow( + /ephemeral session store/, + ); + }); + + test('bare push open persists on-chain deposit and payer', async () => { + const channel = await channelId(); + const store = createMemorySessionStore(); + const method = session(parameters({ rpc: rpcWithChannel(encodeChannel(4_000n)) as never, store })); + await method.verify({ + credential: credential({ + action: 'open', + authorizedSigner: SIGNER, + channelId: channel, + deposit: '1000', + mode: 'push', + payer: CLAIMED_PAYER, + signature: 'open-signature', + }), + request: {} as never, + }); + const state = await store.getChannel(channel); + expect(state?.deposit).toBe(4_000n); + expect(state?.operator).toBe(PAYER); + expect(state?.openSlot).toBe(42n); + expect(state?.salt).toBe(7n); + }); + + 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 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('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/); + }); +}); diff --git a/typescript/packages/mpp/src/server/Session.ts b/typescript/packages/mpp/src/server/Session.ts index 74f251bb0..4c93e39d7 100644 --- a/typescript/packages/mpp/src/server/Session.ts +++ b/typescript/packages/mpp/src/server/Session.ts @@ -23,13 +23,18 @@ import type { import { normalizeSignedVoucher, verifyVoucherSignature } from '../shared/voucher.js'; import { createLifecycle, type Lifecycle } from './session/lifecycle.js'; import { + type GetAccountInfoRpc, + isGetAccountInfoRpc, type MultiDelegateSubmitRpc, PAYMENT_CHANNELS_PROGRAM_ID, submitInitMultiDelegateTxIfMissing, submitOpenTx, submitSettleAndDistribute, type SubmitSettleAndDistributeResult, + type TopUpTransactionRpc, + verifyChannelAccountState, verifyOpenTx, + verifyTopUpTransaction, } from './session/on-chain.js'; import { type ChannelState, @@ -60,6 +65,24 @@ function resolveSessionStore(parameters: session.Parameters): SessionStore { return created; } +function sessionStoreSafetyMessage(store: SessionStore): string { + if (store.sessionStoreDurability === 'ephemeral') { + return 'ephemeral session store is unsafe off localnet; inject a durable shared SessionStore'; + } + return 'session store must explicitly declare durable shared capability off localnet; inject a durable shared SessionStore'; +} + +function requireProductionSessionSafety(parameters: session.Parameters, store: SessionStore): void { + const network = parameters.network ?? 'mainnet'; + if ( + network !== 'localnet' && + store.sessionStoreDurability !== 'durable-shared' && + !parameters.allowUnsafeEphemeralStoreOffLocalnet + ) { + throw new Error(sessionStoreSafetyMessage(store)); + } +} + /** * Creates a Solana `session` MPP method for the server. * @@ -137,6 +160,7 @@ export function session(parameters: session.Parameters) { const rpcUrl = parameters.rpcUrl ?? DEFAULT_RPC_URLS[network] ?? DEFAULT_RPC_URLS['mainnet']; const store = resolveSessionStore(parameters); + requireProductionSessionSafety(parameters, store); const resolvedProgramId = (programId ?? PAYMENT_CHANNELS_PROGRAM_ID) as Address; const resolvedMint = resolveStablecoinMint(currency, network) ?? currency; const tokenProgram = parameters.tokenProgram ?? defaultTokenProgramForCurrency(currency, network); @@ -272,6 +296,7 @@ export function session(parameters: session.Parameters) { pullVoucherStrategy, recipient, rpc, + splits, store, }); case 'voucher': @@ -299,8 +324,14 @@ export function session(parameters: session.Parameters) { challengeId: cred.challenge.id, externalId: cred.challenge.request.externalId, lifecycle: lifecycleRef.value, + mint: resolvedMint, + network, + operator, payload: cred.payload, + programId: resolvedProgramId, + recipient, rpc, + splits, store, }); case 'close': @@ -352,6 +383,7 @@ session.routes = function routes(parameters: session.Parameters): session.Routes const cap = parameters.cap; if (cap === undefined) throw new Error('cap is required'); const store = resolveSessionStore(parameters); + requireProductionSessionSafety(parameters, store); const currency = parameters.currency; return { @@ -422,6 +454,7 @@ interface HandleOpenArgs { readonly pullVoucherStrategy: SessionPullVoucherStrategy | undefined; readonly recipient: string; readonly rpc: RpcLike | undefined; + readonly splits: readonly SessionSplit[] | undefined; readonly store: SessionStore; } @@ -446,6 +479,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'); @@ -476,11 +511,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, @@ -492,6 +533,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 { @@ -504,17 +546,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; @@ -548,6 +585,46 @@ async function handleOpen(args: HandleOpenArgs): Promise { } } + 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', + ); + const channel = await verifyChannelAccountState({ + channelId, + expected: { + authorizedSigner: payload.authorizedSigner, + mint: args.mint, + 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}`); @@ -562,6 +639,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 @@ -698,13 +777,19 @@ interface HandleTopUpArgs { readonly challengeId: string | undefined; readonly externalId: string | undefined; readonly lifecycle: Lifecycle | undefined; + readonly mint: string; + readonly network: string; + readonly operator: string; readonly payload: { readonly action: 'topUp'; readonly channelId: string; readonly newDeposit: string; readonly signature: string; }; + readonly programId: Address; + readonly recipient: string; readonly rpc: RpcLike | undefined; + readonly splits: readonly SessionSplit[] | undefined; readonly store: SessionStore; } @@ -722,10 +807,51 @@ 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) { - await assertSignatureSucceeded(args.rpc as VerifyOpenRpc, args.payload.signature, 'topUp'); + const confirmedSlot = await assertSignatureSucceeded( + args.rpc as VerifyOpenRpc, + args.payload.signature, + 'topUp', + ); + if (!isTopUpTransactionRpc(args.rpc) && args.network !== 'localnet') { + throw new Error('topUp requires an rpc client with getTransaction to bind the deposit delta'); + } + if (isTopUpTransactionRpc(args.rpc)) { + await verifyTopUpTransaction({ + amount: newDeposit - existing.deposit, + channelId: args.payload.channelId, + programId: args.programId.toString(), + rpc: args.rpc, + signature: args.payload.signature as Signature, + }); + } + if (!isGetAccountInfoRpc(args.rpc)) { + 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, + mint: args.mint, + payee: args.recipient, + payer: existing.operator, + programId: args.programId.toString(), + rentPayer: args.operator, + 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 => { @@ -1117,14 +1243,27 @@ 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`); + } + return response.context?.slot ?? 0n; +} + +function isTopUpTransactionRpc(rpc: RpcLike | undefined): rpc is RpcLike & TopUpTransactionRpc { + return typeof (rpc as { getTransaction?: unknown } | undefined)?.getTransaction === 'function'; } /** Throw unless the voucher's Ed25519 signature verifies against `authorizedSigner`. */ @@ -1195,7 +1334,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>; + }>; }; }; @@ -1250,6 +1392,8 @@ export declare namespace session { } interface Parameters { + /** Unsafe explicit dev escape hatch for process-local stores off localnet. */ + readonly allowUnsafeEphemeralStoreOffLocalnet?: boolean; /** Maximum session cap the server will offer (base units). */ readonly cap: bigint; /** Idle-close delay in ms. 0 (default) disables the watchdog. */ diff --git a/typescript/packages/mpp/src/server/session/on-chain.ts b/typescript/packages/mpp/src/server/session/on-chain.ts index 115c18bc8..3c733abe0 100644 --- a/typescript/packages/mpp/src/server/session/on-chain.ts +++ b/typescript/packages/mpp/src/server/session/on-chain.ts @@ -40,10 +40,15 @@ import { import { findAssociatedTokenPda } from '@solana-program/token'; import { ASSOCIATED_TOKEN_PROGRAM, defaultTokenProgramForCurrency, resolveStablecoinMint } from '../../constants.js'; +import { type Channel, getChannelDecoder } from '../../generated/payment-channels/accounts/channel.js'; import { getDistributeInstruction } from '../../generated/payment-channels/instructions/distribute.js'; import { getReclaimInstruction } from '../../generated/payment-channels/instructions/reclaim.js'; import { getSettleAndSealInstruction } from '../../generated/payment-channels/instructions/settleAndSeal.js'; -import { getTopUpInstruction } from '../../generated/payment-channels/instructions/topUp.js'; +import { + getTopUpInstruction, + getTopUpInstructionDataDecoder, + TOP_UP_DISCRIMINATOR, +} 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 { OpenPayload, SignedVoucher } from '../../shared/session-types.js'; @@ -457,7 +462,10 @@ 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; + }>; }; } @@ -674,11 +682,219 @@ export async function verifyOpenTx(args: VerifyOpenTxArgs): Promise; + }; +} + +export interface TopUpTransactionRpc { + getTransaction( + signature: Signature, + config: { readonly encoding: 'base64'; readonly maxSupportedTransactionVersion: 0 }, + ): { + send(): Promise<{ + meta: { err: unknown } | 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 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.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; +} + +export async function verifyTopUpTransaction(args: { + readonly amount: bigint; + readonly channelId: string; + readonly programId: string; + readonly rpc: TopUpTransactionRpc; + readonly signature: Signature; +}): Promise { + const fetched = await args.rpc + .getTransaction(args.signature, { encoding: 'base64', maxSupportedTransactionVersion: 0 }) + .send(); + if (!fetched) throw new Error(`verifyTopUpTransaction: tx ${args.signature} not found on-chain`); + if (fetched.meta?.err) { + throw new Error(`verifyTopUpTransaction: tx failed on-chain: ${JSON.stringify(fetched.meta.err)}`); + } + const [wire, encoding] = fetched.transaction; + if (encoding !== 'base64') throw new Error(`verifyTopUpTransaction: expected base64, got ${encoding}`); + const decoded = getTransactionDecoder().decode(getBase64Codec().encode(wire)); + const message = getCompiledTransactionMessageDecoder().decode(decoded.messageBytes) as unknown as { + instructions: readonly { + accountIndices?: readonly number[]; + data?: Uint8Array; + programAddressIndex: number; + }[]; + staticAccounts: readonly string[]; + }; + let count = 0; + let total = 0n; + for (const instruction of message.instructions) { + if (message.staticAccounts[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; + count += 1; + total += getTopUpInstructionDataDecoder().decode(instruction.data).topUpArgs.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}`); + } +} + /** Tuning knobs for {@link waitForSignatureConfirmation}. */ export interface ConfirmSignatureOptions { /** Delay between status polls in ms. Defaults to 1_000. */ diff --git a/typescript/packages/mpp/src/server/session/store.ts b/typescript/packages/mpp/src/server/session/store.ts index a6f9599fe..aaea745bc 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,6 +77,8 @@ 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. */ @@ -116,6 +123,11 @@ export interface SessionStore { * not found, matching the Rust behavior. */ markSealed(channelId: string): Promise; + /** + * Explicit safety capability. Off localnet, only `durable-shared` is + * accepted; an omitted marker is unsafe by default. + */ + readonly sessionStoreDurability?: 'durable-shared' | 'ephemeral' | undefined; /** Atomically read-modify-write a channel's state. */ updateChannel(channelId: string, mutator: ChannelMutator): Promise; } @@ -150,7 +162,6 @@ export function createMemorySessionStore(): SessionStore { data.delete(channelId); return Promise.resolve(); }, - getChannel(channelId) { return Promise.resolve(data.get(channelId)); }, @@ -184,6 +195,8 @@ export function createMemorySessionStore(): SessionStore { }); }, + sessionStoreDurability: 'ephemeral', + async updateChannel(channelId, mutator) { return await withLock(channelId, async () => { const current = data.get(channelId); 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..ce3867783 --- /dev/null +++ b/typescript/packages/pay-kit/src/__tests__/mpp-session-store.test.ts @@ -0,0 +1,65 @@ +import { createMemorySessionStore } from '@solana/mpp/server'; +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() }, + }); +} + +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 02e59f423..b2d251b86 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 one in-memory 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'; @@ -20,6 +21,10 @@ import { ConfigurationError } from '../errors.js'; import type { Gate } from '../gate.js'; import { toSolanaNetwork } from '../protocol.js'; +type SessionStoreCapability = { + readonly sessionStoreDurability?: 'durable-shared' | 'ephemeral'; +}; + /** Outcome of running the session method on the gated route. */ export type SessionResult = | { readonly challenge: Response; readonly status: 402 } @@ -47,9 +52,9 @@ export type SessionEngine = { }; /** - * Build the session engine for a `session` gate. One in-memory store is shared - * across the gated handler and the side-channel routes so the receipt poll can - * read whichever channel a request opened. + * Build the session engine for a `session` gate. One store is shared across the + * gated handler and the side-channel routes so the receipt poll can read + * whichever channel a request opened. */ export function createSessionEngine(config: PayKitConfig, gate: Gate): SessionEngine { if (!gate.session) { @@ -65,7 +70,23 @@ export function createSessionEngine(config: PayKitConfig, gate: Gate): SessionEn const mint = requireMint(coin, resolveStablecoinMint(coin, network), config.network); const signer = config.operator.signer.signer; - const store = createMemorySessionStore(); + const store = + config.mpp.sessionStore ?? + (config.network === 'solana_localnet' + ? createMemorySessionStore() + : (() => { + throw new ConfigurationError( + `Gate "${gate.name}": mpp.sessionStore is required outside localnet; inject a durable shared store.`, + ); + })()); + if ( + config.network !== 'solana_localnet' && + (store as SessionStoreCapability & typeof store).sessionStoreDurability !== 'durable-shared' + ) { + throw new ConfigurationError( + `Gate "${gate.name}": session store must explicitly declare durable shared capability outside localnet.`, + ); + } const params = { cap: gate.amount.baseUnits(), ...(gate.session.closeDelayMs !== undefined ? { closeDelayMs: gate.session.closeDelayMs } : {}), diff --git a/typescript/packages/pay-kit/src/config.ts b/typescript/packages/pay-kit/src/config.ts index 74c8a15eb..e11be0be5 100644 --- a/typescript/packages/pay-kit/src/config.ts +++ b/typescript/packages/pay-kit/src/config.ts @@ -1,4 +1,5 @@ import { DEFAULT_RPC_URLS } from '@solana/mpp'; +import type { SessionStore } from '@solana/mpp/server'; import type { Store } from 'mppx'; import { ConfigurationError, DemoSignerOnMainnetError, ProtocolNotSupportedError } from './errors.js'; @@ -24,6 +25,11 @@ export type MppOptions = { */ readonly html?: boolean; readonly realm?: string; + /** + * 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; }; /** x402 protocol options. Reserved for future scheme-specific settings. */ @@ -76,6 +82,7 @@ export type PayKitConfig = { readonly expiresIn: number; readonly html: boolean; readonly realm: string; + readonly sessionStore: SessionStore | undefined; }; readonly network: Network; readonly operator: Operator; @@ -177,6 +184,7 @@ export async function configure(params: ConfigureParams = {}): Promise Date: Fri, 10 Jul 2026 23:25:53 +0300 Subject: [PATCH 02/39] fix(mpp): preserve completed open transaction binding --- .../mpp/server/session_method_test.go | 2 +- .../protocols/mpp/server/session_onchain.py | 4 ++ python/tests/test_session_settlement.py | 56 +++++++++++++++++-- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/go/protocols/mpp/server/session_method_test.go b/go/protocols/mpp/server/session_method_test.go index b4511c4d3..f9aad02a9 100644 --- a/go/protocols/mpp/server/session_method_test.go +++ b/go/protocols/mpp/server/session_method_test.go @@ -617,7 +617,7 @@ func TestSessionOpenVerifiesSignatureOnChain(t *testing.T) { signer := newTestVoucherSigner(t) channelID := solana.NewWallet().PublicKey().String() - receipt, channelID := openSessionChannel(t, session, channelID, 1_000, signer.Address(), okSig) + receipt, _ := openSessionChannel(t, session, channelID, 1_000, signer.Address(), okSig) if receipt.Reference != okSig { t.Fatalf("reference = %q", receipt.Reference) } 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 a05cba52e..2096aaf98 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 @@ -935,6 +935,10 @@ async def cosign_and_broadcast_open(payload: OpenPayload, *, fee_payer: Any, rpc f"broadcast open signature {signature} != completed transaction signature {expected_signature}", code="invalid-payload", ) + # Downstream processing verifies the payload again before persisting it. + # Keep the transaction and claimed signature bound to the same completed + # wire bytes instead of leaving the original partially signed transaction. + payload.transaction = base64.b64encode(wire).decode("ascii") await confirm_transaction_signature(rpc, signature, "open") return signature diff --git a/python/tests/test_session_settlement.py b/python/tests/test_session_settlement.py index 7f40c4725..8f0bc88ae 100644 --- a/python/tests/test_session_settlement.py +++ b/python/tests/test_session_settlement.py @@ -6,15 +6,20 @@ from __future__ import annotations +import hashlib +import struct from typing import Any import pytest from solders.keypair import Keypair # type: ignore[import-untyped] +from solders.pubkey import Pubkey # 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.protocols.mpp.server import SessionOptions, new_session from solana_pay_kit.protocols.mpp.server.session_store import ChannelState +from solana_pay_kit.protocols.programs.paymentchannels.accounts.channel import Channel from solana_pay_kit.signer import LocalSigner _BLOCKHASH = "EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N" @@ -39,9 +44,16 @@ class _SettleRpc: can assert no on-chain confirmation was attempted on a trust path. """ - def __init__(self) -> None: + def __init__(self, *, echo_transaction_signature: bool = False) -> None: self.sent: list[bytes] = [] self.status_queries: list[list[str]] = [] + self.accounts: dict[str, tuple[bytes, str] | None] = {} + self.echo_transaction_signature = echo_transaction_signature + + async def get_account_info( + self, address: str, commitment: str = "confirmed", min_context_slot: int | None = None + ) -> tuple[bytes, str] | None: + return self.accounts.get(address) async def get_signature_statuses(self, signatures: list[str]) -> list[dict | None]: self.status_queries.append(list(signatures)) @@ -52,9 +64,36 @@ async def get_latest_blockhash(self, commitment: str = "confirmed") -> _Resp: async def send_raw_transaction(self, raw_tx: bytes) -> _Resp: self.sent.append(raw_tx) + if self.echo_transaction_signature: + return _Resp(str(Transaction.from_bytes(raw_tx).signatures[0])) return _Resp(_SENT_SIGNATURE) +def _seed_open_account(rpc: _SettleRpc, open_: Any) -> None: + distribution_hash = hashlib.sha256(struct.pack(" None: operator = Keypair.from_seed(bytes([8] * 32)) - rpc = _SettleRpc() + rpc = _SettleRpc(echo_transaction_signature=True) session = new_session( SessionOptions( operator=str(operator.pubkey()), @@ -287,11 +326,13 @@ async def test_server_broadcast_open_builds_signs_and_persists() -> None: ) ) open_, payload = _server_open_payload(operator) + _seed_open_account(rpc, open_) payload.deposit = "1500000" signature = await session._handle_open(payload) - assert signature == _SENT_SIGNATURE + assert signature == payload.signature + assert signature == str(Transaction.from_bytes(rpc.sent[0]).signatures[0]) # One open transaction broadcast, a single open instruction (discriminator 1). assert len(rpc.sent) == 1 assert _instruction_discriminators(rpc.sent[0]) == [1] @@ -391,6 +432,7 @@ async def test_open_tx_submitter_client_verifies_pull_transaction() -> None: operator = Keypair.from_seed(bytes([14] * 32)) open_, payload = _server_open_payload(operator) rpc = _SettleRpc() + _seed_open_account(rpc, open_) session = new_session( SessionOptions( operator=str(operator.pubkey()), @@ -663,7 +705,7 @@ async def test_server_broadcast_open_replay_does_not_re_broadcast() -> None: unchanged, the voucher watermark is preserved).""" operator = Keypair.from_seed(bytes([27] * 32)) - rpc = _SettleRpc() + rpc = _SettleRpc(echo_transaction_signature=True) session = new_session( SessionOptions( operator=str(operator.pubkey()), @@ -681,10 +723,12 @@ async def test_server_broadcast_open_replay_does_not_re_broadcast() -> None: ) ) open_, payload = _server_open_payload(operator) + _seed_open_account(rpc, open_) payload.deposit = "1500000" first = await session._handle_open(payload) - assert first == _SENT_SIGNATURE + assert first == payload.signature + assert first == str(Transaction.from_bytes(rpc.sent[0]).signatures[0]) assert len(rpc.sent) == 1 replay = await session._handle_open(payload) @@ -692,7 +736,7 @@ async def test_server_broadcast_open_replay_does_not_re_broadcast() -> None: # No second broadcast on replay. assert len(rpc.sent) == 1 # The replay returns the original signature (already recorded). - assert replay == _SENT_SIGNATURE + assert replay == first persisted = await session._core.store().get_channel(str(open_.channel_id)) assert persisted is not None assert persisted.deposit == open_.deposit From 5042a7689f72584a29a7263596c55e1218f19ce6 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:35:22 +0300 Subject: [PATCH 03/39] fix(python): preserve settlement destination setup --- .../protocols/mpp/server/session_onchain.py | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) 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 2096aaf98..d6d66aba8 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 @@ -31,6 +31,7 @@ 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 spl.token.instructions import create_idempotent_associated_token_account # type: ignore[import-untyped] from solana_pay_kit._paycore.errors import PaymentError from solana_pay_kit._paycore.solana import default_token_program_for_currency, resolve_mint @@ -40,6 +41,7 @@ build_distribute_instruction, build_settle_and_seal_instructions, find_channel_pda, + treasury_owner, ) from solana_pay_kit.protocols.mpp.intents.session import OpenPayload, TopUpPayload from solana_pay_kit.protocols.mpp.server._tx_decode import _transaction_dict @@ -893,21 +895,40 @@ async def settle_and_seal_channel( raise PaymentError( f"channel {state.channel_id} payer is unknown; cannot derive the refund account", code="invalid-config" ) + payee = Pubkey.from_string(config.recipient) + mint = Pubkey.from_string(mint_address) + token_program = Pubkey.from_string(default_token_program_for_currency(config.currency, config.network)) + recipients = [Distribution(recipient=Pubkey.from_string(split.recipient), bps=split.bps) for split in config.splits] + treasury = treasury_owner() distribute = build_distribute_instruction( channel=channel, payer=Pubkey.from_string(payer_address), - payee=Pubkey.from_string(config.recipient), - mint=Pubkey.from_string(mint_address), - recipients=[Distribution(recipient=Pubkey.from_string(s.recipient), bps=s.bps) for s in config.splits], - token_program=Pubkey.from_string(default_token_program_for_currency(config.currency, config.network)), + payee=payee, + mint=mint, + recipients=recipients, + token_program=token_program, program_id=program_id, + treasury=treasury, # rentPayer recovers the channel/escrow rent at distribute (or via # reclaim); it is the operator recorded as rentPayer at open. rent_payer=Pubkey.from_string(config.operator) if config.operator else None, ) + ata_owners = [payee, treasury, *(entry.recipient for entry in recipients)] + seen_owners: set[str] = set() + create_destination_atas = [] + for owner in ata_owners: + if str(owner) in seen_owners: + continue + seen_owners.add(str(owner)) + create_destination_atas.append( + create_idempotent_associated_token_account(merchant_pubkey, owner, mint, token_program) + ) + blockhash = Hash.from_string((await rpc.get_latest_blockhash()).value.blockhash) - tx = Transaction.new_signed_with_payer([*settle, distribute], merchant_pubkey, [merchant], blockhash) + tx = Transaction.new_signed_with_payer( + [*settle, *create_destination_atas, distribute], merchant_pubkey, [merchant], blockhash + ) sent = await rpc.send_raw_transaction(bytes(tx)) signature = str(sent.value) # Confirm before returning, mirroring cosign_and_broadcast_open: a dropped From b335306a9d2d9f1d43da02dccdc31d57767472f5 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:52:53 +0300 Subject: [PATCH 04/39] test(python): pin settlement destination setup --- python/tests/test_session_settlement.py | 48 +++++++++++++++++++++---- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/python/tests/test_session_settlement.py b/python/tests/test_session_settlement.py index 8f0bc88ae..bdbe16bed 100644 --- a/python/tests/test_session_settlement.py +++ b/python/tests/test_session_settlement.py @@ -17,6 +17,7 @@ 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 ASSOCIATED_TOKEN_PROGRAM from solana_pay_kit.protocols.mpp.server import SessionOptions, new_session from solana_pay_kit.protocols.mpp.server.session_store import ChannelState from solana_pay_kit.protocols.programs.paymentchannels.accounts.channel import Channel @@ -94,11 +95,13 @@ def _seed_open_account(rpc: _SettleRpc, open_: Any) -> None: rpc.accounts[str(open_.channel_id)] = (bytes([1]) + bytes(body), PAYMENT_CHANNELS_PROGRAM_ID) -def _session(rpc: _SettleRpc, operator: Keypair): +def _session(rpc: _SettleRpc, operator: Keypair, recipient: str | None = None): + if recipient is None: + recipient = str(operator.pubkey()) return new_session( SessionOptions( operator=str(operator.pubkey()), - recipient=str(operator.pubkey()), + recipient=recipient, cap=1_000_000, currency="USDC", decimals=6, @@ -121,6 +124,17 @@ def _instruction_discriminators(raw_tx: bytes) -> list[int]: return [bytes(ix.data)[0] for ix in msg.instructions] +def _associated_token_account_owners(raw_tx: bytes) -> list[str]: + message = Transaction.from_bytes(raw_tx).message + account_keys = list(message.account_keys) + owners: list[str] = [] + for instruction in message.instructions: + if str(account_keys[instruction.program_id_index]) != ASSOCIATED_TOKEN_PROGRAM: + continue + owners.append(str(account_keys[instruction.accounts[2]])) + return owners + + @pytest.mark.asyncio async def test_close_settles_with_voucher_and_records_signature() -> None: operator = Keypair.from_seed(bytes([1] * 32)) @@ -150,9 +164,32 @@ async def test_close_settles_with_voucher_and_records_signature() -> None: assert final is not None assert final.sealed is True assert final.settled_signature == settled - # Exactly one tx, instructions [ed25519(1), settleAndSeal(4), distribute(7)]. + # Settle, create payee/treasury ATAs idempotently, then distribute. assert len(rpc.sent) == 1 - assert _instruction_discriminators(rpc.sent[0]) == [1, 4, 7] + assert _instruction_discriminators(rpc.sent[0]) == [1, 4, 1, 1, 7] + + +@pytest.mark.asyncio +async def test_settle_creates_missing_primary_recipient_ata_before_distribute() -> None: + operator = Keypair.from_seed(bytes([40] * 32)) + recipient = Keypair.from_seed(bytes([41] * 32)).pubkey() + channel = str(Keypair.from_seed(bytes([42] * 32)).pubkey()) + rpc = _SettleRpc() + session = _session(rpc, operator, str(recipient)) + await _seed( + session, + ChannelState( + channel_id=channel, + authorized_signer=str(operator.pubkey()), + deposit=1_000_000, + cumulative=0, + operator=str(operator.pubkey()), + ), + ) + + await session._settle_channel(channel) + + assert str(recipient) in _associated_token_account_owners(rpc.sent[0]) @pytest.mark.asyncio @@ -212,8 +249,7 @@ async def test_close_without_voucher_omits_ed25519_precompile() -> None: await session._settle_channel(channel) - # No voucher recorded: just [settleAndSeal(4), distribute(7)]. - assert _instruction_discriminators(rpc.sent[0]) == [4, 7] + assert _instruction_discriminators(rpc.sent[0]) == [4, 1, 1, 7] @pytest.mark.asyncio From be025566aab1c84dbbdade04a81b20f0a4e17c49 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:25:55 +0300 Subject: [PATCH 05/39] test(rust): name session mutation callback --- rust/crates/kit/src/mpp/server/session.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rust/crates/kit/src/mpp/server/session.rs b/rust/crates/kit/src/mpp/server/session.rs index d09ceae7b..08097bda8 100644 --- a/rust/crates/kit/src/mpp/server/session.rs +++ b/rust/crates/kit/src/mpp/server/session.rs @@ -3306,7 +3306,8 @@ mod tests { bs58::encode([9u8; 64]).into_string(), ); - let cases: Vec<(&str, Box)> = vec![ + type ChannelMutation = Box; + let cases: Vec<(&str, ChannelMutation)> = vec![ ( "settled", Box::new(|channel| channel.settlement.settled = 1), From bd93d24b3744e9e457d6af6aa8606607d51d2955 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:00:19 +0300 Subject: [PATCH 06/39] fix(mpp): fetch confirmed top-up transactions --- .../src/solana_pay_kit/protocols/mpp/server/session_onchain.py | 1 + 1 file changed, 1 insertion(+) 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 d6d66aba8..cc8d0ead7 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 @@ -679,6 +679,7 @@ async def verifier(payload: TopUpPayload, current: ChannelState) -> None: ) pending: Any = get_transaction( payload.signature, + commitment="confirmed", encoding="jsonParsed", max_supported_transaction_version=0, ) From e20cc52461cec78ee1e4e5e198c7c9aa4fc78903 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:23:01 +0300 Subject: [PATCH 07/39] fix(mpp): align confirmed session state checks --- go/protocols/mpp/server/session_onchain.go | 13 +++--- .../mpp/server/session_onchain_test.go | 46 +++++++++++++++++++ .../protocols/mpp/server/session.py | 7 ++- .../protocols/mpp/server/session_method.py | 1 + .../protocols/mpp/server/session_onchain.py | 7 ++- python/tests/test_session_server.py | 18 ++++++++ python/tests/test_session_settlement.py | 4 +- python/tests/test_session_state_binding.py | 5 ++ .../__tests__/session-state-binding.test.ts | 44 ++++++++++++++++++ typescript/packages/mpp/src/server/Session.ts | 10 +++- 10 files changed, 143 insertions(+), 12 deletions(-) diff --git a/go/protocols/mpp/server/session_onchain.go b/go/protocols/mpp/server/session_onchain.go index 34ec342b1..c93b20469 100644 --- a/go/protocols/mpp/server/session_onchain.go +++ b/go/protocols/mpp/server/session_onchain.go @@ -542,15 +542,14 @@ func NewTopUpStateTxVerifier(config SessionConfig, rpcClient solanatx.RPCClient) !tx.Message.AccountKeys[instruction.Accounts[1]].Equals(channelID) { continue } - amount := binary.LittleEndian.Uint64(instruction.Data[1:]) - if ^uint64(0)-funded < amount { - return fmt.Errorf("top-up instruction amount overflows total") - } - funded += amount 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 == 0 { - return fmt.Errorf("no payment-channels top_up instruction found for channel %s", channelID) + 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) diff --git a/go/protocols/mpp/server/session_onchain_test.go b/go/protocols/mpp/server/session_onchain_test.go index 7b0a524c6..6a2121fa8 100644 --- a/go/protocols/mpp/server/session_onchain_test.go +++ b/go/protocols/mpp/server/session_onchain_test.go @@ -543,6 +543,52 @@ func TestNewTopUpTxVerifierConfirmsSignature(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")) 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 158c1eca4..fcc78bfc8 100644 --- a/python/src/solana_pay_kit/protocols/mpp/server/session.py +++ b/python/src/solana_pay_kit/protocols/mpp/server/session.py @@ -156,6 +156,11 @@ class SessionConfig: # mode) before process_open persists channel state. verify_open_tx: SessionTxVerifier[OpenPayload] | 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" + # Legacy payload-only callback retained for API compatibility. verify_top_up_tx: SessionTxVerifier[TopUpPayload] | None = None @@ -363,7 +368,7 @@ async def process_open(self, payload: OpenPayload) -> ChannelState: # one (pull opens, trusted opens). open_slot=payload.recent_slot or 0, salt=payload.salt or 0, - open_signature=payload.signature or None, + 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 a4f8525bd..ccfabfb5b 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 @@ -1089,6 +1089,7 @@ def new_session(options: SessionOptions) -> Session: min_voucher_delta=options.min_voucher_delta, modes=options.modes, pull_voucher_strategy=options.pull_voucher_strategy, + open_tx_submitter=open_tx_submitter, ) from solana_pay_kit.protocols.mpp.server.session_onchain import ( new_open_tx_verifier, 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 cc8d0ead7..44abd100c 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 @@ -820,7 +820,12 @@ async def confirm_transaction_signature( level = status.get("confirmationStatus") if level in ("confirmed", "finalized"): slot = status.get("slot") - return slot if isinstance(slot, int) and slot >= 0 else 0 + 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 now = time.monotonic() if now >= deadline: diff --git a/python/tests/test_session_server.py b/python/tests/test_session_server.py index 5872c237f..053499968 100644 --- a/python/tests/test_session_server.py +++ b/python/tests/test_session_server.py @@ -161,6 +161,24 @@ async def test_process_open_stores_state() -> None: assert state.authorized_signer == "signer1" +async def test_process_open_does_not_persist_client_submitted_signature() -> None: + server = new_session_test_server(session_test_config()) + + state = await server.process_open(session_open_payload("chan1", 1_000_000, "signer1")) + + assert state.open_signature is None + + +async def test_process_open_persists_server_submitted_signature() -> None: + config = session_test_config() + config.open_tx_submitter = "server" + server = new_session_test_server(config) + + state = await server.process_open(session_open_payload("chan1", 1_000_000, "signer1")) + + assert state.open_signature == "dummy_tx_sig" + + async def test_process_open_zero_deposit_rejected() -> None: """Mirrors TestProcessOpenZeroDepositRejected.""" server = new_session_test_server(session_test_config()) diff --git a/python/tests/test_session_settlement.py b/python/tests/test_session_settlement.py index bdbe16bed..38ac96108 100644 --- a/python/tests/test_session_settlement.py +++ b/python/tests/test_session_settlement.py @@ -58,7 +58,7 @@ async def get_account_info( async def get_signature_statuses(self, signatures: list[str]) -> list[dict | None]: self.status_queries.append(list(signatures)) - return [{"err": None, "confirmationStatus": "confirmed"} for _ in signatures] + return [{"err": None, "confirmationStatus": "confirmed", "slot": 42} for _ in signatures] async def get_latest_blockhash(self, commitment: str = "confirmed") -> _Resp: return _Resp(_Blockhash(_BLOCKHASH)) @@ -551,7 +551,7 @@ async def get_signature_statuses(self, signatures: list[str]) -> list[dict | Non self._bounced_sig = signatures[0] self._none_left -= 1 return [None for _ in signatures] - return [{"err": None, "confirmationStatus": "confirmed"} for _ in signatures] + return [{"err": None, "confirmationStatus": "confirmed", "slot": 42} for _ in signatures] @pytest.mark.asyncio diff --git a/python/tests/test_session_state_binding.py b/python/tests/test_session_state_binding.py index 66952f179..86d976c77 100644 --- a/python/tests/test_session_state_binding.py +++ b/python/tests/test_session_state_binding.py @@ -249,6 +249,11 @@ async def test_processed_signature_rejected_and_account_read_is_slot_pinned() -> 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) diff --git a/typescript/packages/mpp/src/__tests__/session-state-binding.test.ts b/typescript/packages/mpp/src/__tests__/session-state-binding.test.ts index 7fdeed0e4..f3117322e 100644 --- a/typescript/packages/mpp/src/__tests__/session-state-binding.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-state-binding.test.ts @@ -274,6 +274,50 @@ describe('session Channel account state binding', () => { ).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 = { diff --git a/typescript/packages/mpp/src/server/Session.ts b/typescript/packages/mpp/src/server/Session.ts index 4c93e39d7..a13dc4cc6 100644 --- a/typescript/packages/mpp/src/server/Session.ts +++ b/typescript/packages/mpp/src/server/Session.ts @@ -1259,7 +1259,15 @@ async function assertSignatureSucceeded( if (status.confirmationStatus !== 'confirmed' && status.confirmationStatus !== 'finalized') { throw new Error(`${context}: tx ${signature} is only ${String(status.confirmationStatus)}; confirmed required`); } - return response.context?.slot ?? 0n; + 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; } function isTopUpTransactionRpc(rpc: RpcLike | undefined): rpc is RpcLike & TopUpTransactionRpc { From 9c4a207caef683239c42abe04d888aa092cf0d06 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:29:06 +0300 Subject: [PATCH 08/39] fix(rust): decode top-up transactions from wire --- rust/crates/kit/src/mpp/server/session.rs | 138 ++++++++++++++++++---- 1 file changed, 112 insertions(+), 26 deletions(-) diff --git a/rust/crates/kit/src/mpp/server/session.rs b/rust/crates/kit/src/mpp/server/session.rs index 08097bda8..e67752860 100644 --- a/rust/crates/kit/src/mpp/server/session.rs +++ b/rust/crates/kit/src/mpp/server/session.rs @@ -1544,14 +1544,23 @@ fn verify_top_up_transaction( 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(&signature, UiTransactionEncoding::JsonParsed) + .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 @@ -1564,44 +1573,76 @@ fn verify_top_up_transaction( "top-up transaction failed on-chain".to_string(), )); } - let value = serde_json::to_value(&transaction) - .map_err(|e| Error::Other(format!("serialize top-up transaction: {e}")))?; - let instructions = value - .pointer("/transaction/transaction/message/instructions") - .and_then(|value| value.as_array()) - .ok_or_else(|| Error::Other("top-up transaction has no parsed instructions".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 instructions { - if instruction - .get("programId") - .and_then(|value| value.as_str()) - != Some(&program_id.to_string()) - { + for instruction in transaction.message.instructions() { + if account_keys.get(usize::from(instruction.program_id_index)) != Some(program_id) { continue; } - let Some(data) = instruction.get("data").and_then(|value| value.as_str()) else { - continue; - }; - let decoded = bs58::decode(data) - .into_vec() - .map_err(|e| Error::Other(format!("invalid top-up instruction data: {e}")))?; - if decoded.first().copied() != Some(3) { + if instruction.data.first().copied() != Some(3) { continue; } - let accounts = instruction - .get("accounts") - .and_then(|value| value.as_array()) + let channel_index = *instruction + .accounts + .get(1) .ok_or_else(|| Error::Other("top-up instruction has invalid accounts".to_string()))?; - if accounts.get(1).and_then(|value| value.as_str()) != Some(channel_id) { + if account_keys.get(usize::from(channel_index)) != Some(channel_id) { continue; } - let amount_bytes: [u8; 8] = decoded + 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 decoded.len() != 9 { + if instruction.data.len() != 9 { return Err(Error::Other( "top-up instruction has trailing data".to_string(), )); @@ -3155,6 +3196,51 @@ mod tests { .contains("does not match transaction signature")); } + #[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() { From 602a09ed47a4f6f2964c02ff62a42f4587211547 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sat, 11 Jul 2026 05:09:20 +0300 Subject: [PATCH 09/39] fix(mpp): bind session state to confirmed transactions --- go/protocols/mpp/server/session.go | 89 +- go/protocols/mpp/server/session_method.go | 87 +- .../mpp/server/session_method_test.go | 232 ++++- go/protocols/mpp/server/session_onchain.go | 339 ++++++- .../mpp/server/session_onchain_test.go | 192 ++++ .../mpp/server/session_server_test.go | 30 + .../mpp/server/session_state_binding_test.go | 66 +- .../protocols/mpp/server/session.py | 102 +- .../protocols/mpp/server/session_method.py | 86 +- .../protocols/mpp/server/session_onchain.py | 510 +++++++++- python/tests/test_session_method.py | 58 +- python/tests/test_session_onchain.py | 145 ++- python/tests/test_session_server.py | 30 + python/tests/test_session_settlement.py | 23 +- python/tests/test_session_state_binding.py | 159 +++- rust/crates/kit/src/mpp/server/session.rs | 887 +++++++++++++++--- .../src/__tests__/session-on-chain.test.ts | 137 ++- .../__tests__/session-server-on-chain.test.ts | 218 ++++- .../session-signature-only-open.test.ts | 286 ++++++ .../__tests__/session-state-binding.test.ts | 94 +- typescript/packages/mpp/src/server/Session.ts | 64 ++ .../mpp/src/server/session/on-chain.ts | 477 +++++++++- 22 files changed, 3943 insertions(+), 368 deletions(-) create mode 100644 typescript/packages/mpp/src/__tests__/session-signature-only-open.test.ts diff --git a/go/protocols/mpp/server/session.go b/go/protocols/mpp/server/session.go index f6b3e412e..8106a56a2 100644 --- a/go/protocols/mpp/server/session.go +++ b/go/protocols/mpp/server/session.go @@ -15,12 +15,13 @@ 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" @@ -57,6 +58,12 @@ type Split struct { // payload's owner/payer fields. type SessionTxVerifier[P any] func(ctx context.Context, payload *P) (string, 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 @@ -111,10 +118,16 @@ 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 is the legacy payload-only hook retained for API // compatibility. New integrations should use VerifyTopUpStateTx. VerifyTopUpTx SessionTxVerifier[intents.TopUpPayload] @@ -262,31 +275,57 @@ 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 - } - if deposit == 0 { - return ChannelState{}, fmt.Errorf("deposit must be greater than zero") - } - if deposit > s.config.MaxCap { - return ChannelState{}, fmt.Errorf("deposit %d exceeds max cap %d", deposit, s.config.MaxCap) - } - paymentChannelBacked := payload.Mode == intents.SessionModePush || payload.Transaction != nil - if paymentChannelBacked && s.config.Network != "localnet" && s.config.VerifyOpenTx == nil { - return ChannelState{}, fmt.Errorf("payment-channel open requires an on-chain verifier off localnet") + 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 - if paymentChannelBacked && s.config.VerifyOpenTx != nil { - var err error - verifiedPayer, err = s.config.VerifyOpenTx(ctx, payload) + 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") + } + if deposit > s.config.MaxCap { + return ChannelState{}, fmt.Errorf("deposit %d exceeds max cap %d", deposit, s.config.MaxCap) + } + + 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) + } } // The channel payer (the deposit funder / distribute refund destination, @@ -303,6 +342,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 diff --git a/go/protocols/mpp/server/session_method.go b/go/protocols/mpp/server/session_method.go index b7e7ccf83..695cc536c 100644 --- a/go/protocols/mpp/server/session_method.go +++ b/go/protocols/mpp/server/session_method.go @@ -251,6 +251,7 @@ func NewSession(options SessionOptions) (*Session, error) { } if options.RPC != nil { config.VerifyOpenTx = NewOpenTxVerifier(config, options.RPC) + config.VerifyOpenStateTx = NewOpenStateTxVerifier(config, options.RPC) } config.VerifyTopUpStateTx = NewTopUpStateTxVerifier(config, options.RPC) session := &Session{ @@ -420,16 +421,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) + reference, err = s.handleOpen(ctx, action.Open, request.RecentSlot) case action.Voucher != nil: reference, err = s.handleVoucher(ctx, action.Voucher) case action.Commit != nil: @@ -489,7 +498,7 @@ func (s *Session) verifyPinnedSessionFields(credential core.PaymentCredential, r // payload (verifying or broadcasting the attached transaction when present), // enforce the deposit invariants, and insert the channel state atomically and // idempotently. -func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload) (string, error) { +func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload, 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) @@ -516,6 +525,7 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload) openSlot := openSlotFromPayload(payload) var salt uint64 var openSignature string + var verifiedOpen *VerifyOpenTxResult switch { case hasTransaction: @@ -530,6 +540,12 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload) 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 } if s.openTxSubmitter == OpenTxSubmitterServer { if s.rpc == nil { @@ -556,6 +572,7 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload) 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 { @@ -568,12 +585,17 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload) salt = submitted.Salt signature = submitted.Signature openSignature = submitted.Signature + verifiedOpen = &submitted.VerifyOpenTxResult } } else { verified, err := VerifyOpenTx(ctx, expected, payload, s.rpc) 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 @@ -610,30 +632,49 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload) if err != nil { return "", err } + if verifiedOpen != nil { + 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: the client asserts a previously - // broadcast open. With an RPC client the open signature is confirmed - // on-chain before persisting; without one the channelId/deposit - // fields are trusted as-is. + // 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 - var err error - deposit, err = payload.DepositAmount() - if err != nil { - return "", err - } if s.rpc != nil { - confirmedSlot, err := confirmedTransactionSlot(ctx, s.rpc, signature, "open") + expected := VerifyOpenTxExpected{ + AuthorizedSigner: payload.AuthorizedSigner, + Currency: s.currency, + 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, confirmedSlot, err := verifySignatureOnlyOpen(ctx, expected, payload, s.rpc) if err != nil { return "", err } - channelPDA, err := solana.PublicKeyFromBase58(channelID) + channelID = verified.ChannelID + channelPDA, err := solana.PublicKeyFromBase58(verified.ChannelID) if err != nil { - return "", fmt.Errorf("invalid channelId %q: %w", channelID, err) + return "", fmt.Errorf("invalid verified channelId %q: %w", verified.ChannelID, err) } bound, err := fetchAndBindChannelAccount( ctx, @@ -652,12 +693,24 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload) 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("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 + } } default: // Pull mode without a channel transaction: trust the diff --git a/go/protocols/mpp/server/session_method_test.go b/go/protocols/mpp/server/session_method_test.go index f9aad02a9..ac4af96c4 100644 --- a/go/protocols/mpp/server/session_method_test.go +++ b/go/protocols/mpp/server/session_method_test.go @@ -86,10 +86,25 @@ 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) @@ -104,7 +119,8 @@ func openSessionChannel(t *testing.T, session *Session, channelID string, deposi // 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) @@ -142,6 +158,7 @@ func openSessionChannel(t *testing.T, session *Session, channelID string, deposi ) } } + registerSignatureOnlyOpenTransaction(t, session, &payload, payerKey, deposit) receipt, err := verifySessionAction(t, session, intents.NewOpenAction(payload)) if err != nil { t.Fatalf("open: %v", err) @@ -149,6 +166,89 @@ func openSessionChannel(t *testing.T, session *Session, channelID string, deposi 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) + } + tx.Signatures = make([]solana.Signature, len(tx.Message.Signers())) + signature, err := solana.SignatureFromBase58(payload.Signature) + if err != nil { + return + } + tx.Signatures[0] = signature + fake.BySig[payload.Signature] = tx +} + func seedSessionAccountThroughSetter( t *testing.T, setter interface { @@ -504,6 +604,122 @@ func TestSessionOpenTrustsChannelIDAndDeposit(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) @@ -1496,7 +1712,8 @@ func TestSessionMiddlewareSkipsBlockhashPrefetchOnVerifyPath(t *testing.T) { } calls := fake.calls() signer := newTestVoucherSigner(t) - payer := solana.NewWallet().PublicKey() + payerKey := testutil.NewPrivateKey() + payer := payerKey.PublicKey() channel, _, err := paymentchannels.FindChannelPDAForProgram( payer, solana.MustPublicKeyFromBase58(session.recipient), @@ -1511,8 +1728,9 @@ func TestSessionMiddlewareSkipsBlockhashPrefetchOnVerifyPath(t *testing.T) { } channelID := channel.String() seedSessionAccountThroughSetter(t, fake, session, channelID, 1_000, payer.String(), signer.Address()) - credential, err := core.NewPaymentCredential(challenge.ToEcho(), intents.NewOpenAction( - intents.OpenPayloadPush(channelID, "1000", signer.Address(), confirmedSignature(0x88)))) + 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) } diff --git a/go/protocols/mpp/server/session_onchain.go b/go/protocols/mpp/server/session_onchain.go index c93b20469..b888acfb2 100644 --- a/go/protocols/mpp/server/session_onchain.go +++ b/go/protocols/mpp/server/session_onchain.go @@ -14,6 +14,7 @@ package server // provided. import ( + "bytes" "context" "crypto/sha256" "encoding/binary" @@ -38,6 +39,7 @@ type boundChannel struct { AuthorizedSigner string Payee string Mint string + GracePeriod uint32 Salt uint64 OpenSlot uint64 } @@ -159,6 +161,7 @@ func fetchAndBindChannelAccount( AuthorizedSigner: channel.AuthorizedSigner.String(), Payee: channel.Payee.String(), Mint: channel.Mint.String(), + GracePeriod: channel.GracePeriod, Salt: channel.Salt, OpenSlot: channel.OpenSlot, }, nil @@ -229,6 +232,18 @@ type VerifyOpenTxExpected struct { // Recipient is the primary payment recipient (challenge recipient, // base58); the transaction's payee account must match it. Recipient string + + // 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 @@ -298,6 +313,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 the claimed signature to this transaction before trusting it. boundSignature := payload.Signature != "" && !isPlaceholderSignature(payload.Signature) @@ -330,19 +348,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") } @@ -403,6 +413,22 @@ func VerifyOpenTx(ctx context.Context, expected VerifyOpenTxExpected, payload *i deposit := binary.LittleEndian.Uint64(openIx.Data[9:17]) gracePeriod := binary.LittleEndian.Uint32(openIx.Data[17:21]) openSlot := binary.LittleEndian.Uint64(openIx.Data[21:29]) + var openArgs pcgen.OpenArgs + if err := bin.NewBorshDecoder(openIx.Data[1:]).Decode(&openArgs); err != nil { + return VerifyOpenTxResult{}, fmt.Errorf("decode open instruction args: %w", err) + } + if expected.GracePeriod != 0 && openArgs.GracePeriod != expected.GracePeriod { + return VerifyOpenTxResult{}, fmt.Errorf("open gracePeriod %d != expected %d", openArgs.GracePeriod, expected.GracePeriod) + } + if len(openArgs.Recipients) != len(expected.Splits) { + return VerifyOpenTxResult{}, fmt.Errorf("open recipients length %d != expected splits length %d", len(openArgs.Recipients), len(expected.Splits)) + } + for i, recipient := range openArgs.Recipients { + expectedSplit := expected.Splits[i] + if !recipient.Recipient.Equals(expectedSplit.Recipient) || recipient.Bps != expectedSplit.BPS { + return VerifyOpenTxResult{}, fmt.Errorf("open recipient[%d] does not match expected split", i) + } + } if deposit == 0 { return VerifyOpenTxResult{}, fmt.Errorf("open deposit must be greater than zero") @@ -425,6 +451,50 @@ 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. + tokenProgram, err := solana.PublicKeyFromBase58(paycore.DefaultTokenProgramForCurrency(expected.Currency, expected.Network)) + if err != nil { + return VerifyOpenTxResult{}, fmt.Errorf("resolve open token program: %w", err) + } + 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. @@ -461,6 +531,8 @@ func NewOpenTxVerifier(config SessionConfig, rpcClient solanatx.RPCClient) Sessi Operator: config.Operator, ProgramID: config.ProgramID, Recipient: config.Recipient, + Splits: config.Splits, + GracePeriod: expectedSessionGracePeriod(config), } result, err := VerifyOpenTx(ctx, expected, payload, rpcClient) return result.Payer, err @@ -468,10 +540,246 @@ func NewOpenTxVerifier(config SessionConfig, rpcClient solanatx.RPCClient) Sessi if rpcClient == nil { return "", fmt.Errorf("open verification requires a transaction or an RPC client") } + if payload.RecentSlot == nil { + return "", fmt.Errorf("signature-only push open requires recentSlot") + } return "", confirmTransactionSignature(ctx, rpcClient, payload.Signature, "open") } } +// 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 []pcgen.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 confirms the top-up transaction signature // on-chain via getSignatureStatuses. @@ -753,6 +1061,13 @@ func prepareOpenTx(ctx context.Context, expected VerifyOpenTxExpected, payload * if err != nil { 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 // the open with the operator as fee payer and only partial-signs as the diff --git a/go/protocols/mpp/server/session_onchain_test.go b/go/protocols/mpp/server/session_onchain_test.go index 6a2121fa8..6a43d58cd 100644 --- a/go/protocols/mpp/server/session_onchain_test.go +++ b/go/protocols/mpp/server/session_onchain_test.go @@ -201,6 +201,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" @@ -509,6 +567,131 @@ func TestNewOpenTxVerifierWithoutTransactionConfirmsSignature(t *testing.T) { } } +func TestNewOpenTxVerifierKeepsStructuralPlaceholderAcceptance(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + config := openSessionConfig(fixture) + verifier := NewOpenTxVerifier(config, nil) + payload := fixture.payload + payload.Signature = strings.Repeat("1", 64) + payer, err := verifier(context.Background(), &payload) + if err != nil { + t.Fatalf("structural verifier: %v", err) + } + if payer != fixture.payer.PublicKey().String() { + t.Fatalf("verifier payer = %q, want %q", payer, fixture.payer.PublicKey()) + } +} + +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) { @@ -760,6 +943,15 @@ func TestSettlementInstructionsResolvesToken2022FromCurrency(t *testing.T) { 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 8a7263cd1..603043d5b 100644 --- a/go/protocols/mpp/server/session_server_test.go +++ b/go/protocols/mpp/server/session_server_test.go @@ -14,6 +14,7 @@ import ( solana "github.com/gagliardetto/solana-go" + "github.com/solana-foundation/pay-kit/go/paycore" "github.com/solana-foundation/pay-kit/go/protocols/mpp/intents" ) @@ -147,6 +148,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_state_binding_test.go b/go/protocols/mpp/server/session_state_binding_test.go index b8b3e340b..c0629cfff 100644 --- a/go/protocols/mpp/server/session_state_binding_test.go +++ b/go/protocols/mpp/server/session_state_binding_test.go @@ -74,7 +74,8 @@ func seedSessionChannelAccountWithSeeds( func TestSessionBarePushOpenUsesAuthoritativeChannelState(t *testing.T) { fake := testutil.NewFakeRPC() - payer := solana.NewWallet().PublicKey() + payerKey := testutil.NewPrivateKey() + payer := payerKey.PublicKey() signer := solana.NewWallet().PublicKey() mint := solana.MustPublicKeyFromBase58(paycore.ResolveMint("USDC", "localnet")) recipient := solana.MustPublicKeyFromBase58(sessionTestRecipient) @@ -90,9 +91,10 @@ func TestSessionBarePushOpenUsesAuthoritativeChannelState(t *testing.T) { ) session := newTestSession(t, func(options *SessionOptions) { options.RPC = fake }) - payload := intents.OpenPayloadPush(channelID.String(), "1000", signer.String(), confirmedSignature(0x31)) + 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) } @@ -111,6 +113,66 @@ func TestSessionBarePushOpenUsesAuthoritativeChannelState(t *testing.T) { } } +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" 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 fcc78bfc8..2ce676439 100644 --- a/python/src/solana_pay_kit/protocols/mpp/server/session.py +++ b/python/src/solana_pay_kit/protocols/mpp/server/session.py @@ -12,14 +12,12 @@ :meth:`SessionServer.process_close`; on-chain settlement is driven by the host once the close-pending state is recorded. -On-chain verification is a seam in this layer: when -:attr:`SessionConfig.verify_open_tx` / :attr:`SessionConfig.verify_top_up_tx` -are set, :meth:`process_open` (push mode) and :meth:`process_top_up` invoke -them before persisting channel state, binding the payload to the attached -transaction and confirming the signature on-chain. When ``None``, the -transaction signature and deposit amount are trusted as provided, which is -suitable only for unit tests or deployments that verify transactions out of -band. +On-chain verification is a pair of seams in this layer. The legacy +:attr:`SessionConfig.verify_open_tx` hook remains structural/payload-only for +compatibility, while :attr:`SessionConfig.verify_open_state_tx` returns facts +bound to the confirmed on-chain channel account. Off-localnet payment-channel +opens require the state-aware hook so a confirmed signature can never authorize +payload-supplied channel economics. """ from __future__ import annotations @@ -27,7 +25,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 ( @@ -68,6 +66,9 @@ __all__ = [ "Split", "SessionTxVerifier", + "SessionOpenTxVerifier", + "SessionOpenStateTxVerifier", + "VerifiedOpenFacts", "SessionConfig", "DeliveryRequest", "SessionServer", @@ -77,12 +78,23 @@ _P = TypeVar("_P") -# SessionTxVerifier confirms an on-chain transaction referenced by a session -# payload before channel state is persisted. Implementations typically decode -# the attached transaction, bind the payload signature to it, and confirm the -# signature on-chain. This is the seam the on-chain layer plugs into; ``None`` -# skips verification. Raising signals a verification failure. + +# SessionTxVerifier is the legacy payload-only callback retained for host +# compatibility. The state-aware open callback below is the production seam for +# payment-channel-backed opens. +class VerifiedOpenFacts(Protocol): + """Authoritative facts returned by a verified payment-channel open.""" + + channel_id: str + deposit: int + salt: int + open_slot: int + payer: str + + SessionTxVerifier = Callable[[_P], Awaitable[None]] +SessionOpenTxVerifier = Callable[[OpenPayload], Awaitable[None]] +SessionOpenStateTxVerifier = Callable[[OpenPayload], Awaitable[VerifiedOpenFacts]] SessionTopUpTxVerifier = Callable[[TopUpPayload, ChannelState], Awaitable[None]] @@ -152,10 +164,14 @@ class SessionConfig: # Required when modes includes pull. pull_voucher_strategy: SessionPullVoucherStrategy | None = None - # VerifyOpenTx, when set, confirms the open transaction on-chain (push - # mode) before process_open persists channel state. + # VerifyOpenTx is the legacy structural/payload-only callback. It may be + # used on localnet, but it does not authorize payload economics off-localnet. 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. + 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. @@ -339,35 +355,53 @@ 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 - if payment_channel_backed and self._config.network != "localnet" and self._config.verify_open_tx is None: - raise ValueError("payment-channel open requires an on-chain verifier off localnet") - if payment_channel_backed and self._config.verify_open_tx is not None: + 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" + ) + 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 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 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 + 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 decoded these facts from the payment-channel + # transaction. 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, - salt=payload.salt or 0, + open_slot=open_slot, + salt=salt, open_signature=(payload.signature or None) if self._config.open_tx_submitter == "server" else None, ) 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 ccfabfb5b..a6eda1b55 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 @@ -45,7 +45,7 @@ PaymentError, payment_required_response, ) -from solana_pay_kit._paycore.solana import MAX_SPLITS, resolve_mint +from solana_pay_kit._paycore.solana import MAX_SPLITS from solana_pay_kit.protocols.mpp.core.expires import minutes from solana_pay_kit.protocols.mpp.core.headers import ( PAYMENT_RECEIPT_HEADER, @@ -70,9 +70,7 @@ from solana_pay_kit.protocols.mpp.server.session_onchain import ( RpcClient, VerifyOpenTxExpected, - confirm_transaction_signature, cosign_and_broadcast_open, - fetch_and_bind_channel_account, settle_and_seal_channel, verify_open_tx, ) @@ -570,6 +568,8 @@ def _open_tx_expected(self, payload: OpenPayload, challenge_recent_slot: int | N max_cap=self._core.config.max_cap, operator=self._core.config.operator, program_id=(Pubkey.from_string(self._core.config.program_id) if self._core.config.program_id else None), + splits=self._core.config.splits, + grace_period=self._core.config.settlement_window or 900, recent_slot=challenge_recent_slot, ) @@ -649,9 +649,12 @@ async def _handle_open(self, payload: OpenPayload, challenge_recent_slot: int | f"server-submitted open {session_id} is missing its persisted broadcast signature", code="invalid-payload", ) - payload.signature = existing.open_signature - payload.salt = existing.salt - payload.recent_slot = existing.open_slot + # The retry may carry a fresh copy of the original partial + # transaction. Returning the persisted signature directly + # avoids re-validating that partial wire against the completed + # signature and, crucially, avoids rebroadcasting it. + self._touch(existing.channel_id) + return existing.open_signature else: # Built lazily: only the transaction-carrying paths verify # the open on-chain, so the on-chain expected facts (and the @@ -698,30 +701,11 @@ async def _handle_open(self, payload: OpenPayload, challenge_recent_slot: int | code="invalid-payload", ) elif mode == "push" and self._rpc is not None: - confirmed_slot = await confirm_transaction_signature(self._rpc, payload.signature, "open") - expected_mint = resolve_mint(self._currency, self._network) - if not expected_mint: - raise PaymentError( - f"payment-channel push open requires an SPL token, got currency {self._currency!r}", - code="invalid-config", - ) - bound = await fetch_and_bind_channel_account( - self._rpc, - payload.session_id(), - program_id=self._core.config.program_id, - max_cap=self._core.config.max_cap, - expected_authorized_signer=payload.authorized_signer, - expected_payee=self._recipient, - expected_mint=expected_mint, - expected_operator=self._core.config.operator, - min_context_slot=confirmed_slot, - expected_grace_period=(self._core.config.settlement_window or 900), - expected_splits=self._core.config.splits, - ) - payload.deposit = str(bound.deposit) - payload.payer = bound.payer - payload.recent_slot = bound.open_slot - payload.salt = bound.salt + # The state-aware verifier performs the confirmed account bind. Keep + # the challenge incarnation on the payload so it can reject a stale + # channel before state is persisted. + if payload.recent_slot in (None, 0): + payload.recent_slot = challenge_recent_slot elif mode == "push" and self._network != "localnet": raise PaymentError( "payment-channel push open requires an rpc client to bind the on-chain channel off localnet", @@ -733,39 +717,6 @@ async def _handle_open(self, payload: OpenPayload, challenge_recent_slot: int | # as previously broadcast). The server-broadcast path is skipped even when # openTxSubmitter=server is configured. - if has_transaction: - if self._rpc is None: - if self._network != "localnet": - raise PaymentError( - "payment-channel open requires an rpc client to bind the on-chain channel off localnet", - code="invalid-config", - ) - else: - confirmed_slot = await confirm_transaction_signature(self._rpc, payload.signature, "open") - expected_mint = resolve_mint(self._currency, self._network) - if not expected_mint: - raise PaymentError( - f"payment-channel open requires an SPL token, got currency {self._currency!r}", - code="invalid-config", - ) - bound = await fetch_and_bind_channel_account( - self._rpc, - payload.session_id(), - program_id=self._core.config.program_id, - max_cap=self._core.config.max_cap, - expected_authorized_signer=payload.authorized_signer, - expected_payee=self._recipient, - expected_mint=expected_mint, - expected_operator=self._core.config.operator, - min_context_slot=confirmed_slot, - expected_grace_period=(self._core.config.settlement_window or 900), - expected_splits=self._core.config.splits, - ) - payload.deposit = str(bound.deposit) - payload.payer = bound.payer - payload.recent_slot = bound.open_slot - payload.salt = bound.salt - try: state = await self._core.process_open(payload) except ValueError as exc: @@ -1092,13 +1043,20 @@ def new_session(options: SessionOptions) -> Session: open_tx_submitter=open_tx_submitter, ) from solana_pay_kit.protocols.mpp.server.session_onchain import ( + new_open_state_tx_verifier, new_open_tx_verifier, new_top_up_state_tx_verifier, ) config.allow_unsafe_ephemeral_store_off_localnet = options.allow_unsafe_ephemeral_store_off_localnet if options.rpc is not None: - config.verify_open_tx = new_open_tx_verifier(config, options.rpc) + legacy_open_verifier = new_open_tx_verifier(config, options.rpc) + + async def verify_open_tx_compat(payload: OpenPayload) -> None: + await legacy_open_verifier(payload) + + config.verify_open_tx = verify_open_tx_compat + config.verify_open_state_tx = new_open_state_tx_verifier(config, options.rpc) 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 44abd100c..1abcfc29a 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 @@ -23,7 +23,7 @@ import struct import time from collections.abc import Awaitable, Callable -from dataclasses import dataclass +from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, Any, Protocol, cast from solders.hash import Hash # type: ignore[import-untyped] @@ -38,13 +38,16 @@ from solana_pay_kit.protocols.mpp._paymentchannels import ( PROGRAM_ID, Distribution, + OpenChannelParams, build_distribute_instruction, + build_open_instruction, build_settle_and_seal_instructions, find_channel_pda, treasury_owner, ) from solana_pay_kit.protocols.mpp.intents.session import OpenPayload, TopUpPayload from solana_pay_kit.protocols.mpp.server._tx_decode import _transaction_dict +from solana_pay_kit.protocols.programs.paymentchannels.types.openArgs import OpenArgs from solana_pay_kit.protocols.programs.paymentchannels.types.topUpArgs import TopUpArgs if TYPE_CHECKING: @@ -59,6 +62,7 @@ "settle_and_seal_channel", "verify_open_tx", "new_open_tx_verifier", + "new_open_state_tx_verifier", "new_top_up_state_tx_verifier", "new_top_up_tx_verifier", "confirm_transaction_signature", @@ -84,6 +88,8 @@ class RpcClient(Protocol): async def get_signature_statuses(self, signatures: list[str]) -> list[dict | None]: ... + async def get_transaction(self, signature: str, **kwargs: Any) -> Any: ... + async def get_latest_blockhash(self, commitment: str = ...) -> Any: ... async def send_raw_transaction(self, raw_tx: bytes) -> Any: ... @@ -97,7 +103,8 @@ 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]] +OpenTxVerifier = Callable[[OpenPayload], Awaitable["VerifyOpenTxResult | None"]] +OpenStateTxVerifier = Callable[[OpenPayload], Awaitable["VerifyOpenTxResult"]] TopUpTxVerifier = Callable[[TopUpPayload], Awaitable[None]] TopUpStateTxVerifier = Callable[[TopUpPayload, "ChannelState"], Awaitable[None]] @@ -112,6 +119,8 @@ class OpenVerifierConfig(Protocol): recipient: str max_cap: int operator: str + settlement_window: int + splits: list[Any] @property def program_id(self) -> Pubkey | str | None: ... @@ -135,6 +144,12 @@ class VerifyOpenTxExpected: # slot-1 check. operator: str = "" program_id: Pubkey | None = None + # The challenge's ordered payout split distribution. Open instruction data + # must carry these exact entries, not merely a matching count or hash. + splits: list[Any] = field(default_factory=list) + # The channel grace period committed by the challenge. Session defaults to + # the payment-channels program default when no settlement window is set. + grace_period: int = 900 # The challenge-issued recentSlot, when the caller has it. The open # instruction's own openSlot must equal it: without this bind, a payload # that omits recentSlot would let a transaction built against a different @@ -289,6 +304,12 @@ async def verify_open_tx( except Exception as exc: raise PaymentError(f"decode open transaction: {exc}", code="invalid-payload") from exc + if len(instructions) != 1: + raise PaymentError( + f"open transaction must contain exactly one instruction, found {len(instructions)}", + code="invalid-payload", + ) + # Bind the claimed signature to this transaction before trusting it. bound_signature = payload.signature != "" and not is_placeholder_signature(payload.signature) if bound_signature: @@ -319,17 +340,15 @@ def account_at(indices: list[int], slot: int, label: str) -> Pubkey: ) return Pubkey.from_string(account_keys[indices[slot]]) - open_ix = None - for ix in instructions: - program_index = int(ix.program_id_index) - if program_index >= len(account_keys) or account_keys[program_index] != str(program_id): - continue - data = bytes(ix.data) - if len(data) < 1 or data[0] != _OPEN_INSTRUCTION_DISCRIMINATOR: - continue - open_ix = ix - break - if open_ix is None: + open_ix = instructions[0] + program_index = int(open_ix.program_id_index) + data = bytes(open_ix.data) + if ( + program_index >= len(account_keys) + or account_keys[program_index] != str(program_id) + or len(data) < 1 + or data[0] != _OPEN_INSTRUCTION_DISCRIMINATOR + ): raise PaymentError("no payment-channels open instruction found", code="invalid-payload") # Open instruction account layout after the rentPayer (+1) shift: @@ -399,6 +418,65 @@ def account_at(indices: list[int], slot: int, label: str) -> Pubkey: code="invalid-payload", ) + try: + decoded_args = OpenArgs.from_decoded(OpenArgs.layout.parse(data[1:])) + except Exception as exc: + raise PaymentError(f"decode open instruction args: {exc}", code="invalid-payload") from exc + if int(decoded_args.gracePeriod) != expected.grace_period: + raise PaymentError( + f"open gracePeriod {decoded_args.gracePeriod} != expected {expected.grace_period}", + code="invalid-payload", + ) + if len(decoded_args.recipients) != len(expected.splits): + raise PaymentError( + f"open recipients length {len(decoded_args.recipients)} != expected splits length {len(expected.splits)}", + code="invalid-payload", + ) + for index, recipient in enumerate(decoded_args.recipients): + expected_split = expected.splits[index] + if str(recipient.recipient) != str(expected_split.recipient) or int(recipient.bps) != int(expected_split.bps): + raise PaymentError(f"open recipient[{index}] does not match expected split", code="invalid-payload") + + token_program = Pubkey.from_string(default_token_program_for_currency(expected.currency, expected.network)) + canonical = build_open_instruction( + OpenChannelParams( + payer=payer, + rent_payer=rent_payer, + payee=payee, + mint=mint, + authorized_signer=authorized_signer, + salt=salt, + deposit=deposit, + grace_period=grace_period, + open_slot=open_slot, + recipients=[ + Distribution(recipient=entry.recipient, bps=int(entry.bps)) for entry in decoded_args.recipients + ], + token_program=token_program, + program_id=program_id, + ) + ) + if bytes(open_ix.data) != bytes(canonical.data): + raise PaymentError("open instruction data is not canonical", code="invalid-payload") + canonical_accounts = list(canonical.accounts) + if len(accounts) != len(canonical_accounts): + raise PaymentError( + f"open instruction account count {len(accounts)} != canonical count {len(canonical_accounts)}", + code="invalid-payload", + ) + for index, meta in enumerate(canonical_accounts): + account_index = accounts[index] + if account_index < 0 or account_index >= len(account_keys): + raise PaymentError( + f"open instruction account[{index}] index {account_index} is out of range", + code="invalid-payload", + ) + if account_keys[account_index] != str(meta.pubkey): + raise PaymentError( + f"open instruction account[{index}] does not match canonical account {meta.pubkey}", + code="invalid-payload", + ) + # Optional liveness check: only when the caller provides an RPC client and # the client already populated the transaction signature. if rpc_client is not None and bound_signature: @@ -414,6 +492,257 @@ def account_at(indices: list[int], slot: int, label: str) -> Pubkey: ) +def _confirmed_open_transaction_json( + expected: VerifyOpenTxExpected, + payload: OpenPayload, + transaction: dict[str, Any], +) -> VerifyOpenTxResult: + """Verify the exact open instruction returned by ``getTransaction``.""" + meta = transaction.get("meta") + if meta is not None and not isinstance(meta, dict): + raise PaymentError("confirmed open transaction has malformed metadata", code="invalid-payload") + if isinstance(meta, dict) and meta.get("err") is not None: + raise PaymentError("open transaction failed on-chain", code="transaction-failed") + + transaction_value: Any = transaction.get("transaction") + if isinstance(transaction_value, list): + transaction_value = transaction_value[0] if transaction_value else None + if not isinstance(transaction_value, dict): + raise PaymentError("confirmed open transaction has malformed transaction data", code="invalid-payload") + signatures = transaction_value.get("signatures") + if isinstance(signatures, list) and signatures and signatures[0] != payload.signature: + raise PaymentError( + f"open transaction signature {signatures[0]} != payload signature {payload.signature}", + code="invalid-payload", + ) + message = transaction_value.get("message") + instructions = message.get("instructions") if isinstance(message, dict) else None + if not isinstance(instructions, list): + raise PaymentError("confirmed open transaction has no instructions", code="invalid-payload") + + program_id = expected.program_id if expected.program_id is not None else PROGRAM_ID + matches: list[dict[str, Any]] = [] + for instruction in instructions: + if not isinstance(instruction, dict): + raise PaymentError("confirmed open transaction has a malformed instruction", code="invalid-payload") + if instruction.get("programId") != str(program_id): + continue + encoded_data = instruction.get("data") + if not isinstance(encoded_data, str): + raise PaymentError("confirmed open transaction has a malformed open instruction", code="invalid-payload") + try: + data = _base58_decode(encoded_data) + except PaymentError as exc: + raise PaymentError( + "confirmed open transaction has malformed open instruction data", code="invalid-payload" + ) from exc + if data[:1] == bytes([_OPEN_INSTRUCTION_DISCRIMINATOR]): + matches.append(instruction) + if len(matches) != 1: + raise PaymentError( + "confirmed transaction must contain exactly one canonical payment-channel open instruction, " + f"found {len(matches)}", + code="invalid-payload", + ) + + instruction = matches[0] + raw_accounts = instruction.get("accounts") + if not isinstance(raw_accounts, list) or any(not isinstance(account, str) for account in raw_accounts): + raise PaymentError("confirmed open instruction has malformed accounts", code="invalid-payload") + accounts = cast(list[str], raw_accounts) + if len(accounts) < 6: + raise PaymentError("confirmed open instruction has too few accounts", code="invalid-payload") + + def parse_account(slot: int, label: str) -> Pubkey: + try: + return Pubkey.from_string(accounts[slot]) + except Exception as exc: + raise PaymentError( + f"confirmed open instruction has invalid {label} account", code="invalid-payload" + ) from exc + + payer = parse_account(0, "payer") + rent_payer = parse_account(1, "rentPayer") + payee = parse_account(2, "payee") + mint = parse_account(3, "mint") + authorized_signer = parse_account(4, "authorizedSigner") + channel = parse_account(5, "channel") + expected_mint = expected.mint or resolve_mint(expected.currency, expected.network) + if not expected_mint: + raise PaymentError( + f"could not resolve mint from currency {expected.currency!r}", + code="invalid-payload", + ) + if str(payee) != expected.recipient: + raise PaymentError(f"open payee {payee} != expected recipient {expected.recipient}", code="invalid-payload") + if str(mint) != expected_mint: + raise PaymentError(f"open mint {mint} != expected mint {expected_mint}", code="invalid-payload") + if str(authorized_signer) != expected.authorized_signer: + raise PaymentError( + f"open authorizedSigner {authorized_signer} != expected {expected.authorized_signer}", + code="invalid-payload", + ) + if str(rent_payer) != expected.operator: + raise PaymentError( + f"open rentPayer {rent_payer} != expected operator {expected.operator}", + code="invalid-payload", + ) + + try: + encoded_data = instruction["data"] + data = _base58_decode(encoded_data) + except (KeyError, TypeError) as exc: + raise PaymentError("confirmed open instruction has malformed data", code="invalid-payload") from exc + if len(data) < 1 + 8 + 8 + 4 + 8: + raise PaymentError(f"open instruction data too short ({len(data)} bytes)", code="invalid-payload") + try: + salt = struct.unpack_from(" expected.max_cap: + raise PaymentError(f"open deposit {deposit} exceeds max cap {expected.max_cap}", code="invalid-payload") + if grace_period != expected.grace_period: + raise PaymentError( + f"open gracePeriod {grace_period} != expected {expected.grace_period}", + code="invalid-payload", + ) + if len(decoded_args.recipients) != len(expected.splits): + raise PaymentError( + f"open recipients length {len(decoded_args.recipients)} != expected splits length {len(expected.splits)}", + code="invalid-payload", + ) + for index, recipient in enumerate(decoded_args.recipients): + expected_split = expected.splits[index] + if str(recipient.recipient) != str(expected_split.recipient) or int(recipient.bps) != int(expected_split.bps): + raise PaymentError(f"open recipient[{index}] does not match expected split", code="invalid-payload") + + derived_channel, _ = find_channel_pda(payer, payee, mint, authorized_signer, salt, open_slot, program_id) + if derived_channel != channel: + raise PaymentError(f"open channel PDA {channel} != derived {derived_channel}", code="invalid-payload") + if payload.channel_id is not None and payload.channel_id != str(channel): + raise PaymentError( + f"openPayload.channelId {payload.channel_id} != transaction channel {channel}", + code="invalid-payload", + ) + if payload.recent_slot is not None and payload.recent_slot != open_slot: + raise PaymentError( + f"openPayload.recentSlot {payload.recent_slot} != transaction openSlot {open_slot}", + code="invalid-payload", + ) + if expected.recent_slot is not None and expected.recent_slot != open_slot: + raise PaymentError( + f"transaction openSlot {open_slot} != challenge recentSlot {expected.recent_slot}", + code="invalid-payload", + ) + + token_program = Pubkey.from_string(default_token_program_for_currency(expected.currency, expected.network)) + canonical = build_open_instruction( + OpenChannelParams( + payer=payer, + rent_payer=rent_payer, + payee=payee, + mint=mint, + authorized_signer=authorized_signer, + salt=salt, + deposit=deposit, + grace_period=grace_period, + open_slot=open_slot, + recipients=[ + Distribution(recipient=Pubkey.from_string(str(entry.recipient)), bps=int(entry.bps)) + for entry in decoded_args.recipients + ], + token_program=token_program, + program_id=program_id, + ) + ) + if len(accounts) != len(canonical.accounts) or any( + accounts[index] != str(meta.pubkey) for index, meta in enumerate(canonical.accounts) + ): + raise PaymentError("open instruction accounts are not canonical", code="invalid-payload") + if data != bytes(canonical.data): + raise PaymentError("open instruction data is not canonical", code="invalid-payload") + + return VerifyOpenTxResult( + channel_id=str(channel), + deposit=deposit, + grace_period=grace_period, + salt=salt, + open_slot=open_slot, + payer=str(payer), + ) + + +async def _fetch_and_verify_signature_only_open( + expected: VerifyOpenTxExpected, + payload: OpenPayload, + rpc_client: RpcClient, +) -> tuple[int, 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="jsonParsed", + 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") + + transaction_value: Any = transaction.get("transaction") + if ( + isinstance(transaction_value, list) + and len(transaction_value) >= 2 + and transaction_value[1] == "base64" + and isinstance(transaction_value[0], str) + ): + wire_payload = replace(payload, transaction=transaction_value[0]) + structural = await verify_open_tx(expected, wire_payload, None) + else: + structural = _confirmed_open_transaction_json(expected, payload, transaction) + 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_tx_verifier(config: OpenVerifierConfig, rpc_client: RpcClient | None) -> OpenTxVerifier: """Return the on-chain open verifier to install on the session config. @@ -424,7 +753,7 @@ def new_open_tx_verifier(config: OpenVerifierConfig, rpc_client: RpcClient | Non confirmed on-chain via ``getSignatureStatuses``. """ - async def verifier(payload: OpenPayload) -> None: + async def verifier(payload: OpenPayload) -> VerifyOpenTxResult | None: if payload.transaction: expected = VerifyOpenTxExpected( authorized_signer=payload.authorized_signer, @@ -436,15 +765,156 @@ async def verifier(payload: OpenPayload) -> None: Pubkey.from_string(config.program_id) if isinstance(config.program_id, str) else config.program_id ), recipient=config.recipient, + splits=config.splits, + grace_period=_expected_session_grace_period(config.settlement_window), ) - await verify_open_tx(expected, payload, rpc_client) - return + return await verify_open_tx(expected, payload, rpc_client) if rpc_client is None: raise PaymentError( "open verification requires a transaction or an RPC client", code="invalid-payload", ) - await confirm_transaction_signature(rpc_client, payload.signature, "open") + 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, + splits=config.splits, + grace_period=_expected_session_grace_period(config.settlement_window), + recent_slot=payload.recent_slot if payload.recent_slot not in (None, 0) else None, + ) + confirmed_slot, structural = await _fetch_and_verify_signature_only_open(expected, payload, rpc_client) + 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", + ) + session_id = structural.channel_id + bound = await fetch_and_bind_channel_account( + rpc_client, + session_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=payload.recent_slot, + ) + _assert_signature_only_deposit(payload, structural, bound) + return VerifyOpenTxResult( + channel_id=session_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 + + +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, + splits=config.splits, + grace_period=_expected_session_grace_period(config.settlement_window), + 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 @@ -559,6 +1029,7 @@ async def fetch_and_bind_channel_account( 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, @@ -572,6 +1043,11 @@ async def fetch_and_bind_channel_account( 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}", diff --git a/python/tests/test_session_method.py b/python/tests/test_session_method.py index 14bbe0b9a..aa889587e 100644 --- a/python/tests/test_session_method.py +++ b/python/tests/test_session_method.py @@ -19,18 +19,22 @@ from __future__ import annotations +import base64 import hashlib import struct 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 resolve_mint -from solana_pay_kit.protocols.mpp._paymentchannels import find_channel_pda +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.core.types import PaymentChallenge, PaymentCredential from solana_pay_kit.protocols.mpp.intents.session import ( ClosePayload, @@ -129,6 +133,8 @@ def __init__(self, blockhash: str = "FakeBlockhash111111111111111111111111111111 self.statuses: dict[str, dict | None] = {} self.blockhash = blockhash self.accounts: dict[str, tuple[bytes, str] | None] = {} + self.transaction: dict | None = None + self.transaction_signature: str | None = None def seed_channel( self, @@ -143,6 +149,32 @@ def seed_channel( open_slot: int = 0, ) -> None: self.accounts[channel_id] = _channel_account(deposit, payer, payee, signer, mint, rent_payer, salt, open_slot) + instruction = build_open_instruction( + OpenChannelParams( + payer=Pubkey.from_string(payer), + rent_payer=Pubkey.from_string(rent_payer or payee), + payee=Pubkey.from_string(payee), + mint=Pubkey.from_string(mint), + authorized_signer=Pubkey.from_string(signer), + salt=salt, + deposit=deposit, + grace_period=900, + open_slot=open_slot, + token_program=Pubkey.from_string(TOKEN_PROGRAM), + ) + ) + fee_payer = Pubkey.from_string(rent_payer or payee) + blockhash = Hash.from_string("EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N") + message = Message.new_with_blockhash([instruction], fee_payer, blockhash) + signatures = [Signature.default()] * message.header.num_required_signatures + if self.transaction_signature is not None: + signatures[0] = Signature.from_string(self.transaction_signature) + transaction = Transaction.populate(message, signatures) + encoded = base64.b64encode(bytes(transaction)).decode("ascii") + self.transaction = {"meta": {"err": None}, "transaction": [encoded, "base64"]} + + async def get_transaction(self, signature: str, **kwargs): # noqa: ANN003, ANN201 + return self.transaction async def get_account_info( self, address: str, commitment: str = "confirmed", min_context_slot: int | None = None @@ -218,6 +250,7 @@ async def _open_session_channel( if isinstance(session._rpc, _FakeRpc): mint = resolve_mint(session._currency, session._network) assert mint is not None + session._rpc.transaction_signature = signature session._rpc.seed_channel(channel_id, deposit, _new_wallet(), session._recipient, authorized_signer, mint) return await _verify_session_action(session, SessionAction.open_action(payload)) @@ -593,20 +626,25 @@ async def test_session_open_verifies_signature_on_chain() -> None: mint = resolve_mint("USDC", "localnet") assert mint is not None payer = _new_wallet() - channel_id = _derived_channel_id(payer, SESSION_TEST_RECIPIENT, signer.address(), mint) - fake.seed_channel(channel_id, 1_000, payer, SESSION_TEST_RECIPIENT, signer.address(), mint) + channel_id = _derived_channel_id(payer, SESSION_TEST_RECIPIENT, signer.address(), mint, open_slot=42) + fake.transaction_signature = ok_sig + fake.seed_channel(channel_id, 1_000, payer, SESSION_TEST_RECIPIENT, signer.address(), mint, open_slot=42) + open_payload = OpenPayload.push(channel_id, "1000", signer.address(), ok_sig) + open_payload.recent_slot = 42 receipt = await _verify_session_action( - session, SessionAction.open_action(OpenPayload.push(channel_id, "1000", signer.address(), ok_sig)) + session, SessionAction.open_action(open_payload) ) assert receipt.reference == ok_sig ghost_channel = _new_wallet() ghost = OpenPayload.push(ghost_channel, "1000", signer.address(), ghost_sig) + ghost.recent_slot = 42 with pytest.raises(PaymentError, match="not found"): await _verify_session_action(session, SessionAction.open_action(ghost)) assert await _get_channel(session, ghost_channel) is None failed = OpenPayload.push(_new_wallet(), "1000", signer.address(), failed_sig) + failed.recent_slot = 42 with pytest.raises(PaymentError, match="failed on-chain"): await _verify_session_action(session, SessionAction.open_action(failed)) @@ -775,10 +813,13 @@ async def test_session_top_up_verifies_signature_on_chain() -> None: mint = resolve_mint("USDC", "localnet") assert mint is not None payer = _new_wallet() - channel_id = _derived_channel_id(payer, SESSION_TEST_RECIPIENT, signer.address(), mint) - fake.seed_channel(channel_id, 1_000, payer, SESSION_TEST_RECIPIENT, signer.address(), mint) + channel_id = _derived_channel_id(payer, SESSION_TEST_RECIPIENT, signer.address(), mint, open_slot=42) + fake.transaction_signature = open_sig + fake.seed_channel(channel_id, 1_000, payer, SESSION_TEST_RECIPIENT, signer.address(), mint, open_slot=42) + open_payload = OpenPayload.push(channel_id, "1000", signer.address(), open_sig) + open_payload.recent_slot = 42 await _verify_session_action( - session, SessionAction.open_action(OpenPayload.push(channel_id, "1000", signer.address(), open_sig)) + session, SessionAction.open_action(open_payload) ) opened = await _get_channel(session, channel_id) assert opened is not None and opened.operator is not None @@ -958,6 +999,7 @@ async def test_session_push_open_requires_payer_or_transaction_for_settlement() assert mint is not None channel_id = _derived_channel_id(payer, SESSION_TEST_RECIPIENT, signer.address(), mint, 1, 777) assert isinstance(session._rpc, _FakeRpc) + session._rpc.transaction_signature = _confirmed_signature(0x32) session._rpc.seed_channel( channel_id, 1_000, diff --git a/python/tests/test_session_onchain.py b/python/tests/test_session_onchain.py index 30e5af319..46772e637 100644 --- a/python/tests/test_session_onchain.py +++ b/python/tests/test_session_onchain.py @@ -33,11 +33,13 @@ from solana_pay_kit._paycore.solana import TOKEN_PROGRAM from solana_pay_kit.protocols.mpp._paymentchannels import ( PROGRAM_ID, + Distribution, OpenChannelParams, build_open_instruction, find_channel_pda, ) from solana_pay_kit.protocols.mpp.intents.session import OpenPayload, TopUpPayload +from solana_pay_kit.protocols.mpp.server.session import Split from solana_pay_kit.protocols.mpp.server.session_onchain import ( VerifyOpenTxExpected, is_placeholder_signature, @@ -79,6 +81,10 @@ class _FakeRpc: def __init__(self) -> None: self.statuses: dict[str, dict | None] = {} self.accounts: dict[str, tuple[bytes, str] | None] = {} + self.transaction: dict | None = None + + async def get_transaction(self, signature: str, **kwargs): # noqa: ANN003, ANN201 + return self.transaction async def get_account_info( self, address: str, commitment: str = "confirmed", min_context_slot: int | None = None @@ -131,16 +137,18 @@ def _channel_account(fixture: OpenTxFixture, deposit: int) -> tuple[bytes, str]: return bytes([1]) + bytes(body), str(PROGRAM_ID) -def _sign_and_attach(fixture: OpenTxFixture, ix: Instruction, v0: bool) -> tuple[str, OpenPayload]: +def _sign_and_attach_instructions( + fixture: OpenTxFixture, instructions: list[Instruction], v0: bool +) -> tuple[str, OpenPayload]: blockhash = Hash.from_string("EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N") payer_pubkey = fixture.payer.pubkey() if v0: - message_v0 = MessageV0.try_compile(payer_pubkey, [ix], [], blockhash) + message_v0 = MessageV0.try_compile(payer_pubkey, instructions, [], blockhash) vtx = VersionedTransaction(message_v0, [fixture.payer]) encoded = base64.b64encode(bytes(vtx)).decode("ascii") signature = str(vtx.signatures[0]) else: - message = Message.new_with_blockhash([ix], payer_pubkey, blockhash) + message = Message.new_with_blockhash(instructions, payer_pubkey, blockhash) tx = Transaction([fixture.payer], message, blockhash) encoded = base64.b64encode(bytes(tx)).decode("ascii") signature = str(tx.signatures[0]) @@ -159,6 +167,10 @@ def _sign_and_attach(fixture: OpenTxFixture, ix: Instruction, v0: bool) -> tuple return signature, payload +def _sign_and_attach(fixture: OpenTxFixture, ix: Instruction, v0: bool) -> tuple[str, OpenPayload]: + return _sign_and_attach_instructions(fixture, [ix], v0) + + def build_open_tx_fixture(v0: bool) -> OpenTxFixture: payer = _kp(1) payee = _kp(2).pubkey() @@ -205,6 +217,24 @@ def build_open_tx_fixture(v0: bool) -> OpenTxFixture: return fixture +def _fixture_open_instruction(fixture: OpenTxFixture, recipients: list[Distribution] | None = None) -> Instruction: + return build_open_instruction( + OpenChannelParams( + payer=fixture.payer.pubkey(), + payee=fixture.payee, + mint=fixture.mint, + authorized_signer=fixture.authorized, + salt=OPEN_FIXTURE_SALT, + deposit=OPEN_FIXTURE_DEPOSIT, + grace_period=OPEN_FIXTURE_GRACE, + open_slot=OPEN_FIXTURE_SLOT, + recipients=[] if recipients is None else recipients, + token_program=Pubkey.from_string(TOKEN_PROGRAM), + rent_payer=fixture.payer.pubkey(), + ) + ) + + # -- verify_open_tx: accepted encodings --------------------------------------- @@ -452,6 +482,71 @@ async def test_verify_open_tx_rejects_missing_open_instruction() -> None: await verify_open_tx(fixture.expected, fixture.payload, None) +@pytest.mark.parametrize("case", ["extra", "duplicate"]) +async def test_verify_open_tx_rejects_extra_or_duplicate_instructions(case: str) -> None: + """A server must validate the complete signed message before co-signing it.""" + fixture = build_open_tx_fixture(v0=False) + open_ix = _fixture_open_instruction(fixture) + if case == "extra": + instructions = [ + open_ix, + Instruction( + Pubkey.from_string("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"), + b"unexpected", + [], + ), + ] + else: + instructions = [open_ix, open_ix] + _, fixture.payload = _sign_and_attach_instructions(fixture, instructions, v0=False) + + with pytest.raises(PaymentError, match="exactly one instruction"): + await verify_open_tx(fixture.expected, fixture.payload, None) + + +async def test_verify_open_tx_rejects_altered_splits() -> None: + """The open's ordered split entries must match the challenge exactly.""" + fixture = build_open_tx_fixture(v0=False) + expected_recipient = _kp(60).pubkey() + altered_recipient = _kp(61).pubkey() + ix = _fixture_open_instruction( + fixture, + recipients=[Distribution(recipient=altered_recipient, bps=250)], + ) + _, fixture.payload = _sign_and_attach(fixture, ix, v0=False) + expected = replace( + fixture.expected, + splits=[Split(recipient=str(expected_recipient), bps=250)], + ) + + with pytest.raises(PaymentError, match=r"recipient\[0\].*expected split"): + await verify_open_tx(expected, fixture.payload, None) + + +async def test_verify_open_tx_rejects_reordered_fixed_accounts() -> None: + """The fixed accounts must remain in the generated instruction order.""" + fixture = build_open_tx_fixture(v0=False) + ix = _fixture_open_instruction(fixture) + accounts = list(ix.accounts) + accounts[9], accounts[10] = accounts[10], accounts[9] + forged = Instruction(ix.program_id, ix.data, accounts) + _, fixture.payload = _sign_and_attach(fixture, forged, v0=False) + + with pytest.raises(PaymentError, match="canonical account"): + await verify_open_tx(fixture.expected, fixture.payload, None) + + +async def test_verify_open_tx_rejects_trailing_open_data() -> None: + """Trailing bytes after the canonical Borsh open args are not accepted.""" + fixture = build_open_tx_fixture(v0=False) + ix = _fixture_open_instruction(fixture) + forged = Instruction(ix.program_id, bytes(ix.data) + b"\x00", ix.accounts) + _, fixture.payload = _sign_and_attach(fixture, forged, v0=False) + + with pytest.raises(PaymentError, match="canonical"): + await verify_open_tx(fixture.expected, fixture.payload, None) + + async def test_verify_open_tx_rejects_channel_pda_mismatch() -> None: """Mirrors TestVerifyOpenTxRejectsChannelPDAMismatch.""" fixture = build_open_tx_fixture(v0=False) @@ -618,9 +713,49 @@ async def test_new_open_tx_verifier_without_transaction_requires_rpc() -> None: async def test_new_open_tx_verifier_without_transaction_confirms_signature() -> None: """Mirrors TestNewOpenTxVerifierWithoutTransactionConfirmsSignature.""" fixture = build_open_tx_fixture(v0=False) - verifier = new_open_tx_verifier(_open_session_config(fixture), _FakeRpc()) + fake_rpc = _FakeRpc() + fake_rpc.accounts[str(fixture.channel)] = _channel_account(fixture, OPEN_FIXTURE_DEPOSIT) + fake_rpc.transaction = {"meta": {"err": None}, "transaction": [fixture.payload.transaction, "base64"]} + verifier = new_open_tx_verifier(_open_session_config(fixture), fake_rpc) fixture.payload.transaction = None - await verifier(fixture.payload) + result = await verifier(fixture.payload) + assert result is not None + assert result.channel_id == str(fixture.channel) + assert result.deposit == OPEN_FIXTURE_DEPOSIT + assert result.salt == OPEN_FIXTURE_SALT + assert result.open_slot == OPEN_FIXTURE_SLOT + assert result.payer == str(fixture.payer.pubkey()) + + +@pytest.mark.parametrize("case", ["missing", "unrelated", "extra"]) +async def test_signature_only_open_rejects_noncanonical_confirmed_transaction(case: str) -> None: + fixture = build_open_tx_fixture(v0=False) + open_ix = _fixture_open_instruction(fixture) + if case == "missing": + instructions = [ + Instruction( + Pubkey.from_string("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"), + b"unrelated", + [], + ) + ] + elif case == "unrelated": + instructions = [Instruction(Pubkey.new_unique(), b"unrelated", [])] + else: + instructions = [open_ix, open_ix] + signature, wire_payload = _sign_and_attach_instructions(fixture, instructions, v0=False) + wire = wire_payload.transaction + fixture.payload = wire_payload + fixture.payload.transaction = None + fixture.payload.signature = signature + fake_rpc = _FakeRpc() + fake_rpc.accounts[str(fixture.channel)] = _channel_account(fixture, OPEN_FIXTURE_DEPOSIT) + fake_rpc.transaction = {"meta": {"err": None}, "transaction": [wire, "base64"]} + verifier = new_open_tx_verifier(_open_session_config(fixture), fake_rpc) + + with pytest.raises(PaymentError) as error: + await verifier(fixture.payload) + assert error.value.code == "invalid-payload" # -- new_top_up_tx_verifier --------------------------------------------------- diff --git a/python/tests/test_session_server.py b/python/tests/test_session_server.py index 053499968..254bdad44 100644 --- a/python/tests/test_session_server.py +++ b/python/tests/test_session_server.py @@ -30,6 +30,7 @@ SessionServer, Split, ) +from solana_pay_kit.protocols.mpp.server.session_onchain import VerifyOpenTxResult from solana_pay_kit.protocols.mpp.server.session_store import MemoryChannelStore SESSION_TEST_RECIPIENT = "CXhrFZJLKqjzmP3sjYLcF4dTeXWKCy9e2SXXZ2Yo6MPY" @@ -280,6 +281,35 @@ async def verifier(payload: OpenPayload) -> None: assert calls[0].signature == "dummy_tx_sig" +async def test_process_open_persists_authoritative_verified_facts() -> None: + """Verifier facts override payload echoes before state is persisted.""" + + async def verifier(_: OpenPayload) -> VerifyOpenTxResult: + return VerifyOpenTxResult( + channel_id="chan1", + deposit=4_321, + grace_period=900, + salt=44, + open_slot=55, + payer="verified-payer", + ) + + config = session_test_config() + config.verify_open_state_tx = verifier + server = new_session_test_server(config) + payload = session_open_payload("chan1", 1_000, "signer1") + payload.payer = "forged-payer" + payload.salt = 2 + payload.recent_slot = 3 + + state = await server.process_open(payload) + + assert state.deposit == 4_321 + assert state.operator == "verified-payer" + assert state.salt == 44 + assert state.open_slot == 55 + + async def test_process_open_verify_open_tx_error_rejects_without_persisting() -> None: """Mirrors TestProcessOpenVerifyOpenTxErrorRejectsWithoutPersisting.""" diff --git a/python/tests/test_session_settlement.py b/python/tests/test_session_settlement.py index 38ac96108..d4cbd7ec8 100644 --- a/python/tests/test_session_settlement.py +++ b/python/tests/test_session_settlement.py @@ -6,6 +6,8 @@ from __future__ import annotations +import base64 +import copy import hashlib import struct from typing import Any @@ -60,6 +62,9 @@ async def get_signature_statuses(self, signatures: list[str]) -> list[dict | Non self.status_queries.append(list(signatures)) return [{"err": None, "confirmationStatus": "confirmed", "slot": 42} for _ in signatures] + async def get_transaction(self, signature: str, **kwargs: Any) -> None: + return None + async def get_latest_blockhash(self, commitment: str = "confirmed") -> _Resp: return _Resp(_Blockhash(_BLOCKHASH)) @@ -486,6 +491,11 @@ async def test_open_tx_submitter_client_verifies_pull_transaction() -> None: ) ) payload.deposit = "1500000" + from solana_pay_kit.protocols.mpp.server.session_onchain import _complete_open_transaction + + completed_wire, completed_signature = _complete_open_transaction(payload, operator) + payload.transaction = base64.b64encode(completed_wire).decode("ascii") + payload.signature = completed_signature reference = await session._handle_open(payload) @@ -734,11 +744,11 @@ async def test_concurrent_settle_in_progress_guard_blocks_second_caller() -> Non @pytest.mark.asyncio async def test_server_broadcast_open_replay_does_not_re_broadcast() -> None: - """S1: a replayed server-broadcast open (same payload, channel already - persisted) must NOT call ``send_raw_transaction`` again. The store - idempotency pre-check short-circuits the broadcast; ``process_open`` is - still the final source of truth (the existing state is returned - unchanged, the voucher watermark is preserved).""" + """S1: a fresh copy of the original partial open retries idempotently. + + The persisted completed signature is returned without rebroadcasting or + comparing it to the still-partial wire transaction. + """ operator = Keypair.from_seed(bytes([27] * 32)) rpc = _SettleRpc(echo_transaction_signature=True) @@ -759,6 +769,7 @@ async def test_server_broadcast_open_replay_does_not_re_broadcast() -> None: ) ) open_, payload = _server_open_payload(operator) + fresh_partial_retry = copy.copy(payload) _seed_open_account(rpc, open_) payload.deposit = "1500000" @@ -767,7 +778,7 @@ async def test_server_broadcast_open_replay_does_not_re_broadcast() -> None: assert first == str(Transaction.from_bytes(rpc.sent[0]).signatures[0]) assert len(rpc.sent) == 1 - replay = await session._handle_open(payload) + replay = await session._handle_open(fresh_partial_retry) # No second broadcast on replay. assert len(rpc.sent) == 1 diff --git a/python/tests/test_session_state_binding.py b/python/tests/test_session_state_binding.py index 86d976c77..9cee180b5 100644 --- a/python/tests/test_session_state_binding.py +++ b/python/tests/test_session_state_binding.py @@ -1,18 +1,22 @@ 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 resolve_mint -from solana_pay_kit.protocols.mpp._paymentchannels import find_channel_pda +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, TopUpPayload from solana_pay_kit.protocols.mpp.server.session import DeliveryRequest, SessionConfig, SessionServer from solana_pay_kit.protocols.mpp.server.session_method import SessionOptions, new_session @@ -75,6 +79,30 @@ def _channel_bytes( return bytes([1]) + bytes(body) +def _open_transaction(*, deposit: int, payer: str, payee: str, signer: str, mint: str) -> 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] = {} @@ -123,6 +151,10 @@ async def test_bare_push_open_persists_authoritative_channel_deposit_and_payer() _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 = MemoryChannelStore() store.session_store_durability = SessionStoreDurability.DURABLE_SHARED session = new_session( @@ -139,10 +171,10 @@ async def test_bare_push_open_persists_authoritative_channel_deposit_and_payer() ) ) payload = OpenPayload.payment_channel( - channel_id, "1000", _wallet(5), recipient, mint, 7, 900, 0, signer, _signature(6) + channel_id, "4000", _wallet(5), recipient, mint, 7, 900, 0, signer, signature ) payload.transaction = None - await session._handle_open(payload) + 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 @@ -151,6 +183,111 @@ async def test_bare_push_open_persists_authoritative_channel_deposit_and_payer() 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 = MemoryChannelStore() + store.session_store_durability = SessionStoreDurability.DURABLE_SHARED + 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 = MemoryChannelStore() + store.session_store_durability = SessionStoreDurability.DURABLE_SHARED + 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_top_up_binds_resulting_deposit_and_open_status() -> None: rpc = _Rpc() recipient = _wallet(11) @@ -211,8 +348,20 @@ async def test_core_direct_construction_rejects_nonlocalnet_bypasses() -> None: await SessionServer(config, memory).process_open(payload) memory.session_store_durability = SessionStoreDurability.DURABLE_SHARED - with pytest.raises(ValueError, match="requires an on-chain verifier"): + with pytest.raises(ValueError, match="requires an authoritative verifier"): + await SessionServer(config, memory).process_open(payload) + + async def signature_only_without_state(_payload: OpenPayload) -> None: + return None + + config.verify_open_tx = signature_only_without_state + with pytest.raises(ValueError, match="requires an authoritative verifier"): await SessionServer(config, memory).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, memory).process_open(payload) + await memory.update_channel( "topup", lambda _: ChannelState(channel_id="topup", authorized_signer=_wallet(34), deposit=1_000, operator=_wallet(35)), diff --git a/rust/crates/kit/src/mpp/server/session.rs b/rust/crates/kit/src/mpp/server/session.rs index e67752860..5b777dfec 100644 --- a/rust/crates/kit/src/mpp/server/session.rs +++ b/rust/crates/kit/src/mpp/server/session.rs @@ -528,115 +528,272 @@ impl SessionServer { 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) => { - 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) + 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 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(), + 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); } - 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( @@ -1392,26 +1549,24 @@ enum VerifiedTx { #[cfg(feature = "server")] fn bound_client_open_signature(payload: &OpenPayload) -> Result<&str> { - let transaction = payload - .transaction - .as_deref() - .ok_or_else(|| Error::Other("payment-channel open missing transaction".to_string()))?; - 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 - ))); + 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) } @@ -1665,6 +1820,119 @@ fn verify_top_up_wire_transaction( 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() + { + 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 { let bytes = bs58::decode(s) .into_vec() @@ -1762,6 +2030,8 @@ 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, @@ -2920,6 +3190,14 @@ mod tests { #[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; @@ -2931,6 +3209,8 @@ mod tests { 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() { @@ -3004,6 +3284,19 @@ mod tests { "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": [{ @@ -3083,6 +3376,24 @@ mod tests { .unwrap() } + #[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() { @@ -3110,9 +3421,19 @@ mod tests { program_id: payment_channels::default_program_id(), }; let channel_id = payment_channels::derive_channel_addresses(¶ms).channel; - let rpc = SessionAccountRpc::start( + 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 { @@ -3135,14 +3456,9 @@ mod tests { bs58::encode([9u8; 64]).into_string(), ); payload.mode = SessionMode::Pull; - let mut transaction = solana_transaction::Transaction::new_unsigned( - solana_message::Message::new(&[], Some(&payer)), - ); - transaction.signatures[0] = solana_signature::Signature::from([9u8; 64]); - let transaction = solana_transaction::versioned::VersionedTransaction::from(transaction); payload.transaction = Some(base64::Engine::encode( &base64::engine::general_purpose::STANDARD, - bincode::serialize(&transaction).unwrap(), + open_transaction, )); let state = server.process_open(&payload).await.unwrap(); assert_eq!(state.deposit, 4_000); @@ -3153,6 +3469,242 @@ mod tests { ); } + #[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, @@ -3196,6 +3748,87 @@ mod tests { .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() { 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 6cf5ef6fd..50b182652 100644 --- a/typescript/packages/mpp/src/__tests__/session-on-chain.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-on-chain.test.ts @@ -37,6 +37,7 @@ import { MULTI_DELEGATOR_PROGRAM_ID, PAYMENT_CHANNELS_PROGRAM_ID, verifyOpenTx, + verifyTopUpTransaction, } from '../server/session/on-chain.js'; function makeSeed(byte: number): Uint8Array { @@ -61,6 +62,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', () => { @@ -380,7 +404,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, @@ -446,6 +470,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); @@ -621,6 +671,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. */ @@ -657,6 +739,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 5163e5efa..b5328d7e8 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 ─────────────────────────── @@ -265,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); 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 index f3117322e..d34b51e5e 100644 --- a/typescript/packages/mpp/src/__tests__/session-state-binding.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-state-binding.test.ts @@ -44,7 +44,7 @@ async function channelId(): Promise { return derived; } -function encodeChannel(deposit: bigint, status = 0): string { +function encodeChannel(deposit: bigint, status = 0, gracePeriod = 900): string { const bytes = getChannelEncoder().encode({ discriminator: 1, version: 1, @@ -55,7 +55,7 @@ function encodeChannel(deposit: bigint, status = 0): string { settlement: { settled: 0n, payoutWatermark: 0n }, closureStartedAt: 0n, payerWithdrawnAt: 0n, - gracePeriod: 900, + gracePeriod, distributionHash: Array.from(EMPTY_DISTRIBUTION_HASH), payer: address(PAYER), payee: address(RECIPIENT), @@ -83,14 +83,20 @@ function rpcWithChannel(data: string) { }; } -function credential(payload: unknown) { +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 }, + request: { + cap: '10000', + currency: 'USDC', + operator: OPERATOR, + recipient: RECIPIENT, + ...requestOverrides, + }, }, payload, } as never; @@ -127,27 +133,73 @@ describe('session Channel account state binding', () => { ); }); - test('bare push open persists on-chain deposit and payer', async () => { + 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 method.verify({ - credential: credential({ - action: 'open', - authorizedSigner: SIGNER, - channelId: channel, - deposit: '1000', - mode: 'push', - payer: CLAIMED_PAYER, - signature: 'open-signature', + 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, }), - request: {} as never, - }); - const state = await store.getChannel(channel); - expect(state?.deposit).toBe(4_000n); - expect(state?.operator).toBe(PAYER); - expect(state?.openSlot).toBe(42n); - expect(state?.salt).toBe(7n); + ).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 () => { diff --git a/typescript/packages/mpp/src/server/Session.ts b/typescript/packages/mpp/src/server/Session.ts index a13dc4cc6..b6e6fd22a 100644 --- a/typescript/packages/mpp/src/server/Session.ts +++ b/typescript/packages/mpp/src/server/Session.ts @@ -25,7 +25,9 @@ import { createLifecycle, type Lifecycle } from './session/lifecycle.js'; import { type GetAccountInfoRpc, isGetAccountInfoRpc, + isOpenTransactionRpc, type MultiDelegateSubmitRpc, + type OpenTransactionRpc, PAYMENT_CHANNELS_PROGRAM_ID, submitInitMultiDelegateTxIfMissing, submitOpenTx, @@ -34,6 +36,7 @@ import { type TopUpTransactionRpc, verifyChannelAccountState, verifyOpenTx, + verifySignatureOnlyOpenTransaction, verifyTopUpTransaction, } from './session/on-chain.js'; import { @@ -164,6 +167,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. @@ -296,8 +300,10 @@ export function session(parameters: session.Parameters) { pullVoucherStrategy, recipient, rpc, + gracePeriod: sessionGracePeriod, splits, store, + tokenProgram, }); case 'voucher': return await handleVoucher({ @@ -331,6 +337,7 @@ export function session(parameters: session.Parameters) { programId: resolvedProgramId, recipient, rpc, + gracePeriod: sessionGracePeriod, splits, store, }); @@ -454,8 +461,10 @@ interface HandleOpenArgs { readonly pullVoucherStrategy: SessionPullVoucherStrategy | undefined; readonly recipient: string; readonly rpc: RpcLike | undefined; + readonly gracePeriod: number; readonly splits: readonly SessionSplit[] | undefined; readonly store: SessionStore; + readonly tokenProgram: string; } async function handleOpen(args: HandleOpenArgs): Promise { @@ -495,6 +504,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, @@ -502,6 +512,8 @@ async function handleOpen(args: HandleOpenArgs): Promise { operator: args.operator, programId: args.programId.toString(), recipient: args.recipient, + splits: args.splits, + tokenProgram: args.tokenProgram, }; if (args.openTxSubmitter === 'server') { @@ -585,6 +597,17 @@ 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') { @@ -604,11 +627,42 @@ async function handleOpen(args: HandleOpenArgs): Promise { 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, + operator: args.operator, + openSlot, + 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(), @@ -789,6 +843,7 @@ interface HandleTopUpArgs { readonly programId: Address; readonly recipient: string; readonly rpc: RpcLike | undefined; + readonly gracePeriod: number; readonly splits: readonly SessionSplit[] | undefined; readonly store: SessionStore; } @@ -839,6 +894,7 @@ async function handleTopUp(args: HandleTopUpArgs): Promise { expected: { authorizedSigner: existing.authorizedSigner, deposit: newDeposit, + gracePeriod: args.gracePeriod, mint: args.mint, payee: args.recipient, payer: existing.operator, @@ -1319,6 +1375,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); diff --git a/typescript/packages/mpp/src/server/session/on-chain.ts b/typescript/packages/mpp/src/server/session/on-chain.ts index 3c733abe0..6b68a87d5 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,11 +40,21 @@ 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 { getReclaimInstruction } from '../../generated/payment-channels/instructions/reclaim.js'; import { getSettleAndSealInstruction } from '../../generated/payment-channels/instructions/settleAndSeal.js'; +import { + getOpenInstructionDataDecoder, + getOpenInstructionDataEncoder, +} from '../../generated/payment-channels/instructions/open.js'; +import type { OpenInstructionData } from '../../generated/payment-channels/instructions/open.js'; import { getTopUpInstruction, getTopUpInstructionDataDecoder, @@ -97,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]); @@ -429,6 +441,8 @@ export interface VerifyOpenTxExpected { readonly authorizedSigner: string; readonly currency: string; readonly maxCap: bigint; + /** Expected channel close grace period, defaulting to the program default. */ + readonly gracePeriod?: number | undefined; /** Optional override for the SPL mint (otherwise resolved from currency/network). */ readonly mint?: string | undefined; readonly network?: string | undefined; @@ -449,6 +463,8 @@ export interface VerifyOpenTxExpected { readonly programId?: string | undefined; /** Primary recipient (challenge `recipient`). */ readonly recipient: string; + /** Ordered payout distribution committed by the challenge. */ + readonly splits?: readonly { readonly bps: number; readonly recipient: string }[] | undefined; /** Optional explicit token program (otherwise derived from currency/network). */ readonly tokenProgram?: string | undefined; } @@ -469,6 +485,36 @@ export interface SignatureStatusRpc { }; } +/** 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. */ @@ -529,6 +575,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}`); } + 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({ @@ -672,6 +765,57 @@ export async function verifyOpenTx(args: VerifyOpenTxArgs): Promise; }; @@ -722,6 +879,7 @@ export interface VerifyChannelStateExpected { 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; @@ -803,6 +961,11 @@ export async function verifyChannelAccountState(args: { `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) @@ -854,6 +1017,251 @@ export async function verifyChannelAccountState(args: { 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}`, + ); + } +} + export async function verifyTopUpTransaction(args: { readonly amount: bigint; readonly channelId: string; @@ -862,7 +1270,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`); if (fetched.meta?.err) { @@ -879,13 +1291,19 @@ export async function verifyTopUpTransaction(args: { }[]; staticAccounts: readonly string[]; }; + 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; count += 1; total += getTopUpInstructionDataDecoder().decode(instruction.data).topUpArgs.amount; } @@ -931,9 +1349,7 @@ export async function waitForSignatureConfirmation(args: { throw new Error(`${context}: tx ${args.signature} failed on-chain: ${JSON.stringify(status.err)}`); } 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; } } @@ -1272,6 +1688,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 { From cc740a284caef3d0bfceeefdb2f98bafdc276fa1 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sat, 11 Jul 2026 05:09:25 +0300 Subject: [PATCH 10/39] test(ci): execute Rust session security guards --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd20d5988..30f0aeff9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -327,6 +327,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 + - name: Enforce Rust coverage floors (mpp + x402, line + region, aggregate + per-file) working-directory: rust run: | From 7e1826bd4ab8c3cc4cc00df02f14909328722ca1 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sat, 11 Jul 2026 10:36:45 +0300 Subject: [PATCH 11/39] fix(typescript): deduplicate canonical split validation --- .../mpp/src/server/session/on-chain.ts | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/typescript/packages/mpp/src/server/session/on-chain.ts b/typescript/packages/mpp/src/server/session/on-chain.ts index 6353e9de5..2ea3007fc 100644 --- a/typescript/packages/mpp/src/server/session/on-chain.ts +++ b/typescript/packages/mpp/src/server/session/on-chain.ts @@ -715,26 +715,6 @@ export async function verifyOpenTx(args: VerifyOpenTxArgs): Promise Date: Sat, 11 Jul 2026 10:58:55 +0300 Subject: [PATCH 12/39] fix(mpp): make session settlement retry-safe --- go/protocols/mpp/server/session_method.go | 110 ++++++++++---- .../mpp/server/session_method_branch_test.go | 10 +- .../mpp/server/session_method_test.go | 112 +++++++++++++- go/protocols/mpp/server/session_store.go | 7 + .../mpp/src/__tests__/session-server.test.ts | 99 +++++++++++++ typescript/packages/mpp/src/server/Session.ts | 138 ++++++++++-------- .../packages/mpp/src/server/session/store.ts | 2 + .../src/__tests__/mpp-session-store.test.ts | 2 + .../pay-kit/src/__tests__/mpp-session.test.ts | 4 +- .../pay-kit/src/adapters/mpp-session.ts | 3 +- 10 files changed, 394 insertions(+), 93 deletions(-) diff --git a/go/protocols/mpp/server/session_method.go b/go/protocols/mpp/server/session_method.go index 695cc536c..56e5a4cb8 100644 --- a/go/protocols/mpp/server/session_method.go +++ b/go/protocols/mpp/server/session_method.go @@ -17,6 +17,7 @@ package server import ( "context" + "errors" "fmt" "log" "strconv" @@ -292,14 +293,12 @@ 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 - } - if _, err := s.closeAndSettleChannel(context.Background(), channelID); err != nil { + if _, err := s.handleClose(context.Background(), &intents.ClosePayload{ChannelID: channelID}); err != nil { log.Printf("[solana-mpp] idle-close settle failed for %s: %v", channelID, err) } } @@ -924,59 +923,120 @@ 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. +var ( + errSettlementChannelMissing = errors.New("settlement channel missing") + errSettlementAlreadySealed = errors.New("settlement already sealed") + errSettlementAlreadyClaimed = errors.New("settlement already claimed") +) + +// 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. A failed attempt +// releases its claim so close remains re-drivable. Returns "" when the channel +// does not exist or another caller currently owns the settlement claim. func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) (string, error) { - state, err := s.core.store.GetChannel(ctx, channelID) + 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 { + return ChannelState{}, errSettlementAlreadyClaimed + } + next := *current + next.Settling = true + return next, nil + }) if err != nil { - return "", 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 state == nil { - return "", nil + + releaseClaim := func(settlementErr error) (string, error) { + // Confirmation commonly fails because ctx was canceled or timed out; + // retain its values but detach cancellation so cleanup can make the + // channel retryable. + _, releaseErr := s.core.store.UpdateChannel(context.WithoutCancel(ctx), 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 { + return *current, nil + } + next := *current + next.Settling = false + return next, nil + }) + if releaseErr != nil { + return "", errors.Join(settlementErr, fmt.Errorf("release settlement claim: %w", releaseErr)) + } + return "", settlementErr } + 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) + instructions, err := s.core.settlementInstructionsForState(state, channelID, merchant) if err != nil { - return "", err + return releaseClaim(err) } blockhash, err := s.rpc.GetLatestBlockhash(ctx, rpc.CommitmentConfirmed) if err != nil { - return "", core.WrapError(core.ErrCodeRPC, "fetch settlement blockhash", err) + return releaseClaim(core.WrapError(core.ErrCodeRPC, "fetch settlement blockhash", err)) } if blockhash == nil || blockhash.Value == nil { - return "", core.NewError(core.ErrCodeRPC, "fetch settlement blockhash: empty response") + return releaseClaim(core.NewError(core.ErrCodeRPC, "fetch settlement blockhash: empty response")) } tx, err := solana.NewTransaction(instructions, blockhash.Value.Blockhash, solana.TransactionPayer(merchant)) if err != nil { - return "", fmt.Errorf("build settlement transaction: %w", err) + return releaseClaim(fmt.Errorf("build settlement transaction: %w", err)) } if err := solanatx.SignTransaction(tx, s.signer); err != nil { - return "", fmt.Errorf("sign settlement transaction: %w", err) + return releaseClaim(fmt.Errorf("sign settlement transaction: %w", err)) } signature, err := solanatx.SendTransaction(ctx, s.rpc, tx) if err != nil { - return "", core.WrapError(core.ErrCodeRPC, "send settlement transaction", err) + return releaseClaim(core.WrapError(core.ErrCodeRPC, "send settlement transaction", err)) + } + if err := solanatx.WaitForConfirmation(ctx, s.rpc, signature); err != nil { + return releaseClaim(core.WrapError(core.ErrCodeRPC, "confirm settlement transaction", err)) } settled := signature.String() - if _, err := s.core.store.UpdateChannel(ctx, channelID, func(current *ChannelState) (ChannelState, error) { + stored, err := s.core.store.UpdateChannel(ctx, 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 + } next := *current next.Sealed = true next.SettledSignature = &settled + next.Settling = false return next, nil - }); err != nil { + }) + if err != nil { return "", 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 787df3c2d..44b7bc537 100644 --- a/go/protocols/mpp/server/session_method_branch_test.go +++ b/go/protocols/mpp/server/session_method_branch_test.go @@ -310,15 +310,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) } diff --git a/go/protocols/mpp/server/session_method_test.go b/go/protocols/mpp/server/session_method_test.go index ac4af96c4..0a02d011a 100644 --- a/go/protocols/mpp/server/session_method_test.go +++ b/go/protocols/mpp/server/session_method_test.go @@ -1772,6 +1772,31 @@ 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 +} + +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...) + } + 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() } @@ -1811,7 +1836,88 @@ func TestSessionIdleCloseSettlesOnChain(t *testing.T) { } } -func TestSessionIdleCloseWithoutSignerIsInert(t *testing.T) { +func TestExplicitCloseRacingIdleCloseBroadcastsOnce(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 explicit close's atomic settling claim and + // returns without broadcasting a competing transaction. + session.closeOnIdle(channelID) + if len(fake.Sent) != 1 { + t.Fatalf("settlement broadcasts while first close is in flight = %d, want 1", len(fake.Sent)) + } + close(fake.releaseStatus) + 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 { + t.Fatalf("failed confirmation left channel non-retryable: %+v", state) + } + + healthyRPC := testutil.NewFakeRPC() + session.rpc = healthyRPC + settled, err := session.closeAndSettleChannel(context.Background(), channelID) + if err != nil { + t.Fatalf("settlement retry: %v", err) + } + if settled == "" || 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 TestSessionIdleCloseWithoutSignerStillClosesOffChain(t *testing.T) { fake := testutil.NewFakeRPC() session := newTestSession(t, func(o *SessionOptions) { o.RPC = fake @@ -1821,7 +1927,7 @@ func TestSessionIdleCloseWithoutSignerIsInert(t *testing.T) { time.Sleep(80 * time.Millisecond) 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_store.go b/go/protocols/mpp/server/session_store.go index 8272b06b5..830ba7383 100644 --- a/go/protocols/mpp/server/session_store.go +++ b/go/protocols/mpp/server/session_store.go @@ -114,6 +114,13 @@ type ChannelState struct { // channel state without a settlement round-trips cleanly. SettledSignature *string `json:"settled_signature,omitempty"` + // Settling is a transient in-flight claim acquired atomically before this + // server builds or broadcasts a settlement transaction. It prevents an + // explicit close and the idle-close watchdog from both broadcasting for + // the same channel. It is cleared after a failed attempt or by the seal + // write after confirmation, and is intentionally not serialized. + Settling bool `json:"-"` + // Operator is the client wallet pubkey (base58) for pull-mode sessions; // nil for push sessions. Operator *string `json:"operator"` diff --git a/typescript/packages/mpp/src/__tests__/session-server.test.ts b/typescript/packages/mpp/src/__tests__/session-server.test.ts index e49b80a39..9300c1712 100644 --- a/typescript/packages/mpp/src/__tests__/session-server.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-server.test.ts @@ -1372,6 +1372,7 @@ describe('session() verify() close retry', () => { let state = await store.getChannel(channelId); expect(state?.closeRequestedAt).toBeDefined(); expect(state?.sealed).toBe(false); + expect(state?.settling).toBe(false); expect(state?.settledSignature).toBeUndefined(); // Retry succeeds and seals the channel. @@ -1383,6 +1384,7 @@ describe('session() verify() close retry', () => { expect(sends).toHaveLength(1); state = await store.getChannel(channelId); expect(state?.sealed).toBe(true); + expect(state?.settling).toBe(false); expect(state?.settledSignature).toBeDefined(); // A third close on the sealed channel rejects. @@ -1391,6 +1393,103 @@ describe('session() verify() close retry', () => { ).rejects.toThrow(/sealed/); }); + 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'; + const settleSignature = 'SettleSig11111111111111111111111111111111111111111111111111111111'; + const sends: string[] = []; + let releaseConfirmation!: () => void; + let statusRequested!: () => void; + const confirmationGate = new Promise(resolve => { + releaseConfirmation = resolve; + }); + const statusRequest = new Promise(resolve => { + statusRequested = resolve; + }); + const rpc = { + getLatestBlockhash: () => ({ + send: async () => ({ + value: { + blockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N', + lastValidBlockHeight: 0n, + }, + }), + }), + getSignatureStatuses: (sigs: readonly string[]) => ({ + send: async () => { + if (sigs.includes(settleSignature)) { + statusRequested(); + await confirmationGate; + } + return { + context: { slot: 42 }, + value: sigs.map(() => ({ confirmationStatus: 'confirmed', err: null })), + }; + }, + }), + sendTransaction: (wire: string) => ({ + send: async () => { + sends.push(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 firstClose = method.verify({ + credential: makeCred({ action: 'close', channelId }), + request: {} as never, + }); + await statusRequest; + + let state = await store.getChannel(channelId); + expect(state?.settling).toBe(true); + expect(state?.sealed).toBe(false); + expect(state?.settledSignature).toBeUndefined(); + + const secondClose = await method.verify({ + credential: makeCred({ action: 'close', channelId }), + request: {} as never, + }); + expect(secondClose.status).toBe('success'); + expect(sends).toHaveLength(1); + + releaseConfirmation(); + const firstReceipt = await firstClose; + expect(firstReceipt.status).toBe('success'); + state = await store.getChannel(channelId); + expect(state?.settling).toBe(false); + expect(state?.sealed).toBe(true); + expect(state?.settledSignature).toBe(settleSignature); + expect(sends).toHaveLength(1); + }); + test('close refuses to settle when the channel payer (refund destination) was not recorded', async () => { const store = createMemorySessionStore(); const signer = await generateKeyPairSigner(); diff --git a/typescript/packages/mpp/src/server/Session.ts b/typescript/packages/mpp/src/server/Session.ts index 83477d414..8ca9c7601 100644 --- a/typescript/packages/mpp/src/server/Session.ts +++ b/typescript/packages/mpp/src/server/Session.ts @@ -38,6 +38,7 @@ import { verifyOpenTx, verifySignatureOnlyOpenTransaction, verifyTopUpTransaction, + waitForSignatureConfirmation, } from './session/on-chain.js'; import { type ChannelState, @@ -1236,67 +1237,90 @@ interface CloseAndSettleArgs { * 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`, - ); - } + 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 || current.settling) return current; + claimed = true; + return { ...current, settling: true }; + }); + if (!claimed) return undefined; + + 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`, + ); + } - 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), + 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, }, - 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, - }); + 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, - 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; + 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, + }); + await waitForSignatureConfirmation({ + context: `settle channel ${args.channelId}`, + rpc: args.rpc, + signature: result.signature, + }); + 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, + settling: false, + }; + }); + return result; + } catch (error) { + await args.store.updateChannel(args.channelId, current => { + if (!current) throw new Error(`Channel ${args.channelId} disappeared during settle`); + return { ...current, settling: false }; + }); + throw error; + } } // ───────────────────────────────────────────────────────────────────── diff --git a/typescript/packages/mpp/src/server/session/store.ts b/typescript/packages/mpp/src/server/session/store.ts index bc7132bbf..8e8963a5a 100644 --- a/typescript/packages/mpp/src/server/session/store.ts +++ b/typescript/packages/mpp/src/server/session/store.ts @@ -81,6 +81,8 @@ export interface ChannelState { readonly salt?: bigint | undefined; /** True once the channel has been sealed on-chain. */ readonly sealed: boolean; + /** True while one server instance owns the on-chain settlement broadcast. */ + readonly settling?: boolean | undefined; /** On-chain settle_and_seal transaction signature (base58), once submitted. */ readonly settledSignature?: string | undefined; /** 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 index ce3867783..0ac7c7f59 100644 --- a/typescript/packages/pay-kit/src/__tests__/mpp-session-store.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/mpp-session-store.test.ts @@ -1,4 +1,5 @@ import { createMemorySessionStore } from '@solana/mpp/server'; +import { Store } from 'mppx'; import { describe, expect, it } from 'vitest'; import { createSessionEngine } from '../adapters/mpp-session.js'; @@ -30,6 +31,7 @@ async function configWithStore(sessionStore?: SessionStoreWithCapability) { mpp: { challengeBindingSecret: 'session-store-test-secret', sessionStore }, network: 'solana_devnet', operator: { recipient: RECIPIENT, signer: await Signer.generate() }, + replayStore: Store.memory(), }); } diff --git a/typescript/packages/pay-kit/src/__tests__/mpp-session.test.ts b/typescript/packages/pay-kit/src/__tests__/mpp-session.test.ts index b875afbdd..ec9c5c40e 100644 --- a/typescript/packages/pay-kit/src/__tests__/mpp-session.test.ts +++ b/typescript/packages/pay-kit/src/__tests__/mpp-session.test.ts @@ -43,8 +43,8 @@ describe('createSessionEngine', () => { expect(() => createSessionEngine(config, gate)).toThrow(/mpp\.sessionStore is required outside localnet/); }); - it('uses the injected store outside localnet', async () => { - const store = createMemorySessionStore(); + it('uses an explicitly durable shared injected store outside localnet', async () => { + const store = { ...createMemorySessionStore(), sessionStoreDurability: 'durable-shared' as const }; const getChannel = vi.spyOn(store, 'getChannel'); const { config, gate } = await setup({ network: 'solana_devnet', sessionStore: store }); diff --git a/typescript/packages/pay-kit/src/adapters/mpp-session.ts b/typescript/packages/pay-kit/src/adapters/mpp-session.ts index b892cbfea..5ee11509f 100644 --- a/typescript/packages/pay-kit/src/adapters/mpp-session.ts +++ b/typescript/packages/pay-kit/src/adapters/mpp-session.ts @@ -68,8 +68,7 @@ export function createSessionEngine(config: PayKitConfig, gate: Gate): SessionEn const signer = config.operator.signer.signer; const store = resolveSessionStore(config); const allowUnsafeEphemeralStoreOffLocalnet = - config.network !== 'solana_localnet' && - (config.mpp.sessionStore !== undefined || process.env.PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE === '1'); + 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 } : {}), From dd50cb7cb809ff8b96a06000f40b29a67687c698 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:11:16 +0300 Subject: [PATCH 13/39] fix(mpp): persist pending settlement state --- go/protocols/mpp/server/session_method.go | 174 +++++++++--- .../mpp/server/session_method_branch_test.go | 18 +- .../mpp/server/session_method_test.go | 93 ++++++- .../session_settlement_durability_test.go | 248 ++++++++++++++++++ go/protocols/mpp/server/session_store.go | 19 +- go/protocols/mpp/server/session_store_test.go | 24 ++ .../mpp/src/__tests__/session-server.test.ts | 122 +++++++-- .../mpp/src/__tests__/session-store.test.ts | 25 ++ typescript/packages/mpp/src/server/Session.ts | 111 +++++--- .../mpp/src/server/session/on-chain.ts | 26 +- .../packages/mpp/src/server/session/store.ts | 4 +- 11 files changed, 741 insertions(+), 123 deletions(-) create mode 100644 go/protocols/mpp/server/session_settlement_durability_test.go diff --git a/go/protocols/mpp/server/session_method.go b/go/protocols/mpp/server/session_method.go index 56e5a4cb8..c8736aca4 100644 --- a/go/protocols/mpp/server/session_method.go +++ b/go/protocols/mpp/server/session_method.go @@ -839,10 +839,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()) @@ -855,12 +856,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 @@ -929,6 +927,43 @@ var ( errSettlementAlreadyClaimed = errors.New("settlement already claimed") ) +const settlementStateWriteTimeout = 5 * time.Second + +type definiteSettlementFailure struct { + detail any +} + +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 waitForSettlementConfirmation(ctx context.Context, rpcClient solanatx.RPCClient, signature solana.Signature) error { + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + out, err := rpcClient.GetSignatureStatuses(ctx, true, signature) + 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 + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + // 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, @@ -965,11 +1000,13 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( } } - releaseClaim := func(settlementErr error) (string, error) { + releaseClaim := func(settlementErr error, clearSignature bool) (string, error) { // Confirmation commonly fails because ctx was canceled or timed out; // retain its values but detach cancellation so cleanup can make the // channel retryable. - _, releaseErr := s.core.store.UpdateChannel(context.WithoutCancel(ctx), channelID, func(current *ChannelState) (ChannelState, error) { + 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) } @@ -978,6 +1015,9 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( } next := *current next.Settling = false + if clearSignature { + next.SettledSignature = nil + } return next, nil }) if releaseErr != nil { @@ -987,44 +1027,94 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( } 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) - if err != nil { - return releaseClaim(err) - } - blockhash, err := s.rpc.GetLatestBlockhash(ctx, rpc.CommitmentConfirmed) - if err != nil { - return releaseClaim(core.WrapError(core.ErrCodeRPC, "fetch settlement blockhash", err)) - } - if blockhash == nil || blockhash.Value == nil { - return releaseClaim(core.NewError(core.ErrCodeRPC, "fetch settlement blockhash: empty response")) - } - tx, err := solana.NewTransaction(instructions, blockhash.Value.Blockhash, solana.TransactionPayer(merchant)) - if err != nil { - return releaseClaim(fmt.Errorf("build settlement transaction: %w", err)) - } - if err := solanatx.SignTransaction(tx, s.signer); err != nil { - return releaseClaim(fmt.Errorf("sign settlement transaction: %w", err)) - } - signature, err := solanatx.SendTransaction(ctx, s.rpc, tx) - if err != nil { - return releaseClaim(core.WrapError(core.ErrCodeRPC, "send settlement transaction", err)) + var signature solana.Signature + if state.SettledSignature != nil { + signature, err = solana.SignatureFromBase58(*state.SettledSignature) + if err != nil { + return releaseClaim(fmt.Errorf("invalid stored settlement signature %q: %w", *state.SettledSignature, err), false) + } + } 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 releaseClaim(err, true) + } + blockhash, err := s.rpc.GetLatestBlockhash(ctx, rpc.CommitmentConfirmed) + if err != nil { + return releaseClaim(core.WrapError(core.ErrCodeRPC, "fetch settlement blockhash", err), true) + } + if blockhash == nil || blockhash.Value == nil { + return releaseClaim(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 releaseClaim(fmt.Errorf("build settlement transaction: %w", err), true) + } + if err := solanatx.SignTransaction(tx, s.signer); err != nil { + return releaseClaim(fmt.Errorf("sign settlement transaction: %w", err), true) + } + signature, err = solanatx.SendTransaction(ctx, s.rpc, tx) + if err != nil { + return releaseClaim(core.WrapError(core.ErrCodeRPC, "send settlement transaction", err), true) + } + settled := signature.String() + 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 after settlement broadcast", channelID) + } + if current.Sealed { + return *current, nil + } + if !current.Settling { + return ChannelState{}, fmt.Errorf("channel %s lost settlement claim after 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 + return next, nil + }) + cancel() + if persistErr != nil { + // The transaction may have landed. Keep the durable claim intact so a + // caller cannot safely assume it may broadcast another transaction. + return "", fmt.Errorf("persist settlement signature after broadcast: %w", persistErr) + } + if stored.Sealed { + if stored.SettledSignature != nil { + return *stored.SettledSignature, nil + } + return settled, nil + } } - if err := solanatx.WaitForConfirmation(ctx, s.rpc, signature); err != nil { - return releaseClaim(core.WrapError(core.ErrCodeRPC, "confirm settlement transaction", err)) + + if err := waitForSettlementConfirmation(ctx, s.rpc, signature); err != nil { + var definite *definiteSettlementFailure + if errors.As(err, &definite) { + return releaseClaim(core.WrapError(core.ErrCodeRPC, "confirm settlement transaction", err), true) + } + return releaseClaim(core.WrapError(core.ErrCodeRPC, "confirm settlement transaction", err), false) } + settled := signature.String() - stored, 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 @@ -1032,7 +1122,7 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( return next, nil }) if err != nil { - return "", err + return releaseClaim(fmt.Errorf("persist confirmed settlement: %w", err), false) } if stored.SettledSignature != nil { return *stored.SettledSignature, nil diff --git a/go/protocols/mpp/server/session_method_branch_test.go b/go/protocols/mpp/server/session_method_branch_test.go index 44b7bc537..f9816bef9 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) } } diff --git a/go/protocols/mpp/server/session_method_test.go b/go/protocols/mpp/server/session_method_test.go index 0a02d011a..c8fe2f191 100644 --- a/go/protocols/mpp/server/session_method_test.go +++ b/go/protocols/mpp/server/session_method_test.go @@ -1781,6 +1781,33 @@ type blockingConfirmationRPC struct { block atomic.Bool } +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...) @@ -1898,9 +1925,10 @@ func TestSettlementConfirmationFailureReleasesClaimForRetry(t *testing.T) { t.Fatalf("confirmation failure = %v", err) } state := mustGetChannel(t, session, channelID) - if state.Sealed || state.Settling || state.SettledSignature != nil { + if state.Sealed || state.Settling || state.SettledSignature == nil { t.Fatalf("failed confirmation left channel non-retryable: %+v", state) } + pendingSignature := *state.SettledSignature healthyRPC := testutil.NewFakeRPC() session.rpc = healthyRPC @@ -1908,7 +1936,7 @@ func TestSettlementConfirmationFailureReleasesClaimForRetry(t *testing.T) { if err != nil { t.Fatalf("settlement retry: %v", err) } - if settled == "" || len(healthyRPC.Sent) != 1 { + if settled != pendingSignature || len(healthyRPC.Sent) != 0 { t.Fatalf("settlement retry = %q with %d broadcasts", settled, len(healthyRPC.Sent)) } state = mustGetChannel(t, session, channelID) @@ -1917,6 +1945,67 @@ func TestSettlementConfirmationFailureReleasesClaimForRetry(t *testing.T) { } } +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 { + 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 { + t.Fatalf("confirmed settlement was not reconciled: %+v", state) + } +} + func TestSessionIdleCloseWithoutSignerStillClosesOffChain(t *testing.T) { fake := testutil.NewFakeRPC() session := newTestSession(t, func(o *SessionOptions) { 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..0dd5151ab --- /dev/null +++ b/go/protocols/mpp/server/session_settlement_durability_test.go @@ -0,0 +1,248 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/solana-foundation/pay-kit/go/internal/testutil" +) + +// 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 + return next, nil + }) +} + +func (b *sharedJSONChannelBackend) raw(channelID string) string { + b.mu.Lock() + defer b.mu.Unlock() + return string(b.data[channelID]) +} + +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":`) { + 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) + } + if signature, err := second.closeAndSettleChannel(context.Background(), channelID); err != nil || signature != "" { + t.Fatalf("competing store settle = %q, %v", signature, err) + } + if len(baseRPC.Sent) != 1 { + t.Fatalf("shared-store broadcasts = %d, want 1", len(baseRPC.Sent)) + } + + close(blockingRPC.releaseStatus) + result := <-firstDone + if result.err != nil || result.signature == "" { + t.Fatalf("winning store settle = %q, %v", result.signature, result.err) + } + 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 TestSharedJSONStoreRetryReconfirmsPendingSignatureWithoutBroadcast(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 { + 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) != 0 { + t.Fatalf("retry signature=%q broadcasts=%d want signature=%q broadcasts=0", settled, len(retryRPC.Sent), pendingSignature) + } +} diff --git a/go/protocols/mpp/server/session_store.go b/go/protocols/mpp/server/session_store.go index 830ba7383..43d5c7fbe 100644 --- a/go/protocols/mpp/server/session_store.go +++ b/go/protocols/mpp/server/session_store.go @@ -104,22 +104,21 @@ 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 submitted + // settle-and-distribute transaction. It is persisted immediately after + // broadcast, before confirmation. When Sealed is false, retries confirm + // this same transaction instead of broadcasting another settlement. // // 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"` - // Settling is a transient in-flight claim acquired atomically before this - // server builds or broadcasts a settlement transaction. It prevents an - // explicit close and the idle-close watchdog from both broadcasting for - // the same channel. It is cleared after a failed attempt or by the seal - // write after confirmation, and is intentionally not serialized. - Settling bool `json:"-"` + // Settling is the durable in-flight claim acquired atomically before this + // server builds, broadcasts, or confirms a settlement transaction. It + // prevents server instances sharing a store from duplicating broadcasts. + // It is persisted so independent store clients observe the same claim. + Settling bool `json:"settling,omitempty"` // Operator is the client wallet pubkey (base58) for pull-mode sessions; // nil for push sessions. diff --git a/go/protocols/mpp/server/session_store_test.go b/go/protocols/mpp/server/session_store_test.go index 233ed538c..eabba633d 100644 --- a/go/protocols/mpp/server/session_store_test.go +++ b/go/protocols/mpp/server/session_store_test.go @@ -300,3 +300,27 @@ 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 + + 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"`) { + 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 { + t.Fatalf("decoded settlement state = %+v", decoded) + } +} diff --git a/typescript/packages/mpp/src/__tests__/session-server.test.ts b/typescript/packages/mpp/src/__tests__/session-server.test.ts index 9300c1712..bd070a1cb 100644 --- a/typescript/packages/mpp/src/__tests__/session-server.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-server.test.ts @@ -1304,13 +1304,14 @@ 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('an uncertain confirmation retries the persisted signature without rebroadcasting', async () => { const store = createMemorySessionStore(); const signer = await generateKeyPairSigner(); const merchant = await generateKeyPairSigner(); const channelId = '11111111111111111111111111111111'; + const settleSignature = 'SettleSig11111111111111111111111111111111111111111111111111111111'; - let sendFailures = 1; + let settlementStatusCalls = 0; const sends: string[] = []; const rpc = { getLatestBlockhash: () => ({ @@ -1322,19 +1323,20 @@ describe('session() verify() close retry', () => { }), }), getSignatureStatuses: (sigs: readonly string[]) => ({ - send: async () => ({ - context: { slot: 42 }, - value: sigs.map(() => ({ confirmationStatus: 'confirmed', err: null })), - }), + send: async () => { + if (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'; + return settleSignature; }, }), }; @@ -1365,17 +1367,20 @@ describe('session() verify() close retry', () => { request: {} as never, }); - // First close: settlement submit fails — close stays pending. + // The broadcast succeeded but confirmation is uncertain. The signature + // remains durable so a retry cannot create a second transaction. 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?.settlementPendingSignature).toBe(settleSignature); expect(state?.settling).toBe(false); expect(state?.settledSignature).toBeUndefined(); + expect(sends).toHaveLength(1); - // Retry succeeds and seals the channel. + // Retry confirms the same transaction without another broadcast. const receipt = await method.verify({ credential: makeCred({ action: 'close', channelId }), request: {} as never, @@ -1384,8 +1389,9 @@ describe('session() verify() close retry', () => { expect(sends).toHaveLength(1); state = await store.getChannel(channelId); expect(state?.sealed).toBe(true); + expect(state?.settlementPendingSignature).toBeUndefined(); expect(state?.settling).toBe(false); - expect(state?.settledSignature).toBeDefined(); + expect(state?.settledSignature).toBe(settleSignature); // A third close on the sealed channel rejects. await expect( @@ -1393,6 +1399,86 @@ describe('session() verify() close retry', () => { ).rejects.toThrow(/sealed/); }); + 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'; + const retrySignature = 'RetrySig11111111111111111111111111111111111111111111111111111111'; + const sends: string[] = []; + const rpc = { + 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); + 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('concurrent closes broadcast once and seal only after confirmation', async () => { const store = createMemorySessionStore(); const signer = await generateKeyPairSigner(); @@ -1462,16 +1548,19 @@ describe('session() verify() close retry', () => { request: {} as never, }); + const requestAbort = new AbortController(); const firstClose = method.verify({ credential: makeCred({ action: 'close', channelId }), - request: {} as never, + 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?.settling).toBe(true); expect(state?.sealed).toBe(false); expect(state?.settledSignature).toBeUndefined(); + requestAbort.abort(); const secondClose = await method.verify({ credential: makeCred({ action: 'close', channelId }), @@ -1484,6 +1573,7 @@ describe('session() verify() close retry', () => { const firstReceipt = await firstClose; expect(firstReceipt.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); diff --git a/typescript/packages/mpp/src/__tests__/session-store.test.ts b/typescript/packages/mpp/src/__tests__/session-store.test.ts index eb34789d9..6f89f89dc 100644 --- a/typescript/packages/mpp/src/__tests__/session-store.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-store.test.ts @@ -80,6 +80,31 @@ 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' })); + let claims = 0; + + await Promise.all( + Array.from({ length: 20 }, () => + store.updateChannel('c1', current => { + if (current?.settling) return current; + claims += 1; + return { ...current!, settling: true }; + }), + ), + ); + expect(claims).toBe(1); + + const pending = await store.updateChannel('c1', current => ({ + ...current!, + settlementPendingSignature: 'settle-signature', + settling: false, + })); + expect(pending.settlementPendingSignature).toBe('settle-signature'); + expect(pending.settling).toBe(false); + }); + 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 8ca9c7601..e4c42618b 100644 --- a/typescript/packages/mpp/src/server/Session.ts +++ b/typescript/packages/mpp/src/server/Session.ts @@ -29,6 +29,7 @@ import { type MultiDelegateSubmitRpc, type OpenTransactionRpc, PAYMENT_CHANNELS_PROGRAM_ID, + SignatureConfirmationError, submitInitMultiDelegateTxIfMissing, submitOpenTx, submitSettleAndDistribute, @@ -1236,7 +1237,9 @@ 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 { +async function closeAndSettleChannel( + args: CloseAndSettleArgs, +): Promise | undefined> { let claimed = false; const state = await args.store.updateChannel(args.channelId, current => { if (!current) throw new Error(`Channel ${args.channelId} not found`); @@ -1246,6 +1249,7 @@ async function closeAndSettleChannel(args: CloseAndSettleArgs): Promise 0n) { - voucher = { - authorizedSigner: state.authorizedSigner, - signed: { - data: { - channelId: args.channelId, - cumulativeAmount: state.cumulative.toString(), - expiresAt: Number(state.highestVoucherExpiresAt), + 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, }, - 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, + }); + pendingSignature = result.signature; + await args.store.updateChannel(args.channelId, current => { + if (!current) throw new Error(`Channel ${args.channelId} disappeared after settlement broadcast`); + if (!current.settling) throw new Error(`Channel ${args.channelId} lost its settlement claim`); + return { ...current, settlementPendingSignature: result.signature as unknown as string }; + }); } - 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, - }); await waitForSignatureConfirmation({ context: `settle channel ${args.channelId}`, rpc: args.rpc, - signature: result.signature, + signature: pendingSignature, }); await args.store.updateChannel(args.channelId, current => { if (!current) throw new Error(`Channel ${args.channelId} disappeared during settle`); + if (current.settlementPendingSignature !== pendingSignature) { + throw new Error(`Channel ${args.channelId} settlement signature changed during confirmation`); + } return { ...current, sealed: true, - settledSignature: result.signature as unknown as string, + settlementPendingSignature: undefined, + settledSignature: pendingSignature as unknown as string, settling: false, }; }); - return result; + return { signature: pendingSignature }; } catch (error) { + const definiteFailure = error instanceof SignatureConfirmationError && error.outcome === 'definite-failure'; await args.store.updateChannel(args.channelId, current => { if (!current) throw new Error(`Channel ${args.channelId} disappeared during settle`); - return { ...current, settling: false }; + if (current.sealed) return current; + return { + ...current, + ...(definiteFailure && current.settlementPendingSignature === pendingSignature + ? { settlementPendingSignature: undefined } + : {}), + settling: false, + }; }); throw error; } diff --git a/typescript/packages/mpp/src/server/session/on-chain.ts b/typescript/packages/mpp/src/server/session/on-chain.ts index 2ea3007fc..242583f74 100644 --- a/typescript/packages/mpp/src/server/session/on-chain.ts +++ b/typescript/packages/mpp/src/server/session/on-chain.ts @@ -1352,6 +1352,17 @@ 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'; + + constructor(message: string, outcome: 'definite-failure' | 'uncertain') { + super(message); + this.name = 'SignatureConfirmationError'; + this.outcome = outcome; + } +} + /** * Poll `getSignatureStatuses` until `signature` reaches at least * 'confirmed' commitment. Throws if the transaction failed on-chain, the @@ -1370,12 +1381,18 @@ 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', + ); } 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', + ); } const level = status.confirmationStatus; if (level === 'confirmed' || level === 'finalized') { @@ -1383,7 +1400,10 @@ export async function waitForSignatureConfirmation(args: { } } 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', + ); } await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); } diff --git a/typescript/packages/mpp/src/server/session/store.ts b/typescript/packages/mpp/src/server/session/store.ts index 8e8963a5a..e26536bed 100644 --- a/typescript/packages/mpp/src/server/session/store.ts +++ b/typescript/packages/mpp/src/server/session/store.ts @@ -81,9 +81,11 @@ export interface ChannelState { readonly salt?: bigint | undefined; /** True once the channel has been sealed on-chain. */ readonly sealed: boolean; + /** Broadcast settlement awaiting a definite confirmed/failed outcome. */ + readonly settlementPendingSignature?: string | undefined; /** True while one server instance owns the on-chain settlement broadcast. */ readonly settling?: boolean | undefined; - /** On-chain settle_and_seal transaction signature (base58), once submitted. */ + /** Confirmed on-chain settle_and_seal transaction signature (base58). */ readonly settledSignature?: string | undefined; /** * Top-up transaction signatures already applied to this channel. From 3cdd53f31dbaef9e1ae49ebd6d0abf7bcf300369 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:22:30 +0300 Subject: [PATCH 14/39] fix(mpp): recover settlement claims after crashes --- go/protocols/mpp/server/session_method.go | 62 +++++-- .../mpp/server/session_method_branch_test.go | 6 +- .../mpp/server/session_method_test.go | 43 ++++- .../session_settlement_durability_test.go | 155 +++++++++++++++++- go/protocols/mpp/server/session_store.go | 21 ++- go/protocols/mpp/server/session_store_test.go | 9 +- .../mpp/src/__tests__/session-server.test.ts | 129 ++++++++++++++- .../mpp/src/__tests__/session-store.test.ts | 31 +++- typescript/packages/mpp/src/server/Session.ts | 99 +++++++---- .../mpp/src/server/session/on-chain.ts | 22 ++- .../packages/mpp/src/server/session/store.ts | 4 + 11 files changed, 501 insertions(+), 80 deletions(-) diff --git a/go/protocols/mpp/server/session_method.go b/go/protocols/mpp/server/session_method.go index c8736aca4..37e314f38 100644 --- a/go/protocols/mpp/server/session_method.go +++ b/go/protocols/mpp/server/session_method.go @@ -17,6 +17,8 @@ package server import ( "context" + "crypto/rand" + "encoding/hex" "errors" "fmt" "log" @@ -927,7 +929,10 @@ var ( errSettlementAlreadyClaimed = errors.New("settlement already claimed") ) -const settlementStateWriteTimeout = 5 * time.Second +const ( + settlementStateWriteTimeout = 5 * time.Second + settlementClaimLease = 30 * time.Second +) type definiteSettlementFailure struct { detail any @@ -941,6 +946,14 @@ func settlementStateContext(parent context.Context) (context.Context, context.Ca 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) + } + return hex.EncodeToString(token[:]), nil +} + func waitForSettlementConfirmation(ctx context.Context, rpcClient solanatx.RPCClient, signature solana.Signature) error { ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() @@ -971,6 +984,11 @@ func waitForSettlementConfirmation(ctx context.Context, rpcClient solanatx.RPCCl // releases its claim so close remains re-drivable. Returns "" when the channel // does not exist or another caller currently owns the settlement claim. func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) (string, error) { + claimOwner, err := newSettlementClaimOwner() + if err != nil { + return "", err + } + claimedAt := time.Now() var recordedSignature string state, err := s.core.store.UpdateChannel(ctx, channelID, func(current *ChannelState) (ChannelState, error) { if current == nil { @@ -982,11 +1000,16 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( } return ChannelState{}, errSettlementAlreadySealed } - if current.Settling { - return ChannelState{}, errSettlementAlreadyClaimed + 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 { @@ -1010,11 +1033,13 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( if current == nil { return ChannelState{}, fmt.Errorf("channel %s disappeared while releasing settlement claim", channelID) } - if current.Sealed || !current.Settling { + if current.Sealed || !current.Settling || current.SettlementClaimOwner != claimOwner { return *current, nil } next := *current next.Settling = false + next.SettlementClaimOwner = "" + next.SettlementClaimedAt = 0 if clearSignature { next.SettledSignature = nil } @@ -1057,21 +1082,21 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( if err := solanatx.SignTransaction(tx, s.signer); err != nil { return releaseClaim(fmt.Errorf("sign settlement transaction: %w", err), true) } - signature, err = solanatx.SendTransaction(ctx, s.rpc, tx) - if err != nil { - return releaseClaim(core.WrapError(core.ErrCodeRPC, "send settlement transaction", err), true) + if len(tx.Signatures) == 0 || tx.Signatures[0].IsZero() { + return releaseClaim(errors.New("signed settlement transaction has no fee-payer signature"), true) } + signature = tx.Signatures[0] settled := signature.String() 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 after settlement broadcast", channelID) + return ChannelState{}, fmt.Errorf("channel %s disappeared before settlement broadcast", channelID) } if current.Sealed { return *current, nil } - if !current.Settling { - return ChannelState{}, fmt.Errorf("channel %s lost settlement claim after broadcast", channelID) + 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) @@ -1082,9 +1107,9 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( }) cancel() if persistErr != nil { - // The transaction may have landed. Keep the durable claim intact so a - // caller cannot safely assume it may broadcast another transaction. - return "", fmt.Errorf("persist settlement signature after broadcast: %w", persistErr) + // 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 { @@ -1092,6 +1117,15 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( } return settled, nil } + sentSignature, sendErr := solanatx.SendTransaction(ctx, s.rpc, tx) + if sendErr != nil { + // Submission errors can be uncertain. Retain the pre-persisted + // signature so retries only re-confirm this transaction. + return releaseClaim(core.WrapError(core.ErrCodeRPC, "send settlement transaction", sendErr), false) + } + if sentSignature != signature { + return releaseClaim(fmt.Errorf("broadcast settlement signature %s != signed signature %s", sentSignature, signature), false) + } } if err := waitForSettlementConfirmation(ctx, s.rpc, signature); err != nil { @@ -1119,6 +1153,8 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( next.Sealed = true next.SettledSignature = &settled next.Settling = false + next.SettlementClaimOwner = "" + next.SettlementClaimedAt = 0 return next, nil }) if err != nil { diff --git a/go/protocols/mpp/server/session_method_branch_test.go b/go/protocols/mpp/server/session_method_branch_test.go index f9816bef9..af97afc6e 100644 --- a/go/protocols/mpp/server/session_method_branch_test.go +++ b/go/protocols/mpp/server/session_method_branch_test.go @@ -387,8 +387,8 @@ 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 fires and submission returns an uncertain error. The signed + // signature remains pending for confirmation by a later retry. deadline := time.Now().Add(3 * time.Second) for fake.calls() == baseline { if time.Now().After(deadline) { @@ -397,7 +397,7 @@ func TestSessionIdleCloseLogsSettlementFailure(t *testing.T) { time.Sleep(5 * time.Millisecond) } state := mustGetChannel(t, session, channelID) - if state.Sealed || state.SettledSignature != nil { + if state.Sealed || state.Settling || state.SettledSignature == nil { t.Fatalf("failed settle mutated state: %+v", state) } } diff --git a/go/protocols/mpp/server/session_method_test.go b/go/protocols/mpp/server/session_method_test.go index c8fe2f191..a08d3647c 100644 --- a/go/protocols/mpp/server/session_method_test.go +++ b/go/protocols/mpp/server/session_method_test.go @@ -1157,20 +1157,33 @@ func TestSessionCloseRetryAfterFailedSettlement(t *testing.T) { t.Fatalf("voucher: %v", err) } - // First close: settlement broadcast fails; close stays pending and - // re-drivable. + // Submission fails after the signed signature was persisted. The outcome + // is uncertain, so a retry must confirm that signature before rebuilding. 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") { 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 { t.Fatalf("state after failed settle = %+v", state) } + pendingSignature := *state.SettledSignature - // Retry succeeds and seals the channel. + // A definite failed status clears the pending signature without another + // broadcast, allowing a later retry to build a fresh transaction. fake.SendErr = nil + fake.Statuses[pendingSignature] = &rpc.SignatureStatusesResult{Err: "dropped"} + 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 { + t.Fatalf("state after definite pending failure = %+v", state) + } + delete(fake.Statuses, pendingSignature) + receipt, err := verifySessionAction(t, session, intents.NewCloseAction(intents.ClosePayload{ChannelID: channelID})) if err != nil { t.Fatalf("close retry: %v", err) @@ -1186,7 +1199,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) @@ -1779,6 +1792,7 @@ type blockingConfirmationRPC struct { statusEntered chan struct{} releaseStatus chan struct{} block atomic.Bool + statusCalls atomic.Int64 } type definiteFailureRPC struct { @@ -1812,6 +1826,7 @@ func (b *blockingConfirmationRPC) GetSignatureStatuses(ctx context.Context, sear if !b.block.Load() { return b.FakeRPC.GetSignatureStatuses(ctx, searchHistory, signatures...) } + b.statusCalls.Add(1) select { case b.statusEntered <- struct{}{}: default: @@ -1891,13 +1906,25 @@ func TestExplicitCloseRacingIdleCloseBroadcastsOnce(t *testing.T) { t.Fatal("explicit close never reached confirmation") } - // The idle path observes the explicit close's atomic settling claim and - // returns without broadcasting a competing transaction. - session.closeOnIdle(channelID) + // 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) != 1 { t.Fatalf("settlement broadcasts while first close is in flight = %d, want 1", len(fake.Sent)) } close(fake.releaseStatus) + <-idleDone if err := <-explicitDone; err != nil { t.Fatalf("explicit close: %v", err) } diff --git a/go/protocols/mpp/server/session_settlement_durability_test.go b/go/protocols/mpp/server/session_settlement_durability_test.go index 0dd5151ab..50038a32f 100644 --- a/go/protocols/mpp/server/session_settlement_durability_test.go +++ b/go/protocols/mpp/server/session_settlement_durability_test.go @@ -9,6 +9,9 @@ import ( "testing" "time" + solana "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" + "github.com/solana-foundation/pay-kit/go/internal/testutil" ) @@ -133,6 +136,9 @@ func (s *sharedJSONChannelStore) MarkSealed(ctx context.Context, channelID strin } next := *current next.Sealed = true + next.Settling = false + next.SettlementClaimOwner = "" + next.SettlementClaimedAt = 0 return next, nil }) } @@ -143,6 +149,26 @@ func (b *sharedJSONChannelBackend) raw(channelID string) string { 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 + } + if state == nil || !state.Settling || state.SettlementClaimOwner == "" || state.SettlementClaimedAt == 0 || + state.SettledSignature == nil || len(tx.Signatures) == 0 || *state.SettledSignature != tx.Signatures[0].String() { + 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) +} + func TestSharedJSONStoresObserveSettlementClaimAndPendingSignature(t *testing.T) { backend, stores := newSharedJSONChannelStores(2) baseRPC := testutil.NewFakeRPC() @@ -190,8 +216,17 @@ func TestSharedJSONStoresObserveSettlementClaimAndPendingSignature(t *testing.T) if err != nil || state == nil || !state.Settling || state.SettledSignature == nil { t.Fatalf("second store client state=%+v err=%v", state, err) } - if signature, err := second.closeAndSettleChannel(context.Background(), channelID); err != nil || signature != "" { - t.Fatalf("competing store settle = %q, %v", signature, 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) != 1 { t.Fatalf("shared-store broadcasts = %d, want 1", len(baseRPC.Sent)) @@ -202,6 +237,10 @@ func TestSharedJSONStoresObserveSettlementClaimAndPendingSignature(t *testing.T) 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) @@ -246,3 +285,115 @@ func TestSharedJSONStoreRetryReconfirmsPendingSignatureWithoutBroadcast(t *testi t.Fatalf("retry signature=%q broadcasts=%d want signature=%q broadcasts=0", 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 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_store.go b/go/protocols/mpp/server/session_store.go index 43d5c7fbe..5dacab8b5 100644 --- a/go/protocols/mpp/server/session_store.go +++ b/go/protocols/mpp/server/session_store.go @@ -104,10 +104,10 @@ 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 submitted - // settle-and-distribute transaction. It is persisted immediately after - // broadcast, before confirmation. When Sealed is false, retries confirm - // this same transaction instead of broadcasting another settlement. + // 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 confirm this same + // transaction instead of broadcasting another settlement. // // An extension beyond the core channel-state shape, recorded only when // this server drives on-chain settlement. Serialized with omitempty so a @@ -120,6 +120,16 @@ type ChannelState struct { // It is persisted so independent store clients observe the same claim. Settling bool `json:"settling,omitempty"` + // SettlementClaimOwner identifies the settlement attempt that currently + // owns a signature-less 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"` @@ -397,6 +407,9 @@ func (s *MemoryChannelStore) MarkSealed(ctx context.Context, channelID string) ( } next := *current next.Sealed = true + 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 eabba633d..5cf52a81b 100644 --- a/go/protocols/mpp/server/session_store_test.go +++ b/go/protocols/mpp/server/session_store_test.go @@ -306,13 +306,17 @@ func TestChannelStatePersistsSettlementClaimAndPendingSignature(t *testing.T) { state := testChannelState("c1", 1_000) state.Settling = true state.SettledSignature = &signature + 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), `"settled_signature":"pending-settlement-signature"`) || + !strings.Contains(string(encoded), `"settlement_claim_owner":"worker-1"`) || + !strings.Contains(string(encoded), `"settlement_claimed_at":123`) { t.Fatalf("serialized settlement state = %s", encoded) } @@ -320,7 +324,8 @@ func TestChannelStatePersistsSettlementClaimAndPendingSignature(t *testing.T) { if err := json.Unmarshal(encoded, &decoded); err != nil { t.Fatalf("unmarshal channel state: %v", err) } - if !decoded.Settling || decoded.SettledSignature == nil || *decoded.SettledSignature != signature { + if !decoded.Settling || decoded.SettledSignature == nil || *decoded.SettledSignature != signature || + decoded.SettlementClaimOwner != "worker-1" || decoded.SettlementClaimedAt != 123 { t.Fatalf("decoded settlement state = %+v", decoded) } } diff --git a/typescript/packages/mpp/src/__tests__/session-server.test.ts b/typescript/packages/mpp/src/__tests__/session-server.test.ts index bd070a1cb..b3f9dc320 100644 --- a/typescript/packages/mpp/src/__tests__/session-server.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-server.test.ts @@ -5,7 +5,14 @@ // 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 { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import * as Methods from '../Methods.js'; @@ -19,6 +26,10 @@ import { encodeVoucherMessage } from '../shared/voucher.js'; const OPERATOR = '9xAXssX9j7vuK99c7cFwqbixzL3bFrzPy9PUhCtDPAYJ'; const RECIPIENT = '5fKb5cF22cFybZB1H4hLDydFhwoQy9JzKzRWaSbMkB6h'; +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). @@ -1309,7 +1320,7 @@ describe('session() verify() close retry', () => { const signer = await generateKeyPairSigner(); const merchant = await generateKeyPairSigner(); const channelId = '11111111111111111111111111111111'; - const settleSignature = 'SettleSig11111111111111111111111111111111111111111111111111111111'; + let settleSignature: string | undefined; let settlementStatusCalls = 0; const sends: string[] = []; @@ -1324,7 +1335,11 @@ describe('session() verify() close retry', () => { }), getSignatureStatuses: (sigs: readonly string[]) => ({ send: async () => { - if (sigs.includes(settleSignature) && settlementStatusCalls++ === 0) { + if ( + settleSignature !== undefined && + sigs.includes(settleSignature) && + settlementStatusCalls++ === 0 + ) { throw new Error('status RPC unavailable'); } return { @@ -1336,6 +1351,7 @@ describe('session() verify() close retry', () => { sendTransaction: (wire: string) => ({ send: async () => { sends.push(wire); + settleSignature = signedWireSignature(wire); return settleSignature; }, }), @@ -1405,7 +1421,7 @@ describe('session() verify() close retry', () => { const merchant = await generateKeyPairSigner(); const channelId = '11111111111111111111111111111111'; const failedSignature = 'FailedSig11111111111111111111111111111111111111111111111111111111'; - const retrySignature = 'RetrySig11111111111111111111111111111111111111111111111111111111'; + let retrySignature: string | undefined; const sends: string[] = []; const rpc = { getLatestBlockhash: () => ({ @@ -1429,6 +1445,7 @@ describe('session() verify() close retry', () => { sendTransaction: (wire: string) => ({ send: async () => { sends.push(wire); + retrySignature = signedWireSignature(wire); return retrySignature; }, }), @@ -1479,12 +1496,104 @@ describe('session() verify() close retry', () => { 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 = { + 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'; - const settleSignature = 'SettleSig11111111111111111111111111111111111111111111111111111111'; + let settleSignature: string | undefined; const sends: string[] = []; let releaseConfirmation!: () => void; let statusRequested!: () => void; @@ -1505,7 +1614,7 @@ describe('session() verify() close retry', () => { }), getSignatureStatuses: (sigs: readonly string[]) => ({ send: async () => { - if (sigs.includes(settleSignature)) { + if (settleSignature !== undefined && sigs.includes(settleSignature)) { statusRequested(); await confirmationGate; } @@ -1518,6 +1627,7 @@ describe('session() verify() close retry', () => { sendTransaction: (wire: string) => ({ send: async () => { sends.push(wire); + settleSignature = signedWireSignature(wire); return settleSignature; }, }), @@ -1562,16 +1672,17 @@ describe('session() verify() close retry', () => { expect(state?.settledSignature).toBeUndefined(); requestAbort.abort(); - const secondClose = await method.verify({ + const secondClose = method.verify({ credential: makeCred({ action: 'close', channelId }), request: {} as never, }); - expect(secondClose.status).toBe('success'); + await Promise.resolve(); expect(sends).toHaveLength(1); releaseConfirmation(); - const firstReceipt = await firstClose; + 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); diff --git a/typescript/packages/mpp/src/__tests__/session-store.test.ts b/typescript/packages/mpp/src/__tests__/session-store.test.ts index 6f89f89dc..6b3189437 100644 --- a/typescript/packages/mpp/src/__tests__/session-store.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-store.test.ts @@ -83,14 +83,26 @@ describe('createMemorySessionStore', () => { 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 }, () => + Array.from({ length: 20 }, (_, index) => store.updateChannel('c1', current => { - if (current?.settling) return current; + if ( + current?.settling && + current.settlementClaimExpiresAt !== undefined && + current.settlementClaimExpiresAt > now + ) { + return current; + } claims += 1; - return { ...current!, settling: true }; + return { + ...current!, + settlementClaimExpiresAt: now + 30_000n, + settlementClaimOwner: `owner-${index}`, + settling: true, + }; }), ), ); @@ -103,6 +115,19 @@ describe('createMemorySessionStore', () => { })); expect(pending.settlementPendingSignature).toBe('settle-signature'); expect(pending.settling).toBe(false); + + await store.updateChannel('c1', current => ({ + ...current!, + settlementClaimExpiresAt: 0n, + settlementPendingSignature: 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 () => { diff --git a/typescript/packages/mpp/src/server/Session.ts b/typescript/packages/mpp/src/server/Session.ts index e4c42618b..70f38303c 100644 --- a/typescript/packages/mpp/src/server/Session.ts +++ b/typescript/packages/mpp/src/server/Session.ts @@ -32,8 +32,8 @@ import { SignatureConfirmationError, submitInitMultiDelegateTxIfMissing, submitOpenTx, - submitSettleAndDistribute, type SubmitSettleAndDistributeResult, + submitSettleAndDistributeWithPreBroadcastPersistence, type TopUpTransactionRpc, verifyChannelAccountState, verifyOpenTx, @@ -54,6 +54,7 @@ import { buildAndSignWireTransaction } 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 @@ -1240,12 +1241,25 @@ interface CloseAndSettleArgs { async function closeAndSettleChannel( args: CloseAndSettleArgs, ): Promise | undefined> { + 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 || current.settling) return current; + 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, settling: true }; + return { + ...current, + settlementClaimExpiresAt: claimNow + SETTLEMENT_CLAIM_LEASE_MS, + settlementClaimOwner: claimOwner, + settling: true, + }; }); if (!claimed) return undefined; @@ -1277,39 +1291,51 @@ async function closeAndSettleChannel( }; } - 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 }; + const result = await submitSettleAndDistributeWithPreBroadcastPersistence( + { + 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, }, - signer: args.merchantSigner as unknown as TransactionSigner, - splits: args.splits ?? [], - tokenProgram: args.tokenProgram, - voucher, - }); + async prepared => { + pendingSignature = prepared.signature; + 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, + settlementPendingSignature: prepared.signature as unknown as string, + }; + }); + }, + ); pendingSignature = result.signature; - await args.store.updateChannel(args.channelId, current => { - if (!current) throw new Error(`Channel ${args.channelId} disappeared after settlement broadcast`); - if (!current.settling) throw new Error(`Channel ${args.channelId} lost its settlement claim`); - return { ...current, settlementPendingSignature: result.signature as unknown as string }; - }); } await waitForSignatureConfirmation({ @@ -1325,6 +1351,8 @@ async function closeAndSettleChannel( return { ...current, sealed: true, + settlementClaimExpiresAt: undefined, + settlementClaimOwner: undefined, settlementPendingSignature: undefined, settledSignature: pendingSignature as unknown as string, settling: false, @@ -1336,8 +1364,11 @@ async function closeAndSettleChannel( 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, ...(definiteFailure && current.settlementPendingSignature === pendingSignature ? { settlementPendingSignature: undefined } : {}), diff --git a/typescript/packages/mpp/src/server/session/on-chain.ts b/typescript/packages/mpp/src/server/session/on-chain.ts index 242583f74..ce6c091fe 100644 --- a/typescript/packages/mpp/src/server/session/on-chain.ts +++ b/typescript/packages/mpp/src/server/session/on-chain.ts @@ -1507,7 +1507,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; } @@ -1519,6 +1519,21 @@ 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); +} + +async function submitSettleAndDistributeInternal( + args: SubmitSettleAndDistributeArgs, + beforeBroadcast?: (prepared: { readonly signature: Signature; readonly wire: string }) => Promise, ): Promise { const tokenProgram = args.tokenProgram ?? (args.currency ? defaultTokenProgramForCurrency(args.currency, args.network) : undefined); @@ -1545,7 +1560,10 @@ 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 }); + await args.rpc.sendTransaction(wire, { encoding: 'base64' }).send(); return { instructions, signature }; } diff --git a/typescript/packages/mpp/src/server/session/store.ts b/typescript/packages/mpp/src/server/session/store.ts index e26536bed..c34d722d5 100644 --- a/typescript/packages/mpp/src/server/session/store.ts +++ b/typescript/packages/mpp/src/server/session/store.ts @@ -81,6 +81,10 @@ export interface ChannelState { readonly salt?: bigint | undefined; /** True once the channel has been sealed on-chain. */ readonly sealed: boolean; + /** 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; /** Broadcast settlement awaiting a definite confirmed/failed outcome. */ readonly settlementPendingSignature?: string | undefined; /** True while one server instance owns the on-chain settlement broadcast. */ From ff7b4688af0ea54e721d64e4382577bf05029683 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:34:05 +0300 Subject: [PATCH 15/39] fix(mpp): persist settlement transaction outbox --- go/protocols/mpp/server/session_method.go | 62 ++++++--- .../mpp/server/session_method_branch_test.go | 12 +- .../mpp/server/session_method_test.go | 24 ++-- .../session_settlement_durability_test.go | 104 ++++++++++++-- go/protocols/mpp/server/session_store.go | 17 ++- go/protocols/mpp/server/session_store_test.go | 4 +- .../mpp/src/__tests__/session-server.test.ts | 130 ++++++++++++++++-- .../mpp/src/__tests__/session-store.test.ts | 3 + typescript/packages/mpp/src/server/Session.ts | 24 +++- .../packages/mpp/src/server/session/store.ts | 2 + 10 files changed, 324 insertions(+), 58 deletions(-) diff --git a/go/protocols/mpp/server/session_method.go b/go/protocols/mpp/server/session_method.go index 37e314f38..3c95eb04c 100644 --- a/go/protocols/mpp/server/session_method.go +++ b/go/protocols/mpp/server/session_method.go @@ -1023,10 +1023,10 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( } } - releaseClaim := func(settlementErr error, clearSignature bool) (string, error) { - // Confirmation commonly fails because ctx was canceled or timed out; - // retain its values but detach cancellation so cleanup can make the - // channel retryable. + 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) { @@ -1040,8 +1040,9 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( next.Settling = false next.SettlementClaimOwner = "" next.SettlementClaimedAt = 0 - if clearSignature { + if clearOutbox { next.SettledSignature = nil + next.SettlementWire = "" } return next, nil }) @@ -1053,10 +1054,23 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( merchant := s.signer.PublicKey() var signature solana.Signature + var settlementTx *solana.Transaction + 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 releaseClaim(fmt.Errorf("invalid stored settlement signature %q: %w", *state.SettledSignature, err), false) + 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 @@ -1066,27 +1080,32 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( // instead when the payer was never recorded. instructions, err := s.core.settlementInstructionsForState(state, channelID, merchant) if err != nil { - return releaseClaim(err, true) + return clearAttempt(err, true) } blockhash, err := s.rpc.GetLatestBlockhash(ctx, rpc.CommitmentConfirmed) if err != nil { - return releaseClaim(core.WrapError(core.ErrCodeRPC, "fetch settlement blockhash", err), true) + return clearAttempt(core.WrapError(core.ErrCodeRPC, "fetch settlement blockhash", err), true) } if blockhash == nil || blockhash.Value == nil { - return releaseClaim(core.NewError(core.ErrCodeRPC, "fetch settlement blockhash: empty response"), true) + 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 releaseClaim(fmt.Errorf("build settlement transaction: %w", err), true) + return clearAttempt(fmt.Errorf("build settlement transaction: %w", err), true) } if err := solanatx.SignTransaction(tx, s.signer); err != nil { - return releaseClaim(fmt.Errorf("sign settlement transaction: %w", err), true) + return clearAttempt(fmt.Errorf("sign settlement transaction: %w", err), true) } if len(tx.Signatures) == 0 || tx.Signatures[0].IsZero() { - return releaseClaim(errors.New("signed settlement transaction has no fee-payer signature"), true) + 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) + } writeCtx, cancel := settlementStateContext(ctx) stored, persistErr := s.core.store.UpdateChannel(writeCtx, channelID, func(current *ChannelState) (ChannelState, error) { if current == nil { @@ -1103,6 +1122,7 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( } next := *current next.SettledSignature = &settled + next.SettlementWire = wire return next, nil }) cancel() @@ -1117,23 +1137,24 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( } return settled, nil } - sentSignature, sendErr := solanatx.SendTransaction(ctx, s.rpc, tx) + } + + if settlementTx != nil { + sentSignature, sendErr := solanatx.SendTransaction(ctx, s.rpc, settlementTx) if sendErr != nil { - // Submission errors can be uncertain. Retain the pre-persisted - // signature so retries only re-confirm this transaction. - return releaseClaim(core.WrapError(core.ErrCodeRPC, "send settlement transaction", sendErr), false) + return "", core.WrapError(core.ErrCodeRPC, "send settlement transaction", sendErr) } if sentSignature != signature { - return releaseClaim(fmt.Errorf("broadcast settlement signature %s != signed signature %s", sentSignature, signature), false) + return "", fmt.Errorf("broadcast settlement signature %s != signed signature %s", sentSignature, signature) } } if err := waitForSettlementConfirmation(ctx, s.rpc, signature); err != nil { var definite *definiteSettlementFailure if errors.As(err, &definite) { - return releaseClaim(core.WrapError(core.ErrCodeRPC, "confirm settlement transaction", err), true) + return clearAttempt(core.WrapError(core.ErrCodeRPC, "confirm settlement transaction", err), true) } - return releaseClaim(core.WrapError(core.ErrCodeRPC, "confirm settlement transaction", err), false) + return "", core.WrapError(core.ErrCodeRPC, "confirm settlement transaction", err) } settled := signature.String() @@ -1152,13 +1173,14 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( next := *current next.Sealed = true next.SettledSignature = &settled + next.SettlementWire = "" next.Settling = false next.SettlementClaimOwner = "" next.SettlementClaimedAt = 0 return next, nil }) if err != nil { - return releaseClaim(fmt.Errorf("persist confirmed settlement: %w", err), false) + return "", fmt.Errorf("persist confirmed settlement: %w", err) } if stored.SettledSignature != nil { return *stored.SettledSignature, nil diff --git a/go/protocols/mpp/server/session_method_branch_test.go b/go/protocols/mpp/server/session_method_branch_test.go index af97afc6e..b0b03ce46 100644 --- a/go/protocols/mpp/server/session_method_branch_test.go +++ b/go/protocols/mpp/server/session_method_branch_test.go @@ -390,14 +390,18 @@ func TestSessionIdleCloseLogsSettlementFailure(t *testing.T) { // The watchdog fires and submission returns an uncertain error. The signed // signature remains pending for confirmation by a later retry. 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.SettledSignature != nil && state.SettlementWire != "" { + break + } if time.Now().After(deadline) { - t.Fatal("idle-close watchdog never attempted settlement") + t.Fatalf("idle-close watchdog never persisted its outbox: %+v", state) } time.Sleep(5 * time.Millisecond) } - state := mustGetChannel(t, session, channelID) - if state.Sealed || state.Settling || state.SettledSignature == nil { + if state.Sealed || !state.Settling || state.SettledSignature == nil || state.SettlementWire == "" { t.Fatalf("failed settle mutated state: %+v", state) } } diff --git a/go/protocols/mpp/server/session_method_test.go b/go/protocols/mpp/server/session_method_test.go index a08d3647c..5fa1d99b1 100644 --- a/go/protocols/mpp/server/session_method_test.go +++ b/go/protocols/mpp/server/session_method_test.go @@ -1165,7 +1165,7 @@ func TestSessionCloseRetryAfterFailedSettlement(t *testing.T) { t.Fatalf("settlement failure error = %v", err) } state := mustGetChannel(t, session, channelID) - if state.CloseRequestedAt == nil || state.Sealed || state.Settling || state.SettledSignature == nil { + if state.CloseRequestedAt == nil || state.Sealed || !state.Settling || state.SettledSignature == nil || state.SettlementWire == "" { t.Fatalf("state after failed settle = %+v", state) } pendingSignature := *state.SettledSignature @@ -1179,7 +1179,8 @@ func TestSessionCloseRetryAfterFailedSettlement(t *testing.T) { t.Fatalf("pending settlement failure = %v", err) } state = mustGetChannel(t, session, channelID) - if state.Sealed || state.Settling || state.SettledSignature != nil { + if state.Sealed || state.Settling || state.SettledSignature != nil || state.SettlementWire != "" || + state.SettlementClaimOwner != "" || state.SettlementClaimedAt != 0 { t.Fatalf("state after definite pending failure = %+v", state) } delete(fake.Statuses, pendingSignature) @@ -1188,8 +1189,8 @@ func TestSessionCloseRetryAfterFailedSettlement(t *testing.T) { 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 { @@ -1878,7 +1879,7 @@ func TestSessionIdleCloseSettlesOnChain(t *testing.T) { } } -func TestExplicitCloseRacingIdleCloseBroadcastsOnce(t *testing.T) { +func TestExplicitCloseRacingIdleCloseReusesSettlementWire(t *testing.T) { baseRPC := testutil.NewFakeRPC() fake := &blockingConfirmationRPC{ FakeRPC: baseRPC, @@ -1920,8 +1921,8 @@ func TestExplicitCloseRacingIdleCloseBroadcastsOnce(t *testing.T) { } time.Sleep(time.Millisecond) } - if len(fake.Sent) != 1 { - t.Fatalf("settlement broadcasts while first close is in flight = %d, want 1", len(fake.Sent)) + 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 @@ -1952,7 +1953,7 @@ func TestSettlementConfirmationFailureReleasesClaimForRetry(t *testing.T) { t.Fatalf("confirmation failure = %v", err) } state := mustGetChannel(t, session, channelID) - if state.Sealed || state.Settling || state.SettledSignature == nil { + if state.Sealed || !state.Settling || state.SettledSignature == nil || state.SettlementWire == "" { t.Fatalf("failed confirmation left channel non-retryable: %+v", state) } pendingSignature := *state.SettledSignature @@ -1963,7 +1964,7 @@ func TestSettlementConfirmationFailureReleasesClaimForRetry(t *testing.T) { if err != nil { t.Fatalf("settlement retry: %v", err) } - if settled != pendingSignature || len(healthyRPC.Sent) != 0 { + if settled != pendingSignature || len(healthyRPC.Sent) != 1 { t.Fatalf("settlement retry = %q with %d broadcasts", settled, len(healthyRPC.Sent)) } state = mustGetChannel(t, session, channelID) @@ -1989,7 +1990,8 @@ func TestDefiniteSettlementFailureClearsSignatureForRetry(t *testing.T) { t.Fatalf("definite settlement failure = %v", err) } state := mustGetChannel(t, session, channelID) - if state.Sealed || state.Settling || state.SettledSignature != nil { + 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 { @@ -2028,7 +2030,7 @@ func TestConfirmedSettlementReconcilesAfterRequestCancellation(t *testing.T) { 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 { + if !state.Sealed || state.Settling || state.SettledSignature == nil || *state.SettledSignature != settled || state.SettlementWire != "" { t.Fatalf("confirmed settlement was not reconciled: %+v", state) } } diff --git a/go/protocols/mpp/server/session_settlement_durability_test.go b/go/protocols/mpp/server/session_settlement_durability_test.go index 50038a32f..9e8762f95 100644 --- a/go/protocols/mpp/server/session_settlement_durability_test.go +++ b/go/protocols/mpp/server/session_settlement_durability_test.go @@ -3,6 +3,7 @@ package server import ( "context" "encoding/json" + "errors" "fmt" "strings" "sync" @@ -13,6 +14,7 @@ import ( "github.com/gagliardetto/solana-go/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 @@ -136,6 +138,7 @@ func (s *sharedJSONChannelStore) MarkSealed(ctx context.Context, channelID strin } next := *current next.Sealed = true + next.SettlementWire = "" next.Settling = false next.SettlementClaimOwner = "" next.SettlementClaimedAt = 0 @@ -161,14 +164,33 @@ func (p *preBroadcastStateRPC) SendTransactionWithOpts(ctx context.Context, tx * 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.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 +} + +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 TestSharedJSONStoresObserveSettlementClaimAndPendingSignature(t *testing.T) { backend, stores := newSharedJSONChannelStores(2) baseRPC := testutil.NewFakeRPC() @@ -209,7 +231,8 @@ func TestSharedJSONStoresObserveSettlementClaimAndPendingSignature(t *testing.T) } raw := backend.raw(channelID) - if !strings.Contains(raw, `"settling":true`) || !strings.Contains(raw, `"settled_signature":`) { + 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) @@ -228,8 +251,19 @@ func TestSharedJSONStoresObserveSettlementClaimAndPendingSignature(t *testing.T) } time.Sleep(time.Millisecond) } - if len(baseRPC.Sent) != 1 { - t.Fatalf("shared-store broadcasts = %d, want 1", len(baseRPC.Sent)) + 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) @@ -247,7 +281,7 @@ func TestSharedJSONStoresObserveSettlementClaimAndPendingSignature(t *testing.T) } } -func TestSharedJSONStoreRetryReconfirmsPendingSignatureWithoutBroadcast(t *testing.T) { +func TestSharedJSONStoreRetryRebroadcastsExactPendingWire(t *testing.T) { _, stores := newSharedJSONChannelStores(2) baseRPC := testutil.NewFakeRPC() merchant := testutil.NewPrivateKey() @@ -270,7 +304,7 @@ func TestSharedJSONStoreRetryReconfirmsPendingSignatureWithoutBroadcast(t *testi 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 { + 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 @@ -281,8 +315,8 @@ func TestSharedJSONStoreRetryReconfirmsPendingSignatureWithoutBroadcast(t *testi if err != nil { t.Fatalf("shared-store settlement retry: %v", err) } - if settled != pendingSignature || len(retryRPC.Sent) != 0 { - t.Fatalf("retry signature=%q broadcasts=%d want signature=%q broadcasts=0", settled, len(retryRPC.Sent), pendingSignature) + if settled != pendingSignature || len(retryRPC.Sent) != 1 { + t.Fatalf("retry signature=%q submissions=%d want signature=%q submissions=1", settled, len(retryRPC.Sent), pendingSignature) } } @@ -311,6 +345,60 @@ func TestSettlementSignatureIsPersistedBeforeBroadcast(t *testing.T) { } } +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} + first.rpc = crashRPC + + if _, err := first.closeAndSettleChannel(context.Background(), 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 == "" { + 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 TestRestartReconfirmsPersistedPendingSignatureDespiteSettling(t *testing.T) { _, stores := newSharedJSONChannelStores(2) baseRPC := testutil.NewFakeRPC() diff --git a/go/protocols/mpp/server/session_store.go b/go/protocols/mpp/server/session_store.go index 5dacab8b5..7db6aa73e 100644 --- a/go/protocols/mpp/server/session_store.go +++ b/go/protocols/mpp/server/session_store.go @@ -106,18 +106,24 @@ type ChannelState struct { // 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 confirm this same - // transaction instead of broadcasting another 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"` + // Settling is the durable in-flight claim acquired atomically before this - // server builds, broadcasts, or confirms a settlement transaction. It - // prevents server instances sharing a store from duplicating broadcasts. - // It is persisted so independent store clients observe the same claim. + // 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 @@ -407,6 +413,7 @@ func (s *MemoryChannelStore) MarkSealed(ctx context.Context, channelID string) ( } next := *current next.Sealed = true + next.SettlementWire = "" next.Settling = false next.SettlementClaimOwner = "" next.SettlementClaimedAt = 0 diff --git a/go/protocols/mpp/server/session_store_test.go b/go/protocols/mpp/server/session_store_test.go index 5cf52a81b..75affc885 100644 --- a/go/protocols/mpp/server/session_store_test.go +++ b/go/protocols/mpp/server/session_store_test.go @@ -306,6 +306,7 @@ func TestChannelStatePersistsSettlementClaimAndPendingSignature(t *testing.T) { state := testChannelState("c1", 1_000) state.Settling = true state.SettledSignature = &signature + state.SettlementWire = "c2lnbmVkLXdpcmU=" state.SettlementClaimOwner = "worker-1" state.SettlementClaimedAt = 123 @@ -315,6 +316,7 @@ func TestChannelStatePersistsSettlementClaimAndPendingSignature(t *testing.T) { } 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_claim_owner":"worker-1"`) || !strings.Contains(string(encoded), `"settlement_claimed_at":123`) { t.Fatalf("serialized settlement state = %s", encoded) @@ -325,7 +327,7 @@ func TestChannelStatePersistsSettlementClaimAndPendingSignature(t *testing.T) { t.Fatalf("unmarshal channel state: %v", err) } if !decoded.Settling || decoded.SettledSignature == nil || *decoded.SettledSignature != signature || - decoded.SettlementClaimOwner != "worker-1" || decoded.SettlementClaimedAt != 123 { + decoded.SettlementWire != "c2lnbmVkLXdpcmU=" || decoded.SettlementClaimOwner != "worker-1" || decoded.SettlementClaimedAt != 123 { t.Fatalf("decoded settlement state = %+v", decoded) } } diff --git a/typescript/packages/mpp/src/__tests__/session-server.test.ts b/typescript/packages/mpp/src/__tests__/session-server.test.ts index b3f9dc320..c31901e75 100644 --- a/typescript/packages/mpp/src/__tests__/session-server.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-server.test.ts @@ -17,7 +17,7 @@ import { afterEach, 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'; @@ -1383,8 +1383,8 @@ describe('session() verify() close retry', () => { request: {} as never, }); - // The broadcast succeeded but confirmation is uncertain. The signature - // remains durable so a retry cannot create a second transaction. + // 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(/status RPC unavailable/); @@ -1392,20 +1392,24 @@ describe('session() verify() close retry', () => { expect(state?.closeRequestedAt).toBeDefined(); expect(state?.sealed).toBe(false); 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 confirms the same transaction without another broadcast. + // 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?.settlementPendingSignature).toBeUndefined(); + expect(state?.settlementPendingWire).toBeUndefined(); expect(state?.settling).toBe(false); expect(state?.settledSignature).toBe(settleSignature); @@ -1415,6 +1419,106 @@ describe('session() verify() close retry', () => { ).rejects.toThrow(/sealed/); }); + test('a restart after outbox persistence but before send rebroadcasts the stored wire', async () => { + const durableStore = createMemorySessionStore(); + const signer = await generateKeyPairSigner(); + const merchant = await generateKeyPairSigner(); + const channelId = '11111111111111111111111111111111'; + const sends: string[] = []; + let blockhashCalls = 0; + let crashBeforeSend = true; + 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: '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 createMethod = (store: SessionStore) => + session({ + cap: 1_000_000n, + currency: 'USDC', + decimals: 6, + network: 'localnet', + operator: OPERATOR, + pricing: {}, + recipient: RECIPIENT, + rpc: rpc as never, + 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(sends).toHaveLength(0); + expect(blockhashCalls).toBe(1); + + const restartedMethod = createMethod(durableStore); + const receipt = await restartedMethod.verify({ + credential: makeCred({ action: 'close', channelId }), + request: {} as never, + }); + expect(receipt.status).toBe('success'); + expect(sends).toEqual([pending?.settlementPendingWire]); + expect(blockhashCalls).toBe(1); + const sealed = await durableStore.getChannel(channelId); + expect(sealed?.sealed).toBe(true); + expect(sealed?.settledSignature).toBe(pending?.settlementPendingSignature); + 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(); @@ -1596,13 +1700,18 @@ describe('session() verify() close retry', () => { 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 = { getLatestBlockhash: () => ({ send: async () => ({ @@ -1616,6 +1725,8 @@ describe('session() verify() close retry', () => { send: async () => { if (settleSignature !== undefined && sigs.includes(settleSignature)) { statusRequested(); + statusRequests += 1; + if (statusRequests === 2) secondStatusRequested(); await confirmationGate; } return { @@ -1667,6 +1778,7 @@ describe('session() verify() close retry', () => { 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(); @@ -1676,8 +1788,9 @@ describe('session() verify() close retry', () => { credential: makeCred({ action: 'close', channelId }), request: {} as never, }); - await Promise.resolve(); - expect(sends).toHaveLength(1); + await secondStatusRequest; + expect(sends).toHaveLength(2); + expect(new Set(sends).size).toBe(1); releaseConfirmation(); const [firstReceipt, secondReceipt] = await Promise.all([firstClose, secondClose]); @@ -1688,7 +1801,8 @@ describe('session() verify() close retry', () => { expect(state?.settling).toBe(false); expect(state?.sealed).toBe(true); expect(state?.settledSignature).toBe(settleSignature); - expect(sends).toHaveLength(1); + 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 () => { diff --git a/typescript/packages/mpp/src/__tests__/session-store.test.ts b/typescript/packages/mpp/src/__tests__/session-store.test.ts index 6b3189437..8c26f109d 100644 --- a/typescript/packages/mpp/src/__tests__/session-store.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-store.test.ts @@ -111,15 +111,18 @@ describe('createMemorySessionStore', () => { const pending = await store.updateChannel('c1', current => ({ ...current!, settlementPendingSignature: 'settle-signature', + settlementPendingWire: 'signed-wire', settling: false, })); expect(pending.settlementPendingSignature).toBe('settle-signature'); + expect(pending.settlementPendingWire).toBe('signed-wire'); expect(pending.settling).toBe(false); await store.updateChannel('c1', current => ({ ...current!, settlementClaimExpiresAt: 0n, settlementPendingSignature: undefined, + settlementPendingWire: undefined, settling: true, })); const recovered = await store.updateChannel('c1', current => ({ diff --git a/typescript/packages/mpp/src/server/Session.ts b/typescript/packages/mpp/src/server/Session.ts index 70f38303c..30dda5fc9 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, @@ -1264,6 +1267,7 @@ async function closeAndSettleChannel( if (!claimed) return undefined; let pendingSignature = state.settlementPendingSignature as Signature | undefined; + 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. @@ -1321,6 +1325,7 @@ async function closeAndSettleChannel( }, async prepared => { pendingSignature = prepared.signature; + pendingWire = prepared.wire; await args.store.updateChannel(args.channelId, current => { if (!current) { throw new Error(`Channel ${args.channelId} disappeared before settlement broadcast`); @@ -1331,11 +1336,26 @@ async function closeAndSettleChannel( return { ...current, 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`); + } + await ( + args.rpc as unknown as { + sendTransaction: (wire: string, config?: unknown) => { send: () => Promise }; + } + ) + .sendTransaction(pendingWire, { encoding: 'base64' }) + .send(); } await waitForSignatureConfirmation({ @@ -1345,6 +1365,7 @@ async function closeAndSettleChannel( }); 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`); } @@ -1354,6 +1375,7 @@ async function closeAndSettleChannel( settlementClaimExpiresAt: undefined, settlementClaimOwner: undefined, settlementPendingSignature: undefined, + settlementPendingWire: undefined, settledSignature: pendingSignature as unknown as string, settling: false, }; @@ -1370,7 +1392,7 @@ async function closeAndSettleChannel( settlementClaimExpiresAt: undefined, settlementClaimOwner: undefined, ...(definiteFailure && current.settlementPendingSignature === pendingSignature - ? { settlementPendingSignature: undefined } + ? { settlementPendingSignature: undefined, settlementPendingWire: undefined } : {}), settling: false, }; diff --git a/typescript/packages/mpp/src/server/session/store.ts b/typescript/packages/mpp/src/server/session/store.ts index c34d722d5..42e463662 100644 --- a/typescript/packages/mpp/src/server/session/store.ts +++ b/typescript/packages/mpp/src/server/session/store.ts @@ -87,6 +87,8 @@ export interface ChannelState { readonly settlementClaimOwner?: string | 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; /** Confirmed on-chain settle_and_seal transaction signature (base58). */ From bdede6d2fa573f39a9c5e8e994ece706c4bf4531 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:48:51 +0300 Subject: [PATCH 16/39] fix(mpp): retire expired settlement outboxes --- go/protocols/mpp/server/session_method.go | 62 +++++++-- .../mpp/server/session_method_branch_test.go | 14 +- .../mpp/server/session_method_test.go | 26 ++-- .../session_settlement_durability_test.go | 122 +++++++++++++++++- go/protocols/mpp/server/session_store.go | 10 +- go/protocols/mpp/server/session_store_test.go | 5 +- .../mpp/src/__tests__/session-server.test.ts | 110 ++++++++++++++-- .../mpp/src/__tests__/session-store.test.ts | 3 + typescript/packages/mpp/src/server/Session.ts | 73 +++++++++-- .../mpp/src/server/session/on-chain.ts | 16 ++- .../packages/mpp/src/server/session/store.ts | 2 + .../mpp/src/server/session/wire-tx.ts | 14 +- 12 files changed, 394 insertions(+), 63 deletions(-) diff --git a/go/protocols/mpp/server/session_method.go b/go/protocols/mpp/server/session_method.go index 3c95eb04c..ce818b526 100644 --- a/go/protocols/mpp/server/session_method.go +++ b/go/protocols/mpp/server/session_method.go @@ -938,6 +938,19 @@ 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) +} + +type settlementBlockHeightRPC interface { + GetBlockHeight(context.Context, rpc.CommitmentType) (uint64, error) +} + func (e *definiteSettlementFailure) Error() string { return fmt.Sprintf("settlement transaction failed on-chain: %v", e.detail) } @@ -954,11 +967,12 @@ func newSettlementClaimOwner() (string, error) { return hex.EncodeToString(token[:]), nil } -func waitForSettlementConfirmation(ctx context.Context, rpcClient solanatx.RPCClient, signature solana.Signature) error { +func waitForSettlementConfirmation(ctx context.Context, rpcClient solanatx.RPCClient, 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 { @@ -969,6 +983,17 @@ func waitForSettlementConfirmation(ctx context.Context, rpcClient solanatx.RPCCl return nil } } + if notFound && lastValidBlockHeight != 0 { + if blockHeightRPC, ok := rpcClient.(settlementBlockHeightRPC); ok { + currentBlockHeight, heightErr := blockHeightRPC.GetBlockHeight(ctx, rpc.CommitmentConfirmed) + if heightErr == nil && currentBlockHeight > lastValidBlockHeight { + return &expiredSettlementOutbox{ + currentBlockHeight: currentBlockHeight, + lastValidHeight: lastValidBlockHeight, + } + } + } + } select { case <-ctx.Done(): return ctx.Err() @@ -980,9 +1005,10 @@ func waitForSettlementConfirmation(ctx context.Context, rpcClient solanatx.RPCCl // 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. A failed attempt -// releases its claim so close remains re-drivable. Returns "" when the channel -// does not exist or another caller currently owns the settlement claim. +// 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) { claimOwner, err := newSettlementClaimOwner() if err != nil { @@ -1043,6 +1069,7 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( if clearOutbox { next.SettledSignature = nil next.SettlementWire = "" + next.SettlementLastValidBlockHeight = 0 } return next, nil }) @@ -1055,6 +1082,7 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( 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) } @@ -1106,6 +1134,7 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( 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 { @@ -1123,6 +1152,7 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( next := *current next.SettledSignature = &settled next.SettlementWire = wire + next.SettlementLastValidBlockHeight = lastValidBlockHeight return next, nil }) cancel() @@ -1139,22 +1169,29 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( } } + var sendErr error if settlementTx != nil { - sentSignature, sendErr := solanatx.SendTransaction(ctx, s.rpc, settlementTx) - if sendErr != nil { - return "", core.WrapError(core.ErrCodeRPC, "send settlement transaction", sendErr) - } - if sentSignature != signature { - return "", fmt.Errorf("broadcast settlement signature %s != signed signature %s", sentSignature, signature) + var sentSignature solana.Signature + sentSignature, sendErr = solanatx.SendTransaction(ctx, s.rpc, settlementTx) + if sendErr == nil && sentSignature != signature { + sendErr = fmt.Errorf("broadcast settlement signature %s != signed signature %s", sentSignature, signature) } } - if err := waitForSettlementConfirmation(ctx, s.rpc, signature); err != nil { + if err := waitForSettlementConfirmation(ctx, s.rpc, signature, lastValidBlockHeight); err != nil { var definite *definiteSettlementFailure if errors.As(err, &definite) { return clearAttempt(core.WrapError(core.ErrCodeRPC, "confirm settlement transaction", err), true) } - return "", core.WrapError(core.ErrCodeRPC, "confirm settlement transaction", err) + 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() @@ -1174,6 +1211,7 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( next.Sealed = true next.SettledSignature = &settled next.SettlementWire = "" + next.SettlementLastValidBlockHeight = 0 next.Settling = false next.SettlementClaimOwner = "" next.SettlementClaimedAt = 0 diff --git a/go/protocols/mpp/server/session_method_branch_test.go b/go/protocols/mpp/server/session_method_branch_test.go index b0b03ce46..3ed9507c8 100644 --- a/go/protocols/mpp/server/session_method_branch_test.go +++ b/go/protocols/mpp/server/session_method_branch_test.go @@ -375,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() @@ -387,22 +387,22 @@ func TestSessionIdleCloseLogsSettlementFailure(t *testing.T) { _, channelID := openTrustedChannel(t, session, 1_000) baseline := fake.calls() - // The watchdog fires and submission returns an uncertain error. The signed - // signature remains pending for confirmation by a later retry. + // 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) var state *ChannelState for { state = mustGetChannel(t, session, channelID) - if fake.calls() > baseline && state.SettledSignature != nil && state.SettlementWire != "" { + if fake.calls() > baseline && state.Sealed && state.SettledSignature != nil { break } if time.Now().After(deadline) { - t.Fatalf("idle-close watchdog never persisted its outbox: %+v", state) + t.Fatalf("idle-close watchdog never reconciled confirmed signature: %+v", state) } time.Sleep(5 * time.Millisecond) } - if state.Sealed || !state.Settling || state.SettledSignature == nil || state.SettlementWire == "" { - 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 5fa1d99b1..a7874fa44 100644 --- a/go/protocols/mpp/server/session_method_test.go +++ b/go/protocols/mpp/server/session_method_test.go @@ -1157,23 +1157,26 @@ func TestSessionCloseRetryAfterFailedSettlement(t *testing.T) { t.Fatalf("voucher: %v", err) } - // Submission fails after the signed signature was persisted. The outcome - // is uncertain, so a retry must confirm that signature before rebuilding. - 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.Settling || state.SettledSignature == nil || state.SettlementWire == "" { t.Fatalf("state after failed settle = %+v", state) } - pendingSignature := *state.SettledSignature - // A definite failed status clears the pending signature without another - // broadcast, allowing a later retry to build a fresh transaction. - fake.SendErr = nil - fake.Statuses[pendingSignature] = &rpc.SignatureStatusesResult{Err: "dropped"} + // 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) @@ -1183,8 +1186,7 @@ func TestSessionCloseRetryAfterFailedSettlement(t *testing.T) { state.SettlementClaimOwner != "" || state.SettlementClaimedAt != 0 { t.Fatalf("state after definite pending failure = %+v", state) } - delete(fake.Statuses, pendingSignature) - + failureRPC.fail.Store(false) receipt, err := verifySessionAction(t, session, intents.NewCloseAction(intents.ClosePayload{ChannelID: channelID})) if err != nil { t.Fatalf("close retry: %v", err) diff --git a/go/protocols/mpp/server/session_settlement_durability_test.go b/go/protocols/mpp/server/session_settlement_durability_test.go index 9e8762f95..1cdcb14b9 100644 --- a/go/protocols/mpp/server/session_settlement_durability_test.go +++ b/go/protocols/mpp/server/session_settlement_durability_test.go @@ -139,6 +139,7 @@ func (s *sharedJSONChannelStore) MarkSealed(ctx context.Context, channelID strin next := *current next.Sealed = true next.SettlementWire = "" + next.SettlementLastValidBlockHeight = 0 next.Settling = false next.SettlementClaimOwner = "" next.SettlementClaimedAt = 0 @@ -180,6 +181,8 @@ func (p *preBroadcastStateRPC) SendTransactionWithOpts(ctx context.Context, tx * 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) { @@ -191,6 +194,32 @@ func (c *crashBeforeSendRPC) SendTransactionWithOpts(_ context.Context, tx *sola 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() @@ -360,10 +389,12 @@ func TestPreSendCrashRestartRebroadcastsExactPersistedWire(t *testing.T) { o.Signer = merchant }) _, channelID := openTrustedChannel(t, first, 1_000) - crashRPC := &crashBeforeSendRPC{FakeRPC: baseRPC} + crashRPC := &crashBeforeSendRPC{FakeRPC: baseRPC, lastValid: 100, blockHeight: 50} first.rpc = crashRPC - if _, err := first.closeAndSettleChannel(context.Background(), channelID); err == nil || + 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) } @@ -371,7 +402,8 @@ func TestPreSendCrashRestartRebroadcastsExactPersistedWire(t *testing.T) { 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 == "" { + 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 { @@ -399,6 +431,90 @@ func TestPreSendCrashRestartRebroadcastsExactPersistedWire(t *testing.T) { } } +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() diff --git a/go/protocols/mpp/server/session_store.go b/go/protocols/mpp/server/session_store.go index 7db6aa73e..b51af960f 100644 --- a/go/protocols/mpp/server/session_store.go +++ b/go/protocols/mpp/server/session_store.go @@ -120,6 +120,11 @@ type ChannelState struct { // 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 @@ -127,8 +132,8 @@ type ChannelState struct { Settling bool `json:"settling,omitempty"` // SettlementClaimOwner identifies the settlement attempt that currently - // owns a signature-less claim. Release operations compare this token so an - // older attempt cannot clear a claim taken over by another server. + // 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 @@ -414,6 +419,7 @@ 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 diff --git a/go/protocols/mpp/server/session_store_test.go b/go/protocols/mpp/server/session_store_test.go index 75affc885..7bb7ba714 100644 --- a/go/protocols/mpp/server/session_store_test.go +++ b/go/protocols/mpp/server/session_store_test.go @@ -307,6 +307,7 @@ func TestChannelStatePersistsSettlementClaimAndPendingSignature(t *testing.T) { state.Settling = true state.SettledSignature = &signature state.SettlementWire = "c2lnbmVkLXdpcmU=" + state.SettlementLastValidBlockHeight = 456 state.SettlementClaimOwner = "worker-1" state.SettlementClaimedAt = 123 @@ -317,6 +318,7 @@ func TestChannelStatePersistsSettlementClaimAndPendingSignature(t *testing.T) { 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) @@ -327,7 +329,8 @@ func TestChannelStatePersistsSettlementClaimAndPendingSignature(t *testing.T) { t.Fatalf("unmarshal channel state: %v", err) } if !decoded.Settling || decoded.SettledSignature == nil || *decoded.SettledSignature != signature || - decoded.SettlementWire != "c2lnbmVkLXdpcmU=" || decoded.SettlementClaimOwner != "worker-1" || decoded.SettlementClaimedAt != 123 { + decoded.SettlementWire != "c2lnbmVkLXdpcmU=" || decoded.SettlementLastValidBlockHeight != 456 || + decoded.SettlementClaimOwner != "worker-1" || decoded.SettlementClaimedAt != 123 { t.Fatalf("decoded settlement state = %+v", decoded) } } diff --git a/typescript/packages/mpp/src/__tests__/session-server.test.ts b/typescript/packages/mpp/src/__tests__/session-server.test.ts index c31901e75..ff37d8b13 100644 --- a/typescript/packages/mpp/src/__tests__/session-server.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-server.test.ts @@ -1315,7 +1315,7 @@ describe('session() verify() close monotonicity', () => { // ── verify() — close retry after a failed settlement ──────────────────── describe('session() verify() close retry', () => { - test('an uncertain confirmation retries the persisted signature without rebroadcasting', async () => { + test('an uncertain confirmation retries by rebroadcasting the persisted wire', async () => { const store = createMemorySessionStore(); const signer = await generateKeyPairSigner(); const merchant = await generateKeyPairSigner(); @@ -1391,6 +1391,7 @@ describe('session() verify() close retry', () => { 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); @@ -1408,6 +1409,7 @@ describe('session() verify() close retry', () => { expect(new Set(sends).size).toBe(1); state = await store.getChannel(channelId); expect(state?.sealed).toBe(true); + expect(state?.settlementPendingLastValidBlockHeight).toBeUndefined(); expect(state?.settlementPendingSignature).toBeUndefined(); expect(state?.settlementPendingWire).toBeUndefined(); expect(state?.settling).toBe(false); @@ -1419,7 +1421,74 @@ describe('session() verify() close retry', () => { ).rejects.toThrow(/sealed/); }); - test('a restart after outbox persistence but before send rebroadcasts the stored wire', async () => { + 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 = { + 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(); @@ -1427,6 +1496,7 @@ describe('session() verify() close retry', () => { const sends: string[] = []; let blockhashCalls = 0; let crashBeforeSend = true; + let phase: 'crash' | 'expired' | 'fresh' = 'crash'; const crashingStore: SessionStore = { ...durableStore, async updateChannel(id, mutator) { @@ -1449,8 +1519,11 @@ describe('session() verify() close retry', () => { blockhashCalls += 1; return { value: { - blockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N', - lastValidBlockHeight: 0n, + blockhash: + blockhashCalls === 1 + ? 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N' + : '11111111111111111111111111111111', + lastValidBlockHeight: blockhashCalls === 1 ? 100n : 200n, }, }; }, @@ -1458,12 +1531,16 @@ describe('session() verify() close retry', () => { getSignatureStatuses: (sigs: readonly string[]) => ({ send: async () => ({ context: { slot: 42 }, - value: sigs.map(() => ({ confirmationStatus: 'confirmed', err: null })), + 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); }, }), @@ -1478,6 +1555,7 @@ describe('session() verify() close retry', () => { pricing: {}, recipient: RECIPIENT, rpc: rpc as never, + settlementConfirmation: { pollIntervalMs: 1, timeoutMs: 20 }, signer: merchant, store, }); @@ -1501,20 +1579,36 @@ describe('session() verify() close retry', () => { 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).toEqual([pending?.settlementPendingWire]); - expect(blockhashCalls).toBe(1); + 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(pending?.settlementPendingSignature); + expect(sealed?.settledSignature).toBe(signedWireSignature(sends[1]!)); + expect(sealed?.settlementPendingLastValidBlockHeight).toBeUndefined(); expect(sealed?.settlementPendingSignature).toBeUndefined(); expect(sealed?.settlementPendingWire).toBeUndefined(); }); diff --git a/typescript/packages/mpp/src/__tests__/session-store.test.ts b/typescript/packages/mpp/src/__tests__/session-store.test.ts index 8c26f109d..e1fa70406 100644 --- a/typescript/packages/mpp/src/__tests__/session-store.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-store.test.ts @@ -110,17 +110,20 @@ describe('createMemorySessionStore', () => { 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, diff --git a/typescript/packages/mpp/src/server/Session.ts b/typescript/packages/mpp/src/server/Session.ts index 30dda5fc9..d82d71b4c 100644 --- a/typescript/packages/mpp/src/server/Session.ts +++ b/typescript/packages/mpp/src/server/Session.ts @@ -26,6 +26,7 @@ import type { import { normalizeSignedVoucher, verifyVoucherSignature } from '../shared/voucher.js'; import { createLifecycle, type Lifecycle } from './session/lifecycle.js'; import { + type ConfirmSignatureOptions, type GetAccountInfoRpc, isGetAccountInfoRpc, isOpenTransactionRpc, @@ -52,7 +53,7 @@ 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). @@ -141,6 +142,7 @@ export function session(parameters: session.Parameters) { minVoucherDelta, openTxSubmitter = 'client', paymentChannelPayerSigner, + settlementConfirmation, settlementWindowSeconds, } = parameters; @@ -196,6 +198,7 @@ export function session(parameters: session.Parameters) { programId: resolvedProgramId, recipient, rpc, + settlementConfirmation, splits, store, tokenProgram, @@ -362,6 +365,7 @@ export function session(parameters: session.Parameters) { programId: resolvedProgramId, recipient, rpc, + settlementConfirmation, settlementWindow: settlementWindowSeconds, splits, store, @@ -968,6 +972,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; @@ -1042,6 +1047,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, @@ -1228,6 +1234,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; @@ -1267,6 +1274,7 @@ async function closeAndSettleChannel( if (!claimed) return undefined; 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 @@ -1297,12 +1305,15 @@ async function closeAndSettleChannel( const result = await submitSettleAndDistributeWithPreBroadcastPersistence( { - buildAndSignWireTransaction: instructions => - buildAndSignWireTransaction( - args.rpc as unknown as Parameters[0], + 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, currency: args.currency, mint: args.mint, @@ -1326,6 +1337,9 @@ async function closeAndSettleChannel( 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`); @@ -1335,6 +1349,7 @@ async function closeAndSettleChannel( } return { ...current, + settlementPendingLastValidBlockHeight: pendingLastValidBlockHeight, settlementPendingSignature: prepared.signature as unknown as string, settlementPendingWire: prepared.wire, }; @@ -1349,17 +1364,23 @@ async function closeAndSettleChannel( if (wireSignature !== pendingSignature) { throw new Error(`Channel ${args.channelId} pending settlement wire does not match its signature`); } - await ( - args.rpc as unknown as { - sendTransaction: (wire: string, config?: unknown) => { send: () => Promise }; - } - ) - .sendTransaction(pendingWire, { encoding: 'base64' }) - .send(); + 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 waitForSignatureConfirmation({ context: `settle channel ${args.channelId}`, + options: args.settlementConfirmation, rpc: args.rpc, signature: pendingSignature, }); @@ -1374,6 +1395,7 @@ async function closeAndSettleChannel( sealed: true, settlementClaimExpiresAt: undefined, settlementClaimOwner: undefined, + settlementPendingLastValidBlockHeight: undefined, settlementPendingSignature: undefined, settlementPendingWire: undefined, settledSignature: pendingSignature as unknown as string, @@ -1383,6 +1405,23 @@ async function closeAndSettleChannel( 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 + ) { + const getBlockHeight = (args.rpc as unknown as { getBlockHeight?: () => { send(): Promise } }) + .getBlockHeight; + if (getBlockHeight) { + try { + expired = (await getBlockHeight.call(args.rpc).send()) > 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; @@ -1391,8 +1430,12 @@ async function closeAndSettleChannel( ...current, settlementClaimExpiresAt: undefined, settlementClaimOwner: undefined, - ...(definiteFailure && current.settlementPendingSignature === pendingSignature - ? { settlementPendingSignature: undefined, settlementPendingWire: undefined } + ...(retireOutbox && current.settlementPendingSignature === pendingSignature + ? { + settlementPendingLastValidBlockHeight: undefined, + settlementPendingSignature: undefined, + settlementPendingWire: undefined, + } : {}), settling: false, }; @@ -1619,6 +1662,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/session/on-chain.ts b/typescript/packages/mpp/src/server/session/on-chain.ts index ce6c091fe..8e6255e22 100644 --- a/typescript/packages/mpp/src/server/session/on-chain.ts +++ b/typescript/packages/mpp/src/server/session/on-chain.ts @@ -1355,11 +1355,13 @@ export interface ConfirmSignatureOptions { /** 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') { + constructor(message: string, outcome: 'definite-failure' | 'uncertain', reason: 'aborted' | 'failed' | 'timeout') { super(message); this.name = 'SignatureConfirmationError'; this.outcome = outcome; + this.reason = reason; } } @@ -1384,6 +1386,7 @@ export async function waitForSignatureConfirmation(args: { throw new SignatureConfirmationError( `${context}: aborted while waiting for tx ${args.signature} confirmation`, 'uncertain', + 'aborted', ); } const [status] = (await args.rpc.getSignatureStatuses([args.signature]).send()).value; @@ -1392,6 +1395,7 @@ export async function waitForSignatureConfirmation(args: { throw new SignatureConfirmationError( `${context}: tx ${args.signature} failed on-chain: ${JSON.stringify(status.err)}`, 'definite-failure', + 'failed', ); } const level = status.confirmationStatus; @@ -1403,6 +1407,7 @@ export async function waitForSignatureConfirmation(args: { throw new SignatureConfirmationError( `${context}: timed out waiting for tx ${args.signature} confirmation`, 'uncertain', + 'timeout', ); } await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); @@ -1528,12 +1533,13 @@ export async function submitSettleAndDistributeWithPreBroadcastPersistence( args: SubmitSettleAndDistributeArgs, beforeBroadcast: (prepared: { readonly signature: Signature; readonly wire: string }) => Promise, ): Promise { - return await submitSettleAndDistributeInternal(args, beforeBroadcast); + 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); @@ -1563,7 +1569,11 @@ async function submitSettleAndDistributeInternal( const transaction = getTransactionDecoder().decode(getBase64Codec().encode(wire)); const signature = getSignatureFromTransaction(transaction); await beforeBroadcast?.({ signature, wire }); - await args.rpc.sendTransaction(wire, { encoding: 'base64' }).send(); + try { + await args.rpc.sendTransaction(wire, { encoding: 'base64' }).send(); + } catch (error) { + if (!reconcileBroadcastError) throw error; + } return { instructions, signature }; } diff --git a/typescript/packages/mpp/src/server/session/store.ts b/typescript/packages/mpp/src/server/session/store.ts index 42e463662..31a22946a 100644 --- a/typescript/packages/mpp/src/server/session/store.ts +++ b/typescript/packages/mpp/src/server/session/store.ts @@ -85,6 +85,8 @@ export interface ChannelState { 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`. */ 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), + }; } From d716f5f553ef9b93027699985dd68541b2efbc95 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:57:11 +0300 Subject: [PATCH 17/39] fix(mpp): require settlement expiry recovery --- go/internal/testutil/fakes.go | 10 +++ go/protocols/mpp/server/session_method.go | 49 ++++++++++----- .../mpp/server/session_method_test.go | 24 +++++++ .../mpp/src/__tests__/session-server.test.ts | 63 +++++++++++++++++++ typescript/packages/mpp/src/server/Session.ts | 27 +++++--- typescript/packages/mpp/src/server/index.ts | 2 +- 6 files changed, 149 insertions(+), 26 deletions(-) diff --git a/go/internal/testutil/fakes.go b/go/internal/testutil/fakes.go index c8ae9ab9b..903c4034a 100644 --- a/go/internal/testutil/fakes.go +++ b/go/internal/testutil/fakes.go @@ -33,6 +33,8 @@ type FakeRPC struct { mu sync.Mutex Blockhash solana.Hash + BlockHeight uint64 + BlockHeightErr error MintOwners map[string]solana.PublicKey Accounts map[string]*rpc.Account Statuses map[string]*rpc.SignatureStatusesResult @@ -96,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. diff --git a/go/protocols/mpp/server/session_method.go b/go/protocols/mpp/server/session_method.go index ce818b526..3b1d5e475 100644 --- a/go/protocols/mpp/server/session_method.go +++ b/go/protocols/mpp/server/session_method.go @@ -38,6 +38,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. @@ -121,8 +129,9 @@ type SessionOptions struct { AllowUnsafeEphemeralStoreOffLocalnet bool // RPC is the optional RPC client used for on-chain checks, the - // recentBlockhash prefetch, and settlement broadcasts. Nil skips every - // on-chain check and trusts payload claims as provided. + // recentBlockhash prefetch, and settlement broadcasts. When Signer is also + // set, RPC must implement SettlementRPC. Nil skips every on-chain check and + // trusts payload claims as provided. RPC solanatx.RPCClient } @@ -237,6 +246,13 @@ func NewSession(options SessionOptions) (*Session, error) { return nil, core.NewError(core.ErrCodeInvalidConfig, sessionStoreSafetyMessage(store)) } + if options.Signer != nil && options.RPC != nil { + _, ok := options.RPC.(SettlementRPC) + if !ok { + return nil, core.NewError(core.ErrCodeInvalidConfig, + "session settlement RPC must implement GetBlockHeight") + } + } config := SessionConfig{ Operator: options.Operator, @@ -947,10 +963,6 @@ func (e *expiredSettlementOutbox) Error() string { return fmt.Sprintf("settlement transaction expired at block height %d (current %d)", e.lastValidHeight, e.currentBlockHeight) } -type settlementBlockHeightRPC interface { - GetBlockHeight(context.Context, rpc.CommitmentType) (uint64, error) -} - func (e *definiteSettlementFailure) Error() string { return fmt.Sprintf("settlement transaction failed on-chain: %v", e.detail) } @@ -967,7 +979,7 @@ func newSettlementClaimOwner() (string, error) { return hex.EncodeToString(token[:]), nil } -func waitForSettlementConfirmation(ctx context.Context, rpcClient solanatx.RPCClient, signature solana.Signature, lastValidBlockHeight uint64) error { +func waitForSettlementConfirmation(ctx context.Context, rpcClient SettlementRPC, signature solana.Signature, lastValidBlockHeight uint64) error { ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { @@ -984,13 +996,11 @@ func waitForSettlementConfirmation(ctx context.Context, rpcClient solanatx.RPCCl } } if notFound && lastValidBlockHeight != 0 { - if blockHeightRPC, ok := rpcClient.(settlementBlockHeightRPC); ok { - currentBlockHeight, heightErr := blockHeightRPC.GetBlockHeight(ctx, rpc.CommitmentConfirmed) - if heightErr == nil && currentBlockHeight > lastValidBlockHeight { - return &expiredSettlementOutbox{ - currentBlockHeight: currentBlockHeight, - lastValidHeight: lastValidBlockHeight, - } + currentBlockHeight, heightErr := rpcClient.GetBlockHeight(ctx, rpc.CommitmentConfirmed) + if heightErr == nil && currentBlockHeight > lastValidBlockHeight { + return &expiredSettlementOutbox{ + currentBlockHeight: currentBlockHeight, + lastValidHeight: lastValidBlockHeight, } } } @@ -1010,6 +1020,11 @@ func waitForSettlementConfirmation(ctx context.Context, rpcClient solanatx.RPCCl // 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 @@ -1110,7 +1125,7 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( if err != nil { return clearAttempt(err, true) } - blockhash, err := s.rpc.GetLatestBlockhash(ctx, rpc.CommitmentConfirmed) + blockhash, err := settlementRPC.GetLatestBlockhash(ctx, rpc.CommitmentConfirmed) if err != nil { return clearAttempt(core.WrapError(core.ErrCodeRPC, "fetch settlement blockhash", err), true) } @@ -1172,13 +1187,13 @@ func (s *Session) closeAndSettleChannel(ctx context.Context, channelID string) ( var sendErr error if settlementTx != nil { var sentSignature solana.Signature - sentSignature, sendErr = solanatx.SendTransaction(ctx, s.rpc, settlementTx) + sentSignature, sendErr = solanatx.SendTransaction(ctx, settlementRPC, settlementTx) if sendErr == nil && sentSignature != signature { sendErr = fmt.Errorf("broadcast settlement signature %s != signed signature %s", sentSignature, signature) } } - if err := waitForSettlementConfirmation(ctx, s.rpc, signature, lastValidBlockHeight); err != nil { + 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) diff --git a/go/protocols/mpp/server/session_method_test.go b/go/protocols/mpp/server/session_method_test.go index a7874fa44..1780fa16f 100644 --- a/go/protocols/mpp/server/session_method_test.go +++ b/go/protocols/mpp/server/session_method_test.go @@ -33,6 +33,12 @@ import ( const sessionMethodSecret = "session-method-secret" +type rpcWithoutBlockHeight struct { + solanatx.RPCClient +} + +var _ SettlementRPC = (*testutil.FakeRPC)(nil) + // confirmedSignature returns a base58 signature string registered as // confirmed on the fake RPC. func confirmedSignature(fill byte) string { @@ -348,6 +354,24 @@ 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()} + 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) + } + + 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) { diff --git a/typescript/packages/mpp/src/__tests__/session-server.test.ts b/typescript/packages/mpp/src/__tests__/session-server.test.ts index ff37d8b13..1b904d901 100644 --- a/typescript/packages/mpp/src/__tests__/session-server.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-server.test.ts @@ -1315,6 +1315,63 @@ describe('session() verify() close monotonicity', () => { // ── verify() — close retry after a failed settlement ──────────────────── describe('session() verify() close retry', () => { + test('settlement fails clearly before outbox creation when getBlockHeight is unavailable', async () => { + const store = createMemorySessionStore(); + const signer = await generateKeyPairSigner(); + const merchant = await generateKeyPairSigner(); + const channelId = '11111111111111111111111111111111'; + 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' }), + }; + 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, + }); + + await expect( + method.verify({ credential: makeCred({ action: 'close', channelId }), request: {} as never }), + ).rejects.toThrow(/requires rpc\.getBlockHeight/); + const state = await store.getChannel(channelId); + expect(state?.settling).toBeUndefined(); + expect(state?.settlementClaimOwner).toBeUndefined(); + expect(state?.settlementPendingSignature).toBeUndefined(); + expect(state?.settlementPendingWire).toBeUndefined(); + }); + test('an uncertain confirmation retries by rebroadcasting the persisted wire', async () => { const store = createMemorySessionStore(); const signer = await generateKeyPairSigner(); @@ -1325,6 +1382,7 @@ describe('session() verify() close retry', () => { let settlementStatusCalls = 0; const sends: string[] = []; const rpc = { + getBlockHeight: () => ({ send: async () => 0n }), getLatestBlockhash: () => ({ send: async () => ({ value: { @@ -1428,6 +1486,7 @@ describe('session() verify() close retry', () => { const channelId = '11111111111111111111111111111111'; const sends: string[] = []; const rpc = { + getBlockHeight: () => ({ send: async () => 0n }), getLatestBlockhash: () => ({ send: async () => ({ value: { @@ -1622,6 +1681,7 @@ describe('session() verify() close retry', () => { let retrySignature: string | undefined; const sends: string[] = []; const rpc = { + getBlockHeight: () => ({ send: async () => 0n }), getLatestBlockhash: () => ({ send: async () => ({ value: { @@ -1703,6 +1763,7 @@ describe('session() verify() close retry', () => { const pendingSignature = 'PendingSig1111111111111111111111111111111111111111111111111111111'; const sends: string[] = []; const rpc = { + getBlockHeight: () => ({ send: async () => 0n }), getLatestBlockhash: () => ({ send: async () => ({ value: { @@ -1807,6 +1868,7 @@ describe('session() verify() close retry', () => { secondStatusRequested = resolve; }); const rpc = { + getBlockHeight: () => ({ send: async () => 0n }), getLatestBlockhash: () => ({ send: async () => ({ value: { @@ -1905,6 +1967,7 @@ describe('session() verify() close retry', () => { const merchant = await generateKeyPairSigner(); const channelId = '11111111111111111111111111111111'; const rpc = { + getBlockHeight: () => ({ send: async () => 0n }), getLatestBlockhash: () => ({ send: async () => ({ value: { blockhash: 'EkSnNWid2cvwEVnVx9aBqawnmiCNiDgp3gUdkDPTKN1N', lastValidBlockHeight: 0n }, diff --git a/typescript/packages/mpp/src/server/Session.ts b/typescript/packages/mpp/src/server/Session.ts index d82d71b4c..26481d3b3 100644 --- a/typescript/packages/mpp/src/server/Session.ts +++ b/typescript/packages/mpp/src/server/Session.ts @@ -1251,6 +1251,7 @@ interface CloseAndSettleArgs { async function closeAndSettleChannel( args: CloseAndSettleArgs, ): Promise | undefined> { + requireSettlementRpc(args.rpc); const claimOwner = crypto.randomUUID(); const claimNow = BigInt(Date.now()); let claimed = false; @@ -1411,14 +1412,11 @@ async function closeAndSettleChannel( error.reason === 'timeout' && pendingLastValidBlockHeight !== undefined ) { - const getBlockHeight = (args.rpc as unknown as { getBlockHeight?: () => { send(): Promise } }) - .getBlockHeight; - if (getBlockHeight) { - try { - expired = (await getBlockHeight.call(args.rpc).send()) > pendingLastValidBlockHeight; - } catch { - // Failure to prove expiry is uncertainty; retain the outbox. - } + 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; @@ -1513,6 +1511,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); } @@ -1578,6 +1582,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; diff --git a/typescript/packages/mpp/src/server/index.ts b/typescript/packages/mpp/src/server/index.ts index 70e05e13b..357e5f660 100644 --- a/typescript/packages/mpp/src/server/index.ts +++ b/typescript/packages/mpp/src/server/index.ts @@ -1,7 +1,7 @@ export * from '../constants.js'; export { type ChallengeRequest, charge, verifyChargeTransaction } from './Charge.js'; export { solana } from './Methods.js'; -export { type RpcLike, session, type SubmitOpenRpc, type VerifyOpenRpc } from './Session.js'; +export { type RpcLike, session, type SettlementRpc, type SubmitOpenRpc, type VerifyOpenRpc } from './Session.js'; export { type ChannelMutator, type ChannelState, From bf2076eebe5e1c95d59aa85743b299d2670899b5 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:02:03 +0300 Subject: [PATCH 18/39] fix(typescript): validate settlement rpc at startup --- .../__tests__/session-server-on-chain.test.ts | 1 + .../mpp/src/__tests__/session-server.test.ts | 65 +++++++++---------- typescript/packages/mpp/src/server/Session.ts | 3 + 3 files changed, 35 insertions(+), 34 deletions(-) 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 b5328d7e8..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 @@ -611,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 1b904d901..e5a57023d 100644 --- a/typescript/packages/mpp/src/__tests__/session-server.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-server.test.ts @@ -1315,11 +1315,9 @@ describe('session() verify() close monotonicity', () => { // ── verify() — close retry after a failed settlement ──────────────────── describe('session() verify() close retry', () => { - test('settlement fails clearly before outbox creation when getBlockHeight is unavailable', async () => { + test('construction rejects signer-driven settlement without getBlockHeight before store or lifecycle use', async () => { const store = createMemorySessionStore(); - const signer = await generateKeyPairSigner(); const merchant = await generateKeyPairSigner(); - const channelId = '11111111111111111111111111111111'; const rpc = { getLatestBlockhash: () => ({ send: async () => ({ @@ -1337,39 +1335,38 @@ describe('session() verify() close retry', () => { }), sendTransaction: () => ({ send: async () => 'unused' }), }; - 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', + + 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, }), - request: {} as never, - }); + ).toThrow(/requires rpc\.getBlockHeight/); + expect(await store.listChannels()).toEqual([]); - await expect( - method.verify({ credential: makeCred({ action: 'close', channelId }), request: {} as never }), - ).rejects.toThrow(/requires rpc\.getBlockHeight/); - const state = await store.getChannel(channelId); - expect(state?.settling).toBeUndefined(); - expect(state?.settlementClaimOwner).toBeUndefined(); - expect(state?.settlementPendingSignature).toBeUndefined(); - expect(state?.settlementPendingWire).toBeUndefined(); + 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 () => { diff --git a/typescript/packages/mpp/src/server/Session.ts b/typescript/packages/mpp/src/server/Session.ts index 26481d3b3..06bb3b910 100644 --- a/typescript/packages/mpp/src/server/Session.ts +++ b/typescript/packages/mpp/src/server/Session.ts @@ -152,6 +152,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()'); } From 15d92d70d9c1a3dd1f8e08ec364e81dc9dbd72ec Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:25:31 +0300 Subject: [PATCH 19/39] fix(go): simplify verified open state --- go/protocols/mpp/server/session_method.go | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/go/protocols/mpp/server/session_method.go b/go/protocols/mpp/server/session_method.go index 3b1d5e475..e94d55598 100644 --- a/go/protocols/mpp/server/session_method.go +++ b/go/protocols/mpp/server/session_method.go @@ -542,7 +542,7 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload, openSlot := openSlotFromPayload(payload) var salt uint64 var openSignature string - var verifiedOpen *VerifyOpenTxResult + var verifiedOpen VerifyOpenTxResult switch { case hasTransaction: @@ -589,7 +589,7 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload, salt = preVerified.Salt signature = existing.OpenSignature openSignature = existing.OpenSignature - verifiedOpen = &preVerified + verifiedOpen = preVerified } else { submitted, err := SubmitOpenTx(ctx, expected, payload, s.payerSigner, s.rpc) if err != nil { @@ -602,7 +602,7 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload, salt = submitted.Salt signature = submitted.Signature openSignature = submitted.Signature - verifiedOpen = &submitted.VerifyOpenTxResult + verifiedOpen = submitted.VerifyOpenTxResult } } else { verified, err := VerifyOpenTx(ctx, expected, payload, s.rpc) @@ -612,7 +612,7 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload, if err := validateAssertedOpenDeposit(payload, verified.Deposit); err != nil { return "", err } - verifiedOpen = &verified + verifiedOpen = verified channelID = verified.ChannelID deposit = verified.Deposit channelPayer = verified.Payer @@ -649,10 +649,8 @@ func (s *Session) handleOpen(ctx context.Context, payload *intents.OpenPayload, if err != nil { return "", err } - if verifiedOpen != nil { - if err := validateBoundOpenChannel(bound, verifiedOpen, s.cap); 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 From dbc5e2a929eadc419da03a991ff3c00f0fbcbd89 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:37:03 +0300 Subject: [PATCH 20/39] fix(mpp): satisfy session static analysis --- typescript/packages/mpp/src/server/Session.ts | 12 ++++++------ .../packages/mpp/src/server/session/on-chain.ts | 8 ++++---- typescript/packages/mpp/src/server/session/store.ts | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/typescript/packages/mpp/src/server/Session.ts b/typescript/packages/mpp/src/server/Session.ts index 06bb3b910..8c8bdcac4 100644 --- a/typescript/packages/mpp/src/server/Session.ts +++ b/typescript/packages/mpp/src/server/Session.ts @@ -299,6 +299,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, @@ -312,7 +313,6 @@ export function session(parameters: session.Parameters) { pullVoucherStrategy, recipient, rpc, - gracePeriod: sessionGracePeriod, splits, store, tokenProgram, @@ -341,6 +341,7 @@ export function session(parameters: session.Parameters) { cap, challengeId: cred.challenge.id, externalId: cred.challenge.request.externalId, + gracePeriod: sessionGracePeriod, lifecycle: lifecycleRef.value, mint: resolvedMint, network, @@ -349,7 +350,6 @@ export function session(parameters: session.Parameters) { programId: resolvedProgramId, recipient, rpc, - gracePeriod: sessionGracePeriod, splits, store, }); @@ -460,6 +460,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; @@ -474,7 +475,6 @@ interface HandleOpenArgs { readonly pullVoucherStrategy: SessionPullVoucherStrategy | undefined; readonly recipient: string; readonly rpc: RpcLike | undefined; - readonly gracePeriod: number; readonly splits: readonly SessionSplit[] | undefined; readonly store: SessionStore; readonly tokenProgram: string; @@ -659,8 +659,8 @@ async function handleOpen(args: HandleOpenArgs): Promise { maxCap: args.cap, mint: args.mint, network: args.network, - operator: args.operator, openSlot, + operator: args.operator, programId: args.programId.toString(), recipient: args.recipient, splits: args.splits, @@ -847,6 +847,7 @@ 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; @@ -860,7 +861,6 @@ interface HandleTopUpArgs { readonly programId: Address; readonly recipient: string; readonly rpc: RpcLike | undefined; - readonly gracePeriod: number; readonly splits: readonly SessionSplit[] | undefined; readonly store: SessionStore; } @@ -1397,12 +1397,12 @@ async function closeAndSettleChannel( return { ...current, sealed: true, + settledSignature: pendingSignature as unknown as string, settlementClaimExpiresAt: undefined, settlementClaimOwner: undefined, settlementPendingLastValidBlockHeight: undefined, settlementPendingSignature: undefined, settlementPendingWire: undefined, - settledSignature: pendingSignature as unknown as string, settling: false, }; }); diff --git a/typescript/packages/mpp/src/server/session/on-chain.ts b/typescript/packages/mpp/src/server/session/on-chain.ts index 8e6255e22..158e517f0 100644 --- a/typescript/packages/mpp/src/server/session/on-chain.ts +++ b/typescript/packages/mpp/src/server/session/on-chain.ts @@ -48,13 +48,13 @@ import { } from '../../constants.js'; import { type Channel, getChannelDecoder } from '../../generated/payment-channels/accounts/channel.js'; import { getDistributeInstruction } from '../../generated/payment-channels/instructions/distribute.js'; -import { getReclaimInstruction } from '../../generated/payment-channels/instructions/reclaim.js'; -import { getSettleAndSealInstruction } from '../../generated/payment-channels/instructions/settleAndSeal.js'; +import type { OpenInstructionData } from '../../generated/payment-channels/instructions/open.js'; import { getOpenInstructionDataDecoder, getOpenInstructionDataEncoder, } from '../../generated/payment-channels/instructions/open.js'; -import type { OpenInstructionData } 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 { getTopUpInstruction, getTopUpInstructionDataDecoder, @@ -441,9 +441,9 @@ export function buildReclaimInstruction(args: ReclaimBuildArgs): ServerInstructi export interface VerifyOpenTxExpected { readonly authorizedSigner: string; readonly currency: string; - readonly maxCap: bigint; /** 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; readonly network?: string | undefined; diff --git a/typescript/packages/mpp/src/server/session/store.ts b/typescript/packages/mpp/src/server/session/store.ts index 31a22946a..633ae7716 100644 --- a/typescript/packages/mpp/src/server/session/store.ts +++ b/typescript/packages/mpp/src/server/session/store.ts @@ -81,6 +81,8 @@ export interface ChannelState { readonly salt?: bigint | undefined; /** True once the channel has been sealed on-chain. */ readonly sealed: boolean; + /** 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. */ @@ -93,8 +95,6 @@ export interface ChannelState { readonly settlementPendingWire?: string | undefined; /** True while one server instance owns the on-chain settlement broadcast. */ readonly settling?: boolean | undefined; - /** Confirmed on-chain settle_and_seal transaction signature (base58). */ - readonly settledSignature?: string | undefined; /** * Top-up transaction signatures already applied to this channel. * Kept in the channel record so replay rejection and the deposit increase From e201d6a230aebef92b5ff3b1b3f5fde2d6181234 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:49:42 +0300 Subject: [PATCH 21/39] fix(mpp): reject unusable settlement clients --- go/protocols/mpp/server/session_method.go | 40 ++++++++++++++---- .../mpp/server/session_method_test.go | 42 ++++++++++++++++++- .../crates/kit/src/mpp/protocol/core/types.rs | 2 + 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/go/protocols/mpp/server/session_method.go b/go/protocols/mpp/server/session_method.go index e94d55598..7d7f379de 100644 --- a/go/protocols/mpp/server/session_method.go +++ b/go/protocols/mpp/server/session_method.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" "log" + "reflect" "strconv" "time" @@ -111,8 +112,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 @@ -128,10 +129,10 @@ type SessionOptions struct { // memory store outside localnet. Unsafe; intended only for explicit dev use. AllowUnsafeEphemeralStoreOffLocalnet bool - // RPC is the optional RPC client used for on-chain checks, the - // recentBlockhash prefetch, and settlement broadcasts. When Signer is also - // set, RPC must implement SettlementRPC. Nil skips every on-chain check and - // trusts payload claims as provided. + // RPC is the optional RPC client used for on-chain checks and recentBlockhash + // prefetch. It must implement SettlementRPC when Signer is set; without a + // signer, nil skips every on-chain check and trusts payload claims as + // provided. RPC solanatx.RPCClient } @@ -182,6 +183,19 @@ type Session struct { rpc solanatx.RPCClient } +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 { @@ -246,7 +260,19 @@ func NewSession(options SessionOptions) (*Session, error) { return nil, core.NewError(core.ErrCodeInvalidConfig, sessionStoreSafetyMessage(store)) } - if options.Signer != nil && options.RPC != nil { + 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, diff --git a/go/protocols/mpp/server/session_method_test.go b/go/protocols/mpp/server/session_method_test.go index 1780fa16f..eaf6b1821 100644 --- a/go/protocols/mpp/server/session_method_test.go +++ b/go/protocols/mpp/server/session_method_test.go @@ -37,6 +37,13 @@ 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 @@ -356,6 +363,29 @@ func TestNewSessionValidation(t *testing.T) { } 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() @@ -364,10 +394,20 @@ func TestNewSessionValidation(t *testing.T) { 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) + session, err = NewSession(verificationOnly) if err != nil { t.Fatalf("verification-only legacy RPC: %v", err) } 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] From 3f0ba55662afb814fb7ad2705fa1898675a287b3 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:42:53 +0300 Subject: [PATCH 22/39] fix(python): verify session transactions from wire --- python/src/solana_pay_kit/_paycore/rpc.py | 15 ++-- .../protocols/mpp/server/session_onchain.py | 70 +++++++++++-------- python/tests/test_rpc_methods.py | 21 ++++++ python/tests/test_session_onchain.py | 46 ++++++++++++ 4 files changed, 117 insertions(+), 35 deletions(-) diff --git a/python/src/solana_pay_kit/_paycore/rpc.py b/python/src/solana_pay_kit/_paycore/rpc.py index d8bee74fb..b8bca8ea5 100644 --- a/python/src/solana_pay_kit/_paycore/rpc.py +++ b/python/src/solana_pay_kit/_paycore/rpc.py @@ -191,15 +191,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_onchain.py b/python/src/solana_pay_kit/protocols/mpp/server/session_onchain.py index 1abcfc29a..6a7581f09 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 @@ -680,6 +680,19 @@ def parse_account(slot: int, label: str) -> Pubkey: ) +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, @@ -697,7 +710,7 @@ async def _fetch_and_verify_signature_only_open( pending: Any = get_transaction( payload.signature, commitment="confirmed", - encoding="jsonParsed", + encoding="base64", max_supported_transaction_version=0, ) response: Any = await pending @@ -707,17 +720,8 @@ async def _fetch_and_verify_signature_only_open( if transaction is None: raise PaymentError("open transaction not found or not yet confirmed", code="transaction-not-found") - transaction_value: Any = transaction.get("transaction") - if ( - isinstance(transaction_value, list) - and len(transaction_value) >= 2 - and transaction_value[1] == "base64" - and isinstance(transaction_value[0], str) - ): - wire_payload = replace(payload, transaction=transaction_value[0]) - structural = await verify_open_tx(expected, wire_payload, None) - else: - structural = _confirmed_open_transaction_json(expected, payload, transaction) + wire_payload = replace(payload, transaction=_confirmed_transaction_wire(transaction, "open")) + structural = await verify_open_tx(expected, wire_payload, None) return confirmed_slot, structural @@ -1156,14 +1160,19 @@ async def verifier(payload: TopUpPayload, current: ChannelState) -> None: pending: Any = get_transaction( payload.signature, commitment="confirmed", - encoding="jsonParsed", + encoding="base64", max_supported_transaction_version=0, ) response: Any = await pending transaction = _transaction_dict(response) if transaction is None: raise PaymentError("top-up transaction not found or not yet confirmed", code="transaction-not-found") - _verify_confirmed_top_up(transaction, payload, current, config.program_id) + _verify_confirmed_top_up( + _confirmed_transaction_wire(transaction, "top-up"), + payload, + current, + config.program_id, + ) channel = await _fetch_and_validate_channel( rpc_client, payload.channel_id, @@ -1192,27 +1201,26 @@ async def verifier(payload: TopUpPayload, current: ChannelState) -> None: def _verify_confirmed_top_up( - transaction: dict[str, Any], + transaction_b64: str, payload: TopUpPayload, state: ChannelState, configured_program_id: Pubkey | str | None, ) -> None: program_id = str(PROGRAM_ID if configured_program_id is None else configured_program_id) - meta = transaction.get("meta") - if not isinstance(meta, dict) or meta.get("err") is not None: - raise PaymentError("top-up transaction failed on-chain", code="transaction-failed") - message = (transaction.get("transaction") or {}).get("message") - instructions = message.get("instructions") if isinstance(message, dict) else None - if not isinstance(instructions, list): - raise PaymentError("confirmed top-up transaction has no instructions", code="invalid-payload") - matches: list[dict[str, Any]] = [] + try: + account_keys, instructions, signatures = _decode_transaction(transaction_b64) + except PaymentError: + raise + except Exception as exc: + raise PaymentError(f"decode top-up transaction: {exc}", code="invalid-payload") from exc + if not signatures or signatures[0] != payload.signature: + raise PaymentError("top-up payload signature does not match transaction", code="invalid-payload") + matches: list[Any] = [] for instruction in instructions: - if not isinstance(instruction, dict) or instruction.get("programId") != program_id: - continue - raw = instruction.get("data") - if not isinstance(raw, str): + program_index = int(instruction.program_id_index) + if program_index >= len(account_keys) or account_keys[program_index] != program_id: continue - decoded = _base58_decode(raw) + decoded = bytes(instruction.data) if decoded and decoded[0] == _TOP_UP_INSTRUCTION_DISCRIMINATOR: matches.append(instruction) if len(matches) != 1: @@ -1221,10 +1229,10 @@ def _verify_confirmed_top_up( code="invalid-payload", ) instruction = matches[0] - accounts = instruction.get("accounts") - if not isinstance(accounts, list) or len(accounts) < 2 or accounts[1] != payload.channel_id: + accounts = [int(index) for index in instruction.accounts] + if len(accounts) < 2 or accounts[1] >= len(account_keys) or account_keys[accounts[1]] != payload.channel_id: raise PaymentError("top-up instruction channel does not match the session", code="invalid-payload") - raw_data = _base58_decode(instruction["data"]) + raw_data = bytes(instruction.data) decoded_args = TopUpArgs.from_decoded(TopUpArgs.layout.parse(raw_data[1:])) if TopUpArgs.layout.build(decoded_args.to_encodable()) != raw_data[1:]: raise PaymentError("top-up instruction has trailing data", code="invalid-payload") 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 46772e637..eaa660223 100644 --- a/python/tests/test_session_onchain.py +++ b/python/tests/test_session_onchain.py @@ -35,7 +35,9 @@ PROGRAM_ID, Distribution, OpenChannelParams, + TopUpParams, build_open_instruction, + build_top_up_instruction, find_channel_pda, ) from solana_pay_kit.protocols.mpp.intents.session import OpenPayload, TopUpPayload @@ -82,8 +84,10 @@ def __init__(self) -> None: self.statuses: dict[str, dict | None] = {} self.accounts: dict[str, tuple[bytes, str] | None] = {} self.transaction: dict | None = None + self.transaction_kwargs: dict[str, Any] | None = None async def get_transaction(self, signature: str, **kwargs): # noqa: ANN003, ANN201 + self.transaction_kwargs = kwargs return self.transaction async def get_account_info( @@ -725,6 +729,21 @@ async def test_new_open_tx_verifier_without_transaction_confirms_signature() -> assert result.salt == OPEN_FIXTURE_SALT assert result.open_slot == OPEN_FIXTURE_SLOT assert result.payer == str(fixture.payer.pubkey()) + assert fake_rpc.transaction_kwargs is not None + assert fake_rpc.transaction_kwargs["encoding"] == "base64" + + +async def test_signature_only_open_rejects_parsed_rpc_transaction() -> None: + fixture = build_open_tx_fixture(v0=False) + fixture.payload.transaction = None + fake_rpc = _FakeRpc() + fake_rpc.transaction = {"meta": {"err": None}, "transaction": {"message": {"instructions": []}}} + verifier = new_open_tx_verifier(_open_session_config(fixture), fake_rpc) + + with pytest.raises(PaymentError, match="not base64 wire data"): + await verifier(fixture.payload) + assert fake_rpc.transaction_kwargs is not None + assert fake_rpc.transaction_kwargs["encoding"] == "base64" @pytest.mark.parametrize("case", ["missing", "unrelated", "extra"]) @@ -787,6 +806,33 @@ async def test_new_top_up_tx_verifier_confirms_signature() -> None: await verifier(payload, _stored_channel(fixture)) +async def test_top_up_state_verifier_decodes_base64_wire_transaction() -> None: + fixture = build_open_tx_fixture(v0=False) + top_up = build_top_up_instruction( + TopUpParams( + payer=fixture.payer.pubkey(), + channel=fixture.channel, + mint=fixture.mint, + amount=1_000_000, + ) + ) + signature, wire_payload = _sign_and_attach_instructions(fixture, [top_up], v0=False) + fake_rpc = _FakeRpc() + fake_rpc.transaction = {"meta": {"err": None}, "transaction": [wire_payload.transaction, "base64"]} + fake_rpc.accounts[str(fixture.channel)] = _channel_account(fixture, 2_000_000) + config = _open_session_config(fixture) + config.network = "mainnet" + verifier = new_top_up_state_tx_verifier(config, fake_rpc) + assert verifier is not None + + await verifier( + TopUpPayload(channel_id=str(fixture.channel), new_deposit="2000000", signature=signature), + _stored_channel(fixture), + ) + assert fake_rpc.transaction_kwargs is not None + assert fake_rpc.transaction_kwargs["encoding"] == "base64" + + async def test_new_top_up_tx_verifier_surfaces_failure_and_not_found() -> None: """Mirrors TestNewTopUpTxVerifierSurfacesFailureAndNotFound.""" signature = str(_kp(21).sign_message(b"top-up")) From e8e45a5567385f43073bd13bdb1d11fe1314f6cf Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:13:43 +0300 Subject: [PATCH 23/39] fix(mpp): bind session top-ups to channel state --- .../protocols/mpp/server/session_onchain.py | 201 ------------------ python/tests/test_session_onchain.py | 13 ++ rust/crates/kit/src/mpp/server/session.rs | 157 ++++++++++++++ .../__tests__/session-state-binding.test.ts | 66 +++++- typescript/packages/mpp/src/server/Session.ts | 3 + 5 files changed, 237 insertions(+), 203 deletions(-) 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 6a7581f09..6eec57fdf 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 @@ -75,7 +75,6 @@ _OPEN_INSTRUCTION_DISCRIMINATOR = 1 _CHANNEL_STATUS_OPEN = 0 _TOP_UP_INSTRUCTION_DISCRIMINATOR = 3 -_BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" class RpcClient(Protocol): @@ -492,194 +491,6 @@ def account_at(indices: list[int], slot: int, label: str) -> Pubkey: ) -def _confirmed_open_transaction_json( - expected: VerifyOpenTxExpected, - payload: OpenPayload, - transaction: dict[str, Any], -) -> VerifyOpenTxResult: - """Verify the exact open instruction returned by ``getTransaction``.""" - meta = transaction.get("meta") - if meta is not None and not isinstance(meta, dict): - raise PaymentError("confirmed open transaction has malformed metadata", code="invalid-payload") - if isinstance(meta, dict) and meta.get("err") is not None: - raise PaymentError("open transaction failed on-chain", code="transaction-failed") - - transaction_value: Any = transaction.get("transaction") - if isinstance(transaction_value, list): - transaction_value = transaction_value[0] if transaction_value else None - if not isinstance(transaction_value, dict): - raise PaymentError("confirmed open transaction has malformed transaction data", code="invalid-payload") - signatures = transaction_value.get("signatures") - if isinstance(signatures, list) and signatures and signatures[0] != payload.signature: - raise PaymentError( - f"open transaction signature {signatures[0]} != payload signature {payload.signature}", - code="invalid-payload", - ) - message = transaction_value.get("message") - instructions = message.get("instructions") if isinstance(message, dict) else None - if not isinstance(instructions, list): - raise PaymentError("confirmed open transaction has no instructions", code="invalid-payload") - - program_id = expected.program_id if expected.program_id is not None else PROGRAM_ID - matches: list[dict[str, Any]] = [] - for instruction in instructions: - if not isinstance(instruction, dict): - raise PaymentError("confirmed open transaction has a malformed instruction", code="invalid-payload") - if instruction.get("programId") != str(program_id): - continue - encoded_data = instruction.get("data") - if not isinstance(encoded_data, str): - raise PaymentError("confirmed open transaction has a malformed open instruction", code="invalid-payload") - try: - data = _base58_decode(encoded_data) - except PaymentError as exc: - raise PaymentError( - "confirmed open transaction has malformed open instruction data", code="invalid-payload" - ) from exc - if data[:1] == bytes([_OPEN_INSTRUCTION_DISCRIMINATOR]): - matches.append(instruction) - if len(matches) != 1: - raise PaymentError( - "confirmed transaction must contain exactly one canonical payment-channel open instruction, " - f"found {len(matches)}", - code="invalid-payload", - ) - - instruction = matches[0] - raw_accounts = instruction.get("accounts") - if not isinstance(raw_accounts, list) or any(not isinstance(account, str) for account in raw_accounts): - raise PaymentError("confirmed open instruction has malformed accounts", code="invalid-payload") - accounts = cast(list[str], raw_accounts) - if len(accounts) < 6: - raise PaymentError("confirmed open instruction has too few accounts", code="invalid-payload") - - def parse_account(slot: int, label: str) -> Pubkey: - try: - return Pubkey.from_string(accounts[slot]) - except Exception as exc: - raise PaymentError( - f"confirmed open instruction has invalid {label} account", code="invalid-payload" - ) from exc - - payer = parse_account(0, "payer") - rent_payer = parse_account(1, "rentPayer") - payee = parse_account(2, "payee") - mint = parse_account(3, "mint") - authorized_signer = parse_account(4, "authorizedSigner") - channel = parse_account(5, "channel") - expected_mint = expected.mint or resolve_mint(expected.currency, expected.network) - if not expected_mint: - raise PaymentError( - f"could not resolve mint from currency {expected.currency!r}", - code="invalid-payload", - ) - if str(payee) != expected.recipient: - raise PaymentError(f"open payee {payee} != expected recipient {expected.recipient}", code="invalid-payload") - if str(mint) != expected_mint: - raise PaymentError(f"open mint {mint} != expected mint {expected_mint}", code="invalid-payload") - if str(authorized_signer) != expected.authorized_signer: - raise PaymentError( - f"open authorizedSigner {authorized_signer} != expected {expected.authorized_signer}", - code="invalid-payload", - ) - if str(rent_payer) != expected.operator: - raise PaymentError( - f"open rentPayer {rent_payer} != expected operator {expected.operator}", - code="invalid-payload", - ) - - try: - encoded_data = instruction["data"] - data = _base58_decode(encoded_data) - except (KeyError, TypeError) as exc: - raise PaymentError("confirmed open instruction has malformed data", code="invalid-payload") from exc - if len(data) < 1 + 8 + 8 + 4 + 8: - raise PaymentError(f"open instruction data too short ({len(data)} bytes)", code="invalid-payload") - try: - salt = struct.unpack_from(" expected.max_cap: - raise PaymentError(f"open deposit {deposit} exceeds max cap {expected.max_cap}", code="invalid-payload") - if grace_period != expected.grace_period: - raise PaymentError( - f"open gracePeriod {grace_period} != expected {expected.grace_period}", - code="invalid-payload", - ) - if len(decoded_args.recipients) != len(expected.splits): - raise PaymentError( - f"open recipients length {len(decoded_args.recipients)} != expected splits length {len(expected.splits)}", - code="invalid-payload", - ) - for index, recipient in enumerate(decoded_args.recipients): - expected_split = expected.splits[index] - if str(recipient.recipient) != str(expected_split.recipient) or int(recipient.bps) != int(expected_split.bps): - raise PaymentError(f"open recipient[{index}] does not match expected split", code="invalid-payload") - - derived_channel, _ = find_channel_pda(payer, payee, mint, authorized_signer, salt, open_slot, program_id) - if derived_channel != channel: - raise PaymentError(f"open channel PDA {channel} != derived {derived_channel}", code="invalid-payload") - if payload.channel_id is not None and payload.channel_id != str(channel): - raise PaymentError( - f"openPayload.channelId {payload.channel_id} != transaction channel {channel}", - code="invalid-payload", - ) - if payload.recent_slot is not None and payload.recent_slot != open_slot: - raise PaymentError( - f"openPayload.recentSlot {payload.recent_slot} != transaction openSlot {open_slot}", - code="invalid-payload", - ) - if expected.recent_slot is not None and expected.recent_slot != open_slot: - raise PaymentError( - f"transaction openSlot {open_slot} != challenge recentSlot {expected.recent_slot}", - code="invalid-payload", - ) - - token_program = Pubkey.from_string(default_token_program_for_currency(expected.currency, expected.network)) - canonical = build_open_instruction( - OpenChannelParams( - payer=payer, - rent_payer=rent_payer, - payee=payee, - mint=mint, - authorized_signer=authorized_signer, - salt=salt, - deposit=deposit, - grace_period=grace_period, - open_slot=open_slot, - recipients=[ - Distribution(recipient=Pubkey.from_string(str(entry.recipient)), bps=int(entry.bps)) - for entry in decoded_args.recipients - ], - token_program=token_program, - program_id=program_id, - ) - ) - if len(accounts) != len(canonical.accounts) or any( - accounts[index] != str(meta.pubkey) for index, meta in enumerate(canonical.accounts) - ): - raise PaymentError("open instruction accounts are not canonical", code="invalid-payload") - if data != bytes(canonical.data): - raise PaymentError("open instruction data is not canonical", code="invalid-payload") - - return VerifyOpenTxResult( - channel_id=str(channel), - deposit=deposit, - grace_period=grace_period, - salt=salt, - open_slot=open_slot, - payer=str(payer), - ) - - 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") @@ -1244,18 +1055,6 @@ def _verify_confirmed_top_up( ) -def _base58_decode(value: str) -> bytes: - number = 0 - for char in value: - digit = _BASE58_ALPHABET.find(char) - if digit < 0: - raise PaymentError("invalid base58 instruction data", code="invalid-payload") - number = number * 58 + digit - leading_zeros = len(value) - len(value.lstrip("1")) - encoded = b"" if number == 0 else number.to_bytes((number.bit_length() + 7) // 8, "big") - return b"\0" * leading_zeros + encoded - - async def confirm_transaction_signature( rpc_client: RpcClient, signature: str, diff --git a/python/tests/test_session_onchain.py b/python/tests/test_session_onchain.py index eaa660223..59461aa88 100644 --- a/python/tests/test_session_onchain.py +++ b/python/tests/test_session_onchain.py @@ -746,6 +746,19 @@ async def test_signature_only_open_rejects_parsed_rpc_transaction() -> None: assert fake_rpc.transaction_kwargs["encoding"] == "base64" +async def test_signature_only_open_rejects_failed_confirmed_transaction() -> None: + """The shared wire verifier keeps failed RPC results from reaching account binding.""" + fixture = build_open_tx_fixture(v0=False) + fixture.payload.transaction = None + fake_rpc = _FakeRpc() + fake_rpc.transaction = {"meta": {"err": "InstructionError"}, "transaction": ["ignored", "base64"]} + verifier = new_open_tx_verifier(_open_session_config(fixture), fake_rpc) + + with pytest.raises(PaymentError, match="failed on-chain") as error: + await verifier(fixture.payload) + assert error.value.code == "transaction-failed" + + @pytest.mark.parametrize("case", ["missing", "unrelated", "extra"]) async def test_signature_only_open_rejects_noncanonical_confirmed_transaction(case: str) -> None: fixture = build_open_tx_fixture(v0=False) diff --git a/rust/crates/kit/src/mpp/server/session.rs b/rust/crates/kit/src/mpp/server/session.rs index 5b777dfec..237396ff8 100644 --- a/rust/crates/kit/src/mpp/server/session.rs +++ b/rust/crates/kit/src/mpp/server/session.rs @@ -1007,6 +1007,29 @@ impl SessionServer { "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}", @@ -3376,6 +3399,27 @@ mod tests { .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 = @@ -3957,6 +4001,119 @@ mod tests { ); } + #[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() { diff --git a/typescript/packages/mpp/src/__tests__/session-state-binding.test.ts b/typescript/packages/mpp/src/__tests__/session-state-binding.test.ts index d34b51e5e..c2a69cb01 100644 --- a/typescript/packages/mpp/src/__tests__/session-state-binding.test.ts +++ b/typescript/packages/mpp/src/__tests__/session-state-binding.test.ts @@ -44,7 +44,15 @@ async function channelId(): Promise { return derived; } -function encodeChannel(deposit: bigint, status = 0, gracePeriod = 900): string { +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, @@ -52,7 +60,7 @@ function encodeChannel(deposit: bigint, status = 0, gracePeriod = 900): string { status, salt: 7n, deposit, - settlement: { settled: 0n, payoutWatermark: 0n }, + settlement, closureStartedAt: 0n, payerWithdrawnAt: 0n, gracePeriod, @@ -243,6 +251,43 @@ describe('session Channel account state binding', () => { ); }); + 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(); @@ -396,4 +441,21 @@ describe('session Channel account state binding', () => { }), ).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/server/Session.ts b/typescript/packages/mpp/src/server/Session.ts index 8c8bdcac4..46cee36b2 100644 --- a/typescript/packages/mpp/src/server/Session.ts +++ b/typescript/packages/mpp/src/server/Session.ts @@ -919,6 +919,9 @@ async function handleTopUp(args: HandleTopUpArgs): Promise { 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, From 7ecf8e1607a7cccb8a7b01e23debd85cdfc8b007 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:42:53 +0300 Subject: [PATCH 24/39] fix(python): align session RPC protocol --- .../protocols/mpp/server/session_onchain.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 6eec57fdf..d6bb94af0 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 @@ -87,7 +87,14 @@ class RpcClient(Protocol): async def get_signature_statuses(self, signatures: list[str]) -> list[dict | None]: ... - async def get_transaction(self, signature: str, **kwargs: Any) -> Any: ... + async def get_transaction( + self, + signature: str, + *, + encoding: str = ..., + commitment: str = ..., + max_supported_transaction_version: int = ..., + ) -> Any: ... async def get_latest_blockhash(self, commitment: str = ...) -> Any: ... From e29c4464b7c39c073e21f1db4187d9c638f4f4d2 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:25:47 +0300 Subject: [PATCH 25/39] fix(mpp): bind Rust session state to confirmed on-chain channels Read the on-chain Channel account at the confirmed slot and bind session authoritative state (deposit, splits, grace period) to it. Session state is never advanced or trusted for an unconfirmed or economically mismatched channel. No sibling PR touches the Rust session server; this layer is unique to #239 and applies cleanly on the settled base. --- rust/crates/kit/src/core/session.rs | 4 + rust/crates/kit/src/core/store.rs | 33 + rust/crates/kit/src/mpp/mod.rs | 2 +- .../crates/kit/src/mpp/protocol/core/types.rs | 2 + rust/crates/kit/src/mpp/server/session.rs | 2187 ++++++++++++++++- .../kit/src/x402/server/batch_settlement.rs | 4 + 6 files changed, 2180 insertions(+), 52 deletions(-) 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..237396ff8 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![], From 0285194e9841442866bd7adc0a27feeb89e6fe4e Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:55:11 +0300 Subject: [PATCH 26/39] fix(mpp): bind TypeScript session state to the confirmed channel account Add #239's unique fail-closed binding: a session open/top-up is rejected unless the on-chain Channel account (fetched via getAccountInfo at the confirmed slot) confirms the claimed deposit, mint, payee, authorized signer, grace period and distribution hash, and a signature-only open is bound to its canonical broadcast transaction. - server/session/on-chain.ts: verifyChannelAccountState, verifySignatureOnlyOpenTransaction, GetAccountInfoRpc, TopUpTransactionRpc. - server/session/wire-tx.ts + store.ts: openSignature/salt/settlement fields carried on ChannelState for the binding. - server/Session.ts: wire the verify calls into open/top-up/routes; export SettlementRpc. - tests: session-state-binding, session-signature-only-open, plus the #239 additions to session-on-chain / session-server-on-chain / session-store. Dedup against stacked siblings #237/#238 (already in the base): keep their session-store durability guard (resolveSessionStore + allowUnsafeMemoryStore) and replay-store policy; drop #239's independent re-implementation of the same concern (requireProductionSessionSafety + allowUnsafeEphemeralStoreOffLocalnet + the adapter wiring), which conflicted with those siblings' regression tests. Superseded session-store-durability assertions were removed from the #239 test files; the mainnet fail-closed control is unchanged (stronger via #237). --- .../src/__tests__/session-on-chain.test.ts | 138 +++- .../__tests__/session-server-on-chain.test.ts | 226 +++++- .../mpp/src/__tests__/session-server.test.ts | 668 ++++++++++++++-- .../session-signature-only-open.test.ts | 286 +++++++ .../__tests__/session-state-binding.test.ts | 444 +++++++++++ .../mpp/src/__tests__/session-store.test.ts | 56 ++ typescript/packages/mpp/src/server/Session.ts | 507 ++++++++++-- typescript/packages/mpp/src/server/index.ts | 2 +- .../mpp/src/server/session/on-chain.ts | 723 ++++++++++++++++-- .../packages/mpp/src/server/session/store.ts | 23 +- .../mpp/src/server/session/wire-tx.ts | 14 +- typescript/packages/pay-kit/src/config.ts | 4 +- 12 files changed, 2904 insertions(+), 187 deletions(-) create mode 100644 typescript/packages/mpp/src/__tests__/session-signature-only-open.test.ts create mode 100644 typescript/packages/mpp/src/__tests__/session-state-binding.test.ts 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..656fb4849 100644 --- a/typescript/packages/mpp/src/server/index.ts +++ b/typescript/packages/mpp/src/server/index.ts @@ -1,7 +1,7 @@ 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..7e59e371c 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)); }, @@ -215,6 +233,7 @@ export function createMemorySessionStore(): SessionStore { sessionStoreDurability: 'ephemeral' as const, + async updateChannel(channelId, mutator) { return await withLock(channelId, async () => { const current = 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/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; }; From 600577db5abc6d22bd22311e81e1dc4a5a098eeb Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:19:20 +0300 Subject: [PATCH 27/39] fix(mpp): bind Go session state to the confirmed channel account Reconcile #239's on-chain Channel-account state binding onto the base's solana-go v2 migration and hardened open/top-up verification (#214). Adds the authoritative state-binding layer that no sibling or the base delivers: fetchAndBindChannelAccount, NewOpenStateTxVerifier, bindConfirmedOpenChannel, validateBoundOpenChannel, NewTopUpStateTxVerifier and confirmedTransactionSlot. Session open/top-up state is bound to the on-chain Channel account read at the confirmed slot; an unconfirmed or economically mismatched channel is rejected (fail closed). Base store-safety (env-gated) is preserved; the config-field guard reconciles to honor the same PAY_KIT_ALLOW_INMEMORY_REPLAY_STORE opt-in. Ports #239's v1 solana-go usage to v2 and keeps base's structural verifiers + concurrency/replay hardening; drops #239's duplicate top-up-fence and legacy verifier redefinitions superseded by the base. --- go/internal/testutil/fakes.go | 43 +- go/protocols/mpp/server/session.go | 149 +++- go/protocols/mpp/server/session_method.go | 537 ++++++++++++-- .../mpp/server/session_method_branch_test.go | 48 +- .../mpp/server/session_method_test.go | 700 +++++++++++++++++- go/protocols/mpp/server/session_onchain.go | 678 ++++++++++++++--- .../mpp/server/session_onchain_test.go | 247 +++++- .../mpp/server/session_server_test.go | 30 + .../session_settlement_durability_test.go | 603 +++++++++++++++ .../mpp/server/session_state_binding_test.go | 376 ++++++++++ go/protocols/mpp/server/session_store.go | 87 ++- go/protocols/mpp/server/session_store_test.go | 34 + 12 files changed, 3297 insertions(+), 235 deletions(-) create mode 100644 go/protocols/mpp/server/session_settlement_durability_test.go create mode 100644 go/protocols/mpp/server/session_state_binding_test.go 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..9f8c203d6 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 { @@ -261,11 +287,34 @@ func NewSession(options SessionOptions) (*Session, error) { storeDescription, options.Network, allowInMemoryReplayStoreEnvVar)) } if store == nil { + if options.Network != "localnet" { + return nil, core.NewError(core.ErrCodeInvalidConfig, + "session store is required off localnet; inject a durable shared ChannelStore") + } store = NewMemoryChannelStore() } 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 +330,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 +375,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 +512,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 +595,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 +620,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 +639,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 +660,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 +680,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 +696,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), } - verified, err := verifySignatureOnlyOpen(ctx, expected, payload, s.rpc) + if challengeRecentSlot != nil { + recentSlot := uint64(*challengeRecentSlot) + expected.RecentSlot = &recentSlot + } + 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 +850,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 +929,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 +946,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 +1011,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) + } + 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: + } } - if state == nil { - return "", nil +} + +// 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") } - 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) + 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..66c1eada2 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,202 @@ 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 the fee-payer signature. The payload signature is + // then the confirmed transaction's real signature. + if err := solanatx.SignTransaction(tx, payer); 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 +437,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 +497,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 +510,31 @@ 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(), "session store is required") { + 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() + + options.Store = struct{ ChannelStore }{ChannelStore: NewMemoryChannelStore()} + if _, err := NewSession(options); 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 +732,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 +788,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 +977,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,7 +1017,7 @@ func TestSessionOpenVerifiesSignatureOnChain(t *testing.T) { signer := newTestVoucherSigner(t) channelID := solana.NewWallet().PublicKey().String() - receipt := openSessionChannel(t, session, channelID, 1_000, signer.Address(), okSig) + receipt, _ := openSessionChannel(t, session, channelID, 1_000, signer.Address(), okSig) if receipt.Reference != okSig { t.Fatalf("reference = %q", receipt.Reference) } @@ -980,26 +1359,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 +1404,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 +1416,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 +1678,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 +1724,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 +1735,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 +1754,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 +1930,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 +1990,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 +2083,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 TestSessionIdleCloseWithoutSignerStillClosesOffChain(t *testing.T) { fake := testutil.NewFakeRPC() session := newTestSession(t, func(o *SessionOptions) { o.RPC = fake @@ -1603,7 +2249,7 @@ func TestSessionIdleCloseWithoutSignerIsInert(t *testing.T) { time.Sleep(80 * time.Millisecond) 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..5873ddb34 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,39 @@ 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. + verified, err := VerifyOpenTx(ctx, expected, payload, nil) + 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 +1232,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) + return VerifyOpenTxResult{}, nil, solana.Signature{}, fmt.Errorf("open transaction is missing the fee-payer 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 + return verified, tx, tx.Signatures[0], nil } // signerIsRequired reports whether key is one of the transaction's required @@ -705,79 +1256,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_test.go b/go/protocols/mpp/server/session_onchain_test.go index a57a6dc7b..fd14b5118 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 TestNewOpenTxVerifierKeepsStructuralPlaceholderAcceptance(t *testing.T) { + fixture := buildOpenTxFixture(t, false) + config := openSessionConfig(fixture) + verifier := NewOpenTxVerifier(config, nil) + payload := fixture.payload + payload.Signature = strings.Repeat("1", 64) + payer, err := verifier(context.Background(), &payload) + if err != nil { + t.Fatalf("structural verifier: %v", err) + } + if payer != fixture.payer.PublicKey().String() { + t.Fatalf("verifier payer = %q, want %q", payer, fixture.payer.PublicKey()) + } +} + +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..55fdd90f5 100644 --- a/go/protocols/mpp/server/session_server_test.go +++ b/go/protocols/mpp/server/session_server_test.go @@ -14,6 +14,7 @@ import ( solana "github.com/solana-foundation/solana-go/v2" + "github.com/solana-foundation/pay-kit/go/paycore" "github.com/solana-foundation/pay-kit/go/protocols/mpp/intents" ) @@ -147,6 +148,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) + } +} From f00c322c06693478edc47f8f29e38b458806863d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:20:05 +0300 Subject: [PATCH 28/39] fix(mpp): bind Python session open to the confirmed channel account Add PR #239's unique behavior on top of sibling #228: the state-aware open verifier that fetches the on-chain Channel account at the confirmed slot and binds deposit, splits, grace period and PDA seeds to on-chain reality, so a payload's claimed economics are never persisted as facts. - session_onchain: BoundChannel, fetch_and_bind_channel_account, new_open_state_tx_verifier (+ signature-only confirm/bind helpers, _session_distribution_hash, _expected_session_grace_period). The structural open path reuses #228's verify_open_tx via the recipients contract. - confirm_transaction_signature now returns the confirmed slot and rejects a present-but-malformed slot, so the account read is pinned with min_context_slot; a status that omits the slot still confirms. - session: SessionConfig.verify_open_state_tx / open_tx_submitter seams, VerifiedOpenFacts, and process_open fail-closed: off localnet a payment-channel open requires the state verifier and persists only account-derived facts. - session_method: new_session installs the state verifier off localnet; _handle_open propagates the challenge recentSlot for signature-only push. - session_store: ChannelState.salt / open_signature round-trip. - rpc: SolanaRpc.get_account_info min_context_slot + typed get_transaction. - tests: add test_session_state_binding.py (adapted to #228's ProductionChannelStore policy, which is kept as-is). #228's structural open, top-up fence, channel-store policy and replay code are kept unchanged; their tests continue to pass. --- python/src/solana_pay_kit/_paycore/rpc.py | 24 +- .../protocols/mpp/server/session.py | 100 +++- .../protocols/mpp/server/session_method.py | 25 +- .../protocols/mpp/server/session_onchain.py | 372 +++++++++++++- .../protocols/mpp/server/session_store.py | 37 ++ python/tests/test_rpc_methods.py | 21 + python/tests/test_session_state_binding.py | 462 ++++++++++++++++++ python/tests/test_session_store.py | 15 + 8 files changed, 1025 insertions(+), 31 deletions(-) create mode 100644 python/tests/test_session_state_binding.py 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..6faa3decd 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 @@ -1325,10 +1334,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..420ab58a7 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, 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_state_binding.py b/python/tests/test_session_state_binding.py new file mode 100644 index 000000000..db39683be --- /dev/null +++ b/python/tests/test_session_state_binding.py @@ -0,0 +1,462 @@ +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]) -> 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( From e8febc8bb43ad25a7e4f87d66876fc8929b5acc8 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:25:10 +0300 Subject: [PATCH 29/39] test(mpp): sign Go session open fixtures for the hardened verifier Give the shared test operator a real signing key (sessionTestOperatorKey backs sessionTestRecipient) and co-sign the rentPayer slot so signature-only open fixtures reproduce a fully signed confirmed transaction. Base #214 verifies open signatures cryptographically, so the prior dummy-signature fixtures no longer pass. Also restores base's env-gated nil-store default (a merge artifact had re-added an unconditional off-localnet rejection that ignored the opt-in). --- go/protocols/mpp/server/session_method.go | 4 ---- go/protocols/mpp/server/session_method_test.go | 17 ++++++++++++++--- go/protocols/mpp/server/session_server_test.go | 8 +++++++- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/go/protocols/mpp/server/session_method.go b/go/protocols/mpp/server/session_method.go index 9f8c203d6..9fc493c03 100644 --- a/go/protocols/mpp/server/session_method.go +++ b/go/protocols/mpp/server/session_method.go @@ -287,10 +287,6 @@ func NewSession(options SessionOptions) (*Session, error) { storeDescription, options.Network, allowInMemoryReplayStoreEnvVar)) } if store == nil { - if options.Network != "localnet" { - return nil, core.NewError(core.ErrCodeInvalidConfig, - "session store is required off localnet; inject a durable shared ChannelStore") - } store = NewMemoryChannelStore() } if options.Logger == nil { diff --git a/go/protocols/mpp/server/session_method_test.go b/go/protocols/mpp/server/session_method_test.go index 66c1eada2..1f885022d 100644 --- a/go/protocols/mpp/server/session_method_test.go +++ b/go/protocols/mpp/server/session_method_test.go @@ -329,9 +329,20 @@ func registerSignatureOnlyOpenTransaction( t.Fatalf("build fetched signature-only transaction: %v", err) } // Sign the fetched transaction for real so the hardened open verifier can - // cryptographically bind the fee-payer signature. The payload signature is - // then the confirmed transaction's real signature. - if err := solanatx.SignTransaction(tx, payer); err != nil { + // 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() diff --git a/go/protocols/mpp/server/session_server_test.go b/go/protocols/mpp/server/session_server_test.go index 55fdd90f5..9304ed236 100644 --- a/go/protocols/mpp/server/session_server_test.go +++ b/go/protocols/mpp/server/session_server_test.go @@ -14,11 +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{ From 2cd522756c20cbd976778a9e9e1e83dd01684a9f Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:29:04 +0300 Subject: [PATCH 30/39] style(mpp): drop em-dash from Rust session error string --- rust/crates/kit/src/mpp/server/session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/kit/src/mpp/server/session.rs b/rust/crates/kit/src/mpp/server/session.rs index 237396ff8..7d100b4dd 100644 --- a/rust/crates/kit/src/mpp/server/session.rs +++ b/rust/crates/kit/src/mpp/server/session.rs @@ -1630,7 +1630,7 @@ fn verify_transaction_signature(sig_str: &str, rpc_url: &str, tx: VerifiedTx) -> .flatten() .ok_or_else(|| { Error::Other(format!( - "{tx} tx '{sig_str}' not found — not yet confirmed or does not exist" + "{tx} tx '{sig_str}' not found; not yet confirmed or does not exist" )) })?; if let Some(error) = response.err { From 1e59ae12e91b9f35708f93c2ba05e8b2db1cdd29 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:09:22 +0300 Subject: [PATCH 31/39] test(mpp): reconcile Go session tests to union-base open verification Base #214 hardened open verification (real wire-bound signatures, no placeholder acceptance) and the session store policy became env-gated. Update the stale Go test fixtures/assertions to the reconciled behavior: - flip TestNewOpenTxVerifierKeepsStructuralPlaceholderAcceptance to TestNewOpenTxVerifierRejectsPlaceholderSignature: the seam now rejects the pending placeholder ("must be a real transaction signature") - assert the real confirmed signature and derived PDA in the signature-only open test rather than the dummy reference the caller supplied - thread the derived channel PDA into the top-up test and reflect the post-top-up deposit in the authoritative on-chain account - match the reconciled off-localnet store-policy message, and enforce the unmarked-store rejection at the runtime safety gate (where it now lives) rather than at construction - rename the idle-close no-signer test and assert the no-op: closeOnIdle bails before touching the channel when no merchant signer is configured Test-only; no production source changed. --- .../mpp/server/session_method_test.go | 54 ++++++++++++++++--- .../mpp/server/session_onchain_test.go | 14 ++--- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/go/protocols/mpp/server/session_method_test.go b/go/protocols/mpp/server/session_method_test.go index 1f885022d..8e5ea2a63 100644 --- a/go/protocols/mpp/server/session_method_test.go +++ b/go/protocols/mpp/server/session_method_test.go @@ -529,7 +529,9 @@ func TestNewSessionRequiresInjectedStoreOffLocalnet(t *testing.T) { Network: "devnet", SecretKey: sessionMethodSecret, } - if _, err := NewSession(options); err == nil || !strings.Contains(err.Error(), "session store is required") { + 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) } @@ -540,8 +542,17 @@ func TestNewSessionRequiresInjectedStoreOffLocalnet(t *testing.T) { } 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()} - if _, err := NewSession(options); err == nil || !strings.Contains(err.Error(), "explicitly declare durable shared") { + 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) } } @@ -1028,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() @@ -1259,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 @@ -2250,7 +2284,7 @@ func TestConfirmedSettlementReconcilesAfterRequestCancellation(t *testing.T) { } } -func TestSessionIdleCloseWithoutSignerStillClosesOffChain(t *testing.T) { +func TestSessionIdleCloseWithoutSignerIsNoOp(t *testing.T) { fake := testutil.NewFakeRPC() session := newTestSession(t, func(o *SessionOptions) { o.RPC = fake @@ -2259,8 +2293,12 @@ func TestSessionIdleCloseWithoutSignerStillClosesOffChain(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.CloseRequestedAt == nil || state.Sealed || len(fake.Sent) != 0 { + 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_test.go b/go/protocols/mpp/server/session_onchain_test.go index fd14b5118..a5178652b 100644 --- a/go/protocols/mpp/server/session_onchain_test.go +++ b/go/protocols/mpp/server/session_onchain_test.go @@ -751,18 +751,18 @@ func TestNewOpenTxVerifierWithoutTransactionVerifiesFetchedTransaction(t *testin } } -func TestNewOpenTxVerifierKeepsStructuralPlaceholderAcceptance(t *testing.T) { +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) - payer, err := verifier(context.Background(), &payload) - if err != nil { - t.Fatalf("structural verifier: %v", err) - } - if payer != fixture.payer.PublicKey().String() { - t.Fatalf("verifier payer = %q, want %q", payer, fixture.payer.PublicKey()) + 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) } } From 701200896d396228cd46d3d8bc7c868654584ef9 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:12:13 +0300 Subject: [PATCH 32/39] fix(go): accept the unsigned fee-payer slot in the server-submit open path prepareOpenTx (SubmitOpenTx, the gasless server-submit flow) verified the open with allowUnsignedFeePayer=false, rejecting the placeholder fee-payer signature before the co-sign step the same function performs. The Go client signs only as the channel payer and leaves the operator slot for the server, so the server rejected its own client's open. Use the same verifyOpenTx(..., true) pre-verification handleOpen already uses: the payer signature is still verified and the post-co-sign zero-signature guard keeps broadcast fail-closed. --- go/protocols/mpp/server/session_onchain.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/go/protocols/mpp/server/session_onchain.go b/go/protocols/mpp/server/session_onchain.go index 5873ddb34..b5290be83 100644 --- a/go/protocols/mpp/server/session_onchain.go +++ b/go/protocols/mpp/server/session_onchain.go @@ -1211,7 +1211,13 @@ func SubmitOpenTx(ctx context.Context, expected VerifyOpenTxExpected, payload *i 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. - verified, err := VerifyOpenTx(ctx, expected, payload, nil) + // 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 } From d6a015dfff6fa4376ab6d1b9b2920328dc507261 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:11:05 +0300 Subject: [PATCH 33/39] fix(mpp): type the session open-confirmation slot as int|None and refresh test doubles confirm_transaction_signature returns int|None (a confirmed status may omit the slot), so _fetch_and_verify_signature_only_open's tuple[int, ...] annotation was unsound; the slot flows into min_context_slot (int|None), so widen the return. Refresh the _Rpc/_OpenConfig test doubles to the current RpcClient (search_transaction_history kwarg) and OpenVerifierConfig (settlement_window) protocols so pyright's structural check passes (13 errors -> 0). --- .../solana_pay_kit/protocols/mpp/server/session_onchain.py | 2 +- python/tests/test_session_onchain.py | 1 + python/tests/test_session_state_binding.py | 4 +++- 3 files changed, 5 insertions(+), 2 deletions(-) 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 420ab58a7..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 @@ -541,7 +541,7 @@ async def _fetch_and_verify_signature_only_open( expected: VerifyOpenTxExpected, payload: OpenPayload, rpc_client: RpcClient, -) -> tuple[int, VerifyOpenTxResult]: +) -> 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) 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 index db39683be..4f6f4f111 100644 --- a/python/tests/test_session_state_binding.py +++ b/python/tests/test_session_state_binding.py @@ -146,7 +146,9 @@ async def get_account_info( self.min_context_slot = min_context_slot return self.accounts.get(address) - async def get_signature_statuses(self, signatures: list[str]) -> list[dict | None]: + 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 From 677cc510a3e9902ff5204980ed434b7a483a877d Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:11:05 +0300 Subject: [PATCH 34/39] test(mpp): cover Go session on-chain verifier fail-closed rejection paths Add rejection-branch tests for validateBoundOpenChannel, resolveSessionTokenProgram, NewOpenStateTxVerifier/verifySignatureOnlyOpen, fetchAndBindChannelAccount, and the top-up verifiers (each asserts the concrete fail-closed error, not just coverage). Raises Go aggregate coverage 90.3% -> 91.1%, back over the 91.0 floor. --- .../server/session_onchain_coverage_test.go | 480 ++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 go/protocols/mpp/server/session_onchain_coverage_test.go 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) + } + }) +} From de04b25c69a1d26baf3c908da93f033ffa06b599 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:11:05 +0300 Subject: [PATCH 35/39] style(mpp): format server store re-exports; annotate channel-store guard for radar Prettier the mpp server index/session store re-exports. Mark the localnet-only MemoryChannelStore fallback nosemgrep: enforce_channel_store_policy above already rejects an absent/in-memory store off-localnet without the opt-in, which the radar rule's inline-guard pattern cannot see. --- .../protocols/mpp/server/session_method.py | 5 +++++ typescript/packages/mpp/src/server/index.ts | 9 ++++++++- typescript/packages/mpp/src/server/session/store.ts | 1 - 3 files changed, 13 insertions(+), 2 deletions(-) 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 6faa3decd..b18f9b54a 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 @@ -1317,8 +1317,13 @@ 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) + # nosemgrep: failopen-default-store-python -- fail-closed via the shared + # enforce_channel_store_policy guard above (not the rule's inline if/raise). store = options.store if options.store is not None else MemoryChannelStore() config = SessionConfig( diff --git a/typescript/packages/mpp/src/server/index.ts b/typescript/packages/mpp/src/server/index.ts index 656fb4849..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 SettlementRpc, 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/store.ts b/typescript/packages/mpp/src/server/session/store.ts index 7e59e371c..1d4013c4a 100644 --- a/typescript/packages/mpp/src/server/session/store.ts +++ b/typescript/packages/mpp/src/server/session/store.ts @@ -233,7 +233,6 @@ export function createMemorySessionStore(): SessionStore { sessionStoreDurability: 'ephemeral' as const, - async updateChannel(channelId, mutator) { return await withLock(channelId, async () => { const current = data.get(channelId); From 04827949a55d85fa668a35de7d45ab2d07f746b2 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:27:54 +0300 Subject: [PATCH 36/39] test(mpp): provide recentSlot in the session open defect-verifier fixture The epoch-addressed channel PDA seeds include open_slot, so the session challenge now pre-fetches recentSlot alongside recentBlockhash and the open builder fails closed without it. The defect-verifier fixture predates that contract and crashed at construction instead of reaching its rejection assertions; supply the challenge slot so all five value-binding rejections (and the accept controls) exercise their intended paths. --- harness/test/value-binding-verify.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/harness/test/value-binding-verify.test.ts b/harness/test/value-binding-verify.test.ts index 60480333a..f21b71502 100644 --- a/harness/test/value-binding-verify.test.ts +++ b/harness/test/value-binding-verify.test.ts @@ -131,6 +131,9 @@ function openRequest(recipient?: string): SessionRequest { network: 'localnet', operator: payer.address, recentBlockhash: RECENT_BLOCKHASH as never, + // The epoch-addressed channel PDA seeds include open_slot, so the + // server challenge now pre-fetches recentSlot alongside recentBlockhash. + recentSlot: '42', recipient: recipient ?? payee.address, }; } From 6e277d9f01b7085a18d3058e60045df9021d5c84 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:53:30 +0300 Subject: [PATCH 37/39] style(mpp): move the channel-store nosemgrep marker onto its own line semgrep only honors a nosemgrep comment on the match line or the line immediately before it; the wrapped two-line comment left the marker one line too far and the blocking radar scan still flagged the guarded localnet-only MemoryChannelStore fallback. --- .../solana_pay_kit/protocols/mpp/server/session_method.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 b18f9b54a..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 @@ -1322,8 +1322,9 @@ def new_session(options: SessionOptions) -> Session: # below is only reachable on localnet or under the acknowledged opt-in. enforce_channel_store_policy(options.store, network) - # nosemgrep: failopen-default-store-python -- fail-closed via the shared - # enforce_channel_store_policy guard above (not the rule's inline if/raise). + # 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( From d31fcdfd95ce39f6a00dd5e47c9e7174e4b65452 Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:58:16 +0300 Subject: [PATCH 38/39] test(harness): compile a real signed wire in the close-settle fixture This leaf's session close decodes the signed wire to bind the broadcast signature before submitting, so the fixture's placeholder string no longer satisfies the contract. Bring forward the reconciled fixture: it compiles the composed instructions into a real signed transaction and asserts the reported signature is derived from the wire, never trusted from the RPC response. The gate travels with the hardening that created its need. --- .../test/session-close-settle-verify.test.ts | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) 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", () => { From d92facbe0076118958862eaaaec3651c06565dae Mon Sep 17 00:00:00 2001 From: Efe Baran Durmaz <75971010+EfeDurmaz16@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:10:48 +0300 Subject: [PATCH 39/39] fix(python): align the playground example with the hardened SessionOptions The playground example still passed the removed allow_unsafe_ephemeral_store_off_localnet escape hatch to SessionOptions, so importing it (and collecting tests/test_playground_api.py) fails with a TypeError against this leaf's hardened session surface. Bring the example to the reconciled version, which constructs the session method without the ephemeral-store escape. Full python suite: 1518 passed, 1 skipped. --- python/examples/playground_api/sessions.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/python/examples/playground_api/sessions.py b/python/examples/playground_api/sessions.py index 44c880cef..c5358c833 100644 --- a/python/examples/playground_api/sessions.py +++ b/python/examples/playground_api/sessions.py @@ -28,7 +28,6 @@ from solana_pay_kit._paycore.solana import resolve_mint, stablecoin_decimals from solana_pay_kit.fastapi import RequireSession from solana_pay_kit.protocols.mpp.server import ( - MemoryChannelStore, SessionChallengeOptions, SessionOptions, new_session, @@ -59,9 +58,6 @@ rpc=SolanaRpc(_cfg.effective_rpc_url()), open_tx_submitter="server", close_delay=2.0, - store=MemoryChannelStore(), - # Playground-only process state; production must inject a durable store. - allow_unsafe_ephemeral_store_off_localnet=True, ) )