Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/chains/legacyevm/evm_txm.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ func newEvmTxm(
logPoller,
opts.KeyStore,
estimator,
cfg.GasEstimator().LimitTransfer(),
)
if cfg.Transactions().TransactionManagerV2().DualBroadcast() == nil || !*cfg.Transactions().TransactionManagerV2().DualBroadcast() {
return txmv2, err
Expand Down
26 changes: 18 additions & 8 deletions pkg/txm/attempt_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,26 @@ import (

type attemptBuilder struct {
gas.EvmFeeEstimator
priceMaxKey func(common.Address) *assets.Wei
keystore keys.TxSigner
priceMaxKey func(common.Address) *assets.Wei
keystore keys.TxSigner
emptyTxLimitDefault uint64
}

func NewAttemptBuilder(priceMaxKey func(common.Address) *assets.Wei, estimator gas.EvmFeeEstimator, keystore keys.TxSigner) *attemptBuilder {
func NewAttemptBuilder(priceMaxKey func(common.Address) *assets.Wei, estimator gas.EvmFeeEstimator, keystore keys.TxSigner, emptyTxLimitDefault uint64) *attemptBuilder {
return &attemptBuilder{
priceMaxKey: priceMaxKey,
EvmFeeEstimator: estimator,
keystore: keystore,
priceMaxKey: priceMaxKey,
EvmFeeEstimator: estimator,
keystore: keystore,
emptyTxLimitDefault: emptyTxLimitDefault,
}
}

func (a *attemptBuilder) NewAttempt(ctx context.Context, lggr logger.Logger, tx *types.Transaction, dynamic bool) (*types.Attempt, error) {
fee, estimatedGasLimit, err := a.EvmFeeEstimator.GetFee(ctx, tx.Data, tx.SpecifiedGasLimit, a.priceMaxKey(tx.FromAddress), &tx.FromAddress, &tx.ToAddress)
gasLimit := tx.SpecifiedGasLimit
if tx.IsPurgeable {
gasLimit = a.emptyTxLimitDefault
}
fee, estimatedGasLimit, err := a.EvmFeeEstimator.GetFee(ctx, tx.Data, gasLimit, a.priceMaxKey(tx.FromAddress), &tx.FromAddress, &tx.ToAddress)
if err != nil {
return nil, err
}
Expand All @@ -42,7 +48,11 @@ func (a *attemptBuilder) NewAttempt(ctx context.Context, lggr logger.Logger, tx
}

func (a *attemptBuilder) NewBumpAttempt(ctx context.Context, lggr logger.Logger, tx *types.Transaction, previousAttempt types.Attempt) (*types.Attempt, error) {
bumpedFee, bumpedFeeLimit, err := a.EvmFeeEstimator.BumpFee(ctx, previousAttempt.Fee, tx.SpecifiedGasLimit, a.priceMaxKey(tx.FromAddress), nil)
gasLimit := tx.SpecifiedGasLimit
if tx.IsPurgeable {
gasLimit = a.emptyTxLimitDefault
}
bumpedFee, bumpedFeeLimit, err := a.EvmFeeEstimator.BumpFee(ctx, previousAttempt.Fee, gasLimit, a.priceMaxKey(tx.FromAddress), nil)
if err != nil {
return nil, err
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/txm/attempt_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
)

func TestAttemptBuilder_newLegacyAttempt(t *testing.T) {
ab := NewAttemptBuilder(nil, nil, keystest.TxSigner(nil))
ab := NewAttemptBuilder(nil, nil, keystest.TxSigner(nil), 100)
address := testutils.NewAddress()
lggr := logger.Test(t)
var gasLimit uint64 = 100
Expand Down Expand Up @@ -51,7 +51,7 @@ func TestAttemptBuilder_newLegacyAttempt(t *testing.T) {
}

func TestAttemptBuilder_newDynamicFeeAttempt(t *testing.T) {
ab := NewAttemptBuilder(nil, nil, keystest.TxSigner(nil))
ab := NewAttemptBuilder(nil, nil, keystest.TxSigner(nil), 100)
address := testutils.NewAddress()

lggr := logger.Test(t)
Expand Down
15 changes: 9 additions & 6 deletions pkg/txm/clientwrappers/dualbroadcast/meta_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ import (
)

const (
timeout = time.Second * 5
metaABI = `[
timeout = time.Second * 5
NoBidsError = "no bids"
NoSolverOps = "no solver operations received"
NoSolverOpsAfterSimulation = "no valid solver operations after simulation"
metaABI = `[
{
"type": "function",
"name": "metacall",
Expand Down Expand Up @@ -142,7 +145,7 @@ func NewMetaClient(lggr logger.Logger, c MetaClientRPC, ks MetaClientKeystore, c
}
Copy link

@lhmerino lhmerino Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the MetaClient currently active -- it looks A-specific?
I noticed a potential dependency in the metrics setup. If beholder is unavailable, will this cause the client to error out as well?

Ideally, we want to ensure the client is decoupled so that a beholder outage doesn't block the main flow. For example, on 10/10 I saw many alerts about beholder potentially having issues.


return &MetaClient{
lggr: logger.Sugared(logger.Named(lggr, "Txm.Txm.MetaClient")),
lggr: logger.Sugared(logger.Named(lggr, "Txm.MetaClient")),
c: c,
ks: ks,
customURL: customURL,
Expand Down Expand Up @@ -179,7 +182,7 @@ func (a *MetaClient) SendTransaction(ctx context.Context, tx *types.Transaction,
return nil
}
a.lggr.Infof("No bids for transactionID(%d): ", tx.ID)
return nil
return errors.New(NoBidsError)
}
a.lggr.Infow("Broadcasting attempt to public mempool", "tx", tx)
return a.c.SendTransaction(ctx, attempt.SignedTransaction)
Expand Down Expand Up @@ -355,7 +358,7 @@ func (a *MetaClient) SendRequest(parentCtx context.Context, tx *types.Transactio
}

if response.Error.ErrorMessage != "" {
if strings.Contains(response.Error.ErrorMessage, "no solver operations received") {
if strings.Contains(response.Error.ErrorMessage, NoSolverOps) || strings.Contains(response.Error.ErrorMessage, NoSolverOpsAfterSimulation) {
a.metrics.RecordBidsReceived(ctx, 0)
return nil, nil
}
Expand Down Expand Up @@ -521,7 +524,7 @@ func (a *MetaClient) SendOperation(ctx context.Context, tx *types.Transaction, a
if err != nil {
return fmt.Errorf("failed to sign attempt for txID: %v, err: %w", tx.ID, err)
}
a.lggr.Infow("Intercepted attempt for tx", "txID", tx.ID, "toAddress", meta.ToAddress, "gasLimit", meta.GasLimit,
a.lggr.Infow("Intercepted attempt for tx", "txID", tx.ID, "hash", signedTx.Hash(), "toAddress", meta.ToAddress, "gasLimit", meta.GasLimit,
"TipCap", tip, "FeeCap", meta.MaxFeePerGas)
return a.c.SendTransaction(ctx, signedTx)
}
31 changes: 31 additions & 0 deletions pkg/txm/clientwrappers/dualbroadcast/meta_error_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package dualbroadcast

import (
"context"
"fmt"
"strings"

"github.com/ethereum/go-ethereum/common"

"github.com/smartcontractkit/chainlink-evm/pkg/txm"
"github.com/smartcontractkit/chainlink-evm/pkg/txm/types"
)

type errorHandler struct{}

func NewErrorHandler() *errorHandler {
return &errorHandler{}
}

func (e *errorHandler) HandleError(ctx context.Context, tx *types.Transaction, txErr error, txStore txm.TxStore, setNonce func(common.Address, uint64), isFromBroadcastMethod bool) error {
// If this isn't the first broadcast, don't mark the tx as fatal as other txs might be included on-chain.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// If this isn't the first broadcast, don't mark the tx as fatal as other txs might be included on-chain.
// Only if this is the first broadcast, mark the tx as fatal. In other cases, other txs might be included on-chain

if strings.Contains(txErr.Error(), NoBidsError) && tx.AttemptCount == 1 {
if err := txStore.MarkTxFatal(ctx, tx, tx.FromAddress); err != nil {
return err
}
setNonce(tx.FromAddress, *tx.Nonce)
return fmt.Errorf("transaction with txID: %d marked as fatal", tx.ID)
}

return txErr
}
10 changes: 7 additions & 3 deletions pkg/txm/clientwrappers/dualbroadcast/selector.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@ import (
"github.com/smartcontractkit/chainlink-evm/pkg/txm"
)

func SelectClient(lggr logger.Logger, client client.Client, keyStore keys.ChainStore, url *url.URL, chainID *big.Int) (txm.Client, error) {
func SelectClient(lggr logger.Logger, client client.Client, keyStore keys.ChainStore, url *url.URL, chainID *big.Int) (txm.Client, txm.ErrorHandler, error) {
urlString := url.String()
switch {
case strings.Contains(urlString, "flashbots"):
return NewFlashbotsClient(client, keyStore, url), nil
return NewFlashbotsClient(client, keyStore, url), nil, nil
default:
return NewMetaClient(lggr, client, keyStore, url, chainID)
Copy link

@lhmerino lhmerino Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code could then be made cleaner if NewMetaClient does not fail.

mc, err := NewMetaClient(lggr, client, keyStore, url, chainID)
if err != nil {
return nil, nil, err
}
return mc, NewErrorHandler(), nil
}
}
13 changes: 10 additions & 3 deletions pkg/txm/storage/inmemory_store.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package storage

import (
"errors"
"fmt"
"math/big"
"sort"
Expand Down Expand Up @@ -122,6 +121,7 @@ func (m *InMemoryStore) CreateEmptyUnconfirmedTransaction(nonce uint64, gasLimit
SpecifiedGasLimit: gasLimit,
Copy link

@lhmerino lhmerino Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: We could simplify this by hardcoding the gas limit to 21_000.

Since a standard ETH transfer to an EOA (0x0) has a fixed cost (21_000), passing in gasLimit and maintaining a separate EmptyTxLimitDefault config seems like unnecessary overhead. In addition, it would also remove the need to add gasEstimatorConfig parameter to func NewTxmV2.

If we were to instead send ETH to/from a smart contract, only then would the gas amount differ.

CreatedAt: time.Now(),
State: txmgr.TxUnconfirmed,
IsPurgeable: true,
}

if _, exists := m.UnconfirmedTransactions[nonce]; exists {
Expand Down Expand Up @@ -366,8 +366,15 @@ func (m *InMemoryStore) DeleteAttemptForUnconfirmedTx(transactionNonce uint64, a
return fmt.Errorf("attempt with hash: %v for txID: %v was not found", attempt.Hash, attempt.TxID)
}

func (m *InMemoryStore) MarkTxFatal(*types.Transaction) error {
return errors.New("not implemented")
func (m *InMemoryStore) MarkTxFatal(txToMark *types.Transaction) error {
m.Lock()
defer m.Unlock()

// TODO: for now do the simple thing and drop the transaction instead of adding it to the fatal queue.
delete(m.UnconfirmedTransactions, *txToMark.Nonce)
delete(m.Transactions, txToMark.ID)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a few safety concerns about deleting these entries:

  • Nonce Gaps: Could deleting this nonce create a gap in our internal inflight list? I'm concerned that higher-nonce transactions currently inflight might stall indefinitely waiting for the tx associated with this nonce to fill.
  • Panic Risk: Dereferencing *txToMark.Nonce without checking if txToMark.Nonce is not nil can cause a panic.
  • Less important but still relevant: could this complicate debugging by deleting this information?

txToMark.State = txmgr.TxFatalError // update the state in case the caller needs to log
return nil
}

// Orchestrator
Expand Down
14 changes: 14 additions & 0 deletions pkg/txm/storage/inmemory_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,20 @@ func TestFindTxWithIdempotencyKey(t *testing.T) {
assert.Nil(t, itx)
}

func TestMarkTxFatal(t *testing.T) {
t.Parallel()
fromAddress := testutils.NewAddress()
m := NewInMemoryStore(logger.Test(t), fromAddress, testutils.FixtureChainID)

var nonce uint64 = 1
tx, err := insertUnconfirmedTransaction(m, nonce)
require.NoError(t, err)
require.NoError(t, m.MarkTxFatal(tx))
assert.Equal(t, txmgr.TxFatalError, tx.State)
assert.Empty(t, m.UnconfirmedTransactions)
assert.Empty(t, m.Transactions)
}

func TestPruneConfirmedTransactions(t *testing.T) {
t.Parallel()
fromAddress := testutils.NewAddress()
Expand Down
Loading