diff --git a/pkg/transaction/backend/backend.go b/pkg/transaction/backend/backend.go index 827e7f8176a..67323d14e45 100644 --- a/pkg/transaction/backend/backend.go +++ b/pkg/transaction/backend/backend.go @@ -20,7 +20,7 @@ type Geth interface { CallContract(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) ChainID(ctx context.Context) (*big.Int, error) Close() - EstimateGasAtBlock(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) (uint64, error) + EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error) FilterLogs(ctx context.Context, q ethereum.FilterQuery) ([]types.Log, error) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) diff --git a/pkg/transaction/backendmock/backend.go b/pkg/transaction/backendmock/backend.go index e07095fd352..364493b0341 100644 --- a/pkg/transaction/backendmock/backend.go +++ b/pkg/transaction/backendmock/backend.go @@ -15,12 +15,14 @@ import ( "github.com/ethersphere/bee/v2/pkg/transaction" ) +var ErrNotImplemented = errors.New("not implemented") + type backendMock struct { callContract func(ctx context.Context, call ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) sendTransaction func(ctx context.Context, tx *types.Transaction) error suggestedFeeAndTip func(ctx context.Context, gasPrice *big.Int, boostPercent int) (*big.Int, *big.Int, error) suggestGasTipCap func(ctx context.Context) (*big.Int, error) - estimateGasAtBlock func(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) (gas uint64, err error) + estimateGas func(ctx context.Context, msg ethereum.CallMsg) (gas uint64, err error) transactionReceipt func(ctx context.Context, txHash common.Hash) (*types.Receipt, error) pendingNonceAt func(ctx context.Context, account common.Address) (uint64, error) transactionByHash func(ctx context.Context, hash common.Hash) (tx *types.Transaction, isPending bool, err error) @@ -34,92 +36,92 @@ func (m *backendMock) CallContract(ctx context.Context, call ethereum.CallMsg, b if m.callContract != nil { return m.callContract(ctx, call, blockNumber) } - return nil, errors.New("not implemented") + return nil, ErrNotImplemented } func (m *backendMock) PendingNonceAt(ctx context.Context, account common.Address) (uint64, error) { if m.pendingNonceAt != nil { return m.pendingNonceAt(ctx, account) } - return 0, errors.New("not implemented") + return 0, ErrNotImplemented } func (m *backendMock) SuggestedFeeAndTip(ctx context.Context, gasPrice *big.Int, boostPercent int) (*big.Int, *big.Int, error) { if m.suggestedFeeAndTip != nil { return m.suggestedFeeAndTip(ctx, gasPrice, boostPercent) } - return nil, nil, errors.New("not implemented") + return nil, nil, ErrNotImplemented } -func (m *backendMock) EstimateGasAtBlock(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) (uint64, error) { - if m.estimateGasAtBlock != nil { - return m.estimateGasAtBlock(ctx, msg, blockNumber) +func (m *backendMock) EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error) { + if m.estimateGas != nil { + return m.estimateGas(ctx, msg) } - return 0, errors.New("not implemented") + return 0, ErrNotImplemented } func (m *backendMock) SendTransaction(ctx context.Context, tx *types.Transaction) error { if m.sendTransaction != nil { return m.sendTransaction(ctx, tx) } - return errors.New("not implemented") + return ErrNotImplemented } func (*backendMock) FilterLogs(ctx context.Context, query ethereum.FilterQuery) ([]types.Log, error) { - return nil, errors.New("not implemented") + return nil, ErrNotImplemented } func (m *backendMock) TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) { if m.transactionReceipt != nil { return m.transactionReceipt(ctx, txHash) } - return nil, errors.New("not implemented") + return nil, ErrNotImplemented } func (m *backendMock) TransactionByHash(ctx context.Context, hash common.Hash) (tx *types.Transaction, isPending bool, err error) { if m.transactionByHash != nil { return m.transactionByHash(ctx, hash) } - return nil, false, errors.New("not implemented") + return nil, false, ErrNotImplemented } func (m *backendMock) BlockNumber(ctx context.Context) (uint64, error) { if m.blockNumber != nil { return m.blockNumber(ctx) } - return 0, errors.New("not implemented") + return 0, ErrNotImplemented } func (m *backendMock) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) { if m.headerByNumber != nil { return m.headerByNumber(ctx, number) } - return nil, errors.New("not implemented") + return nil, ErrNotImplemented } func (m *backendMock) BalanceAt(ctx context.Context, address common.Address, block *big.Int) (*big.Int, error) { if m.balanceAt != nil { return m.balanceAt(ctx, address, block) } - return nil, errors.New("not implemented") + return nil, ErrNotImplemented } func (m *backendMock) NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) { if m.nonceAt != nil { return m.nonceAt(ctx, account, blockNumber) } - return 0, errors.New("not implemented") + return 0, ErrNotImplemented } func (m *backendMock) SuggestGasTipCap(ctx context.Context) (*big.Int, error) { if m.suggestGasTipCap != nil { return m.suggestGasTipCap(ctx) } - return nil, errors.New("not implemented") + return nil, ErrNotImplemented } func (m *backendMock) ChainID(ctx context.Context) (*big.Int, error) { - return nil, errors.New("not implemented") + return nil, ErrNotImplemented } func (m *backendMock) Close() {} @@ -171,9 +173,9 @@ func WithSuggestGasTipCapFunc(f func(ctx context.Context) (*big.Int, error)) Opt }) } -func WithEstimateGasAtBlockFunc(f func(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) (gas uint64, err error)) Option { +func WithEstimateGasFunc(f func(ctx context.Context, msg ethereum.CallMsg) (gas uint64, err error)) Option { return optionFunc(func(s *backendMock) { - s.estimateGasAtBlock = f + s.estimateGas = f }) } diff --git a/pkg/transaction/backendnoop/backend.go b/pkg/transaction/backendnoop/backend.go index cc09b459bd2..4b149369981 100644 --- a/pkg/transaction/backendnoop/backend.go +++ b/pkg/transaction/backendnoop/backend.go @@ -55,7 +55,7 @@ func (b *Backend) SuggestGasTipCap(context.Context) (*big.Int, error) { return nil, postagecontract.ErrChainDisabled } -func (b *Backend) EstimateGasAtBlock(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) (uint64, error) { +func (b *Backend) EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error) { return 0, postagecontract.ErrChainDisabled } diff --git a/pkg/transaction/backendsimulation/backend.go b/pkg/transaction/backendsimulation/backend.go index eacddc44f0f..9c647b6404a 100644 --- a/pkg/transaction/backendsimulation/backend.go +++ b/pkg/transaction/backendsimulation/backend.go @@ -16,6 +16,8 @@ import ( "github.com/ethersphere/bee/v2/pkg/transaction" ) +var ErrNotImplemented = errors.New("not implemented") + type AccountAtKey struct { BlockNumber uint64 Account common.Address @@ -84,27 +86,27 @@ func (m *simulatedBackend) advanceBlock() { } func (*simulatedBackend) CallContract(ctx context.Context, call ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) { - return nil, errors.New("not implemented") + return nil, ErrNotImplemented } func (m *simulatedBackend) PendingNonceAt(ctx context.Context, account common.Address) (uint64, error) { - return 0, errors.New("not implemented") + return 0, ErrNotImplemented } func (m *simulatedBackend) SuggestedFeeAndTip(ctx context.Context, gasPrice *big.Int, boostPercent int) (*big.Int, *big.Int, error) { - return nil, nil, errors.New("not implemented") + return nil, nil, ErrNotImplemented } -func (m *simulatedBackend) EstimateGasAtBlock(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) (uint64, error) { - return 0, errors.New("not implemented") +func (m *simulatedBackend) EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error) { + return 0, ErrNotImplemented } func (m *simulatedBackend) SendTransaction(ctx context.Context, tx *types.Transaction) error { - return errors.New("not implemented") + return ErrNotImplemented } func (*simulatedBackend) FilterLogs(ctx context.Context, query ethereum.FilterQuery) ([]types.Log, error) { - return nil, errors.New("not implemented") + return nil, ErrNotImplemented } func (m *simulatedBackend) TransactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) { @@ -117,7 +119,7 @@ func (m *simulatedBackend) TransactionReceipt(ctx context.Context, txHash common } func (m *simulatedBackend) TransactionByHash(ctx context.Context, hash common.Hash) (tx *types.Transaction, isPending bool, err error) { - return nil, false, errors.New("not implemented") + return nil, false, ErrNotImplemented } func (m *simulatedBackend) BlockNumber(ctx context.Context) (uint64, error) { @@ -126,11 +128,11 @@ func (m *simulatedBackend) BlockNumber(ctx context.Context) (uint64, error) { } func (m *simulatedBackend) HeaderByNumber(ctx context.Context, number *big.Int) (*types.Header, error) { - return nil, errors.New("not implemented") + return nil, ErrNotImplemented } func (m *simulatedBackend) BalanceAt(ctx context.Context, address common.Address, block *big.Int) (*big.Int, error) { - return nil, errors.New("not implemented") + return nil, ErrNotImplemented } func (m *simulatedBackend) NonceAt(ctx context.Context, account common.Address, blockNumber *big.Int) (uint64, error) { @@ -143,11 +145,11 @@ func (m *simulatedBackend) NonceAt(ctx context.Context, account common.Address, } func (m *simulatedBackend) SuggestGasTipCap(ctx context.Context) (*big.Int, error) { - return nil, errors.New("not implemented") + return nil, ErrNotImplemented } func (m *simulatedBackend) ChainID(ctx context.Context) (*big.Int, error) { - return nil, errors.New("not implemented") + return nil, ErrNotImplemented } func (m *simulatedBackend) Close() {} diff --git a/pkg/transaction/transaction.go b/pkg/transaction/transaction.go index cc88e7a3ab4..d3494fd866c 100644 --- a/pkg/transaction/transaction.go +++ b/pkg/transaction/transaction.go @@ -44,8 +44,12 @@ var ( ) const ( - DefaultGasLimit = 1_000_000 + DefaultGasLimit = 1_000_000 // Used for contract operations when setGasLimit flag is enabled DefaultTipBoostPercent = 25 + MaxGasLimit = 10_000_000 // Maximum allowed gas limit to prevent excessive values + MinGasLimit = 21_000 // Minimum gas for any transaction + GasBufferPercent = 33 // Add 33% buffer to estimated gas + FallbackGasLimit = 500_000 // Fallback when estimation fails and no minimum is set ) // TxRequest describes a request for a transaction that can be executed. @@ -273,22 +277,55 @@ func (t *transactionService) StoredTransaction(txHash common.Hash) (*StoredTrans func (t *transactionService) prepareTransaction(ctx context.Context, request *TxRequest, nonce uint64, boostPercent int) (tx *types.Transaction, err error) { var gasLimit uint64 if request.GasLimit == 0 { - gasLimit, err = t.backend.EstimateGasAtBlock(ctx, ethereum.CallMsg{ - From: t.sender, - To: request.To, - Data: request.Data, - }, nil) // nil for latest block + // Estimate gas using pending state for consistency with PendingNonceAt + gasLimit, err = t.backend.EstimateGas(ctx, ethereum.CallMsg{ + From: t.sender, + To: request.To, + Data: request.Data, + Value: request.Value, + }) + if err != nil { - t.logger.Debug("estimate gas failed", "error", err) - gasLimit = request.MinEstimatedGasLimit + t.logger.Warning("gas estimation failed, using fallback", + "error", err, + "description", request.Description, + ) + + if request.MinEstimatedGasLimit > 0 { + gasLimit = request.MinEstimatedGasLimit + } else if len(request.Data) > 0 { + // Contract call - use reasonable fallback + gasLimit = FallbackGasLimit + } else { + // Simple transfer - use minimum + gasLimit = MinGasLimit + } + } else { + // Estimation succeeded - add buffer for state changes + gasLimit += gasLimit * GasBufferPercent / 100 + + // Apply minimum if specified + if gasLimit < request.MinEstimatedGasLimit { + gasLimit = request.MinEstimatedGasLimit + } + + // Cap at maximum + if gasLimit > MaxGasLimit { + gasLimit = MaxGasLimit + } } - gasLimit += gasLimit / 2 // add 50% buffer to the estimated gas limit - if gasLimit < request.MinEstimatedGasLimit { - gasLimit = request.MinEstimatedGasLimit + // Ensure absolute minimum + if gasLimit < MinGasLimit { + gasLimit = MinGasLimit } } else { - gasLimit = request.GasLimit + // Use provided gas limit with bounds validation + gasLimit = min(max(request.GasLimit, MinGasLimit), MaxGasLimit) + } + + if gasLimit == 0 { + return nil, errors.New("gas limit cannot be zero") } /* @@ -308,6 +345,16 @@ func (t *transactionService) prepareTransaction(ctx context.Context, request *Tx return nil, err } + t.logger.Debug("prepared transaction", + "to", request.To, + "value", request.Value, + "gas_limit", gasLimit, + "gas_fee_cap", gasFeeCap, + "gas_tip_cap", gasTipCap, + "nonce", nonce, + "description", request.Description, + ) + return types.NewTx(&types.DynamicFeeTx{ Nonce: nonce, ChainID: t.chainID, diff --git a/pkg/transaction/transaction_test.go b/pkg/transaction/transaction_test.go index e9f4e385c32..615962ca513 100644 --- a/pkg/transaction/transaction_test.go +++ b/pkg/transaction/transaction_test.go @@ -117,8 +117,8 @@ func TestTransactionSend(t *testing.T) { txData := common.Hex2Bytes("0xabcdee") value := big.NewInt(1) suggestedGasTip := minimumTip - estimatedGasLimit := uint64(3) - gasLimit := estimatedGasLimit + estimatedGasLimit/2 // added 50% buffer + estimatedGasLimit := uint64(30000) + gasLimit := estimatedGasLimit + estimatedGasLimit*transaction.GasBufferPercent/100 // added 33% buffer nonce := uint64(2) chainID := big.NewInt(5) gasFeeCap := new(big.Int).Add(new(big.Int).Mul(baseFee, big.NewInt(2)), suggestedGasTip) @@ -151,7 +151,7 @@ func TestTransactionSend(t *testing.T) { } return nil }), - backendmock.WithEstimateGasAtBlockFunc(func(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) (gas uint64, err error) { + backendmock.WithEstimateGasFunc(func(ctx context.Context, msg ethereum.CallMsg) (gas uint64, err error) { if !bytes.Equal(msg.To.Bytes(), recipient.Bytes()) { t.Fatalf("estimating with wrong recipient. wanted %x, got %x", recipient, msg.To) } @@ -208,12 +208,15 @@ func TestTransactionSend(t *testing.T) { t.Run("send with estimate error", func(t *testing.T) { t.Parallel() + // When estimation fails, use MinEstimatedGasLimit without buffer + gasLimitFallback := estimatedGasLimit + signedTx := types.NewTx(&types.DynamicFeeTx{ ChainID: chainID, Nonce: nonce, To: &recipient, Value: value, - Gas: gasLimit, + Gas: gasLimitFallback, GasFeeCap: gasFeeCap, GasTipCap: suggestedGasTip, Data: txData, @@ -234,7 +237,7 @@ func TestTransactionSend(t *testing.T) { } return nil }), - backendmock.WithEstimateGasAtBlockFunc(func(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) (gas uint64, err error) { + backendmock.WithEstimateGasFunc(func(ctx context.Context, msg ethereum.CallMsg) (gas uint64, err error) { return 0, errors.New("estimate failure") }), backendmock.WithPendingNonceAtFunc(func(ctx context.Context, account common.Address) (uint64, error) { @@ -267,7 +270,7 @@ func TestTransactionSend(t *testing.T) { t.Fatal("returning wrong transaction hash") } - checkStoredTransaction(t, transactionService, txHash, request, recipient, gasLimit, gasFeeCap, nonce) + checkStoredTransaction(t, transactionService, txHash, request, recipient, gasLimitFallback, gasFeeCap, nonce) pending, err := transactionService.PendingTransactions() if err != nil { @@ -314,7 +317,7 @@ func TestTransactionSend(t *testing.T) { } return nil }), - backendmock.WithEstimateGasAtBlockFunc(func(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) (gas uint64, err error) { + backendmock.WithEstimateGasFunc(func(ctx context.Context, msg ethereum.CallMsg) (gas uint64, err error) { if !bytes.Equal(msg.To.Bytes(), recipient.Bytes()) { t.Fatalf("estimating with wrong recipient. wanted %x, got %x", recipient, msg.To) } @@ -396,7 +399,7 @@ func TestTransactionSend(t *testing.T) { } return nil }), - backendmock.WithEstimateGasAtBlockFunc(func(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) (gas uint64, err error) { + backendmock.WithEstimateGasFunc(func(ctx context.Context, msg ethereum.CallMsg) (gas uint64, err error) { if !bytes.Equal(msg.To.Bytes(), recipient.Bytes()) { t.Fatalf("estimating with wrong recipient. wanted %x, got %x", recipient, msg.To) } @@ -461,7 +464,7 @@ func TestTransactionSend(t *testing.T) { } return nil }), - backendmock.WithEstimateGasAtBlockFunc(func(ctx context.Context, call ethereum.CallMsg, blockNumber *big.Int) (gas uint64, err error) { + backendmock.WithEstimateGasFunc(func(ctx context.Context, call ethereum.CallMsg) (gas uint64, err error) { if !bytes.Equal(call.To.Bytes(), recipient.Bytes()) { t.Fatalf("estimating with wrong recipient. wanted %x, got %x", recipient, call.To) } @@ -527,7 +530,7 @@ func TestTransactionSend(t *testing.T) { } return nil }), - backendmock.WithEstimateGasAtBlockFunc(func(ctx context.Context, call ethereum.CallMsg, blockNumber *big.Int) (gas uint64, err error) { + backendmock.WithEstimateGasFunc(func(ctx context.Context, call ethereum.CallMsg) (gas uint64, err error) { if !bytes.Equal(call.To.Bytes(), recipient.Bytes()) { t.Fatalf("estimating with wrong recipient. wanted %x, got %x", recipient, call.To) } @@ -570,6 +573,496 @@ func TestTransactionSend(t *testing.T) { t.Fatalf("got wrong gas tip in stored transaction. wanted %d, got %d", customGasFeeCap, storedTransaction.GasTipCap) } }) + + t.Run("send with contract fallback", func(t *testing.T) { + t.Parallel() + + // When estimation fails for contract call (has data), use FallbackGasLimit (500k) + contractData := []byte{0xab, 0xcd, 0xef} // Explicit non-empty data for contract call + signedTx := types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, + Nonce: nonce, + To: &recipient, + Value: value, + Gas: transaction.FallbackGasLimit, + GasFeeCap: gasFeeCap, + GasTipCap: suggestedGasTip, + Data: contractData, + }) + request := &transaction.TxRequest{ + To: &recipient, + Data: contractData, + Value: value, + } + store := storemock.NewStateStore() + + transactionService, err := transaction.NewService(logger, sender, + backendmock.New( + backendmock.WithSendTransactionFunc(func(ctx context.Context, tx *types.Transaction) error { + if tx != signedTx { + t.Fatal("not sending signed transaction") + } + return nil + }), + backendmock.WithEstimateGasFunc(func(ctx context.Context, msg ethereum.CallMsg) (gas uint64, err error) { + return 0, errors.New("estimation failed") + }), + backendmock.WithPendingNonceAtFunc(func(ctx context.Context, account common.Address) (uint64, error) { + return nonce, nil + }), + backendmock.WithSuggestedFeeAndTipFunc(func(ctx context.Context, gasPrice *big.Int, boostPercent int) (*big.Int, *big.Int, error) { + return gasFeeCap, suggestedGasTip, nil + }), + ), + signerMockForTransaction(t, signedTx, sender, chainID), + store, + chainID, + monitormock.New(), + ) + if err != nil { + t.Fatal(err) + } + testutil.CleanupCloser(t, transactionService) + + txHash, err := transactionService.Send(context.Background(), request, 0) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(txHash.Bytes(), signedTx.Hash().Bytes()) { + t.Fatal("returning wrong transaction hash") + } + + storedTransaction, err := transactionService.StoredTransaction(txHash) + if err != nil { + t.Fatal(err) + } + + if storedTransaction.GasLimit != transaction.FallbackGasLimit { + t.Fatalf("expected fallback gas limit %d, got %d", transaction.FallbackGasLimit, storedTransaction.GasLimit) + } + }) + + t.Run("send with simple transfer fallback", func(t *testing.T) { + t.Parallel() + + // When estimation fails for simple transfer (no data), use MinGasLimit (21k) + signedTx := types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, + Nonce: nonce, + To: &recipient, + Value: value, + Gas: transaction.MinGasLimit, + GasFeeCap: gasFeeCap, + GasTipCap: suggestedGasTip, + Data: nil, + }) + request := &transaction.TxRequest{ + To: &recipient, + Data: nil, + Value: value, + } + store := storemock.NewStateStore() + + transactionService, err := transaction.NewService(logger, sender, + backendmock.New( + backendmock.WithSendTransactionFunc(func(ctx context.Context, tx *types.Transaction) error { + if tx != signedTx { + t.Fatal("not sending signed transaction") + } + return nil + }), + backendmock.WithEstimateGasFunc(func(ctx context.Context, msg ethereum.CallMsg) (gas uint64, err error) { + return 0, errors.New("estimation failed") + }), + backendmock.WithPendingNonceAtFunc(func(ctx context.Context, account common.Address) (uint64, error) { + return nonce, nil + }), + backendmock.WithSuggestedFeeAndTipFunc(func(ctx context.Context, gasPrice *big.Int, boostPercent int) (*big.Int, *big.Int, error) { + return gasFeeCap, suggestedGasTip, nil + }), + ), + signerMockForTransaction(t, signedTx, sender, chainID), + store, + chainID, + monitormock.New(), + ) + if err != nil { + t.Fatal(err) + } + testutil.CleanupCloser(t, transactionService) + + txHash, err := transactionService.Send(context.Background(), request, 0) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(txHash.Bytes(), signedTx.Hash().Bytes()) { + t.Fatal("returning wrong transaction hash") + } + + storedTransaction, err := transactionService.StoredTransaction(txHash) + if err != nil { + t.Fatal(err) + } + + if storedTransaction.GasLimit != transaction.MinGasLimit { + t.Fatalf("expected min gas limit %d, got %d", transaction.MinGasLimit, storedTransaction.GasLimit) + } + }) + + t.Run("send with max gas limit cap", func(t *testing.T) { + t.Parallel() + + // When estimation returns value that exceeds MaxGasLimit, cap it + highEstimate := uint64(15_000_000) // Above MaxGasLimit of 10M + expectedGasLimit := uint64(transaction.MaxGasLimit) + + signedTx := types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, + Nonce: nonce, + To: &recipient, + Value: value, + Gas: expectedGasLimit, + GasFeeCap: gasFeeCap, + GasTipCap: suggestedGasTip, + Data: txData, + }) + request := &transaction.TxRequest{ + To: &recipient, + Data: txData, + Value: value, + } + store := storemock.NewStateStore() + + transactionService, err := transaction.NewService(logger, sender, + backendmock.New( + backendmock.WithSendTransactionFunc(func(ctx context.Context, tx *types.Transaction) error { + if tx != signedTx { + t.Fatal("not sending signed transaction") + } + return nil + }), + backendmock.WithEstimateGasFunc(func(ctx context.Context, msg ethereum.CallMsg) (gas uint64, err error) { + return highEstimate, nil + }), + backendmock.WithPendingNonceAtFunc(func(ctx context.Context, account common.Address) (uint64, error) { + return nonce, nil + }), + backendmock.WithSuggestedFeeAndTipFunc(func(ctx context.Context, gasPrice *big.Int, boostPercent int) (*big.Int, *big.Int, error) { + return gasFeeCap, suggestedGasTip, nil + }), + ), + signerMockForTransaction(t, signedTx, sender, chainID), + store, + chainID, + monitormock.New(), + ) + if err != nil { + t.Fatal(err) + } + testutil.CleanupCloser(t, transactionService) + + txHash, err := transactionService.Send(context.Background(), request, 0) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(txHash.Bytes(), signedTx.Hash().Bytes()) { + t.Fatal("returning wrong transaction hash") + } + + storedTransaction, err := transactionService.StoredTransaction(txHash) + if err != nil { + t.Fatal(err) + } + + if storedTransaction.GasLimit != transaction.MaxGasLimit { + t.Fatalf("expected max gas limit %d, got %d", transaction.MaxGasLimit, storedTransaction.GasLimit) + } + }) + + t.Run("send with provided gas limit", func(t *testing.T) { + t.Parallel() + + // When GasLimit is explicitly provided, use it with bounds validation + providedGasLimit := uint64(100_000) + + signedTx := types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, + Nonce: nonce, + To: &recipient, + Value: value, + Gas: providedGasLimit, + GasFeeCap: gasFeeCap, + GasTipCap: suggestedGasTip, + Data: txData, + }) + request := &transaction.TxRequest{ + To: &recipient, + Data: txData, + Value: value, + GasLimit: providedGasLimit, + } + store := storemock.NewStateStore() + + transactionService, err := transaction.NewService(logger, sender, + backendmock.New( + backendmock.WithSendTransactionFunc(func(ctx context.Context, tx *types.Transaction) error { + if tx != signedTx { + t.Fatal("not sending signed transaction") + } + return nil + }), + // EstimateGas should not be called when GasLimit is provided + backendmock.WithEstimateGasFunc(func(ctx context.Context, msg ethereum.CallMsg) (gas uint64, err error) { + t.Fatal("EstimateGas should not be called when GasLimit is provided") + return 0, nil + }), + backendmock.WithPendingNonceAtFunc(func(ctx context.Context, account common.Address) (uint64, error) { + return nonce, nil + }), + backendmock.WithSuggestedFeeAndTipFunc(func(ctx context.Context, gasPrice *big.Int, boostPercent int) (*big.Int, *big.Int, error) { + return gasFeeCap, suggestedGasTip, nil + }), + ), + signerMockForTransaction(t, signedTx, sender, chainID), + store, + chainID, + monitormock.New(), + ) + if err != nil { + t.Fatal(err) + } + testutil.CleanupCloser(t, transactionService) + + txHash, err := transactionService.Send(context.Background(), request, 0) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(txHash.Bytes(), signedTx.Hash().Bytes()) { + t.Fatal("returning wrong transaction hash") + } + + storedTransaction, err := transactionService.StoredTransaction(txHash) + if err != nil { + t.Fatal(err) + } + + if storedTransaction.GasLimit != providedGasLimit { + t.Fatalf("expected provided gas limit %d, got %d", providedGasLimit, storedTransaction.GasLimit) + } + }) + + t.Run("send with MinEstimatedGasLimit enforced after buffer", func(t *testing.T) { + t.Parallel() + + // When estimated gas + buffer is below MinEstimatedGasLimit, enforce the minimum + lowEstimate := uint64(50_000) + minGas := uint64(100_000) + // lowEstimate + 33% = 66,500, which is < minGas, so minGas should be used + + signedTx := types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, + Nonce: nonce, + To: &recipient, + Value: value, + Gas: minGas, + GasFeeCap: gasFeeCap, + GasTipCap: suggestedGasTip, + Data: txData, + }) + request := &transaction.TxRequest{ + To: &recipient, + Data: txData, + Value: value, + MinEstimatedGasLimit: minGas, + } + store := storemock.NewStateStore() + + transactionService, err := transaction.NewService(logger, sender, + backendmock.New( + backendmock.WithSendTransactionFunc(func(ctx context.Context, tx *types.Transaction) error { + if tx != signedTx { + t.Fatal("not sending signed transaction") + } + return nil + }), + backendmock.WithEstimateGasFunc(func(ctx context.Context, msg ethereum.CallMsg) (gas uint64, err error) { + return lowEstimate, nil + }), + backendmock.WithPendingNonceAtFunc(func(ctx context.Context, account common.Address) (uint64, error) { + return nonce, nil + }), + backendmock.WithSuggestedFeeAndTipFunc(func(ctx context.Context, gasPrice *big.Int, boostPercent int) (*big.Int, *big.Int, error) { + return gasFeeCap, suggestedGasTip, nil + }), + ), + signerMockForTransaction(t, signedTx, sender, chainID), + store, + chainID, + monitormock.New(), + ) + if err != nil { + t.Fatal(err) + } + testutil.CleanupCloser(t, transactionService) + + txHash, err := transactionService.Send(context.Background(), request, 0) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(txHash.Bytes(), signedTx.Hash().Bytes()) { + t.Fatal("returning wrong transaction hash") + } + + storedTransaction, err := transactionService.StoredTransaction(txHash) + if err != nil { + t.Fatal(err) + } + + if storedTransaction.GasLimit != minGas { + t.Fatalf("expected MinEstimatedGasLimit %d, got %d", minGas, storedTransaction.GasLimit) + } + }) + + t.Run("send with provided gas limit below minimum", func(t *testing.T) { + t.Parallel() + + // When provided GasLimit is below MinGasLimit, enforce MinGasLimit + lowGasLimit := uint64(10_000) // Below MinGasLimit of 21k + + signedTx := types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, + Nonce: nonce, + To: &recipient, + Value: value, + Gas: transaction.MinGasLimit, + GasFeeCap: gasFeeCap, + GasTipCap: suggestedGasTip, + Data: txData, + }) + request := &transaction.TxRequest{ + To: &recipient, + Data: txData, + Value: value, + GasLimit: lowGasLimit, + } + store := storemock.NewStateStore() + + transactionService, err := transaction.NewService(logger, sender, + backendmock.New( + backendmock.WithSendTransactionFunc(func(ctx context.Context, tx *types.Transaction) error { + if tx != signedTx { + t.Fatal("not sending signed transaction") + } + return nil + }), + backendmock.WithPendingNonceAtFunc(func(ctx context.Context, account common.Address) (uint64, error) { + return nonce, nil + }), + backendmock.WithSuggestedFeeAndTipFunc(func(ctx context.Context, gasPrice *big.Int, boostPercent int) (*big.Int, *big.Int, error) { + return gasFeeCap, suggestedGasTip, nil + }), + ), + signerMockForTransaction(t, signedTx, sender, chainID), + store, + chainID, + monitormock.New(), + ) + if err != nil { + t.Fatal(err) + } + testutil.CleanupCloser(t, transactionService) + + txHash, err := transactionService.Send(context.Background(), request, 0) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(txHash.Bytes(), signedTx.Hash().Bytes()) { + t.Fatal("returning wrong transaction hash") + } + + storedTransaction, err := transactionService.StoredTransaction(txHash) + if err != nil { + t.Fatal(err) + } + + if storedTransaction.GasLimit != transaction.MinGasLimit { + t.Fatalf("expected min gas limit enforced %d, got %d", transaction.MinGasLimit, storedTransaction.GasLimit) + } + }) + + t.Run("send with provided gas limit above maximum", func(t *testing.T) { + t.Parallel() + + // When provided GasLimit is above MaxGasLimit, cap at MaxGasLimit + highGasLimit := uint64(15_000_000) // Above MaxGasLimit of 10M + + signedTx := types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, + Nonce: nonce, + To: &recipient, + Value: value, + Gas: transaction.MaxGasLimit, + GasFeeCap: gasFeeCap, + GasTipCap: suggestedGasTip, + Data: txData, + }) + request := &transaction.TxRequest{ + To: &recipient, + Data: txData, + Value: value, + GasLimit: highGasLimit, + } + store := storemock.NewStateStore() + + transactionService, err := transaction.NewService(logger, sender, + backendmock.New( + backendmock.WithSendTransactionFunc(func(ctx context.Context, tx *types.Transaction) error { + if tx != signedTx { + t.Fatal("not sending signed transaction") + } + return nil + }), + backendmock.WithPendingNonceAtFunc(func(ctx context.Context, account common.Address) (uint64, error) { + return nonce, nil + }), + backendmock.WithSuggestedFeeAndTipFunc(func(ctx context.Context, gasPrice *big.Int, boostPercent int) (*big.Int, *big.Int, error) { + return gasFeeCap, suggestedGasTip, nil + }), + ), + signerMockForTransaction(t, signedTx, sender, chainID), + store, + chainID, + monitormock.New(), + ) + if err != nil { + t.Fatal(err) + } + testutil.CleanupCloser(t, transactionService) + + txHash, err := transactionService.Send(context.Background(), request, 0) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(txHash.Bytes(), signedTx.Hash().Bytes()) { + t.Fatal("returning wrong transaction hash") + } + + storedTransaction, err := transactionService.StoredTransaction(txHash) + if err != nil { + t.Fatal(err) + } + + if storedTransaction.GasLimit != transaction.MaxGasLimit { + t.Fatalf("expected max gas limit enforced %d, got %d", transaction.MaxGasLimit, storedTransaction.GasLimit) + } + }) } func TestTransactionWaitForReceipt(t *testing.T) { diff --git a/pkg/transaction/wrapped/wrapped.go b/pkg/transaction/wrapped/wrapped.go index 1d4e452aef3..f573934a7d3 100644 --- a/pkg/transaction/wrapped/wrapped.go +++ b/pkg/transaction/wrapped/wrapped.go @@ -138,10 +138,10 @@ func (b *wrappedBackend) SuggestGasTipCap(ctx context.Context) (*big.Int, error) } return gasTipCap, nil } -func (b *wrappedBackend) EstimateGasAtBlock(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) (uint64, error) { +func (b *wrappedBackend) EstimateGas(ctx context.Context, msg ethereum.CallMsg) (uint64, error) { b.metrics.TotalRPCCalls.Inc() b.metrics.EstimateGasCalls.Inc() - gas, err := b.backend.EstimateGasAtBlock(ctx, msg, blockNumber) + gas, err := b.backend.EstimateGas(ctx, msg) if err != nil { b.metrics.TotalRPCErrors.Inc() return 0, err