Skip to content

Commit e14f152

Browse files
committed
Reject transactions that have already been submitted to the tx pool
1 parent ff7b58e commit e14f152

2 files changed

Lines changed: 88 additions & 6 deletions

File tree

services/requester/batch_tx_pool.go

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package requester
33
import (
44
"context"
55
"encoding/hex"
6+
"fmt"
7+
"slices"
68
"sort"
79
"sync"
810
"time"
@@ -17,6 +19,7 @@ import (
1719
"github.com/onflow/flow-evm-gateway/config"
1820
"github.com/onflow/flow-evm-gateway/metrics"
1921
"github.com/onflow/flow-evm-gateway/models"
22+
errs "github.com/onflow/flow-evm-gateway/models/errors"
2023
"github.com/onflow/flow-evm-gateway/services/requester/keystore"
2124
)
2225

@@ -30,6 +33,11 @@ type pooledEvmTx struct {
3033
nonce uint64
3134
}
3235

36+
type eoaActivityMetadata struct {
37+
submittedAt time.Time
38+
txHashes []gethCommon.Hash
39+
}
40+
3341
// BatchTxPool is a `TxPool` implementation that collects and groups
3442
// transactions based on their EOA signer, and submits them for execution
3543
// using a batch.
@@ -47,7 +55,7 @@ type BatchTxPool struct {
4755
*SingleTxPool
4856
pooledTxs map[gethCommon.Address][]pooledEvmTx
4957
txMux sync.Mutex
50-
eoaActivity *expirable.LRU[gethCommon.Address, time.Time]
58+
eoaActivity *expirable.LRU[gethCommon.Address, eoaActivityMetadata]
5159
}
5260

5361
var _ TxPool = &BatchTxPool{}
@@ -73,7 +81,7 @@ func NewBatchTxPool(
7381
keystore,
7482
)
7583

76-
eoaActivity := expirable.NewLRU[gethCommon.Address, time.Time](
84+
eoaActivity := expirable.NewLRU[gethCommon.Address, eoaActivityMetadata](
7785
eoaActivityCacheSize,
7886
nil,
7987
eoaActivityCacheTTL,
@@ -109,7 +117,6 @@ func (t *BatchTxPool) Add(
109117
if err != nil {
110118
return err
111119
}
112-
113120
txData, err := tx.MarshalBinary()
114121
if err != nil {
115122
return err
@@ -136,8 +143,21 @@ func (t *BatchTxPool) Add(
136143
// For all 3 cases, we record the activity time for the next
137144
// transactions that might come from the same EOA.
138145
// [X] is equal to the configured `TxBatchInterval` duration.
139-
lastActivityTime, found := t.eoaActivity.Get(from)
140-
if !found || time.Since(lastActivityTime) > t.config.TxBatchInterval {
146+
lastActivity, found := t.eoaActivity.Get(from)
147+
txHash := tx.Hash()
148+
149+
// Reject transactions that have already been submitted,
150+
// as they are *bound* to fail. Two transactions with
151+
// identical hashes, are expected to have the exact same
152+
// payload. If the first one was invalid, with a nonce
153+
// mismatch, the second one is guaranteed to be invalid
154+
// as well. No reason in wasting signing keys in this
155+
// case.
156+
if found && slices.Contains(lastActivity.txHashes, txHash) {
157+
return fmt.Errorf("%w: a tx with hash %s has already been submitted", errs.ErrInvalid, txHash)
158+
}
159+
160+
if !found || time.Since(lastActivity.submittedAt) > t.config.TxBatchInterval {
141161
if err := t.submitSingleTransaction(ctx, hexEncodedTx); err != nil {
142162
return err
143163
}
@@ -146,7 +166,16 @@ func (t *BatchTxPool) Add(
146166
t.pooledTxs[from] = append(t.pooledTxs[from], userTx)
147167
}
148168

149-
t.eoaActivity.Add(from, time.Now())
169+
// Update metadata for the last EOA activity
170+
lastActivity.submittedAt = time.Now()
171+
lastActivity.txHashes = append(lastActivity.txHashes, txHash)
172+
// To avoid the slice of hashes from growing indefinitely,
173+
// maintain only a handful of the last tx hashes.
174+
if len(lastActivity.txHashes) > 10 {
175+
lastActivity.txHashes = lastActivity.txHashes[1:]
176+
}
177+
178+
t.eoaActivity.Add(from, lastActivity)
150179

151180
return nil
152181
}

tests/tx_batching_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,59 @@ func Test_MultipleTransactionSubmissionsWithinNonRecentInterval(t *testing.T) {
513513
)
514514
}
515515

516+
func Test_TransactionSubmissionWithPreviouslySubmittedTransactions(t *testing.T) {
517+
_, cfg, stop := setupGatewayNode(t)
518+
defer stop()
519+
520+
rpcTester := &rpcTest{
521+
url: fmt.Sprintf("%s:%d", cfg.RPCHost, cfg.RPCPort),
522+
}
523+
524+
eoaKey, err := crypto.HexToECDSA(eoaTestPrivateKey)
525+
require.NoError(t, err)
526+
527+
testAddr := common.HexToAddress("0x061B63D29332e4de81bD9F51A48609824CD113a8")
528+
nonces := []uint64{0, 1, 2, 3, 2, 3, 4, 5}
529+
var errors []error
530+
hashes := []common.Hash{}
531+
532+
// transfer some funds to the test address
533+
for _, nonce := range nonces {
534+
signed, _, err := evmSign(big.NewInt(1_000_000_000), 23_500, eoaKey, nonce, &testAddr, nil)
535+
require.NoError(t, err)
536+
537+
txHash, err := rpcTester.sendRawTx(signed)
538+
if err != nil {
539+
errors = append(errors, err)
540+
} else {
541+
hashes = append(hashes, txHash)
542+
}
543+
}
544+
545+
require.Len(t, errors, 2)
546+
assert.ErrorContains(
547+
t,
548+
errors[0],
549+
"a tx with hash 0x2bdf4aa4c3e273a624dddfdbde6614786b6a5329e246c531d3e0e9f92e79e04d has already been submitted",
550+
)
551+
assert.ErrorContains(
552+
t,
553+
errors[1],
554+
"a tx with hash 0xb72e1f83861a63b5ad4b927295af07fa9546b01aac5dfce046a5fb20f9be9f2f has already been submitted",
555+
)
556+
557+
assert.Eventually(t, func() bool {
558+
for _, h := range hashes {
559+
rcp, err := rpcTester.getReceipt(h.String())
560+
if err != nil || rcp == nil || rcp.Status != 1 {
561+
return false
562+
}
563+
}
564+
565+
return true
566+
}, time.Second*15, time.Second*1, "all transactions were not executed")
567+
}
568+
516569
func setupGatewayNode(t *testing.T) (emulator.Emulator, config.Config, func()) {
517570
srv, err := startEmulator(true)
518571
require.NoError(t, err)

0 commit comments

Comments
 (0)