From ade2558da3c920290f4893d4a4fbb94094f0873b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 5 May 2026 13:43:12 +0000 Subject: [PATCH 01/13] fix(ecdsa): add GroupQuorum >= HonestThreshold invariant check Validate that activeThreshold/groupQuorum is not less than groupThreshold/honestThreshold when reading group parameters from EcdsaDkgValidator. Malformed on-chain data could otherwise produce contradictory protocol parameters that pass the individual range checks while being internally inconsistent. --- pkg/chain/ethereum/ecdsa_dkg_validator_chain.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/chain/ethereum/ecdsa_dkg_validator_chain.go b/pkg/chain/ethereum/ecdsa_dkg_validator_chain.go index 1acea42b15..885bf20f0a 100644 --- a/pkg/chain/ethereum/ecdsa_dkg_validator_chain.go +++ b/pkg/chain/ethereum/ecdsa_dkg_validator_chain.go @@ -133,6 +133,13 @@ func ecdsaWalletGroupParametersFromValidator( groupSize, ) } + if groupQuorum < honestThreshold { + return nil, fmt.Errorf( + "activeThreshold/groupQuorum [%d] is less than groupThreshold/honestThreshold [%d]", + groupQuorum, + honestThreshold, + ) + } return &tbtc.GroupParameters{ GroupSize: groupSize, From 397a2fa156f8c72adf4e4b4251afe17f73f5981c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 5 May 2026 13:43:31 +0000 Subject: [PATCH 02/13] fix(tbtc): log mainnet group parameters in default branch The Sepolia/Developer branch already logs the parameters it chooses. Add the same log in the default (mainnet) branch so operators can verify the group sizing on startup regardless of network. --- pkg/tbtc/tbtc.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index b4767078bb..cc6c0efe45 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -63,6 +63,10 @@ func defaultGroupParameters(n ethereum.Network) *GroupParameters { HonestThreshold: 2, } default: + logger.Infof( + "TBTC group parameters: mainnet defaults (size=100, quorum=90, honest=51) for %s", + n, + ) return &GroupParameters{ GroupSize: 100, GroupQuorum: 90, From fac8fd1e0d0934c922abb22485e1e09cd58c2efc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 5 May 2026 13:43:49 +0000 Subject: [PATCH 03/13] refactor(tbtc): extract named groupParametersProvider interface Replace the inline anonymous interface assertion with a named package-level interface. This makes the contract explicit, enables jump-to-definition, and follows standard Go interface naming conventions. --- pkg/tbtc/tbtc.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index cc6c0efe45..5cf1e5bf54 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -98,6 +98,12 @@ type Config struct { KeyGenerationConcurrency int } +// groupParametersProvider is implemented by chain handles that can supply +// ECDSA wallet group parameters from the on-chain EcdsaDkgValidator contract. +type groupParametersProvider interface { + EcdsaWalletGroupParametersFromChain(context.Context) (*GroupParameters, error) +} + // Initialize kicks off the TBTC by initializing internal state, ensuring // preconditions like staking are met, and then kicking off the internal TBTC // implementation. Returns an error if this failed. @@ -117,9 +123,7 @@ func Initialize( ) error { groupParameters := defaultGroupParameters(ethereumNetwork) - if ethChain, ok := chain.(interface { - EcdsaWalletGroupParametersFromChain(context.Context) (*GroupParameters, error) - }); ok { + if ethChain, ok := chain.(groupParametersProvider); ok { gp, err := ethChain.EcdsaWalletGroupParametersFromChain(ctx) if err != nil { return fmt.Errorf( From a4bd8c718ef6d0d85453446bedfaf9185e2d3ca3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 5 May 2026 13:45:42 +0000 Subject: [PATCH 04/13] docs(local_v1): document lazy watcher cleanup and stop ticker on return Cleanup of cancelled watchers happens on the next block broadcast, not immediately on context cancellation. Document this so callers understand why channels may remain open after cancellation in no-block scenarios. Also add defer ticker.Stop() for hygiene (count runs as a long-lived goroutine, but the deferred stop is correct and silences the linter). --- pkg/chain/local_v1/blockcounter.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/chain/local_v1/blockcounter.go b/pkg/chain/local_v1/blockcounter.go index d87cb3cbe7..e27689f7ac 100644 --- a/pkg/chain/local_v1/blockcounter.go +++ b/pkg/chain/local_v1/blockcounter.go @@ -59,6 +59,13 @@ func (lbc *localBlockCounter) CurrentBlock() (uint64, error) { return lbc.blockHeight, nil } +// WatchBlocks returns a channel that receives each new block height. Cleanup of +// cancelled watchers is lazy: a cancelled watcher is removed and its channel +// closed on the next call to count(). If no new blocks are produced after +// cancellation (e.g., test teardown with no ticker ticks), the channel is +// never explicitly closed and callers blocked on it will not be unblocked. +// The 16-element buffer makes this acceptable in practice for tests and local +// coordination flows. func (lbc *localBlockCounter) WatchBlocks(ctx context.Context) <-chan uint64 { watcher := &watcher{ ctx: ctx, @@ -86,6 +93,7 @@ func (lbc *localBlockCounter) count(blockTime ...time.Duration) { } ticker := time.NewTicker(resolvedBlockTime) + defer ticker.Stop() for range ticker.C { lbc.structMutex.Lock() From cb1f42d5202ed1526849b4d353b587075b8e920f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 5 May 2026 13:48:06 +0000 Subject: [PATCH 05/13] fix(electrum): use typed feeOracleFailure and fix ticker leak in keepAlive 1. Replace string-based -32603 detection with a typed feeOracleFailure sentinel. getFeeBtcPerKbOnce classifies oracle failures at the source; isElectrumFeeOracleFailure uses errors.As for type-safe detection. 2. Stop the previous ticker before reassigning in keepAlive. Each successful ping resets the interval by creating a new ticker; without Stop() the old ticker's goroutine and timer resource leaked until GC. --- pkg/bitcoin/electrum/electrum.go | 36 +++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/pkg/bitcoin/electrum/electrum.go b/pkg/bitcoin/electrum/electrum.go index f40c4f935c..1ed1469db8 100644 --- a/pkg/bitcoin/electrum/electrum.go +++ b/pkg/bitcoin/electrum/electrum.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "fmt" "math" "sort" @@ -988,21 +989,28 @@ func feeEstimateWithFallbackTargets(primary uint32) []uint32 { // deposit sweep max-fee checks on the Bridge bound the total fee. const defaultFallbackSatPerVByteWhenEstimateFails int64 = 2 -// isElectrumFeeOracleFailure reports whether the error is the usual -// "no fee data" / JSON-RPC -32603 from estimatefee, as opposed to transport -// or auth failures where we should not invent a feerate. +// feeOracleFailure wraps errors that indicate the Electrum server could not +// produce a fee estimate (as opposed to transport or auth failures). Using a +// typed sentinel allows EstimateSatPerVByteFee to distinguish oracle failures +// from general connectivity failures without string matching at the call site. +type feeOracleFailure struct{ cause error } + +func (f feeOracleFailure) Error() string { return f.cause.Error() } +func (f feeOracleFailure) Unwrap() error { return f.cause } + +// isElectrumFeeOracleFailure reports whether err originated from the fee +// estimation oracle (i.e. the server responded but had no fee data), as +// opposed to a transport or auth failure where inventing a feerate is wrong. func isElectrumFeeOracleFailure(err error) bool { - if err == nil { - return false - } - s := err.Error() - return strings.Contains(s, "cannot estimate fee") || - strings.Contains(s, "-32603") + var f feeOracleFailure + return errors.As(err, &f) } // getFeeBtcPerKbOnce issues a single blockchain.estimatefee call (no multi-minute // retry loop). Persistent RPC errors for one confirmation target should not // exhaust RequestRetryTimeout; EstimateSatPerVByteFee tries looser targets next. +// Errors that indicate the server processed the request but has no fee data are +// wrapped in feeOracleFailure so callers can distinguish them from transport errors. func (c *Connection) getFeeBtcPerKbOnce(blocks uint32) (float32, error) { if err := c.reconnectIfShutdown(); err != nil { return 0, err @@ -1016,7 +1024,12 @@ func (c *Connection) getFeeBtcPerKbOnce(blocks uint32) (float32, error) { fee, err := c.client.GetFee(requestCtx, blocks) c.clientMutex.Unlock() if err != nil { - return 0, fmt.Errorf("request failed: [%w]", err) + wrapped := fmt.Errorf("request failed: [%w]", err) + s := wrapped.Error() + if strings.Contains(s, "cannot estimate fee") || strings.Contains(s, "-32603") { + return 0, feeOracleFailure{wrapped} + } + return 0, wrapped } return fee, nil } @@ -1170,6 +1183,9 @@ func (c *Connection) keepAlive() { ) } else { // Adjust ticker starting at the time of the latest successful ping. + // Stop the current ticker before replacing it to avoid leaking the + // internal goroutine and timer resource. + ticker.Stop() ticker = time.NewTicker(c.config.KeepAliveInterval) } case <-c.parentCtx.Done(): From c8d3e286305611869273e87f6de005f11d9034e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 5 May 2026 13:51:59 +0000 Subject: [PATCH 06/13] fix(electrum): gate static fee fallback on non-mainnet networks Add network bitcoin.Network parameter to Connect so the connection knows which network it is operating on. EstimateSatPerVByteFee now returns an error instead of the 2 sat/vByte static fallback when all fee oracle queries fail on mainnet. The fallback remains active on testnet4 and other non-mainnet networks where quiet mempools are expected. All call sites updated to pass the bitcoin network from config. --- cmd/maintainer.go | 2 +- cmd/maintainercli.go | 8 ++--- cmd/start.go | 2 +- pkg/bitcoin/electrum/electrum.go | 12 ++++++- .../electrum/electrum_integration_test.go | 36 +++++++++---------- 5 files changed, 35 insertions(+), 25 deletions(-) diff --git a/cmd/maintainer.go b/cmd/maintainer.go index b8d81e2aa5..c7958a8e76 100644 --- a/cmd/maintainer.go +++ b/cmd/maintainer.go @@ -44,7 +44,7 @@ func init() { func maintainers(cmd *cobra.Command, args []string) error { ctx := context.Background() - btcChain, err := electrum.Connect(ctx, clientConfig.Bitcoin.Electrum) + btcChain, err := electrum.Connect(ctx, clientConfig.Bitcoin.Network, clientConfig.Bitcoin.Electrum) if err != nil { return fmt.Errorf("could not connect to Electrum chain: [%v]", err) } diff --git a/cmd/maintainercli.go b/cmd/maintainercli.go index e674375fc3..23eed6e785 100644 --- a/cmd/maintainercli.go +++ b/cmd/maintainercli.go @@ -86,7 +86,7 @@ var listDepositsCommand = cobra.Command{ ) } - btcChain, err := electrum.Connect(ctx, clientConfig.Bitcoin.Electrum) + btcChain, err := electrum.Connect(ctx, clientConfig.Bitcoin.Network, clientConfig.Bitcoin.Electrum) if err != nil { return fmt.Errorf("could not connect to Electrum chain: [%v]", err) } @@ -179,7 +179,7 @@ var estimateDepositsSweepFeeCommand = cobra.Command{ ) } - btcChain, err := electrum.Connect(ctx, clientConfig.Bitcoin.Electrum) + btcChain, err := electrum.Connect(ctx, clientConfig.Bitcoin.Network, clientConfig.Bitcoin.Electrum) if err != nil { return fmt.Errorf("could not connect to Electrum chain: [%v]", err) } @@ -296,7 +296,7 @@ var submitDepositSweepProofCommand = cobra.Command{ ) } - btcChain, err := electrum.Connect(ctx, clientConfig.Bitcoin.Electrum) + btcChain, err := electrum.Connect(ctx, clientConfig.Bitcoin.Network, clientConfig.Bitcoin.Electrum) if err != nil { return fmt.Errorf("could not connect to Electrum chain: [%v]", err) } @@ -386,7 +386,7 @@ var submitRedemptionProofCommand = cobra.Command{ ) } - btcChain, err := electrum.Connect(ctx, clientConfig.Bitcoin.Electrum) + btcChain, err := electrum.Connect(ctx, clientConfig.Bitcoin.Network, clientConfig.Bitcoin.Electrum) if err != nil { return fmt.Errorf("could not connect to Electrum chain: [%v]", err) } diff --git a/cmd/start.go b/cmd/start.go index dded68c74c..b625e36dff 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -110,7 +110,7 @@ func start(cmd *cobra.Command) error { // Skip initialization for bootstrap nodes as they are only used for network // discovery. if !isBootstrap() { - btcChain, err := electrum.Connect(ctx, clientConfig.Bitcoin.Electrum) + btcChain, err := electrum.Connect(ctx, clientConfig.Bitcoin.Network, clientConfig.Bitcoin.Electrum) if err != nil { return fmt.Errorf("could not connect to Electrum chain: [%v]", err) } diff --git a/pkg/bitcoin/electrum/electrum.go b/pkg/bitcoin/electrum/electrum.go index 1ed1469db8..eeaab634fc 100644 --- a/pkg/bitcoin/electrum/electrum.go +++ b/pkg/bitcoin/electrum/electrum.go @@ -33,10 +33,11 @@ type Connection struct { client *electrum.Client clientMutex *sync.Mutex config Config + network bitcoin.Network } // Connect initializes handle with provided Config. -func Connect(parentCtx context.Context, config Config) (bitcoin.Chain, error) { +func Connect(parentCtx context.Context, network bitcoin.Network, config Config) (bitcoin.Chain, error) { if config.ConnectTimeout == 0 { config.ConnectTimeout = DefaultConnectTimeout } @@ -57,6 +58,7 @@ func Connect(parentCtx context.Context, config Config) (bitcoin.Chain, error) { parentCtx: parentCtx, config: config, clientMutex: &sync.Mutex{}, + network: network, } if err := c.electrumConnect(); err != nil { @@ -1075,6 +1077,14 @@ func (c *Connection) EstimateSatPerVByteFee(blocks uint32) (int64, error) { } if sawFeeOracleFailure { + if c.network == bitcoin.Mainnet { + return 0, fmt.Errorf( + "Electrum fee oracle returned no estimate for any target %v "+ + "(last error: [%v]); refusing static fallback on mainnet", + targets, + lastErr, + ) + } logger.Warnf( "Electrum returned no fee estimate for any target %v; using "+ "fallback [%d] sat/vbyte (last error: [%v])", diff --git a/pkg/bitcoin/electrum/electrum_integration_test.go b/pkg/bitcoin/electrum/electrum_integration_test.go index 6da4d4c4c0..0cf33bca26 100644 --- a/pkg/bitcoin/electrum/electrum_integration_test.go +++ b/pkg/bitcoin/electrum/electrum_integration_test.go @@ -147,14 +147,14 @@ func init() { func TestConnect_Integration(t *testing.T) { runParallel(t, func(t *testing.T, testConfig testConfig) { - _, cancelCtx := newTestConnection(t, testConfig.clientConfig) + _, cancelCtx := newTestConnection(t, testConfig) defer cancelCtx() }) } func TestGetTransaction_Integration(t *testing.T) { runParallel(t, func(t *testing.T, testConfig testConfig) { - electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) + electrum, cancelCtx := newTestConnection(t, testConfig) defer cancelCtx() for txName, tx := range testData.Transactions[testConfig.network] { @@ -180,7 +180,7 @@ func TestGetTransaction_Integration(t *testing.T) { func TestGetTransaction_Negative_Integration(t *testing.T) { runParallel(t, func(t *testing.T, testConfig testConfig) { - electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) + electrum, cancelCtx := newTestConnection(t, testConfig) defer cancelCtx() _, err := electrum.GetTransaction(invalidTxID) @@ -206,7 +206,7 @@ func TestGetTransaction_Negative_Integration(t *testing.T) { func TestGetTransactionConfirmations_Integration(t *testing.T) { runParallel(t, func(t *testing.T, testConfig testConfig) { - electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) + electrum, cancelCtx := newTestConnection(t, testConfig) defer cancelCtx() for txName, tx := range testData.Transactions[testConfig.network] { @@ -233,7 +233,7 @@ func TestGetTransactionConfirmations_Integration(t *testing.T) { func TestGetTransactionConfirmations_Negative_Integration(t *testing.T) { runParallel(t, func(t *testing.T, testConfig testConfig) { - electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) + electrum, cancelCtx := newTestConnection(t, testConfig) defer cancelCtx() _, err := electrum.GetTransactionConfirmations(invalidTxID) @@ -268,7 +268,7 @@ func TestGetTransactionConfirmations_Negative_Integration(t *testing.T) { // for testName, testConfig := range testConfigs { // t.Run(testName+"_get", func(t *testing.T) { -// electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) +// electrum, cancelCtx := newTestConnection(t, testConfig) // defer cancelCtx() // var wg sync.WaitGroup @@ -306,7 +306,7 @@ func TestGetLatestBlockHeight_Integration(t *testing.T) { for testName, testConfig := range testConfigs { t.Run(testName+"_get", func(t *testing.T) { - electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) + electrum, cancelCtx := newTestConnection(t, testConfig) defer cancelCtx() result, err := electrum.GetLatestBlockHeight() @@ -357,7 +357,7 @@ func TestGetLatestBlockHeight_Integration(t *testing.T) { func TestGetBlockHeader_Integration(t *testing.T) { runParallel(t, func(t *testing.T, testConfig testConfig) { - electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) + electrum, cancelCtx := newTestConnection(t, testConfig) defer cancelCtx() blockData, ok := testData.Blocks[testConfig.network] @@ -380,7 +380,7 @@ func TestGetBlockHeader_Negative_Integration(t *testing.T) { blockHeight := uint(math.MaxUint32) runParallel(t, func(t *testing.T, testConfig testConfig) { - electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) + electrum, cancelCtx := newTestConnection(t, testConfig) defer cancelCtx() _, err := electrum.GetBlockHeader(blockHeight) @@ -396,7 +396,7 @@ func TestGetBlockHeader_Negative_Integration(t *testing.T) { func TestGetTransactionMerkleProof_Integration(t *testing.T) { runParallel(t, func(t *testing.T, testConfig testConfig) { - electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) + electrum, cancelCtx := newTestConnection(t, testConfig) defer cancelCtx() txMerkleProofData, ok := testData.TxMerkleProofs[testConfig.network] @@ -427,7 +427,7 @@ func TestGetTransactionMerkleProof_Negative_Integration(t *testing.T) { blockHeight := uint(123456) runParallel(t, func(t *testing.T, testConfig testConfig) { - electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) + electrum, cancelCtx := newTestConnection(t, testConfig) defer cancelCtx() _, err := electrum.GetTransactionMerkleProof( @@ -446,7 +446,7 @@ func TestGetTransactionMerkleProof_Negative_Integration(t *testing.T) { func TestGetTransactionsForPublicKeyHash_Integration(t *testing.T) { runParallel(t, func(t *testing.T, testConfig testConfig) { - electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) + electrum, cancelCtx := newTestConnection(t, testConfig) defer cancelCtx() txMerkleProofData, ok := testData.TransactionsForPublicKeyHash[testConfig.network] @@ -475,7 +475,7 @@ func TestGetTransactionsForPublicKeyHash_Integration(t *testing.T) { func TestGetTxHashesForPublicKeyHash_Integration(t *testing.T) { runParallel(t, func(t *testing.T, testConfig testConfig) { - electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) + electrum, cancelCtx := newTestConnection(t, testConfig) defer cancelCtx() data, ok := testData.TransactionsForPublicKeyHash[testConfig.network] @@ -505,7 +505,7 @@ func TestGetTxHashesForPublicKeyHash_Integration(t *testing.T) { func TestGetUtxosForPublicKeyHash_Integration(t *testing.T) { runParallel(t, func(t *testing.T, testConfig testConfig) { - electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) + electrum, cancelCtx := newTestConnection(t, testConfig) defer cancelCtx() data, ok := testData.TransactionsForPublicKeyHash[testConfig.network] @@ -549,7 +549,7 @@ func TestGetUtxosForPublicKeyHash_Integration(t *testing.T) { func TestEstimateSatPerVByteFee_Integration(t *testing.T) { runParallel(t, func(t *testing.T, testConfig testConfig) { - electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) + electrum, cancelCtx := newTestConnection(t, testConfig) defer cancelCtx() // A 1-block target often returns no estimate on public testnets; 25 is @@ -588,7 +588,7 @@ func isFeeEstimateUnavailable(err error) bool { func TestGetCoinbaseTxHash_Integration(t *testing.T) { runParallel(t, func(t *testing.T, testConfig testConfig) { - electrum, cancelCtx := newTestConnection(t, testConfig.clientConfig) + electrum, cancelCtx := newTestConnection(t, testConfig) defer cancelCtx() blockData, ok := testData.Blocks[testConfig.network] @@ -628,9 +628,9 @@ func runParallel(t *testing.T, runFunc func(t *testing.T, testConfig testConfig) } } -func newTestConnection(t *testing.T, config electrum.Config) (bitcoin.Chain, context.CancelFunc) { +func newTestConnection(t *testing.T, testConfig testConfig) (bitcoin.Chain, context.CancelFunc) { ctx, cancelCtx := context.WithCancel(context.Background()) - electrum, err := electrum.Connect(ctx, config) + electrum, err := electrum.Connect(ctx, testConfig.network, testConfig.clientConfig) if err != nil { t.Fatal(err) } From 44baa2eb8e0b2e96b7886198ded2f1b9c39ec767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 5 May 2026 13:52:23 +0000 Subject: [PATCH 07/13] fix(ethereum): add 30-minute timeout to waitDeployBackendTransactionMined Using context.Background() blocked indefinitely if a retarget transaction was never mined (e.g. network congestion, dropped mempool). Replace with a 30-minute deadline so the maintainer goroutine can detect a stuck transaction and return an error instead of blocking shutdown. --- pkg/chain/ethereum/bitcoin_difficulty.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/chain/ethereum/bitcoin_difficulty.go b/pkg/chain/ethereum/bitcoin_difficulty.go index 08c41959cb..73d1662477 100644 --- a/pkg/chain/ethereum/bitcoin_difficulty.go +++ b/pkg/chain/ethereum/bitcoin_difficulty.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math/big" + "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -138,7 +139,9 @@ func (bdc *BitcoinDifficultyChain) waitDeployBackendTransactionMined( if tx == nil { return fmt.Errorf("nil transaction waiting for [%s]", method) } - receipt, err := bind.WaitMined(context.Background(), bdc.client, tx) + waitCtx, waitCancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer waitCancel() + receipt, err := bind.WaitMined(waitCtx, bdc.client, tx) if err != nil { return fmt.Errorf("waiting for transaction [%s] [%s]: [%w]", method, tx.Hash().Hex(), err) } From f76e76598683047ffc6fbe38885a100af6f7f42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 5 May 2026 13:52:45 +0000 Subject: [PATCH 08/13] fix(ecdsa): add contract-code preflight check before ABI calls Before calling the hand-maintained ABI getters on EcdsaDkgValidator, verify that the target address actually contains contract code. Without this check, an EOA address or a wrong deployment address causes a cryptic ABI decode failure instead of an actionable error message. --- pkg/chain/ethereum/ecdsa_dkg_validator_chain.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/chain/ethereum/ecdsa_dkg_validator_chain.go b/pkg/chain/ethereum/ecdsa_dkg_validator_chain.go index 885bf20f0a..b14a3eadc3 100644 --- a/pkg/chain/ethereum/ecdsa_dkg_validator_chain.go +++ b/pkg/chain/ethereum/ecdsa_dkg_validator_chain.go @@ -89,6 +89,17 @@ func ecdsaWalletGroupParametersFromValidator( if addr == (common.Address{}) { return nil, fmt.Errorf("EcdsaDkgValidator address is zero") } + code, err := client.CodeAt(ctx, addr, nil) + if err != nil { + return nil, fmt.Errorf("checking code at EcdsaDkgValidator [%s]: %w", addr.Hex(), err) + } + if len(code) == 0 { + return nil, fmt.Errorf( + "EcdsaDkgValidator address [%s] has no contract code; "+ + "verify the address is correct and the contract is deployed", + addr.Hex(), + ) + } gsBig, err := readEcdsaDkgValidatorUint256(ctx, client, addr, "groupSize") if err != nil { return nil, err From e097aa13e0820ed11fe38c2a361595e27240d38e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 5 May 2026 13:53:33 +0000 Subject: [PATCH 09/13] fix(btcdiff): escalate log to Errorf after 20 consecutive idle ticks When IdleOnPreflightFailure is true, the maintainer silently idles on min-difficulty epochs with no signal for operators monitoring health. Track consecutive idle ticks and emit Errorf after 20 ticks (~20 minutes with default IdleBackOffTime=60s) so the LightRelay falling behind the Bitcoin chain surfaces in monitoring. Reset the counter when an epoch is successfully proven. --- pkg/maintainer/btcdiff/bitcoin_difficulty.go | 24 +++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/pkg/maintainer/btcdiff/bitcoin_difficulty.go b/pkg/maintainer/btcdiff/bitcoin_difficulty.go index b33e391182..a56e27bf79 100644 --- a/pkg/maintainer/btcdiff/bitcoin_difficulty.go +++ b/pkg/maintainer/btcdiff/bitcoin_difficulty.go @@ -68,12 +68,18 @@ func Initialize( go bitcoinDifficultyMaintainer.startControlLoop(ctx) } +// idleEscalationThreshold is the number of consecutive idle ticks (each +// lasting IdleBackOffTime, default 60s) after which the maintainer emits an +// Errorf to indicate the LightRelay may be falling behind. +const idleEscalationThreshold = 20 + // bitcoinDifficultyMaintainer is the part of maintainer responsible for // maintaining the state of the Bitcoin difficulty on-chain contract. type bitcoinDifficultyMaintainer struct { - config Config - btcChain bitcoin.Chain - chain Chain + config Config + btcChain bitcoin.Chain + chain Chain + consecutiveIdles int } // startControlLoop starts the loop responsible for controlling the Bitcoin @@ -125,11 +131,23 @@ func (bdm *bitcoinDifficultyMaintainer) proveEpochs(ctx context.Context) error { // in the new epoch). Do not sleep if a Bitcoin epoch was proven as // there are likely more Bitcoin epochs to prove. if !epochProven { + bdm.consecutiveIdles++ + if bdm.consecutiveIdles >= idleEscalationThreshold { + logger.Errorf( + "bitcoin difficulty maintainer has been idle for [%d] "+ + "consecutive ticks (~%s); the LightRelay may be falling "+ + "behind the Bitcoin chain", + bdm.consecutiveIdles, + time.Duration(bdm.consecutiveIdles)*bdm.config.IdleBackOffTime, + ) + } select { case <-time.After(bdm.config.IdleBackOffTime): case <-ctx.Done(): return ctx.Err() } + } else { + bdm.consecutiveIdles = 0 } } } From 1dc5280c22ed9ce5da5c8a67c73d3d2de83cb8f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 5 May 2026 13:53:51 +0000 Subject: [PATCH 10/13] fix(deploy): warn on EcdsaDkgValidator redeploy when WalletRegistry update needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On non-mainnet networks, EcdsaDkgValidator is redeployed whenever bytecode changes (e.g. groupSize 100→3). Emit a log warning that includes the old address so operators know to update the WalletRegistry to point at the new validator before the system is used. --- solidity/ecdsa/deploy/02_deploy_dkg_validator.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/solidity/ecdsa/deploy/02_deploy_dkg_validator.ts b/solidity/ecdsa/deploy/02_deploy_dkg_validator.ts index fd0bd97546..28dc32f8f6 100644 --- a/solidity/ecdsa/deploy/02_deploy_dkg_validator.ts +++ b/solidity/ecdsa/deploy/02_deploy_dkg_validator.ts @@ -21,6 +21,15 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { // still points at the old on-chain validator (THRESHOLD_FORCE_DKG_COMPILE only forces compile). const skipIfAlreadyDeployed = hre.network.name === "mainnet" + const existingDeployment = await deployments.getOrNull("EcdsaDkgValidator") + if (existingDeployment && !skipIfAlreadyDeployed) { + hre.deployments.log( + `WARNING: redeploying EcdsaDkgValidator on ${hre.network.name} ` + + `(previous address ${existingDeployment.address}). ` + + `Ensure WalletRegistry is updated to point to the new address.` + ) + } + const EcdsaDkgValidator = await deployments.deploy("EcdsaDkgValidator", { from: deployer, args: [EcdsaSortitionPool.address], From 03a4226560efdc7b3c99b88a862e8ce45aab123f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 5 May 2026 13:55:55 +0000 Subject: [PATCH 11/13] fix(deploy): remove redundant try/catch in approve_wallet_registry script The ABI check (ifaceHasFunction) is the authoritative guard for whether approveApplication exists on TokenStaking. The subsequent try/catch for 'No method named approveApplication' is unreachable given the guard and could silently swallow genuine revert errors. Remove it to make error handling deterministic. --- .../deploy/07_approve_wallet_registry.ts | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/solidity/ecdsa/deploy/07_approve_wallet_registry.ts b/solidity/ecdsa/deploy/07_approve_wallet_registry.ts index 58d34b22a5..8901e153af 100644 --- a/solidity/ecdsa/deploy/07_approve_wallet_registry.ts +++ b/solidity/ecdsa/deploy/07_approve_wallet_registry.ts @@ -27,23 +27,12 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { return } - try { - await execute( - "TokenStaking", - { from: deployer, log: true, waitConfirmations: 1 }, - "approveApplication", - WalletRegistry.address - ) - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e) - if (msg.includes("No method named") && msg.includes("approveApplication")) { - hre.deployments.log( - "TokenStaking has no approveApplication callable on this network; skipping WalletRegistry approval" - ) - return - } - throw e - } + await execute( + "TokenStaking", + { from: deployer, log: true, waitConfirmations: 1 }, + "approveApplication", + WalletRegistry.address + ) } export default func From d93738bcd96aed75df9da9d71b874d1aa984d69e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 5 May 2026 14:07:58 +0000 Subject: [PATCH 12/13] fix(ci): gofmt and eslint fixes for review-followup changes - gofmt: electrum_test.go, tbtc.go, bitcoin_difficulty_test.go - eslint: replace bare template literal with double-quoted string in 02_deploy_dkg_validator.ts (line 29, no substitution) --- pkg/bitcoin/electrum/electrum_test.go | 4 ++-- pkg/chain/ethereum/tbtc.go | 16 ++++++++-------- .../btcdiff/bitcoin_difficulty_test.go | 2 +- solidity/ecdsa/deploy/02_deploy_dkg_validator.ts | 2 +- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/bitcoin/electrum/electrum_test.go b/pkg/bitcoin/electrum/electrum_test.go index b9deaf77ed..2b09742a2d 100644 --- a/pkg/bitcoin/electrum/electrum_test.go +++ b/pkg/bitcoin/electrum/electrum_test.go @@ -10,9 +10,9 @@ import ( func TestFeeEstimateWithFallbackTargets(t *testing.T) { t.Parallel() for _, tc := range []struct { - name string + name string primary uint32 - want []uint32 + want []uint32 }{ { name: "primary 1 tries common confirmation horizons", diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 06be82cc67..2103dc587f 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -263,15 +263,15 @@ func newTbtcChain( } return &TbtcChain{ - baseChain: baseChain, - bridge: bridge, - maintainerProxy: maintainerProxy, - walletRegistry: walletRegistry, - sortitionPool: sortitionPool, - walletProposalValidator: walletProposalValidator, - redemptionWatchtower: redemptionWatchtower, + baseChain: baseChain, + bridge: bridge, + maintainerProxy: maintainerProxy, + walletRegistry: walletRegistry, + sortitionPool: sortitionPool, + walletProposalValidator: walletProposalValidator, + redemptionWatchtower: redemptionWatchtower, ecdsaDkgValidatorAddress: ecdsaDkgValidatorAddress, - sweptDepositsCache: cache.NewGenericTimeCache[*tbtc.DepositChainRequest](sweptDepositsCachePeriod), + sweptDepositsCache: cache.NewGenericTimeCache[*tbtc.DepositChainRequest](sweptDepositsCachePeriod), }, nil } diff --git a/pkg/maintainer/btcdiff/bitcoin_difficulty_test.go b/pkg/maintainer/btcdiff/bitcoin_difficulty_test.go index 765824cd66..bf68ed69ef 100644 --- a/pkg/maintainer/btcdiff/bitcoin_difficulty_test.go +++ b/pkg/maintainer/btcdiff/bitcoin_difficulty_test.go @@ -979,4 +979,4 @@ func TestProveNextEpoch_PreflightAcceptsMinDifficultyPreRetarget(t *testing.T) { if retargetEvents[0].oldDifficulty != 0x1d00ffff { t.Fatalf("unexpected old difficulty bits in event: %#x", retargetEvents[0].oldDifficulty) } -} \ No newline at end of file +} diff --git a/solidity/ecdsa/deploy/02_deploy_dkg_validator.ts b/solidity/ecdsa/deploy/02_deploy_dkg_validator.ts index 28dc32f8f6..d636992135 100644 --- a/solidity/ecdsa/deploy/02_deploy_dkg_validator.ts +++ b/solidity/ecdsa/deploy/02_deploy_dkg_validator.ts @@ -26,7 +26,7 @@ const func: DeployFunction = async (hre: HardhatRuntimeEnvironment) => { hre.deployments.log( `WARNING: redeploying EcdsaDkgValidator on ${hre.network.name} ` + `(previous address ${existingDeployment.address}). ` + - `Ensure WalletRegistry is updated to point to the new address.` + "Ensure WalletRegistry is updated to point to the new address." ) } From 6c29e4d9814dcdda3664175126f08b2d3b1741e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 5 May 2026 15:00:28 +0000 Subject: [PATCH 13/13] test: add regression tests for idle escalation, fee oracle fallback, and DKG validator parameters Extract recordIdleTick/resetIdleTicks from proveEpochs loop for deterministic counter testing. Narrow internal ecdsa_dkg_validator_chain functions to bind.ContractCaller (2 methods) to enable a lightweight mock. Extract feeOracleFallback from EstimateSatPerVByteFee for direct testability. Tests added: - btcdiff: idle counter increments to threshold, resets, and re-escalates - ecdsa_dkg_validator_chain: zero address, no-code preflight, quorum packed return + callErr map[string]error +} + +func (m *mockContractCaller) CodeAt( + _ context.Context, + _ common.Address, + _ *big.Int, +) ([]byte, error) { + return m.code, m.codeErr +} + +func (m *mockContractCaller) CallContract( + _ context.Context, + call ethereum.CallMsg, + _ *big.Int, +) ([]byte, error) { + if call.Data == nil || len(call.Data) < 4 { + return nil, fmt.Errorf("calldata too short") + } + sel := hex.EncodeToString(call.Data[:4]) + if err, ok := m.callErr[sel]; ok && err != nil { + return nil, err + } + if data, ok := m.callResults[sel]; ok { + return data, nil + } + return nil, fmt.Errorf("no mock for selector %s", sel) +} + +// packUint256 encodes a uint256 return value as a 32-byte ABI word. +func packUint256(v *big.Int) []byte { + b := make([]byte, 32) + vb := v.Bytes() + copy(b[32-len(vb):], vb) + return b +} + +// selectorOf returns the 4-byte ABI selector hex for a method name. +func selectorOf(method string) string { + packed, _ := ecdsaDkgValidatorConstantsABI.Pack(method) + return hex.EncodeToString(packed[:4]) +} + +var nonZeroAddr = common.HexToAddress("0x0000000000000000000000000000000000000001") + +func TestEcdsaWalletGroupParametersFromValidator_ZeroAddress(t *testing.T) { + _, err := ecdsaWalletGroupParametersFromValidator( + context.Background(), + &mockContractCaller{code: []byte{0x60}}, + common.Address{}, + ) + if err == nil { + t.Fatal("expected error for zero address, got nil") + } +} + +func TestEcdsaWalletGroupParametersFromValidator_NoCode(t *testing.T) { + _, err := ecdsaWalletGroupParametersFromValidator( + context.Background(), + &mockContractCaller{code: []byte{}}, + nonZeroAddr, + ) + if err == nil { + t.Fatal("expected error for empty code, got nil") + } +} + +func TestEcdsaWalletGroupParametersFromValidator_QuorumLtHonestThreshold(t *testing.T) { + // activeThreshold (groupQuorum) < groupThreshold (honestThreshold) must be rejected. + mc := &mockContractCaller{ + code: []byte{0x60}, + callResults: map[string][]byte{ + selectorOf("groupSize"): packUint256(big.NewInt(100)), + selectorOf("activeThreshold"): packUint256(big.NewInt(33)), // quorum < threshold + selectorOf("groupThreshold"): packUint256(big.NewInt(51)), + }, + } + _, err := ecdsaWalletGroupParametersFromValidator( + context.Background(), + mc, + nonZeroAddr, + ) + if err == nil { + t.Fatal("expected error when groupQuorum < honestThreshold, got nil") + } +} + +func TestEcdsaWalletGroupParametersFromValidator_HappyPath(t *testing.T) { + mc := &mockContractCaller{ + code: []byte{0x60}, + callResults: map[string][]byte{ + selectorOf("groupSize"): packUint256(big.NewInt(100)), + selectorOf("activeThreshold"): packUint256(big.NewInt(90)), + selectorOf("groupThreshold"): packUint256(big.NewInt(51)), + }, + } + params, err := ecdsaWalletGroupParametersFromValidator( + context.Background(), + mc, + nonZeroAddr, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if params.GroupSize != 100 { + t.Errorf("GroupSize: expected 100, got %d", params.GroupSize) + } + if params.GroupQuorum != 90 { + t.Errorf("GroupQuorum: expected 90, got %d", params.GroupQuorum) + } + if params.HonestThreshold != 51 { + t.Errorf("HonestThreshold: expected 51, got %d", params.HonestThreshold) + } +} diff --git a/pkg/maintainer/btcdiff/bitcoin_difficulty.go b/pkg/maintainer/btcdiff/bitcoin_difficulty.go index a56e27bf79..72a727e7bf 100644 --- a/pkg/maintainer/btcdiff/bitcoin_difficulty.go +++ b/pkg/maintainer/btcdiff/bitcoin_difficulty.go @@ -131,8 +131,7 @@ func (bdm *bitcoinDifficultyMaintainer) proveEpochs(ctx context.Context) error { // in the new epoch). Do not sleep if a Bitcoin epoch was proven as // there are likely more Bitcoin epochs to prove. if !epochProven { - bdm.consecutiveIdles++ - if bdm.consecutiveIdles >= idleEscalationThreshold { + if bdm.recordIdleTick() { logger.Errorf( "bitcoin difficulty maintainer has been idle for [%d] "+ "consecutive ticks (~%s); the LightRelay may be falling "+ @@ -147,11 +146,23 @@ func (bdm *bitcoinDifficultyMaintainer) proveEpochs(ctx context.Context) error { return ctx.Err() } } else { - bdm.consecutiveIdles = 0 + bdm.resetIdleTicks() } } } +// recordIdleTick increments the consecutive-idle counter and returns true once +// the escalation threshold is reached, signaling the caller to emit a warning. +func (bdm *bitcoinDifficultyMaintainer) recordIdleTick() bool { + bdm.consecutiveIdles++ + return bdm.consecutiveIdles >= idleEscalationThreshold +} + +// resetIdleTicks zeroes the consecutive-idle counter after a successful epoch proof. +func (bdm *bitcoinDifficultyMaintainer) resetIdleTicks() { + bdm.consecutiveIdles = 0 +} + // verifySubmissionEligibility verifies whether a maintainer is eligible to // submit block headers to the Bitcoin difficulty chain. func (bdm *bitcoinDifficultyMaintainer) verifySubmissionEligibility() error { diff --git a/pkg/maintainer/btcdiff/bitcoin_difficulty_test.go b/pkg/maintainer/btcdiff/bitcoin_difficulty_test.go index bf68ed69ef..26877f6ab3 100644 --- a/pkg/maintainer/btcdiff/bitcoin_difficulty_test.go +++ b/pkg/maintainer/btcdiff/bitcoin_difficulty_test.go @@ -980,3 +980,53 @@ func TestProveNextEpoch_PreflightAcceptsMinDifficultyPreRetarget(t *testing.T) { t.Fatalf("unexpected old difficulty bits in event: %#x", retargetEvents[0].oldDifficulty) } } + +func TestRecordIdleTick_EscalatesAtThreshold(t *testing.T) { + bdm := &bitcoinDifficultyMaintainer{} + + for i := 1; i < idleEscalationThreshold; i++ { + if bdm.recordIdleTick() { + t.Fatalf("expected false before threshold (tick %d)", i) + } + } + if !bdm.recordIdleTick() { + t.Fatalf("expected true at threshold tick %d", idleEscalationThreshold) + } + if bdm.consecutiveIdles != idleEscalationThreshold { + t.Fatalf( + "unexpected consecutiveIdles: expected %d, got %d", + idleEscalationThreshold, + bdm.consecutiveIdles, + ) + } +} + +func TestResetIdleTicks_ZerosCounter(t *testing.T) { + bdm := &bitcoinDifficultyMaintainer{consecutiveIdles: 15} + bdm.resetIdleTicks() + if bdm.consecutiveIdles != 0 { + t.Fatalf("expected consecutiveIdles=0, got %d", bdm.consecutiveIdles) + } +} + +func TestRecordIdleTick_ResetAndReescalates(t *testing.T) { + bdm := &bitcoinDifficultyMaintainer{} + + for i := 0; i < idleEscalationThreshold; i++ { + bdm.recordIdleTick() + } + bdm.resetIdleTicks() + if bdm.consecutiveIdles != 0 { + t.Fatalf("counter should be zero after reset, got %d", bdm.consecutiveIdles) + } + + // After reset, next idleEscalationThreshold ticks should escalate again. + for i := 1; i < idleEscalationThreshold; i++ { + if bdm.recordIdleTick() { + t.Fatalf("expected false before threshold after reset (tick %d)", i) + } + } + if !bdm.recordIdleTick() { + t.Fatal("expected true at threshold tick after reset") + } +}