Skip to content

Commit dcb57f6

Browse files
pthmasclaude
andauthored
fix(submit): robust nonce recovery via chain re-query (#51)
Replace fragile error-string parsing for sequence mismatch recovery with a direct re-query of the chain via AccountInfo. Detect ErrWrongSequence using ABCI code 32 instead of text patterns. Increase max retry rounds from 2 to 3 and add attempt/address context to mismatch errors. Closes #8 Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b88cd3f commit dcb57f6

2 files changed

Lines changed: 160 additions & 50 deletions

File tree

pkg/submit/direct.go

Lines changed: 21 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"context"
55
"errors"
66
"fmt"
7-
"strconv"
87
"strings"
98
"sync"
109
"time"
@@ -17,7 +16,7 @@ import (
1716
const (
1817
defaultFeeDenom = "utia"
1918
defaultPollInterval = time.Second
20-
maxSequenceRetryRounds = 2
19+
maxSequenceRetryRounds = 3
2120
)
2221

2322
var (
@@ -137,7 +136,7 @@ func (s *DirectSubmitter) broadcastTx(ctx context.Context, req *Request) (*TxSta
137136
defer s.mu.Unlock()
138137

139138
var lastErr error
140-
for range maxSequenceRetryRounds {
139+
for attempt := range maxSequenceRetryRounds {
141140
account, err := s.nextAccountLocked(ctx)
142141
if err != nil {
143142
return nil, err
@@ -151,16 +150,20 @@ func (s *DirectSubmitter) broadcastTx(ctx context.Context, req *Request) (*TxSta
151150
broadcast, err := s.app.BroadcastTx(ctx, txBytes)
152151
if err != nil {
153152
if isSequenceMismatchText(err.Error()) {
154-
s.recoverSequenceLocked(account, err.Error())
155-
lastErr = fmt.Errorf("%w: %w", errSequenceMismatch, err)
153+
if recoverErr := s.recoverSequenceLocked(ctx); recoverErr != nil {
154+
return nil, recoverErr
155+
}
156+
lastErr = fmt.Errorf("%w: attempt %d, address %s: %w", errSequenceMismatch, attempt+1, s.signer.Address(), err)
156157
continue
157158
}
158159
return nil, fmt.Errorf("broadcast blob tx: %w", err)
159160
}
160161
if err := checkTxStatus("broadcast", broadcast); err != nil {
161162
if errors.Is(err, errSequenceMismatch) {
162-
s.recoverSequenceLocked(account, err.Error())
163-
lastErr = err
163+
if recoverErr := s.recoverSequenceLocked(ctx); recoverErr != nil {
164+
return nil, recoverErr
165+
}
166+
lastErr = fmt.Errorf("attempt %d, address %s: %w", attempt+1, s.signer.Address(), err)
164167
continue
165168
}
166169
return nil, err
@@ -228,16 +231,19 @@ func (s *DirectSubmitter) finishSubmission() {
228231
}
229232
}
230233

231-
func (s *DirectSubmitter) recoverSequenceLocked(account *AccountInfo, errText string) {
232-
expected, ok := expectedSequenceFromMismatchText(errText)
233-
if !ok {
234-
s.invalidateSequenceLocked()
235-
return
234+
func (s *DirectSubmitter) recoverSequenceLocked(ctx context.Context) error {
235+
s.invalidateSequenceLocked()
236+
account, err := s.app.AccountInfo(ctx, s.signer.Address())
237+
if err != nil {
238+
return fmt.Errorf("re-query account sequence after mismatch (address: %s): %w", s.signer.Address(), err)
239+
}
240+
if account == nil {
241+
return fmt.Errorf("re-query account sequence after mismatch: empty response for %s", s.signer.Address())
236242
}
237-
238243
s.accountNumber = account.AccountNumber
239-
s.nextSequence = expected
244+
s.nextSequence = account.Sequence
240245
s.sequenceReady = true
246+
return nil
241247
}
242248

243249
func (s *DirectSubmitter) reconcilePendingLocked(ctx context.Context) error {
@@ -453,7 +459,7 @@ func checkTxStatus(stage string, tx *TxStatus) error {
453459
return nil
454460
}
455461

456-
if isSequenceMismatchText(tx.RawLog) {
462+
if tx.Code == 32 {
457463
return fmt.Errorf("%w: %s", errSequenceMismatch, tx.RawLog)
458464
}
459465
if tx.Codespace != "" {
@@ -467,29 +473,6 @@ func isSequenceMismatchText(text string) bool {
467473
return strings.Contains(text, "account sequence mismatch") || strings.Contains(text, "incorrect account sequence")
468474
}
469475

470-
func expectedSequenceFromMismatchText(text string) (uint64, bool) {
471-
lower := strings.ToLower(text)
472-
idx := strings.Index(lower, "expected ")
473-
if idx < 0 {
474-
return 0, false
475-
}
476-
477-
start := idx + len("expected ")
478-
end := start
479-
for end < len(lower) && lower[end] >= '0' && lower[end] <= '9' {
480-
end++
481-
}
482-
if end == start {
483-
return 0, false
484-
}
485-
486-
sequence, err := strconv.ParseUint(lower[start:end], 10, 64)
487-
if err != nil {
488-
return 0, false
489-
}
490-
return sequence, true
491-
}
492-
493476
func isTxNotFound(err error) bool {
494477
return status.Code(err) == codes.NotFound
495478
}

pkg/submit/direct_test.go

Lines changed: 139 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,8 @@ func TestDirectSubmitterRetriesSequenceMismatch(t *testing.T) {
151151
if result.Height != 77 {
152152
t.Fatalf("height = %d, want 77", result.Height)
153153
}
154-
if client.accountCalls != 1 {
155-
t.Fatalf("account calls = %d, want 1", client.accountCalls)
154+
if client.accountCalls != 2 {
155+
t.Fatalf("account calls = %d, want 2", client.accountCalls)
156156
}
157157
if client.broadcastCalls != 2 {
158158
t.Fatalf("broadcast calls = %d, want 2", client.broadcastCalls)
@@ -345,6 +345,7 @@ func TestDirectSubmitterRecoversAfterRestartWithPendingSequences(t *testing.T) {
345345

346346
signer := mustSigner(t)
347347
client := newSequenceRecoveryAppClient(signer.Address(), 7, 11, 16)
348+
client.accountSequenceQueue = []uint64{11, 16}
348349

349350
// Simulate a fresh process with no local sequence cache while the mempool
350351
// still holds earlier pending transactions.
@@ -365,8 +366,8 @@ func TestDirectSubmitterRecoversAfterRestartWithPendingSequences(t *testing.T) {
365366
if result.Height != 116 {
366367
t.Fatalf("height = %d, want 116", result.Height)
367368
}
368-
if client.accountCalls != 1 {
369-
t.Fatalf("account calls = %d, want 1", client.accountCalls)
369+
if client.accountCalls != 2 {
370+
t.Fatalf("account calls = %d, want 2", client.accountCalls)
370371
}
371372
if !slices.Equal(client.attemptSequences, []uint64{11, 16}) {
372373
t.Fatalf("attempt sequences = %v, want [11 16]", client.attemptSequences)
@@ -382,6 +383,7 @@ func TestDirectSubmitterRecoversWhenCachedSequenceFallsBehindExternalWriter(t *t
382383
signer := mustSigner(t)
383384
client := newSequenceRecoveryAppClient(signer.Address(), 7, 16, 16)
384385
client.afterSuccessNext = []uint64{19}
386+
client.accountSequenceQueue = []uint64{16, 19}
385387

386388
submitter, err := NewDirectSubmitter(client, signer, DirectConfig{
387389
ChainID: "mocha-4",
@@ -408,8 +410,8 @@ func TestDirectSubmitterRecoversWhenCachedSequenceFallsBehindExternalWriter(t *t
408410
if second.Height != 119 {
409411
t.Fatalf("second height = %d, want 119", second.Height)
410412
}
411-
if client.accountCalls != 1 {
412-
t.Fatalf("account calls = %d, want 1", client.accountCalls)
413+
if client.accountCalls != 2 {
414+
t.Fatalf("account calls = %d, want 2", client.accountCalls)
413415
}
414416
if !slices.Equal(client.attemptSequences, []uint64{16, 17, 19}) {
415417
t.Fatalf("attempt sequences = %v, want [16 17 19]", client.attemptSequences)
@@ -454,6 +456,125 @@ func TestDirectSubmitterReconcilesPersistedPendingSequenceBeforeBroadcast(t *tes
454456
}
455457
}
456458

459+
func TestDirectSubmitterMismatchDetectedByABCICode(t *testing.T) {
460+
t.Parallel()
461+
462+
signer := mustSigner(t)
463+
// RawLog contains no parseable sequence — detection must come from Code 32 alone.
464+
client := &fakeAppClient{
465+
accountInfos: []*AccountInfo{
466+
{Address: signer.Address(), AccountNumber: 7, Sequence: 1},
467+
{Address: signer.Address(), AccountNumber: 7, Sequence: 2},
468+
},
469+
broadcastStatuses: []*TxStatus{
470+
{Code: 32, RawLog: "wrong sequence"},
471+
{Hash: "ABCDEF"},
472+
},
473+
getTxStatuses: []*TxStatus{{Hash: "ABCDEF", Height: 88}},
474+
}
475+
476+
submitter, err := NewDirectSubmitter(client, signer, DirectConfig{
477+
ChainID: "mocha-4",
478+
GasPrice: 0.002,
479+
ConfirmationTimeout: 100 * time.Millisecond,
480+
})
481+
if err != nil {
482+
t.Fatalf("NewDirectSubmitter: %v", err)
483+
}
484+
submitter.pollInterval = time.Millisecond
485+
486+
result, err := submitter.Submit(context.Background(), testRequest())
487+
if err != nil {
488+
t.Fatalf("Submit: %v", err)
489+
}
490+
if result.Height != 88 {
491+
t.Fatalf("height = %d, want 88", result.Height)
492+
}
493+
if client.broadcastCalls != 2 {
494+
t.Fatalf("broadcast calls = %d, want 2", client.broadcastCalls)
495+
}
496+
if client.accountCalls != 2 {
497+
t.Fatalf("account calls = %d, want 2", client.accountCalls)
498+
}
499+
}
500+
501+
func TestDirectSubmitterMismatchReQueryFails(t *testing.T) {
502+
t.Parallel()
503+
504+
signer := mustSigner(t)
505+
reQueryErr := errors.New("network unreachable")
506+
client := &fakeAppClient{
507+
accountInfos: []*AccountInfo{
508+
{Address: signer.Address(), AccountNumber: 7, Sequence: 1},
509+
},
510+
accountErrs: []error{nil, reQueryErr},
511+
broadcastStatuses: []*TxStatus{
512+
{Code: 32, RawLog: "account sequence mismatch, expected 2, got 1"},
513+
},
514+
}
515+
516+
submitter, err := NewDirectSubmitter(client, signer, DirectConfig{
517+
ChainID: "mocha-4",
518+
GasPrice: 0.002,
519+
ConfirmationTimeout: 100 * time.Millisecond,
520+
})
521+
if err != nil {
522+
t.Fatalf("NewDirectSubmitter: %v", err)
523+
}
524+
submitter.pollInterval = time.Millisecond
525+
526+
_, err = submitter.Submit(context.Background(), testRequest())
527+
if err == nil {
528+
t.Fatal("expected error, got nil")
529+
}
530+
if !errors.Is(err, reQueryErr) {
531+
t.Fatalf("expected re-query error in chain, got: %v", err)
532+
}
533+
}
534+
535+
func TestDirectSubmitterExhaustsAllRetries(t *testing.T) {
536+
t.Parallel()
537+
538+
signer := mustSigner(t)
539+
client := &fakeAppClient{
540+
accountInfos: []*AccountInfo{
541+
{Address: signer.Address(), AccountNumber: 7, Sequence: 1},
542+
{Address: signer.Address(), AccountNumber: 7, Sequence: 2},
543+
{Address: signer.Address(), AccountNumber: 7, Sequence: 3},
544+
{Address: signer.Address(), AccountNumber: 7, Sequence: 4},
545+
},
546+
broadcastStatuses: []*TxStatus{
547+
{Code: 32, RawLog: "account sequence mismatch"},
548+
{Code: 32, RawLog: "account sequence mismatch"},
549+
{Code: 32, RawLog: "account sequence mismatch"},
550+
},
551+
}
552+
553+
submitter, err := NewDirectSubmitter(client, signer, DirectConfig{
554+
ChainID: "mocha-4",
555+
GasPrice: 0.002,
556+
ConfirmationTimeout: 100 * time.Millisecond,
557+
})
558+
if err != nil {
559+
t.Fatalf("NewDirectSubmitter: %v", err)
560+
}
561+
submitter.pollInterval = time.Millisecond
562+
563+
_, err = submitter.Submit(context.Background(), testRequest())
564+
if err == nil {
565+
t.Fatal("expected error after exhausting retries, got nil")
566+
}
567+
if !errors.Is(err, errSequenceMismatch) {
568+
t.Fatalf("expected errSequenceMismatch in chain, got: %v", err)
569+
}
570+
if client.broadcastCalls != 3 {
571+
t.Fatalf("broadcast calls = %d, want 3", client.broadcastCalls)
572+
}
573+
if client.accountCalls != 4 {
574+
t.Fatalf("account calls = %d, want 4", client.accountCalls)
575+
}
576+
}
577+
457578
func TestDirectSubmitterRejectsWhenMaxInFlightExceeded(t *testing.T) {
458579
t.Parallel()
459580

@@ -679,11 +800,12 @@ func decodeInnerTx(raw []byte) ([]byte, error) {
679800
}
680801

681802
type sequenceRecoveryAppClient struct {
682-
address string
683-
accountNumber uint64
684-
committedSequence uint64
685-
nextAvailable uint64
686-
afterSuccessNext []uint64
803+
address string
804+
accountNumber uint64
805+
committedSequence uint64
806+
nextAvailable uint64
807+
afterSuccessNext []uint64
808+
accountSequenceQueue []uint64
687809

688810
mu sync.Mutex
689811
accountCalls int
@@ -707,10 +829,15 @@ func (c *sequenceRecoveryAppClient) AccountInfo(_ context.Context, _ string) (*A
707829
defer c.mu.Unlock()
708830

709831
c.accountCalls++
832+
seq := c.committedSequence
833+
if len(c.accountSequenceQueue) > 0 {
834+
seq = c.accountSequenceQueue[0]
835+
c.accountSequenceQueue = c.accountSequenceQueue[1:]
836+
}
710837
return &AccountInfo{
711838
Address: c.address,
712839
AccountNumber: c.accountNumber,
713-
Sequence: c.committedSequence,
840+
Sequence: seq,
714841
}, nil
715842
}
716843

0 commit comments

Comments
 (0)