From 772a4232521885b40fc6a70f4b4975f317724273 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?=
Date: Thu, 4 Jun 2026 12:27:13 +0000
Subject: [PATCH 1/3] fix(go): bound bitcoin_difficulty retarget wait under a
deadline
WaitMined previously used context.Background() with no timeout, and the
follow-up confirmation-depth wait called the context-less
BlockCounter.WaitForBlockHeight. If the RPC stalls or the chain stops
producing blocks, the maintainer would hang indefinitely on every
Retarget / RetargetWithRefund call.
Wrap both waits under a 10-minute shared deadline and add a context
shim around WaitForBlockHeight so callers can enforce timeouts on the
context-less BlockCounter interface.
---
pkg/chain/ethereum/bitcoin_difficulty.go | 38 ++++++++++-
pkg/chain/ethereum/bitcoin_difficulty_test.go | 63 +++++++++++++++++++
2 files changed, 99 insertions(+), 2 deletions(-)
create mode 100644 pkg/chain/ethereum/bitcoin_difficulty_test.go
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..7b9ada0511
--- /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)
+ }
+}
From d4b2e1e0eb188b868a5367c475b34b2bc5bcaac6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?=
Date: Thu, 4 Jun 2026 12:27:51 +0000
Subject: [PATCH 2/3] fix(tbtc): warn loudly about Sepolia DKG fragility and
missing validator
Elevate the Sepolia/Developer defaultGroupParameters log to Warn level
and spell out that GroupQuorum equals GroupSize (3/3/2), so all three
operators must stay online for DKG to progress.
Also emit a Warn when the EcdsaDkgValidator contract address is not
configured. The fallthrough was previously silent, leaving operators
unaware that group sizing was coming from compile-time defaults rather
than the on-chain validator.
---
pkg/chain/ethereum/tbtc.go | 6 +++++-
pkg/tbtc/tbtc.go | 8 ++++++--
2 files changed, 11 insertions(+), 3 deletions(-)
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{
From 754346d386d87c42f81680cf4f153ce438cda7aa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?=
Date: Thu, 4 Jun 2026 13:01:00 +0000
Subject: [PATCH 3/3] style(go): apply gofmt alignment to
bitcoin_difficulty_test
---
pkg/chain/ethereum/bitcoin_difficulty_test.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pkg/chain/ethereum/bitcoin_difficulty_test.go b/pkg/chain/ethereum/bitcoin_difficulty_test.go
index 7b9ada0511..52d9c8131d 100644
--- a/pkg/chain/ethereum/bitcoin_difficulty_test.go
+++ b/pkg/chain/ethereum/bitcoin_difficulty_test.go
@@ -14,7 +14,7 @@ type blockingBlockCounter struct {
ch chan struct{}
}
-func (b *blockingBlockCounter) CurrentBlock() (uint64, error) { return 0, nil }
+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