Skip to content

Commit eff23ea

Browse files
Merge pull request #977 from onflow/vishal/port-971-nonce-mempool-soft-finality
Port nonce-aware tx mempool (#971) to the soft-finality branch
2 parents b15d8f5 + 1e6390a commit eff23ea

12 files changed

Lines changed: 3052 additions & 427 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,12 @@ The application can be configured using the following flags at runtime:
265265
| `profiler-host` | `localhost` | Host for the pprof profiler |
266266
| `profiler-port` | `6060` | Port for the pprof profiler |
267267
| `tx-state-validation` | `""` | When set to `local-index` will validate EVM transaction state locally |
268+
| `tx-mempool-mode` | `false` | Enable the nonce-aware transaction mempool: expected-nonce transactions are submitted immediately, out-of-order transactions are held per-EOA until their nonce gap fills. Mutually exclusive with `tx-batch-mode` and requires `tx-state-validation=local-index`. |
269+
| `tx-collection-window` | `300ms` | Per-EOA sliding collection window for the transaction mempool. Resets on each arrival from the same EOA. Only applies when `tx-mempool-mode=true`. |
270+
| `tx-submission-spacing` | `1200ms` | Minimum gap between consecutive Cadence submissions for the same EOA in the transaction mempool; also serves as the flush deadline for a continuously-fed collection window. Recommended ~1.5x the block production rate. Must be >= `tx-collection-window`. Only applies when `tx-mempool-mode=true`. |
271+
| `tx-pool-ttl` | `30s` | How long the transaction mempool holds an out-of-order transaction waiting for its nonce gap to fill, before submitting it anyway. Only applies when `tx-mempool-mode=true`. |
272+
| `tx-max-batch-size` | `5` | Maximum number of EVM transactions per `EVM.batchRun` Cadence transaction in the transaction mempool. Only applies when `tx-mempool-mode=true`. |
273+
| `tx-max-nonce-gap` | `500` | How far ahead of an EOA's on-chain nonce the transaction mempool accepts a nonce; nonces beyond `indexedNonce + gap` are rejected as nonce-too-high. `0` disables the upper bound. A nonce below the indexed nonce is always rejected as nonce-too-low. Only applies when `tx-mempool-mode=true`. |
268274

269275

270276
# EVM Gateway Endpoints

bootstrap/bootstrap.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,11 +269,29 @@ func (b *Bootstrap) StartAPIServer(ctx context.Context) error {
269269
// create transaction pool
270270
var txPool requester.TxPool
271271
var err error
272-
if b.config.TxBatchMode {
272+
if b.config.TxMemPoolMode {
273273
nonceProvider := requester.NewLocalNonceProvider(
274274
b.config.FlowNetworkID,
275275
b.storages.Registers,
276276
b.storages.Blocks,
277+
b.collector,
278+
)
279+
txPool, err = requester.NewTxMemPool(
280+
ctx,
281+
b.client,
282+
b.publishers.Transaction,
283+
b.logger,
284+
b.config,
285+
b.collector,
286+
b.keystore,
287+
nonceProvider,
288+
)
289+
} else if b.config.TxBatchMode {
290+
nonceProvider := requester.NewLocalNonceProvider(
291+
b.config.FlowNetworkID,
292+
b.storages.Registers,
293+
b.storages.Blocks,
294+
b.collector,
277295
)
278296
txPool, err = requester.NewBatchTxPool(
279297
ctx,

cmd/run/cmd.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,33 @@ func parseConfigFromFlags() error {
230230
cfg.ExperimentalSoftFinalityEnabled = experimentalSoftFinalityEnabled
231231
cfg.ExperimentalSealingVerificationEnabled = experimentalSealingVerificationEnabled
232232

233+
if cfg.TxMemPoolMode {
234+
if cfg.TxBatchMode {
235+
return fmt.Errorf("tx-mempool-mode and tx-batch-mode are mutually exclusive")
236+
}
237+
if cfg.TxStateValidation != config.LocalIndexValidation {
238+
return fmt.Errorf("tx-mempool-mode requires tx-state-validation=local-index")
239+
}
240+
if cfg.TxCollectionWindow <= 0 {
241+
return fmt.Errorf("tx-collection-window must be > 0 when tx-mempool-mode is enabled")
242+
}
243+
if cfg.TxSubmissionSpacing <= 0 {
244+
return fmt.Errorf("tx-submission-spacing must be > 0 when tx-mempool-mode is enabled")
245+
}
246+
if cfg.TxCollectionWindow > cfg.TxSubmissionSpacing {
247+
return fmt.Errorf(
248+
"tx-collection-window (%s) must not exceed tx-submission-spacing (%s)",
249+
cfg.TxCollectionWindow, cfg.TxSubmissionSpacing,
250+
)
251+
}
252+
if cfg.TxPoolTTL <= 0 {
253+
return fmt.Errorf("tx-pool-ttl must be > 0 when tx-mempool-mode is enabled")
254+
}
255+
if cfg.TxMaxBatchSize < 1 {
256+
return fmt.Errorf("tx-max-batch-size must be >= 1 when tx-mempool-mode is enabled")
257+
}
258+
}
259+
233260
return nil
234261
}
235262

@@ -299,6 +326,12 @@ func init() {
299326
Cmd.Flags().BoolVar(&experimentalSoftFinalityEnabled, "experimental-soft-finality-enabled", false, "Sets whether the gateway should use the experimental soft finality feature. WARNING: This may result in incorrect results being returned in certain circumstances. Use only if you know what you are doing.")
300327
Cmd.Flags().BoolVar(&experimentalSealingVerificationEnabled, "experimental-sealing-verification-enabled", true, "Sets whether the gateway should use the experimental soft finality sealing verification feature. WARNING: This may result in indexing halts if events do not match. Use only if you know what you are doing.")
301328
Cmd.Flags().DurationVar(&cfg.EOAActivityCacheTTL, "eoa-activity-cache-ttl", time.Second*10, "[DEPRECATED] No longer has any effect. BatchTxPool now always pools every transaction before submission.")
329+
Cmd.Flags().BoolVar(&cfg.TxMemPoolMode, "tx-mempool-mode", false, "Enable the transaction mempool: expected-nonce transactions are submitted immediately, out-of-order transactions are held until their nonce gap fills. Mutually exclusive with --tx-batch-mode and requires --tx-state-validation=local-index.")
330+
Cmd.Flags().DurationVar(&cfg.TxCollectionWindow, "tx-collection-window", 300*time.Millisecond, "Per-EOA sliding collection window for the transaction mempool. Resets on each arrival from the same EOA.")
331+
Cmd.Flags().DurationVar(&cfg.TxSubmissionSpacing, "tx-submission-spacing", 1200*time.Millisecond, "Minimum gap between consecutive Cadence submissions for the same EOA in the transaction mempool; also serves as the flush deadline for a continuously-fed collection window. Recommended ~1.5x the block production rate.")
332+
Cmd.Flags().DurationVar(&cfg.TxPoolTTL, "tx-pool-ttl", 30*time.Second, "How long the transaction mempool holds an out-of-order transaction waiting for its nonce gap to fill, before submitting it anyway.")
333+
Cmd.Flags().IntVar(&cfg.TxMaxBatchSize, "tx-max-batch-size", 5, "Maximum number of EVM transactions per EVM.batchRun Cadence transaction in the transaction mempool.")
334+
Cmd.Flags().Uint64Var(&cfg.TxMaxNonceGap, "tx-max-nonce-gap", 500, "How far ahead of an EOA's on-chain nonce the transaction mempool accepts a nonce; nonces beyond indexedNonce+gap are rejected as nonce-too-high. 0 means no upper bound. A nonce below the indexed nonce is always rejected as nonce-too-low regardless of this setting.")
302335
Cmd.Flags().DurationVar(&cfg.RpcRequestTimeout, "rpc-request-timeout", time.Second*120, "Sets the maximum duration at which JSON-RPC requests should generate a response, before they timeout. The default is 120 seconds.")
303336

304337
err := Cmd.Flags().MarkDeprecated("init-cadence-height", "This flag is no longer necessary and will be removed in future version. The initial Cadence height is known for testnet/mainnet and this was only required for fresh deployments of EVM Gateway. Once the DB has been initialized, the latest index Cadence height will be used upon start-up.")

config/config.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,37 @@ type Config struct {
133133
// parsing so existing deployments with --eoa-activity-cache-ttl set do not break.
134134
// Deprecated: has no effect since BatchTxPool now always pools every transaction.
135135
EOAActivityCacheTTL time.Duration
136+
// TxMemPoolMode configures the gateway to use the transaction mempool:
137+
// transactions carrying the expected next nonce (with nothing in flight)
138+
// are submitted immediately, out-of-order transactions are held until their
139+
// nonce gap fills, and consecutive Cadence submissions for the same EOA are
140+
// spaced apart to avoid Collection Node re-ordering.
141+
TxMemPoolMode bool
142+
// TxCollectionWindow is the per-EOA sliding collection window used by the
143+
// transaction mempool. The window resets on each new transaction arrival
144+
// from the same EOA; when it elapses, the collected transactions are flushed.
145+
TxCollectionWindow time.Duration
146+
// TxSubmissionSpacing is the minimum gap between two consecutive Cadence
147+
// transaction submissions for the same EOA (recommended ~1.5x the block
148+
// production rate). It also serves as the flush deadline for a
149+
// continuously-fed collection window, anchored at first enqueue.
150+
TxSubmissionSpacing time.Duration
151+
// TxPoolTTL is how long the transaction mempool holds an out-of-order
152+
// transaction waiting for its nonce gap to fill. On expiry the transaction
153+
// is submitted anyway, so the failure is observable instead of a silent drop.
154+
TxPoolTTL time.Duration
155+
// TxMaxBatchSize is the maximum number of EVM transactions submitted in a
156+
// single EVM.batchRun Cadence transaction by the transaction mempool,
157+
// bounded by the Cadence transaction computation limit.
158+
TxMaxBatchSize int
159+
// TxMaxNonceGap is how far ahead of an EOA's on-chain nonce the transaction
160+
// mempool will accept a nonce. A transaction whose nonce exceeds
161+
// indexedNonce + TxMaxNonceGap is rejected up front with ErrNonceTooHigh,
162+
// giving the client immediate feedback instead of holding an unexecutable tx
163+
// until TTL. 0 means no upper bound (any future nonce is accepted). This
164+
// bounds only the upper end: a nonce below the indexed nonce is always
165+
// rejected with ErrNonceTooLow regardless of this setting.
166+
TxMaxNonceGap uint64
136167
// RpcRequestTimeout is the maximum duration at which JSON-RPC requests should generate
137168
// a response, before they timeout.
138169
RpcRequestTimeout time.Duration

metrics/collector.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,26 @@ var flowTotalSupply = prometheus.NewGauge(prometheus.GaugeOpts{
100100
Help: "Total supply of FLOW tokens in EVM at a given time (in smallest unit, wei)",
101101
})
102102

103+
var txPoolQueues = prometheus.NewGauge(prometheus.GaugeOpts{
104+
Name: prefixedName("txpool_queues"),
105+
Help: "Number of per-EOA queues currently held by the transaction mempool",
106+
})
107+
108+
var txPoolQueuedTransactions = prometheus.NewGauge(prometheus.GaugeOpts{
109+
Name: prefixedName("txpool_queued_transactions"),
110+
Help: "Total number of transactions currently held across all queues in the transaction mempool",
111+
})
112+
113+
var txPoolSubmissions = prometheus.NewCounterVec(prometheus.CounterOpts{
114+
Name: prefixedName("txpool_submissions_total"),
115+
Help: "Total EVM transaction batches the mempool submitted to Flow, by flush reason (fast-path, consecutive-prefix, ttl-expiry)",
116+
}, []string{"reason"})
117+
118+
var txPoolNonceViewCache = prometheus.NewCounterVec(prometheus.CounterOpts{
119+
Name: prefixedName("txpool_nonce_view_cache_total"),
120+
Help: "Block-view cache accesses when reading EOA nonces (hit = reused the view built for the indexed height; miss = rebuilt it)",
121+
}, []string{"result"})
122+
103123
var metrics = []prometheus.Collector{
104124
apiErrors,
105125
serverPanicsCounters,
@@ -118,6 +138,10 @@ var metrics = []prometheus.Collector{
118138
transactionsDroppedCounter,
119139
rateLimitedTransactionsCounter,
120140
flowTotalSupply,
141+
txPoolQueues,
142+
txPoolQueuedTransactions,
143+
txPoolSubmissions,
144+
txPoolNonceViewCache,
121145
}
122146

123147
type Collector interface {
@@ -137,6 +161,9 @@ type Collector interface {
137161
TransactionsDropped(count int)
138162
TransactionRateLimited()
139163
FlowTotalSupply(totalSupply *big.Int)
164+
TxPoolSize(queues int, queuedTransactions int)
165+
TxPoolSubmission(reason string)
166+
NonceViewCache(hit bool)
140167
}
141168

142169
var _ Collector = &DefaultCollector{}
@@ -162,6 +189,10 @@ type DefaultCollector struct {
162189
transactionsDroppedCounter prometheus.Counter
163190
rateLimitedTransactionsCounter prometheus.Counter
164191
flowTotalSupply prometheus.Gauge
192+
txPoolQueues prometheus.Gauge
193+
txPoolQueuedTransactions prometheus.Gauge
194+
txPoolSubmissions *prometheus.CounterVec
195+
txPoolNonceViewCache *prometheus.CounterVec
165196
}
166197

167198
func NewCollector(logger zerolog.Logger) Collector {
@@ -189,6 +220,10 @@ func NewCollector(logger zerolog.Logger) Collector {
189220
transactionsDroppedCounter: transactionsDroppedCounter,
190221
rateLimitedTransactionsCounter: rateLimitedTransactionsCounter,
191222
flowTotalSupply: flowTotalSupply,
223+
txPoolQueues: txPoolQueues,
224+
txPoolQueuedTransactions: txPoolQueuedTransactions,
225+
txPoolSubmissions: txPoolSubmissions,
226+
txPoolNonceViewCache: txPoolNonceViewCache,
192227
}
193228
}
194229

@@ -288,6 +323,23 @@ func (c *DefaultCollector) FlowTotalSupply(totalSupply *big.Int) {
288323
c.flowTotalSupply.Set(floatTotalSupply)
289324
}
290325

326+
func (c *DefaultCollector) TxPoolSize(queues int, queuedTransactions int) {
327+
c.txPoolQueues.Set(float64(queues))
328+
c.txPoolQueuedTransactions.Set(float64(queuedTransactions))
329+
}
330+
331+
func (c *DefaultCollector) TxPoolSubmission(reason string) {
332+
c.txPoolSubmissions.With(prometheus.Labels{"reason": reason}).Inc()
333+
}
334+
335+
func (c *DefaultCollector) NonceViewCache(hit bool) {
336+
result := "miss"
337+
if hit {
338+
result = "hit"
339+
}
340+
c.txPoolNonceViewCache.With(prometheus.Labels{"result": result}).Inc()
341+
}
342+
291343
func prefixedName(name string) string {
292344
return fmt.Sprintf("evm_gateway_%s", name)
293345
}

metrics/nop.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,6 @@ func (c *nopCollector) RequestRateLimited(method string) {}
2727
func (c *nopCollector) TransactionsDropped(count int) {}
2828
func (c *nopCollector) TransactionRateLimited() {}
2929
func (c *nopCollector) FlowTotalSupply(totalSupply *big.Int) {}
30+
func (c *nopCollector) TxPoolSize(queues int, queued int) {}
31+
func (c *nopCollector) TxPoolSubmission(reason string) {}
32+
func (c *nopCollector) NonceViewCache(hit bool) {}

models/errors/errors.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ var (
3232
ErrFailedTransaction = errors.New("failed transaction")
3333
ErrInvalidTransaction = fmt.Errorf("%w: %w", ErrInvalid, ErrFailedTransaction)
3434
ErrDuplicateTransaction = fmt.Errorf("%w: %s", ErrInvalid, "transaction already in pool")
35+
// ErrInFlightNonce is returned when a transaction carries a nonce that has
36+
// already been submitted to the network and is awaiting execution. Letting
37+
// it through would burn Flow fees on a guaranteed nonce-mismatch failure.
38+
ErrInFlightNonce = fmt.Errorf("%w: %s", ErrInvalid, "transaction with the same nonce already submitted")
39+
// ErrNonceTooLow is returned when a transaction's nonce is below the EOA's
40+
// current on-chain nonce: it has already been used and can never execute.
41+
ErrNonceTooLow = fmt.Errorf("%w: %s", ErrInvalid, "nonce too low")
42+
// ErrNonceTooHigh is returned when a transaction's nonce is more than the
43+
// configured maximum gap ahead of the EOA's on-chain nonce. Such a tx cannot
44+
// execute until the gap fills, so it is rejected up front for fast feedback.
45+
ErrNonceTooHigh = fmt.Errorf("%w: %s", ErrInvalid, "nonce too high")
3546

3647
// Storage errors
3748

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,63 @@
11
package requester
22

33
import (
4+
"sync"
5+
46
gethCommon "github.com/ethereum/go-ethereum/common"
57
"github.com/onflow/flow-go/fvm/evm"
68
"github.com/onflow/flow-go/fvm/evm/offchain/query"
79
flowGo "github.com/onflow/flow-go/model/flow"
810

11+
"github.com/onflow/flow-evm-gateway/metrics"
912
"github.com/onflow/flow-evm-gateway/storage"
1013
"github.com/onflow/flow-evm-gateway/storage/pebble"
1114
)
1215

13-
// NonceProvider returns the current nonce of the given EOA address.
14-
// The transaction mempool uses it to determine the expected next nonce.
16+
// NonceView reads EOA nonces at a single, fixed EVM state (one built block
17+
// view). The mempool reads many EOAs' nonces from one view per flush tick
18+
// rather than rebuilding the (expensive) view per address. It is an interface
19+
// so tests can fake it without constructing a real query.View.
20+
type NonceView interface {
21+
// GetNonce returns the EOA's account nonce (the next nonce to use) at this
22+
// view's state. Named GetNonce — not GetNextNonce — because this interface is
23+
// satisfied directly by flow-go's query.View, whose method is GetNonce.
24+
GetNonce(address gethCommon.Address) (uint64, error)
25+
}
26+
27+
// NonceProvider returns the next nonce of the given EOA address. The transaction
28+
// mempool uses it to determine the expected next nonce.
1529
type NonceProvider interface {
16-
// GetNonce returns the current nonce of the given EOA address.
30+
// GetNextNonce returns the account nonce of the given EOA — its transaction
31+
// count, i.e. the next nonce the EOA should use (matches eth_getTransactionCount).
1732
//
1833
// A non-nil error represents an EXCEPTION, not an expected condition:
1934
// the underlying read is a local state-index lookup that should not
2035
// fail under normal operation. Callers must therefore treat an error
2136
// as a hard failure (reject the transaction / abort the operation)
2237
// rather than a routine, recoverable condition to swallow.
23-
GetNonce(address gethCommon.Address) (uint64, error)
38+
GetNextNonce(address gethCommon.Address) (uint64, error)
2439

25-
// GetBlockView provides query capabilities over a specific state of the EVM chain.
26-
GetBlockView() (*query.View, error)
40+
// GetBlockView returns a NonceView over the latest indexed EVM state. A
41+
// non-nil error is an EXCEPTION, same contract as GetNextNonce.
42+
GetBlockView() (NonceView, error)
2743
}
2844

2945
// LocalNonceProvider reads the EOA nonce from the latest height of the
30-
// local state index.
46+
// local state index. It caches the built block view and reuses it while the
47+
// indexed height is unchanged (see GetBlockView).
3148
type LocalNonceProvider struct {
3249
chainID flowGo.ChainID
3350
registerStore *pebble.RegisterStorage
3451
blocks storage.BlockIndexer
52+
collector metrics.Collector
53+
54+
// mu guards only the cached-view slot below (its read and update), NOT the
55+
// expensive view build in GetBlockView. Note the cached view itself is shared
56+
// across reads; callers that read it concurrently must still serialize (the
57+
// mempool does, via its queueMux).
58+
mu sync.Mutex
59+
cachedView NonceView
60+
cachedHeight uint64
3561
}
3662

3763
var _ NonceProvider = &LocalNonceProvider{}
@@ -40,20 +66,44 @@ func NewLocalNonceProvider(
4066
chainID flowGo.ChainID,
4167
registerStore *pebble.RegisterStorage,
4268
blocks storage.BlockIndexer,
69+
collector metrics.Collector,
4370
) *LocalNonceProvider {
4471
return &LocalNonceProvider{
4572
chainID: chainID,
4673
registerStore: registerStore,
4774
blocks: blocks,
75+
collector: collector,
4876
}
4977
}
5078

51-
func (p *LocalNonceProvider) GetBlockView() (*query.View, error) {
79+
// GetBlockView returns a NonceView over the latest indexed EVM height. The view
80+
// is cached and reused while the indexed height is unchanged, so a burst of
81+
// reads within one block — many Add calls, or a collectDueBatches pass — builds
82+
// the (expensive) view only once. It is rebuilt when a new block is indexed.
83+
// Reuse is safe because an EOA's on-chain nonce cannot change without a new
84+
// block being indexed.
85+
func (p *LocalNonceProvider) GetBlockView() (NonceView, error) {
5286
height, err := p.blocks.LatestEVMHeight()
5387
if err != nil {
5488
return nil, err
5589
}
5690

91+
// Fast path: reuse the cached view for this indexed height. Only the cache
92+
// read (and the update below) is locked — the expensive view build runs
93+
// OUTSIDE the lock. A concurrent miss may build the view more than once for
94+
// the same height, which is wasteful but correct (same height => same view)
95+
// and does not occur under the mempool's serialized (queueMux) access.
96+
p.mu.Lock()
97+
if p.cachedView != nil && p.cachedHeight == height {
98+
view := p.cachedView
99+
p.mu.Unlock()
100+
p.collector.NonceViewCache(true)
101+
return view, nil
102+
}
103+
p.mu.Unlock()
104+
105+
p.collector.NonceViewCache(false)
106+
57107
viewProvider := query.NewViewProvider(
58108
p.chainID,
59109
evm.StorageAccountAddress(p.chainID),
@@ -67,13 +117,19 @@ func (p *LocalNonceProvider) GetBlockView() (*query.View, error) {
67117
return nil, err
68118
}
69119

120+
p.mu.Lock()
121+
p.cachedView = view
122+
p.cachedHeight = height
123+
p.mu.Unlock()
124+
70125
return view, nil
71126
}
72127

73-
func (p *LocalNonceProvider) GetNonce(address gethCommon.Address) (uint64, error) {
128+
func (p *LocalNonceProvider) GetNextNonce(address gethCommon.Address) (uint64, error) {
74129
view, err := p.GetBlockView()
75130
if err != nil {
76131
return 0, err
77132
}
133+
78134
return view.GetNonce(address)
79135
}

0 commit comments

Comments
 (0)