From 01aa7ba50e583a7e164bd1caf1b081157aa89ad3 Mon Sep 17 00:00:00 2001 From: masih Date: Tue, 15 Sep 2026 13:41:59 +0000 Subject: [PATCH 1/3] Read the EVM account nonce before taking the producer mempool lock --- .../internal/autobahn/producer/mempool.go | 31 +++++++- .../autobahn/producer/mempool_test.go | 77 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/producer/mempool.go b/sei-tendermint/internal/autobahn/producer/mempool.go index 13d4f1e359..b0d4a1352d 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool.go +++ b/sei-tendermint/internal/autobahn/producer/mempool.go @@ -201,6 +201,20 @@ func (s *State) getMempool(ctx context.Context) (*mempool, error) { return mp, nil } +func (s *State) preReadEvmNonce(mp *mempool, addr common.Address) (nonce uint64, first types.BlockNumber, haveAppNonce bool, err error) { + for m := range mp.inner.Lock() { + if m.closed { + return 0, 0, false, ErrNotProducing + } + first = m.first + _, tracked := m.evmNonces[addr] + if tracked { + return 0, first, false, nil + } + } + return s.app.EvmNonce(addr), first, true, nil +} + // Inserts transaction. Blocks until there is capacity in the mempool. // NOTE: we currently don't do any tx filtering, which would prevent expensive CheckTxSafe calls. // It has to be added after testnet launch. @@ -244,6 +258,16 @@ func (s *State) insertTx(ctx context.Context, tx tmtypes.Tx, waitIfFull bool) (* return nil, errTooLarge } + var appNonce uint64 + var first types.BlockNumber + var haveAppNonce bool + if resp.IsEVM { + appNonce, first, haveAppNonce, err = s.preReadEvmNonce(mp, resp.EVMSenderAddress) + if err != nil { + return nil, err + } + } + for m, ctrl := range mp.inner.Lock() { if m.closed { return nil, ErrNotProducing @@ -269,7 +293,12 @@ func (s *State) insertTx(ctx context.Context, tx tmtypes.Tx, waitIfFull bool) (* addr := resp.EVMSenderAddress nonce, ok := m.evmNonces[addr] if !ok { - nonce = s.app.EvmNonce(addr) + // The pre-read is valid only while the lane's first block is unchanged. + if haveAppNonce && m.first == first { + nonce = appNonce + } else { + nonce = s.app.EvmNonce(addr) + } } if nonce != resp.EVMNonce { return nil, fmt.Errorf("%w: got %v, want %v", errBadNonce, resp.EVMNonce, nonce) diff --git a/sei-tendermint/internal/autobahn/producer/mempool_test.go b/sei-tendermint/internal/autobahn/producer/mempool_test.go index 4da150ac16..f553c7d13e 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool_test.go +++ b/sei-tendermint/internal/autobahn/producer/mempool_test.go @@ -368,6 +368,83 @@ func TestMempool_BadNonce(t *testing.T) { require.NoError(t, err) } +func TestInsertTx_NewSenderUsesAppNonce(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + app := newTestApp() + env := newTestEnv(rng, app.Cfg(), app.Proxy()) + env.alignLocalMempool() + addr, nonce := app.NewAccount(rng) + + for _, txNonce := range []uint64{nonce, nonce + 1} { + _, err := env.state.InsertTx(ctx, env.genTx(rng, addr, txNonce).encode()) + require.NoError(t, err) + } + require.Equal(t, nonce+2, env.state.EvmNextPendingNonce(addr)) +} + +func TestInsertTx_ConcurrentSequentialNonces(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + app := newTestApp() + env := newTestEnv(rng, app.Cfg(), app.Proxy()) + env.alignLocalMempool() + + const ( + accountCount = 5 + txCount = 20 + ) + type account struct { + addr common.Address + start uint64 + rng utils.Rng + } + accounts := make([]account, accountCount) + for i := range accounts { + accounts[i] = account{rng: rng.Split()} + accounts[i].addr, accounts[i].start = app.NewAccount(rng) + } + + require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { + for _, account := range accounts { + s.Spawn(func() error { + for nonce := account.start; nonce < account.start+txCount; nonce++ { + if _, err := env.state.InsertTx(ctx, env.genTx(account.rng, account.addr, nonce).encode()); err != nil { + return fmt.Errorf("InsertTx(): %w", err) + } + } + return nil + }) + } + return nil + })) + + for _, account := range accounts { + require.Equal(t, account.start+txCount, env.state.EvmNextPendingNonce(account.addr)) + } +} + +func TestInsertTx_BadNonceRejected(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + app := newTestApp() + env := newTestEnv(rng, app.Cfg(), app.Proxy()) + env.alignLocalMempool() + addr, nonce := app.NewAccount(rng) + + for _, txNonce := range []uint64{nonce - 1, nonce + 1} { + _, err := env.state.InsertTx(ctx, env.genTx(rng, addr, txNonce).encode()) + require.ErrorIs(t, err, errBadNonce) + } + _, err := env.state.InsertTx(ctx, env.genTx(rng, addr, nonce).encode()) + require.NoError(t, err) + + for _, txNonce := range []uint64{nonce, nonce + 2} { + _, err := env.state.InsertTx(ctx, env.genTx(rng, addr, txNonce).encode()) + require.ErrorIs(t, err, errBadNonce) + } +} + type blockStats struct { count uint64 sizeBytes uint64 From 1517047dc2a270352fbcb5488e804ee71637bb60 Mon Sep 17 00:00:00 2001 From: masih Date: Tue, 15 Sep 2026 13:42:42 +0000 Subject: [PATCH 2/3] Document the nonce selection invariant in insertTx --- sei-tendermint/internal/autobahn/producer/mempool.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/producer/mempool.go b/sei-tendermint/internal/autobahn/producer/mempool.go index b0d4a1352d..a0cc8baf99 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool.go +++ b/sei-tendermint/internal/autobahn/producer/mempool.go @@ -201,6 +201,9 @@ func (s *State) getMempool(ctx context.Context) (*mempool, error) { return mp, nil } +// preReadEvmNonce reads the app nonce of addr outside the mempool lock, unless the +// mempool already tracks addr. It also returns the lane's first block at the time of +// the check, which insertTx uses to detect prunes racing the read. func (s *State) preReadEvmNonce(mp *mempool, addr common.Address) (nonce uint64, first types.BlockNumber, haveAppNonce bool, err error) { for m := range mp.inner.Lock() { if m.closed { @@ -293,7 +296,11 @@ func (s *State) insertTx(ctx context.Context, tx tmtypes.Tx, waitIfFull bool) (* addr := resp.EVMSenderAddress nonce, ok := m.evmNonces[addr] if !ok { - // The pre-read is valid only while the lane's first block is unchanged. + // The tracked entry, when present, is authoritative: it covers txs already + // sequenced but not yet executed. The pre-read app nonce is used only when + // there is no entry and no block was pruned since it was taken (m.first + // unchanged), since pruning may delete this sender's entry and advance the + // app nonce. if haveAppNonce && m.first == first { nonce = appNonce } else { From 41f18b31809ff0221fbddeb7073617482c604e4b Mon Sep 17 00:00:00 2001 From: masih Date: Wed, 16 Sep 2026 10:06:40 +0000 Subject: [PATCH 3/3] Return the pre-read nonce as an Option --- .../internal/autobahn/producer/mempool.go | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/sei-tendermint/internal/autobahn/producer/mempool.go b/sei-tendermint/internal/autobahn/producer/mempool.go index dc9c6f141b..fb4ae1814a 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool.go +++ b/sei-tendermint/internal/autobahn/producer/mempool.go @@ -203,21 +203,21 @@ func (s *State) getMempool(ctx context.Context) (*mempool, error) { return mp, nil } -// preReadEvmNonce reads the app nonce of addr outside the mempool lock, unless the -// mempool already tracks addr. It also returns the lane's first block at the time of -// the check, which insertTx uses to detect prunes racing the read. -func (s *State) preReadEvmNonce(mp *mempool, addr common.Address) (nonce uint64, first types.BlockNumber, haveAppNonce bool, err error) { +// preReadEvmNonce reads the app nonce of addr outside the mempool lock, returning None +// when the mempool already tracks addr. It also returns the lane's first block at the +// time of the check, which insertTx uses to detect prunes racing the read. +func (s *State) preReadEvmNonce(mp *mempool, addr common.Address) (utils.Option[uint64], types.BlockNumber, error) { + var first types.BlockNumber for m := range mp.inner.Lock() { if m.closed { - return 0, 0, false, ErrNotProducing + return utils.None[uint64](), 0, ErrNotProducing } first = m.first - _, tracked := m.evmNonces[addr] - if tracked { - return 0, first, false, nil + if _, tracked := m.evmNonces[addr]; tracked { + return utils.None[uint64](), first, nil } } - return s.evmNonce(addr), first, true, nil + return utils.Some(s.evmNonce(addr)), first, nil } // checkTx runs the app CheckTx for tx. @@ -331,11 +331,10 @@ func (s *State) doInsertTx(ctx context.Context, tx tmtypes.Tx, waitIfFull bool) return nil, errTooLarge } - var appNonce uint64 + appNonce := utils.None[uint64]() var first types.BlockNumber - var haveAppNonce bool if resp.IsEVM { - appNonce, first, haveAppNonce, err = s.preReadEvmNonce(mp, resp.EVMSenderAddress) + appNonce, first, err = s.preReadEvmNonce(mp, resp.EVMSenderAddress) if err != nil { return nil, err } @@ -371,8 +370,8 @@ func (s *State) doInsertTx(ctx context.Context, tx tmtypes.Tx, waitIfFull bool) // there is no entry and no block was pruned since it was taken (m.first // unchanged), since pruning may delete this sender's entry and advance the // app nonce. - if haveAppNonce && m.first == first { - nonce = appNonce + if pre, ok := appNonce.Get(); ok && m.first == first { + nonce = pre } else { nonce = s.evmNonce(addr) }