From b66c54c598986c384342be619aa677160d51e72a Mon Sep 17 00:00:00 2001 From: Dimitris Date: Mon, 18 May 2026 12:09:05 +0300 Subject: [PATCH 1/3] TXMv2 changes for Hedera --- pkg/config/toml/defaults/Hedera_Testnet.toml | 35 ++++++----- pkg/txm/attempt_builder.go | 62 ++++++++++++++++++-- pkg/txm/attempt_builder_test.go | 22 +++---- pkg/txm/stuck_tx_detector.go | 17 +++++- pkg/txmgr/builder.go | 2 +- 5 files changed, 107 insertions(+), 31 deletions(-) diff --git a/pkg/config/toml/defaults/Hedera_Testnet.toml b/pkg/config/toml/defaults/Hedera_Testnet.toml index c266d3187c..828fbe3422 100644 --- a/pkg/config/toml/defaults/Hedera_Testnet.toml +++ b/pkg/config/toml/defaults/Hedera_Testnet.toml @@ -13,22 +13,29 @@ FinalizedBlockOffset = 2 Enabled = true [GasEstimator] -Mode = 'SuggestedPrice' -# Since Hedera dont have mempool and there's no way for a node to front run or a user to bribe a node to submit the transaction earlier than it's consensus timestamp, -# But they have automated congesting pricing throttling which would mean at high sustained level the gasPrice itself could be increased to prevent malicious behaviour. -# Disabling the Bumpthreshold as TXM now implicity handles the bumping after checking on-chain nonce & re-broadcast for Hedera chain type -BumpThreshold = 0 -BumpMin = '10 gwei' -BumpPercent = 20 -# Dynamic gas estimation is a must Hedera, since Hedera consumes 80% of gaslimit by default, we will end up overpaying for gas +EIP1559DynamicFees = true +Mode = 'FeeHistory' +BumpThreshold = 1 # Force retry after each block. EstimateLimit = true +LimitTransfer = 21_300 +LimitDefault = 200_000 +LimitMax = 200_000 -[Transactions] -# To hit throttling you'd need to maintain 15 m gas /sec over a prolonged period of time. -# Because Hedera's block times are every 2 secs it's less less likely to happen as compared to other chains -# Setting this to little higher even though Hedera has High TPS, We have seen 10-12s to get the trasaction mined & 20-25s incase of failures -# Accounting for Node syncs & avoid re-sending txns before fetching the receipt, setting to 2m -ResendAfterThreshold = '2m' +[GasEstimator.FeeHistory] +CacheTimeout = '4s' + +[GasEstimator.BlockHistory] +# This is used by the FeeHistory estimator in chains that don't have a mempool. +BlockHistorySize = 0 + +[Transactions.TransactionManagerV2] +Enabled = true +BlockTime = '3s' + +[Transactions.AutoPurge] +Enabled = true +Threshold = 5 +MinAttempts = 10000 [NodePool] diff --git a/pkg/txm/attempt_builder.go b/pkg/txm/attempt_builder.go index b50de9c0da..ed85a73938 100644 --- a/pkg/txm/attempt_builder.go +++ b/pkg/txm/attempt_builder.go @@ -10,13 +10,16 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-evm/pkg/assets" + "github.com/smartcontractkit/chainlink-evm/pkg/config/chaintype" "github.com/smartcontractkit/chainlink-evm/pkg/gas" "github.com/smartcontractkit/chainlink-evm/pkg/keys" "github.com/smartcontractkit/chainlink-evm/pkg/txm/types" ) -// maxBumpThreshold controls the maximum number of bumps for an attempt. -const maxBumpThreshold = 5 +const ( + maxBumpThreshold = 5 // maxBumpThreshold controls the maximum number of bumps for an attempt. + hederaWeiToTinybar = 10_000_000_001 // hederaWeiToTinybar is the minimum allowed value for a transfer in Hedera plus 1. Hedera uses HBAR instead of ETH +) type attemptBuilder struct { gas.EvmFeeEstimator @@ -24,15 +27,17 @@ type attemptBuilder struct { keystore keys.TxSigner emptyTxLimitDefault uint64 feeBoost bool + chainType chaintype.ChainType } -func NewAttemptBuilder(priceMaxKey func(common.Address) *assets.Wei, estimator gas.EvmFeeEstimator, keystore keys.TxSigner, emptyTxLimitDefault uint64, feeBoost bool) *attemptBuilder { +func NewAttemptBuilder(priceMaxKey func(common.Address) *assets.Wei, estimator gas.EvmFeeEstimator, keystore keys.TxSigner, emptyTxLimitDefault uint64, feeBoost bool, chainType chaintype.ChainType) *attemptBuilder { return &attemptBuilder{ priceMaxKey: priceMaxKey, EvmFeeEstimator: estimator, keystore: keystore, emptyTxLimitDefault: emptyTxLimitDefault, feeBoost: feeBoost, + chainType: chainType, } } @@ -75,9 +80,11 @@ func (a *attemptBuilder) NewBumpAttempt(ctx context.Context, lggr logger.Logger, } func (a *attemptBuilder) NewAgnosticBumpAttempt(ctx context.Context, lggr logger.Logger, tx *types.Transaction, dynamic bool) (attempt *types.Attempt, err error) { + if a.chainType == chaintype.ChainHedera { + return a.newHederaAttempt(ctx, lggr, tx, a.priceMaxKey(tx.FromAddress), dynamic) + } // if the transaction is purgeable or feeBoost is enabled, NewAttempt will return the max fee instantly, so there is no need to bump attempt, err = a.NewAttempt(ctx, lggr, tx, dynamic) - if err != nil { return } @@ -99,6 +106,45 @@ func (a *attemptBuilder) NewAgnosticBumpAttempt(ctx context.Context, lggr logger return attempt, nil } +// newHederaAttempt is used to build a new attempt for Hedera. +// Hedera is a special case. It doesn't have a mempool but can reject an attempt for unknown reasons, even though the RPC returns success. +// The network binds transactions with unique IDs and a timestamp. If the timestamp exceeds a threshold it will auto-reject the +// transaction no matter how many times we retry. To bypass this case, we fetch a new market price and bump the fee by 1 per attempt +// to forcefully generate a new hash. We avoid max pricing purgeable transactions for the same reason. +func (a *attemptBuilder) newHederaAttempt(ctx context.Context, lggr logger.Logger, tx *types.Transaction, maxPrice *assets.Wei, dynamic bool) (*types.Attempt, error) { + gasLimit := tx.SpecifiedGasLimit + if tx.IsPurgeable { + gasLimit = a.emptyTxLimitDefault + } + fee, estimatedGasLimit, err := a.GetFee(ctx, tx.Data, gasLimit, maxPrice, &tx.FromAddress, &tx.ToAddress) + if err != nil { + return nil, err + } + txType := evmtypes.LegacyTxType + if dynamic { + txType = evmtypes.DynamicFeeTxType + } + + attempt, err := a.newCustomAttempt(ctx, tx, fee, estimatedGasLimit, byte(txType), lggr) + if err != nil { + return nil, err + } + for range tx.AttemptCount { + if attempt.Fee.ValidDynamic() && maxPrice.Cmp(attempt.Fee.GasFeeCap) > 0 { + fee.GasFeeCap = attempt.Fee.GasFeeCap.Add(assets.NewWeiI(1)) // Hedera doesn't have a mempool so maxPriorityFeePerGas is always 0. + } else if attempt.Fee.GasPrice != nil && maxPrice.Cmp(attempt.Fee.GasPrice) > 0 { + fee.GasPrice = attempt.Fee.GasPrice.Add(assets.NewWeiI(1)) + } else { + break + } + attempt, err = a.newCustomAttempt(ctx, tx, fee, estimatedGasLimit, byte(txType), lggr) + if err != nil { + return nil, err + } + } + return attempt, nil +} + func (a *attemptBuilder) newCustomAttempt( ctx context.Context, tx *types.Transaction, @@ -136,6 +182,10 @@ func (a *attemptBuilder) newLegacyAttempt(ctx context.Context, tx *types.Transac toAddress = tx.ToAddress value = tx.Value } + if a.chainType == chaintype.ChainHedera && tx.IsPurgeable { + value = big.NewInt(hederaWeiToTinybar) + toAddress = tx.FromAddress + } if tx.Nonce == nil { return nil, fmt.Errorf("failed to create attempt for txID: %v: nonce empty", tx.ID) } @@ -174,6 +224,10 @@ func (a *attemptBuilder) newDynamicFeeAttempt(ctx context.Context, tx *types.Tra toAddress = tx.ToAddress value = tx.Value } + if a.chainType == chaintype.ChainHedera && tx.IsPurgeable { + value = big.NewInt(10_000_000_001) + toAddress = tx.FromAddress + } if tx.Nonce == nil { return nil, fmt.Errorf("failed to create attempt for txID: %v: nonce empty", tx.ID) } diff --git a/pkg/txm/attempt_builder_test.go b/pkg/txm/attempt_builder_test.go index d387595ea1..5a539818a3 100644 --- a/pkg/txm/attempt_builder_test.go +++ b/pkg/txm/attempt_builder_test.go @@ -22,7 +22,7 @@ import ( ) func TestAttemptBuilder_newLegacyAttempt(t *testing.T) { - ab := NewAttemptBuilder(nil, nil, keystest.TxSigner(nil), 100, false) + ab := NewAttemptBuilder(nil, nil, keystest.TxSigner(nil), 100, false, "") address := testutils.NewAddress() lggr := logger.Test(t) var gasLimit uint64 = 100 @@ -57,7 +57,7 @@ func TestAttemptBuilder_newLegacyAttempt(t *testing.T) { } func TestAttemptBuilder_newDynamicFeeAttempt(t *testing.T) { - ab := NewAttemptBuilder(nil, nil, keystest.TxSigner(nil), 100, false) + ab := NewAttemptBuilder(nil, nil, keystest.TxSigner(nil), 100, false, "") address := testutils.NewAddress() lggr := logger.Test(t) @@ -100,7 +100,7 @@ func TestAttemptBuilder_NewAttempt(t *testing.T) { var nonce uint64 = 1 var specifiedGasLimit uint64 = 200 var emptyGasLimit uint64 = 100 - ab := NewAttemptBuilder(priceMaxKey, mockEstimator, keystest.TxSigner(nil), emptyGasLimit, false) + ab := NewAttemptBuilder(priceMaxKey, mockEstimator, keystest.TxSigner(nil), emptyGasLimit, false, "") address := testutils.NewAddress() lggr := logger.Test(t) @@ -163,7 +163,7 @@ func TestAttemptBuilder_NewAttempt(t *testing.T) { t.Run("uses SpecifiedGasLimit when feeBoost is enabled and tx is not purgeable", func(t *testing.T) { boostEstimator := mocks.NewEvmFeeEstimator(t) - boostAb := NewAttemptBuilder(priceMaxKey, boostEstimator, keystest.TxSigner(nil), emptyGasLimit, true) + boostAb := NewAttemptBuilder(priceMaxKey, boostEstimator, keystest.TxSigner(nil), emptyGasLimit, true, "") tx := &types.Transaction{ID: 10, FromAddress: address, Nonce: &nonce, SpecifiedGasLimit: specifiedGasLimit} boostEstimator.On("GetMaxFee", mock.Anything, mock.Anything, specifiedGasLimit, mock.Anything, mock.Anything, mock.Anything). Return(gas.EvmFee{GasPrice: assets.NewWeiI(100)}, specifiedGasLimit, nil).Once() @@ -175,7 +175,7 @@ func TestAttemptBuilder_NewAttempt(t *testing.T) { t.Run("uses emptyTxLimitDefault when feeBoost is enabled and tx is purgeable", func(t *testing.T) { boostEstimator := mocks.NewEvmFeeEstimator(t) - boostAb := NewAttemptBuilder(priceMaxKey, boostEstimator, keystest.TxSigner(nil), emptyGasLimit, true) + boostAb := NewAttemptBuilder(priceMaxKey, boostEstimator, keystest.TxSigner(nil), emptyGasLimit, true, "") tx := &types.Transaction{ID: 10, FromAddress: address, IsPurgeable: true, Nonce: &nonce, SpecifiedGasLimit: specifiedGasLimit} boostEstimator.On("GetMaxFee", mock.Anything, mock.Anything, emptyGasLimit, mock.Anything, mock.Anything, mock.Anything). Return(gas.EvmFee{GasPrice: assets.NewWeiI(100)}, emptyGasLimit, nil).Once() @@ -196,7 +196,7 @@ func TestAttemptBuilder_NewAgnosticBumpAttempt(t *testing.T) { t.Run("returns original attempt when AttemptCount is 0", func(t *testing.T) { mockEstimator := mocks.NewEvmFeeEstimator(t) - ab := NewAttemptBuilder(priceMaxKey, mockEstimator, keystest.TxSigner(nil), 100, false) + ab := NewAttemptBuilder(priceMaxKey, mockEstimator, keystest.TxSigner(nil), 100, false, "") tx := &types.Transaction{ ID: 10, @@ -220,7 +220,7 @@ func TestAttemptBuilder_NewAgnosticBumpAttempt(t *testing.T) { t.Run("bumps once when AttemptCount is 1", func(t *testing.T) { mockEstimator := mocks.NewEvmFeeEstimator(t) - ab := NewAttemptBuilder(priceMaxKey, mockEstimator, keystest.TxSigner(nil), 100, false) + ab := NewAttemptBuilder(priceMaxKey, mockEstimator, keystest.TxSigner(nil), 100, false, "") tx := &types.Transaction{ ID: 10, @@ -246,7 +246,7 @@ func TestAttemptBuilder_NewAgnosticBumpAttempt(t *testing.T) { t.Run("bumps N times when AttemptCount is N", func(t *testing.T) { mockEstimator := mocks.NewEvmFeeEstimator(t) - ab := NewAttemptBuilder(priceMaxKey, mockEstimator, keystest.TxSigner(nil), 100, false) + ab := NewAttemptBuilder(priceMaxKey, mockEstimator, keystest.TxSigner(nil), 100, false, "") tx := &types.Transaction{ ID: 10, @@ -277,7 +277,7 @@ func TestAttemptBuilder_NewAgnosticBumpAttempt(t *testing.T) { t.Run("returns last valid attempt when BumpFee fails", func(t *testing.T) { mockEstimator := mocks.NewEvmFeeEstimator(t) - ab := NewAttemptBuilder(priceMaxKey, mockEstimator, keystest.TxSigner(nil), 100, false) + ab := NewAttemptBuilder(priceMaxKey, mockEstimator, keystest.TxSigner(nil), 100, false, "") tx := &types.Transaction{ ID: 10, @@ -306,7 +306,7 @@ func TestAttemptBuilder_NewAgnosticBumpAttempt(t *testing.T) { t.Run("caps bumps at maxBumpThreshold", func(t *testing.T) { mockEstimator := mocks.NewEvmFeeEstimator(t) - ab := NewAttemptBuilder(priceMaxKey, mockEstimator, keystest.TxSigner(nil), 100, false) + ab := NewAttemptBuilder(priceMaxKey, mockEstimator, keystest.TxSigner(nil), 100, false, "") tx := &types.Transaction{ ID: 10, @@ -331,7 +331,7 @@ func TestAttemptBuilder_NewAgnosticBumpAttempt(t *testing.T) { t.Run("returns max percentile attempt when transaction is purgeable", func(t *testing.T) { mockEstimator := mocks.NewEvmFeeEstimator(t) - ab := NewAttemptBuilder(priceMaxKey, mockEstimator, keystest.TxSigner(nil), 100, false) + ab := NewAttemptBuilder(priceMaxKey, mockEstimator, keystest.TxSigner(nil), 100, false, "") tx := &types.Transaction{ ID: 10, diff --git a/pkg/txm/stuck_tx_detector.go b/pkg/txm/stuck_tx_detector.go index d9b7465ee7..f1164fb3e5 100644 --- a/pkg/txm/stuck_tx_detector.go +++ b/pkg/txm/stuck_tx_detector.go @@ -16,6 +16,8 @@ import ( "github.com/smartcontractkit/chainlink-evm/pkg/txm/types" ) +const maxHederaAttemptsThreshold = 4 + type StuckTxDetectorConfig struct { BlockTime time.Duration StuckTxBlockThreshold uint32 @@ -42,12 +44,14 @@ func NewStuckTxDetector(lggr logger.Logger, chaintype chaintype.ChainType, confi func (s *stuckTxDetector) DetectStuckTransaction(ctx context.Context, tx *types.Transaction) (bool, error) { //nolint:gocritic //placeholder for upcoming chaintypes switch s.chainType { + case chaintype.ChainHedera: + return s.hederaDetection(tx), nil default: return s.timeBasedDetection(tx), nil } } -// timeBasedDetection marks a transaction if: +// timeBasedDetection marks a transaction purgeable if: // - LastBroadcastAt is nil // - Total attempt count is equal or greater than the maxAttemptsThreshold // @@ -80,6 +84,17 @@ func (s *stuckTxDetector) timeBasedDetection(tx *types.Transaction) bool { return false } +// hederaDetection mars a transaction as purgeable starting from maxHederaAttemptsThreshold. +// Hedera is a unique chain that is not EVM based, but it provides an RPC endpoint that mimics EVM RPC calls. +// This means that the RPC will respond unreliably and can drop your requests without an error. +// To bypass that we want to optimistically broadcast transactions and then fallback to purgeable transactions to clear the nonce. +func (s *stuckTxDetector) hederaDetection(tx *types.Transaction) bool { + if tx.AttemptCount >= maxHederaAttemptsThreshold { + return true + } + return false +} + type APIResponse struct { Status string `json:"status,omitempty"` Hash common.Hash `json:"hash,omitempty"` diff --git a/pkg/txmgr/builder.go b/pkg/txmgr/builder.go index 1f39fa6637..facb63b307 100644 --- a/pkg/txmgr/builder.go +++ b/pkg/txmgr/builder.go @@ -142,7 +142,7 @@ func NewTxmV2( } feeBoost := txmV2Config.DualBroadcast() != nil && *txmV2Config.DualBroadcast() && txmV2Config.FeeBoost() - attemptBuilder := txm.NewAttemptBuilder(fCfg.PriceMaxKey, estimator, keyStore, gasEstimatorConfig.LimitTransfer(), feeBoost) + attemptBuilder := txm.NewAttemptBuilder(fCfg.PriceMaxKey, estimator, keyStore, gasEstimatorConfig.LimitTransfer(), feeBoost, chainConfig.ChainType()) inMemoryStoreManager := storage.NewInMemoryStoreManager(lggr, chainID) readRequestsToMultipleNodes := false if txmV2Config.ReadRequestsToMultipleNodes() != nil && *txmV2Config.ReadRequestsToMultipleNodes() { From 122439418ea9074cb539bed007c711a10813ee54 Mon Sep 17 00:00:00 2001 From: Dimitris Date: Mon, 18 May 2026 12:16:48 +0300 Subject: [PATCH 2/3] Update limit transfer --- pkg/config/toml/defaults/Hedera_Testnet.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/config/toml/defaults/Hedera_Testnet.toml b/pkg/config/toml/defaults/Hedera_Testnet.toml index 828fbe3422..1738bd9cf2 100644 --- a/pkg/config/toml/defaults/Hedera_Testnet.toml +++ b/pkg/config/toml/defaults/Hedera_Testnet.toml @@ -17,7 +17,7 @@ EIP1559DynamicFees = true Mode = 'FeeHistory' BumpThreshold = 1 # Force retry after each block. EstimateLimit = true -LimitTransfer = 21_300 +LimitTransfer = 25_000 LimitDefault = 200_000 LimitMax = 200_000 From 4671b3ba3577c472409413ad1ca9ae86a96dd282 Mon Sep 17 00:00:00 2001 From: Dimitris Date: Mon, 18 May 2026 12:40:53 +0300 Subject: [PATCH 3/3] Fix lint and tests --- pkg/txm/integration-tests/integration_test.go | 4 ++-- pkg/txm/stuck_tx_detector.go | 5 +---- pkg/txm/txm_test.go | 4 ++-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/pkg/txm/integration-tests/integration_test.go b/pkg/txm/integration-tests/integration_test.go index c8cba9a6b8..562391a455 100644 --- a/pkg/txm/integration-tests/integration_test.go +++ b/pkg/txm/integration-tests/integration_test.go @@ -99,7 +99,7 @@ func setupTestnetTXM( require.NoError(t, err, "failed to add private key to keystore") // AttemptBuilder - ab := txm.NewAttemptBuilder(configs.PriceMaxKey, estimator, keystore, configs.LimitTransfer(), false) + ab := txm.NewAttemptBuilder(configs.PriceMaxKey, estimator, keystore, configs.LimitTransfer(), false, "") // InMemory storage store := storage.NewInMemoryStoreManager(lggr, chainID) @@ -158,7 +158,7 @@ func setupDevnetTXM( require.NoError(t, keystore.Add(privateKeyHex), "failed to add private key to keystore") // AttemptBuilder - ab := txm.NewAttemptBuilder(configs.PriceMaxKey, estimator, keystore, configs.LimitDefault(), false) + ab := txm.NewAttemptBuilder(configs.PriceMaxKey, estimator, keystore, configs.LimitDefault(), false, "") // InMemory storage store := storage.NewInMemoryStoreManager(lggr, chainID) diff --git a/pkg/txm/stuck_tx_detector.go b/pkg/txm/stuck_tx_detector.go index f1164fb3e5..f0f4fe8918 100644 --- a/pkg/txm/stuck_tx_detector.go +++ b/pkg/txm/stuck_tx_detector.go @@ -89,10 +89,7 @@ func (s *stuckTxDetector) timeBasedDetection(tx *types.Transaction) bool { // This means that the RPC will respond unreliably and can drop your requests without an error. // To bypass that we want to optimistically broadcast transactions and then fallback to purgeable transactions to clear the nonce. func (s *stuckTxDetector) hederaDetection(tx *types.Transaction) bool { - if tx.AttemptCount >= maxHederaAttemptsThreshold { - return true - } - return false + return tx.AttemptCount >= maxHederaAttemptsThreshold } type APIResponse struct { diff --git a/pkg/txm/txm_test.go b/pkg/txm/txm_test.go index e45c32a901..b16f369922 100644 --- a/pkg/txm/txm_test.go +++ b/pkg/txm/txm_test.go @@ -427,7 +427,7 @@ func TestFlow_ResendTransaction(t *testing.T) { mockEstimator := mocks.NewEvmFeeEstimator(t) defaultGasLimit := uint64(100000) keystore := &keystest.FakeChainStore{} - attemptBuilder := txm.NewAttemptBuilder(func(address common.Address) *assets.Wei { return assets.NewWeiI(1) }, mockEstimator, keystore, 22000, false) + attemptBuilder := txm.NewAttemptBuilder(func(address common.Address) *assets.Wei { return assets.NewWeiI(1) }, mockEstimator, keystore, 22000, false, "") stuckTxDetector := txm.NewStuckTxDetector(logger.Test(t), "", txm.StuckTxDetectorConfig{BlockTime: config.BlockTime, StuckTxBlockThreshold: uint32(config.RetryBlockThreshold + 1)}) tm := txm.NewTxm(logger.Test(t), testutils.FixtureChainID, client, attemptBuilder, txStoreManager, stuckTxDetector, config, keystore, nil, txm.NewNoopTxmMetrics()) initialNonce := uint64(0) @@ -501,7 +501,7 @@ func TestFlow_ErrorHandler(t *testing.T) { config := txm.Config{EIP1559: true, EmptyTxLimitDefault: 22000, RetryBlockThreshold: 0, BlockTime: 2 * time.Second} mockEstimator := mocks.NewEvmFeeEstimator(t) keystore := &keystest.FakeChainStore{} - attemptBuilder := txm.NewAttemptBuilder(func(address common.Address) *assets.Wei { return assets.NewWeiI(1) }, mockEstimator, keystore, 22000, false) + attemptBuilder := txm.NewAttemptBuilder(func(address common.Address) *assets.Wei { return assets.NewWeiI(1) }, mockEstimator, keystore, 22000, false, "") stuckTxDetector := txm.NewStuckTxDetector(lggr, "", txm.StuckTxDetectorConfig{BlockTime: config.BlockTime, StuckTxBlockThreshold: uint32(config.RetryBlockThreshold + 1)}) errorHandler := dualbroadcast.NewErrorHandler() tm := txm.NewTxm(lggr, testutils.FixtureChainID, client, attemptBuilder, txStoreManager, stuckTxDetector, config, keystore, errorHandler, txm.NewNoopTxmMetrics())