diff --git a/pkg/chain/ethereum/bitcoin_difficulty.go b/pkg/chain/ethereum/bitcoin_difficulty.go index 08c41959cb..fe5d9fb86b 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" @@ -23,6 +24,12 @@ const ( LightRelayMaintainerProxyContractName = "LightRelayMaintainerProxy" ) +// waitDeployBackendTransactionMinedTimeout bounds the synchronous post-submit +// wait for retarget transactions. The wait covers both receipt polling and the +// follow-up confirmation-depth wait. Without a bound, a stalled RPC or a chain +// that stops producing blocks would hang the maintainer indefinitely. +const waitDeployBackendTransactionMinedTimeout = 10 * time.Minute + // BitcoinDifficultyChain represents a Bitcoin difficulty-specific chain handle. type BitcoinDifficultyChain struct { *baseChain @@ -138,7 +145,13 @@ 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) + ctx, cancel := context.WithTimeout( + context.Background(), + waitDeployBackendTransactionMinedTimeout, + ) + defer cancel() + + receipt, err := bind.WaitMined(ctx, bdc.client, tx) if err != nil { return fmt.Errorf("waiting for transaction [%s] [%s]: [%w]", method, tx.Hash().Hex(), err) } @@ -155,7 +168,9 @@ func (bdc *BitcoinDifficultyChain) waitDeployBackendTransactionMined( if receipt.BlockNumber != nil && bdc.blockCounter != nil { includedAt := receipt.BlockNumber.Uint64() const confirmDepth = uint64(3) - if err := bdc.blockCounter.WaitForBlockHeight(includedAt + confirmDepth); err != nil { + if err := waitForBlockHeightCtx( + ctx, bdc.blockCounter, includedAt+confirmDepth, + ); err != nil { return fmt.Errorf( "waiting confirmation depth after [%s] [%s]: [%w]", method, @@ -167,6 +182,25 @@ func (bdc *BitcoinDifficultyChain) waitDeployBackendTransactionMined( return nil } +// waitForBlockHeightCtx wraps the context-less chain.BlockCounter.WaitForBlockHeight +// so the caller can enforce a deadline. The underlying interface does not accept +// a context; if ctx fires before the height is reached, the wait goroutine stays +// parked until the next block tick eventually unblocks it. +func waitForBlockHeightCtx( + ctx context.Context, + bc chain.BlockCounter, + blockNumber uint64, +) error { + done := make(chan error, 1) + go func() { done <- bc.WaitForBlockHeight(blockNumber) }() + select { + case err := <-done: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + // Ready checks whether the relay is active (i.e. genesis has been performed). // Note that if the relay is used by querying the current and previous epoch // difficulty, at least one retarget needs to be provided after genesis; diff --git a/pkg/chain/ethereum/bitcoin_difficulty_test.go b/pkg/chain/ethereum/bitcoin_difficulty_test.go new file mode 100644 index 0000000000..52d9c8131d --- /dev/null +++ b/pkg/chain/ethereum/bitcoin_difficulty_test.go @@ -0,0 +1,63 @@ +package ethereum + +import ( + "context" + "errors" + "testing" + "time" +) + +// blockingBlockCounter blocks WaitForBlockHeight until ch is closed, simulating +// a chain that has stopped producing blocks (or an RPC stuck behind a stale +// load balancer). +type blockingBlockCounter struct { + ch chan struct{} +} + +func (b *blockingBlockCounter) CurrentBlock() (uint64, error) { return 0, nil } +func (b *blockingBlockCounter) WatchBlocks(_ context.Context) <-chan uint64 { return nil } +func (b *blockingBlockCounter) BlockHeightWaiter(_ uint64) (<-chan uint64, error) { + return nil, nil +} +func (b *blockingBlockCounter) WaitForBlockHeight(_ uint64) error { + <-b.ch + return nil +} + +// TestWaitForBlockHeightCtx_DeadlineExceeded asserts that the context shim +// honors the parent context's deadline when WaitForBlockHeight blocks forever. +// Regression: the original implementation called WaitForBlockHeight without any +// cancellation path, which could hang the maintainer indefinitely if the chain +// stalled mid-retarget. See waitDeployBackendTransactionMinedTimeout. +func TestWaitForBlockHeightCtx_DeadlineExceeded(t *testing.T) { + bc := &blockingBlockCounter{ch: make(chan struct{})} + defer close(bc.ch) // release the parked goroutine + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + start := time.Now() + err := waitForBlockHeightCtx(ctx, bc, 100) + elapsed := time.Since(start) + + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected context.DeadlineExceeded, got %v", err) + } + if elapsed > 500*time.Millisecond { + t.Fatalf("wait took too long: %v", elapsed) + } +} + +// TestWaitForBlockHeightCtx_ReturnsImmediatelyOnSuccess asserts the shim does +// not introduce extra latency when the underlying counter returns promptly. +func TestWaitForBlockHeightCtx_ReturnsImmediatelyOnSuccess(t *testing.T) { + bc := &blockingBlockCounter{ch: make(chan struct{})} + close(bc.ch) // pre-close: WaitForBlockHeight returns nil immediately + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + if err := waitForBlockHeightCtx(ctx, bc, 1); err != nil { + t.Fatalf("expected nil error, got %v", err) + } +} diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 2103dc587f..275c68ff0a 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -253,7 +253,11 @@ func newTbtcChain( case err == nil: ecdsaDkgValidatorAddress = validatorAddr case errors.Is(err, ethereum.ErrAddressNotConfigured): - // Optional: without it TBTC falls back to defaultGroupParameters(network). + logger.Warnf( + "%s contract address is not configured; TBTC group parameters "+ + "will fall back to network defaults instead of on-chain values", + EcdsaDkgValidatorContractName, + ) default: return nil, fmt.Errorf( "failed to resolve %s contract address: [%w]", diff --git a/pkg/tbtc/tbtc.go b/pkg/tbtc/tbtc.go index b4767078bb..fa009348b9 100644 --- a/pkg/tbtc/tbtc.go +++ b/pkg/tbtc/tbtc.go @@ -53,8 +53,12 @@ func (gp *GroupParameters) DishonestThreshold() int { func defaultGroupParameters(n ethereum.Network) *GroupParameters { switch n { case ethereum.Sepolia, ethereum.Developer: - logger.Infof( - "TBTC group parameters: testnet/small group (size=3, quorum=3, honest=2) for %s", + logger.Warnf( + "TBTC group parameters: testnet/small group (size=3, quorum=3, "+ + "honest=2) for %s; quorum equals size so all three operators "+ + "must remain online for DKG to progress. Configure an "+ + "EcdsaDkgValidator contract address to override these defaults "+ + "with on-chain values.", n, ) return &GroupParameters{