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 f40c4f935c..f8c8db4782 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" @@ -32,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 } @@ -56,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 { @@ -988,21 +991,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 +1026,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 } @@ -1062,14 +1077,7 @@ func (c *Connection) EstimateSatPerVByteFee(blocks uint32) (int64, error) { } if sawFeeOracleFailure { - logger.Warnf( - "Electrum returned no fee estimate for any target %v; using "+ - "fallback [%d] sat/vbyte (last error: [%v])", - targets, - defaultFallbackSatPerVByteWhenEstimateFails, - lastErr, - ) - return defaultFallbackSatPerVByteWhenEstimateFails, nil + return feeOracleFallback(c.network, targets, lastErr) } if lastErr != nil { @@ -1081,6 +1089,32 @@ func (c *Connection) EstimateSatPerVByteFee(blocks uint32) (int64, error) { ) } +// feeOracleFallback decides what to return when all Electrum fee oracle calls +// failed. On mainnet it refuses to invent a feerate; on other networks it +// returns the static fallback so testnet4 deposits can still be swept. +func feeOracleFallback( + network bitcoin.Network, + targets []uint32, + lastErr error, +) (int64, error) { + if 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])", + targets, + defaultFallbackSatPerVByteWhenEstimateFails, + lastErr, + ) + return defaultFallbackSatPerVByteWhenEstimateFails, nil +} + func convertBtcKbToSatVByte(btcPerKbFee float32) int64 { // To convert from BTC/KB to sat/vbyte, we need to multiply by 1e8/1e3. satPerVByte := (1e8 / 1e3) * float64(btcPerKbFee) @@ -1170,6 +1204,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(): 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) } diff --git a/pkg/bitcoin/electrum/electrum_test.go b/pkg/bitcoin/electrum/electrum_test.go index b9deaf77ed..f02d1779a6 100644 --- a/pkg/bitcoin/electrum/electrum_test.go +++ b/pkg/bitcoin/electrum/electrum_test.go @@ -1,18 +1,22 @@ package electrum import ( + "errors" + "fmt" "reflect" + "strings" "testing" "github.com/keep-network/keep-core/internal/testutils" + "github.com/keep-network/keep-core/pkg/bitcoin" ) 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", @@ -96,3 +100,61 @@ func TestConvertBtcKbToSatVByte(t *testing.T) { }) } } + +func TestIsElectrumFeeOracleFailure(t *testing.T) { + t.Parallel() + inner := errors.New("inner cause") + for _, tc := range []struct { + name string + err error + want bool + }{ + {"nil error", nil, false}, + {"plain error", errors.New("plain"), false}, + {"feeOracleFailure direct", feeOracleFailure{inner}, true}, + {"feeOracleFailure wrapped", fmt.Errorf("outer: %w", feeOracleFailure{inner}), true}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := isElectrumFeeOracleFailure(tc.err) + if got != tc.want { + t.Fatalf("isElectrumFeeOracleFailure(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} + +func TestFeeOracleFallback_MainnetRefuses(t *testing.T) { + targets := []uint32{1, 6, 25} + lastErr := errors.New("cannot estimate fee") + fee, err := feeOracleFallback(bitcoin.Mainnet, targets, lastErr) + if err == nil { + t.Fatal("expected error on mainnet, got nil") + } + if fee != 0 { + t.Fatalf("expected 0 fee on mainnet error, got %d", fee) + } + if !strings.Contains(err.Error(), "refusing static fallback on mainnet") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestFeeOracleFallback_NonMainnetReturnsFallback(t *testing.T) { + targets := []uint32{1, 6, 25} + lastErr := errors.New("cannot estimate fee") + for _, network := range []bitcoin.Network{bitcoin.Testnet4} { + fee, err := feeOracleFallback(network, targets, lastErr) + if err != nil { + t.Fatalf("expected no error on %v, got: %v", network, err) + } + if fee != defaultFallbackSatPerVByteWhenEstimateFails { + t.Errorf( + "expected fallback %d on %v, got %d", + defaultFallbackSatPerVByteWhenEstimateFails, + network, + fee, + ) + } + } +} 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) } diff --git a/pkg/chain/ethereum/ecdsa_dkg_validator_chain.go b/pkg/chain/ethereum/ecdsa_dkg_validator_chain.go index 1acea42b15..53718db963 100644 --- a/pkg/chain/ethereum/ecdsa_dkg_validator_chain.go +++ b/pkg/chain/ethereum/ecdsa_dkg_validator_chain.go @@ -8,8 +8,8 @@ import ( "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" - "github.com/keep-network/keep-common/pkg/chain/ethereum/ethutil" "github.com/keep-network/keep-core/pkg/tbtc" ) @@ -30,7 +30,7 @@ func mustParseABI(raw string) abi.ABI { func readEcdsaDkgValidatorUint256( ctx context.Context, - client ethutil.EthereumClient, + client bind.ContractCaller, addr common.Address, method string, ) (*big.Int, error) { @@ -83,12 +83,23 @@ func bigIntPositiveIntLimited(name string, v *big.Int, limit int) (int, error) { // - HonestThreshold <- groupThreshold() (threshold signing parameter) func ecdsaWalletGroupParametersFromValidator( ctx context.Context, - client ethutil.EthereumClient, + client bind.ContractCaller, addr common.Address, ) (*tbtc.GroupParameters, error) { 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 @@ -133,6 +144,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, diff --git a/pkg/chain/ethereum/ecdsa_dkg_validator_chain_test.go b/pkg/chain/ethereum/ecdsa_dkg_validator_chain_test.go new file mode 100644 index 0000000000..964da66e23 --- /dev/null +++ b/pkg/chain/ethereum/ecdsa_dkg_validator_chain_test.go @@ -0,0 +1,132 @@ +package ethereum + +import ( + "context" + "encoding/hex" + "fmt" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" +) + +// mockContractCaller implements bind.ContractCaller with configurable responses. +type mockContractCaller struct { + code []byte + codeErr error + callResults map[string][]byte // method selector hex -> 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/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/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() diff --git a/pkg/maintainer/btcdiff/bitcoin_difficulty.go b/pkg/maintainer/btcdiff/bitcoin_difficulty.go index b33e391182..72a727e7bf 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,15 +131,38 @@ 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 { + if bdm.recordIdleTick() { + 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.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 765824cd66..26877f6ab3 100644 --- a/pkg/maintainer/btcdiff/bitcoin_difficulty_test.go +++ b/pkg/maintainer/btcdiff/bitcoin_difficulty_test.go @@ -979,4 +979,54 @@ 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 +} + +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") + } +} diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index b4767078bb..5cf1e5bf54 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, @@ -94,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. @@ -113,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( diff --git a/solidity/ecdsa/deploy/02_deploy_dkg_validator.ts b/solidity/ecdsa/deploy/02_deploy_dkg_validator.ts index fd0bd97546..d636992135 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], 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