Skip to content

Commit 2738d44

Browse files
Merge pull request #968 from onflow/vishal/batch_fix_with_soft_finality
fix(batch): always pool transactions to eliminate first-tx nonce race (soft-finality)
2 parents be688f3 + 7c70770 commit 2738d44

5 files changed

Lines changed: 187 additions & 170 deletions

File tree

cmd/run/cmd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ func init() {
298298
Cmd.Flags().DurationVar(&cfg.TxBatchInterval, "tx-batch-interval", time.Millisecond*1200, "Time interval upon which to submit the transaction batches to the Flow network.")
299299
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.")
300300
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.")
301-
Cmd.Flags().DurationVar(&cfg.EOAActivityCacheTTL, "eoa-activity-cache-ttl", time.Second*10, "Time interval used to track EOA activity. Tx send more frequently than this interval will be batched. Useful only when batch transaction submission is enabled.")
301+
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.")
302302
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.")
303303

304304
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: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,9 @@ type Config struct {
129129
// of the events from the sealed block in the Flow network.
130130
// CAUTION: This feature is experimental and will cause the node to halt if the events don't match.
131131
ExperimentalSealingVerificationEnabled bool
132-
// EOAActivityCacheTTL is the time interval used to track EOA activity. Tx send more
133-
// frequently than this interval will be batched.
134-
// Useful only when batch transaction submission is enabled.
132+
// EOAActivityCacheTTL is no longer used. Kept for backwards-compatible config
133+
// parsing so existing deployments with --eoa-activity-cache-ttl set do not break.
134+
// Deprecated: has no effect since BatchTxPool now always pools every transaction.
135135
EOAActivityCacheTTL time.Duration
136136
// RpcRequestTimeout is the maximum duration at which JSON-RPC requests should generate
137137
// a response, before they timeout.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ require (
77
github.com/ethereum/go-ethereum v1.16.8
88
github.com/goccy/go-json v0.10.4
99
github.com/hashicorp/go-multierror v1.1.1
10-
github.com/hashicorp/golang-lru/v2 v2.0.7
1110
github.com/holiman/uint256 v1.3.2
1211
github.com/onflow/atree v0.16.0
1312
github.com/onflow/cadence v1.10.3
@@ -99,6 +98,7 @@ require (
9998
github.com/gorilla/websocket v1.5.3 // indirect
10099
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
101100
github.com/hashicorp/errwrap v1.1.0 // indirect
101+
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
102102
github.com/hashicorp/hcl v1.0.0 // indirect
103103
github.com/holiman/bloomfilter/v2 v2.0.3 // indirect
104104
github.com/huandu/go-clone v1.6.0 // indirect

services/requester/batch_tx_pool.go

Lines changed: 66 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010

1111
gethCommon "github.com/ethereum/go-ethereum/common"
1212
gethTypes "github.com/ethereum/go-ethereum/core/types"
13-
"github.com/hashicorp/golang-lru/v2/expirable"
1413
"github.com/onflow/cadence"
1514
"github.com/onflow/flow-go-sdk"
1615
"github.com/rs/zerolog"
@@ -22,34 +21,64 @@ import (
2221
"github.com/onflow/flow-evm-gateway/services/requester/keystore"
2322
)
2423

25-
const eoaActivityCacheSize = 10_000
24+
// BatchTxPool is a TxPool implementation that collects and groups transactions
25+
// by EOA signer, sorts them by nonce, and submits them as a batch via
26+
// EVM.batchRun on each flush interval.
27+
//
28+
// # Problem
29+
//
30+
// Flow does not have a traditional EVM mempool. On standard EVM chains, when a
31+
// wallet sends transactions out-of-nonce-sequence (e.g., nonces 5, 7, 6 in
32+
// parallel), the mempool holds future-nonce transactions until the gap is
33+
// filled. Flow EVM has no such holding mechanism — a transaction whose nonce
34+
// does not match the current account nonce is simply dropped.
35+
//
36+
// The original BatchTxPool implementation partially addressed this by batching
37+
// transactions that arrived from an EOA with "recent activity" (i.e., a prior
38+
// transaction within TxBatchInterval). However, it still submitted the FIRST
39+
// transaction from any burst immediately — before the rest of the burst had
40+
// a chance to arrive. If that first transaction happened to carry a future
41+
// nonce (due to parallel dispatch), it failed, and the gap it left caused all
42+
// subsequent nonces in the batch to fail as well.
43+
//
44+
// # Root cause (confirmed on testnet, 2026-06-03)
45+
//
46+
// A burst of 10 transactions was sent in parallel from a fresh wallet using
47+
// shuffled nonce order: [8 7 3 9 2 1 6 0 4 5].
48+
//
49+
// - The gateway accepted all 10 (eth_sendRawTransaction returned success).
50+
// - Nonce 6 arrived first → no prior EOA activity → submitted immediately
51+
// as a standalone Cadence transaction → failed (on-chain nonce was 0).
52+
// - The remaining 9 nonces [7 3 9 2 1 0 4 5 8] were pooled, sorted to
53+
// [0 1 2 3 4 5 7 8 9], and submitted as EVM.batchRun.
54+
// - EVM.batchRun executed 0→5 successfully, then encountered nonce 7.
55+
// The expected nonce was 6 (which had already failed), so execution
56+
// stopped. Nonces 7, 8, 9 were dropped.
57+
// - Final result: 6/10 landed on-chain; 4 were silently dropped.
58+
//
59+
// # Fix
60+
//
61+
// All transactions are now always enqueued in the pool regardless of prior EOA
62+
// activity. The flush timer (TxBatchInterval) is the sole submission trigger.
63+
// This guarantees that parallel transactions from the same wallet accumulate
64+
// in the pool before being sorted by nonce and submitted atomically.
65+
//
66+
// Trade-off: every transaction now incurs up to TxBatchInterval of additional
67+
// latency before it is submitted to Flow. For the mainnet default of 2.5 s this
68+
// is acceptable; operators can lower the interval for latency-sensitive
69+
// deployments.
70+
type BatchTxPool struct {
71+
*SingleTxPool
72+
pooledTxs map[gethCommon.Address][]pooledEvmTx
73+
txMux sync.Mutex
74+
}
2675

2776
type pooledEvmTx struct {
2877
txPayload cadence.String
2978
txHash gethCommon.Hash
3079
nonce uint64
3180
}
3281

33-
// BatchTxPool is a `TxPool` implementation that collects and groups
34-
// transactions based on their EOA signer, and submits them for execution
35-
// using a batch.
36-
//
37-
// The underlying Cadence EVM API used, is `EVM.batchRun`, instead of the
38-
// `EVM.run` used in `SingleTxPool`.
39-
//
40-
// The main advantage of this implementation over the `SingleTxPool`, is the
41-
// guarantee that transactions originated from the same EOA address, which
42-
// arrive in a short time interval (about the same as Flow's block production rate),
43-
// will be executed in the same order their arrived.
44-
// This helps to reduce the nonce mismatch errors which mainly occur from the
45-
// re-ordering of Cadence transactions that happens from Collection nodes.
46-
type BatchTxPool struct {
47-
*SingleTxPool
48-
pooledTxs map[gethCommon.Address][]pooledEvmTx
49-
txMux sync.Mutex
50-
eoaActivity *expirable.LRU[gethCommon.Address, time.Time]
51-
}
52-
5382
var _ TxPool = &BatchTxPool{}
5483

5584
func NewBatchTxPool(
@@ -77,27 +106,24 @@ func NewBatchTxPool(
77106
return nil, err
78107
}
79108

80-
eoaActivity := expirable.NewLRU[gethCommon.Address, time.Time](
81-
eoaActivityCacheSize,
82-
nil,
83-
config.EOAActivityCacheTTL,
84-
)
85109
batchPool := &BatchTxPool{
86110
SingleTxPool: singleTxPool,
87111
pooledTxs: make(map[gethCommon.Address][]pooledEvmTx),
88112
txMux: sync.Mutex{},
89-
eoaActivity: eoaActivity,
90113
}
91114

92115
go batchPool.processPooledTransactions(ctx)
93116

94117
return batchPool, nil
95118
}
96119

97-
// Add adds the EVM transaction to the tx pool, grouped with the rest of the
98-
// transactions from the same EOA signer.
99-
// After the configured `TxBatchInterval`, the collected transations
100-
// are batched and sent to the Flow network using `EVM.batchRun`, for execution.
120+
// Add enqueues the transaction in the per-EOA pool. It is never submitted
121+
// immediately; the flush goroutine (processPooledTransactions) handles
122+
// submission on every TxBatchInterval tick.
123+
//
124+
// Enqueueing everything — including the first transaction from a burst — is
125+
// the key invariant that prevents the "first tx escapes" race described in the
126+
// type-level comment above.
101127
func (t *BatchTxPool) Add(
102128
ctx context.Context,
103129
tx *gethTypes.Transaction,
@@ -123,44 +149,14 @@ func (t *BatchTxPool) Add(
123149
return err
124150
}
125151

126-
// Scenarios
127-
// 1. EOA activity not found:
128-
// => We send the transaction individually, without adding it
129-
// to the batch pool.
130-
//
131-
// 2. EOA activity found AND it was more than [X] seconds ago:
132-
// => We send the transaction individually, without adding it
133-
// to the batch pool.
134-
//
135-
// 3. EOA activity found AND it was less than [X] seconds ago:
136-
// => We add the transaction to the batch pool, so that it gets
137-
// processed and submitted according to the configured
138-
// `TxBatchInterval`.
139-
//
140-
// For all 3 cases, we record the activity time for the next
141-
// transactions that might come from the same EOA.
142-
// [X] is equal to the configured `TxBatchInterval` duration.
143-
lastActivityTime, found := t.eoaActivity.Get(from)
144-
145-
if !found {
146-
// Case 1. EOA activity not found:
147-
err = t.submitSingleTransaction(ctx, hexEncodedTx)
148-
} else if time.Since(lastActivityTime) > t.config.TxBatchInterval {
149-
// Case 2. EOA activity found AND it was more than [X] seconds ago:
150-
err = t.submitSingleTransaction(ctx, hexEncodedTx)
151-
} else {
152-
// Case 3. EOA activity found AND it was less than [X] seconds ago:
153-
userTx := pooledEvmTx{txPayload: hexEncodedTx, txHash: tx.Hash(), nonce: tx.Nonce()}
154-
// Prevent submission of duplicate transactions, based on their tx hash
155-
if slices.Contains(t.pooledTxs[from], userTx) {
156-
return errs.ErrDuplicateTransaction
157-
}
158-
t.pooledTxs[from] = append(t.pooledTxs[from], userTx)
152+
userTx := pooledEvmTx{txPayload: hexEncodedTx, txHash: tx.Hash(), nonce: tx.Nonce()}
153+
// Prevent submission of duplicate transactions, based on their tx hash
154+
if slices.Contains(t.pooledTxs[from], userTx) {
155+
return errs.ErrDuplicateTransaction
159156
}
157+
t.pooledTxs[from] = append(t.pooledTxs[from], userTx)
160158

161-
t.eoaActivity.Add(from, time.Now())
162-
163-
return err
159+
return nil
164160
}
165161

166162
func (t *BatchTxPool) processPooledTransactions(ctx context.Context) {
@@ -203,8 +199,8 @@ func (t *BatchTxPool) batchSubmitTransactionsForSameAddress(
203199
referenceBlockHeader *flow.BlockHeader,
204200
pooledTxs []pooledEvmTx,
205201
) error {
206-
// Sort the transactions based on their nonce, to make sure
207-
// that no re-ordering has happened due to races etc.
202+
// Sort by nonce to guarantee correct execution order regardless of the
203+
// order in which transactions arrived at the gateway.
208204
sort.Slice(pooledTxs, func(i, j int) bool {
209205
return pooledTxs[i].nonce < pooledTxs[j].nonce
210206
})
@@ -240,34 +236,3 @@ func (t *BatchTxPool) batchSubmitTransactionsForSameAddress(
240236

241237
return nil
242238
}
243-
244-
func (t *BatchTxPool) submitSingleTransaction(
245-
ctx context.Context,
246-
hexEncodedTx cadence.String,
247-
) error {
248-
coinbaseAddress, err := cadence.NewString(t.config.Coinbase.Hex())
249-
if err != nil {
250-
return err
251-
}
252-
253-
script := replaceAddresses(runTxScript, t.config.FlowNetworkID)
254-
flowTx, err := t.buildTransaction(
255-
ctx,
256-
t.getReferenceBlock(),
257-
script,
258-
cadence.NewArray([]cadence.Value{hexEncodedTx}),
259-
coinbaseAddress,
260-
)
261-
if err != nil {
262-
// If there was any error during the transaction build
263-
// process, we record it as a dropped transaction.
264-
t.collector.TransactionsDropped(1)
265-
return err
266-
}
267-
268-
if err := t.client.SendTransaction(ctx, *flowTx); err != nil {
269-
return err
270-
}
271-
272-
return nil
273-
}

0 commit comments

Comments
 (0)