From 9723c66332767e4a16f1ec8d76cf1ae5176c93f3 Mon Sep 17 00:00:00 2001 From: PhilBot <9mmnwvp6vs@privaterelay.appleid.com> Date: Fri, 2 Oct 2026 12:15:54 +0000 Subject: [PATCH 1/2] test(svm): cover server-signed refund recovery after restart A restarted client with discovery disabled must refund the persisted server-signed channel when the operator is trusted, and must refuse before reading that record when the operator is not. Co-authored-by: phdargen --- .../batch-settlement/client/harness_test.go | 34 +++++++++++ .../batch-settlement/client/lifecycle_test.go | 61 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/go/mechanisms/svm/batch-settlement/client/harness_test.go b/go/mechanisms/svm/batch-settlement/client/harness_test.go index 2de7d78337..f85a3ccb41 100644 --- a/go/mechanisms/svm/batch-settlement/client/harness_test.go +++ b/go/mechanisms/svm/batch-settlement/client/harness_test.go @@ -38,6 +38,8 @@ func boolPtr(value bool) *bool { return &value } type memoryStorage struct { mu sync.Mutex records map[string]BatchClientChannelRecord + gets []string + sets []string } func newMemoryStorage() *memoryStorage { @@ -47,6 +49,7 @@ func newMemoryStorage() *memoryStorage { func (s *memoryStorage) Get(key string) (*BatchClientChannelRecord, error) { s.mu.Lock() defer s.mu.Unlock() + s.gets = append(s.gets, key) record, ok := s.records[key] if !ok { return nil, nil @@ -58,6 +61,7 @@ func (s *memoryStorage) Get(key string) (*BatchClientChannelRecord, error) { func (s *memoryStorage) Set(key string, record BatchClientChannelRecord) error { s.mu.Lock() defer s.mu.Unlock() + s.sets = append(s.sets, key) s.records[key] = record return nil } @@ -78,6 +82,36 @@ func (s *memoryStorage) only() BatchClientChannelRecord { return BatchClientChannelRecord{} } +func (s *memoryStorage) got(key string) bool { + s.mu.Lock() + defer s.mu.Unlock() + for _, got := range s.gets { + if got == key { + return true + } + } + return false +} + +func (s *memoryStorage) setCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.sets) +} + +func (s *memoryStorage) size() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.records) +} + +func (s *memoryStorage) resetCalls() { + s.mu.Lock() + defer s.mu.Unlock() + s.gets = nil + s.sets = nil +} + type rpcStub struct { mu sync.Mutex owner solana.PublicKey diff --git a/go/mechanisms/svm/batch-settlement/client/lifecycle_test.go b/go/mechanisms/svm/batch-settlement/client/lifecycle_test.go index b21fcd77f9..cacc993780 100644 --- a/go/mechanisms/svm/batch-settlement/client/lifecycle_test.go +++ b/go/mechanisms/svm/batch-settlement/client/lifecycle_test.go @@ -39,6 +39,7 @@ func TestBatchClientLifecycle(t *testing.T) { t.Run("validates client terms and configuration boundaries", testValidatesTerms) t.Run("builds a refund from a cached channel and rejects a missing one", testRefundFromCache) t.Run("refunds a client-signed channel when the probe lists server-signed first", testRefundServerFirst) + t.Run("refunds a persisted server-signed channel after restart without discovery", testRefundPersistedServerSignedAfterRestart) t.Run("reports no channel when the probe is server-signed and nothing is open", testRefundNoChannelServerProbe) } @@ -561,6 +562,66 @@ func testRefundServerFirst(t *testing.T) { require.Equal(t, "1000", nestedString(t, cooperative.Payload, "voucher", "maxClaimableAmount")) } +func testRefundPersistedServerSignedAfterRestart(t *testing.T) { + h := newHarness(t) + operator := newKey(t) + storage := newMemoryStorage() + serverRequirements := h.requirements("", map[string]any{ + batchsettlement.ExtraOperator: operator.Address().String(), + batchsettlement.ExtraVoucherSigner: batchsettlement.VoucherSignerServer, + }) + keyed := h.scheme(t, &BatchSvmClientConfig{ + ChannelStorage: storage, + DiscoverChannels: boolPtr(false), + ServerSignedChannelsPolicy: &BatchServerSignedChannelsPolicy{ + AllowedOperators: []string{operator.Address().String()}, + }, + }) + key := keyed.channelKey(serverRequirements, h.feePayer.String(), 900) + storage.records[key] = BatchClientChannelRecord{ + ChannelConfig: batchsettlement.BatchChannelConfig{ + OpenSlot: 123, + Payer: h.payer.Address().String(), + PayerAuthorizer: operator.Address().String(), + Receiver: svm.USDCMainnetAddress, + ReceiverAuthorizer: h.receiverAuthorizer.String(), + Salt: "0", + Token: testMint, + VoucherSigner: batchsettlement.VoucherSignerServer, + WithdrawDelay: 900, + }, + ChannelID: svm.USDCMainnetAddress, + ChargedCumulativeAmount: "1000", + Deposit: "5000", + } + + untrusted := h.scheme(t, &BatchSvmClientConfig{ + ChannelStorage: storage, + DiscoverChannels: boolPtr(false), + }) + _, err := untrusted.CreateRefundPayload(context.Background(), 2, serverRequirements, RefundPayloadOptions{}) + require.ErrorIs(t, err, ErrNoBatchChannelToRefund) + require.False(t, storage.got(key)) + storage.resetCalls() + + restarted := h.scheme(t, &BatchSvmClientConfig{ + ChannelStorage: storage, + DiscoverChannels: boolPtr(false), + ServerSignedChannelsPolicy: &BatchServerSignedChannelsPolicy{ + AllowedOperators: []string{operator.Address().String()}, + }, + }) + cooperative, err := restarted.CreateRefundPayload(context.Background(), 2, serverRequirements, RefundPayloadOptions{}) + require.NoError(t, err) + require.Equal(t, 2, cooperative.X402Version) + require.Equal(t, "refund", cooperative.Payload["type"]) + require.Equal(t, "0", nestedString(t, cooperative.Payload, "authorization", "authorizedAmount")) + require.Equal(t, svm.USDCMainnetAddress, nestedString(t, cooperative.Payload, "authorization", "channelId")) + require.True(t, storage.got(key)) + require.Zero(t, storage.setCount()) + require.Equal(t, 1, storage.size()) +} + func testRefundNoChannelServerProbe(t *testing.T) { h := newHarness(t) operator := newKey(t) From 7f1960aaa0692e6c8e23ddfad8423e652e95c1c4 Mon Sep 17 00:00:00 2001 From: PhilBot <9mmnwvp6vs@privaterelay.appleid.com> Date: Fri, 2 Oct 2026 12:20:17 +0000 Subject: [PATCH 2/2] fix(svm): restore server-signed channel refunds after restart Refund lookup rewrote server-signed requirements to client mode before reading durable storage. With discovery disabled, that skipped the persisted server-signed record and returned ErrNoBatchChannelToRefund. The server-mode fallback now calls loadRefundChannel after serverSignedChannelsPolicy accepts the operator. The client-signed fallback is unchanged. Co-authored-by: phdargen --- go/.changes/unreleased/fixed-20261002-124000.yaml | 3 +++ .../svm/batch-settlement/client/scheme_state.go | 14 +++++++------- 2 files changed, 10 insertions(+), 7 deletions(-) create mode 100644 go/.changes/unreleased/fixed-20261002-124000.yaml diff --git a/go/.changes/unreleased/fixed-20261002-124000.yaml b/go/.changes/unreleased/fixed-20261002-124000.yaml new file mode 100644 index 0000000000..b54e2907ff --- /dev/null +++ b/go/.changes/unreleased/fixed-20261002-124000.yaml @@ -0,0 +1,3 @@ +kind: fixed +body: Fixed refunds for persisted server-signed batch channels after a client restart. +time: 2026-10-02T12:40:00.000000Z diff --git a/go/mechanisms/svm/batch-settlement/client/scheme_state.go b/go/mechanisms/svm/batch-settlement/client/scheme_state.go index bf2223b878..6faced5575 100644 --- a/go/mechanisms/svm/batch-settlement/client/scheme_state.go +++ b/go/mechanisms/svm/batch-settlement/client/scheme_state.go @@ -204,17 +204,17 @@ func (s *BatchSvmScheme) locateRefundChannel( } return nil, lookup, resolvedTerms{}, serverErr } - discovered, discoverErr := s.discoverChannel(ctx, requirements, serverTerms) - if discoverErr != nil { - return nil, lookup, resolvedTerms{}, discoverErr + serverChannel, loadErr := s.loadRefundChannel(ctx, requirements, serverTerms, nil) + if loadErr != nil { + return nil, lookup, resolvedTerms{}, loadErr } - if discovered != nil { - lookup = AlignRefundRequirements(requirements, discovered.tracker.ChannelConfig) - terms, err = s.resolveRefundTerms(ctx, lookup, discovered) + if serverChannel != nil { + lookup = AlignRefundRequirements(requirements, serverChannel.tracker.ChannelConfig) + terms, err = s.resolveRefundTerms(ctx, lookup, serverChannel) if err != nil { return nil, lookup, resolvedTerms{}, err } - return discovered, lookup, terms, nil + return serverChannel, lookup, terms, nil } }