Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion chainbackends/lndclient_adapters.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package chainbackends

import (
"context"
"errors"
"fmt"
"log/slog"
"time"
Expand All @@ -15,6 +16,8 @@ import (
"github.com/lightningnetwork/lnd/chainntnfs"
fn "github.com/lightningnetwork/lnd/fn/v2"
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// lndRegistrationTimeout bounds how long a conf/spend registration call into
Expand All @@ -27,6 +30,19 @@ import (
// genuinely wedged backend surfaces as an error rather than a silent hang.
const lndRegistrationTimeout = 15 * time.Second

// isBlockEpochShutdownError reports whether an LND call only failed because
// the owning block-epoch subscription is shutting down. The context check is
// required so an independent Canceled status from a live backend remains an
// actionable warning.
func isBlockEpochShutdownError(ctx context.Context, err error) bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F10 (Minor) — Alert-classification predicate ships untested · chainbackends/lndclient_adapters.go:37

isBlockEpochShutdownError decides whether an operator-actionable Canceled from a live backend stays a warning or is silenced to debug, and no test in this PR covers it — files[] contains no chainbackends test file. Inverting the ctx.Err() == nil guard, or dropping it entirely, would suppress every genuine block-epoch failure with no failing test. Two table cases (live context + codes.Canceled → false; cancelled context + context.Canceled → true) would pin the branch that the whole change rests on.

if ctx.Err() == nil {
return false
}

return errors.Is(err, context.Canceled) ||
status.Code(err) == codes.Canceled
}

// LndClientTxBroadcaster implements TxBroadcaster using
// lndclient.WalletKitClient.
type LndClientTxBroadcaster struct {
Expand Down Expand Up @@ -507,6 +523,20 @@ func (n *LndClientChainNotifier) RegisterBlockEpochNtfn(
ctx, int64(height),
)
if err != nil {
if isBlockEpochShutdownError(ctx, err) {
log.DebugS(ctx, "Block hash "+
"lookup cancelled during "+

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F5 (Minor) — Rewrapped lines still exceed 80 columns · chainbackends/lndclient_adapters.go:528 · partially_addressed

All three cited lines were restructured, but the new wrapping still overflows at tab-width 8: chainbackends/lndclient_adapters.go:528 ("lookup cancelled during "+ at 7 tabs, 83), :570 ("subscription cancelled",, 81), and unroll/actor.go:886 (slog.String("proof_txid", txid.String()), at 5 tabs, 81). Splitting each one segment earlier clears them.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F5 (Minor) · chainbackends/lndclient_adapters.go:528 · partially_addressed

unroll/actor.go is fixed (every added line now lands at or under 80 columns), but two added lines in this file still exceed the limit at 8-space tabs: :528 ("lookup cancelled during "+, 7 tabs + 27 = 83) and :570 ("subscription cancelled",, 7 tabs + 25 = 81). Splitting each string one word earlier clears both.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F5 (Minor) · chainbackends/lndclient_adapters.go:528 · partially_addressed

The rewrapped log.DebugS message lines fit, but the deeper nesting still pushes several added lines past 80 columns at the documented 8-column tab width: chainbackends/lndclient_adapters.go:528 (83) and :571 (81), and in unroll/actor.go the block the new if firstAlert { indented one level — :888 (81), :892 (84), :900 (84), :905 (83). Wrapping each one segment earlier, or hoisting proofNodeHeightHintLookback / int64(b.desc.CreatedHeight) into locals above the WarnS call, resolves them.

@lightninglabs-gateway lightninglabs-gateway Bot Aug 27, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 Dismissed by @bhandras

🟡 F5 (Minor) · chainbackends/lndclient_adapters.go:528 · partially_addressed

Three added lines still exceed the 80-column limit at tab-width 8: chainbackends/lndclient_adapters.go:528 ("lookup cancelled during "+, 83), the "subscription cancelled", continuation in the errChan branch (81), and slog.String("proof_txid", txid.String()), inside the WarnS call at unroll/actor.go (81). Splitting each string literal one segment earlier clears all three; if ll is configured with a different tab width in this repo, disregard.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/gateway dismiss

"subscription shutdown",
slog.Any("err", err),
slog.Int(
"height",
int(height),
),
)

continue
}

log.WarnS(
ctx,
"Failed to get block hash",
Expand Down Expand Up @@ -535,7 +565,18 @@ func (n *LndClientChainNotifier) RegisterBlockEpochNtfn(

case err, ok := <-errChan:
if ok && err != nil {
log.WarnS(ctx, "Block epoch error", err)
if isBlockEpochShutdownError(ctx, err) {
log.DebugS(ctx, "Block epoch "+
"subscription cancelled",
slog.Any("err", err),
)
} else {
log.WarnS(
ctx,
"Block epoch error",
err,
)
}
}

return
Expand Down
57 changes: 57 additions & 0 deletions chainbackends/lndclient_adapters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,65 @@ import (
"github.com/lightninglabs/lndclient"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// TestIsBlockEpochShutdownError pins the classification boundary between an
// expected subscription shutdown and an actionable live-backend failure.
func TestIsBlockEpochShutdownError(t *testing.T) {
t.Parallel()

tests := []struct {
name string
cancelContext bool
err error
want bool
}{
{
name: "live backend canceled status",
err: status.Error(codes.Canceled, "backend canceled"),
},
{
name: "owning context canceled",
cancelContext: true,
err: context.Canceled,
want: true,
},
{
name: "shutdown canceled status",
cancelContext: true,
err: status.Error(
codes.Canceled, "subscription closed",
),
want: true,
},
{
name: "unrelated shutdown error",
cancelContext: true,
err: errors.New("backend unavailable"),
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()

ctx := t.Context()
if test.cancelContext {
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
cancel()
}

require.Equal(
t, test.want,
isBlockEpochShutdownError(ctx, test.err),
)
})
}
}

// stubLndClientNotifier exposes controllable lndclient notification streams.
type stubLndClientNotifier struct {
lndclient.ChainNotifierClient
Expand Down
17 changes: 10 additions & 7 deletions round/actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -2333,8 +2333,10 @@ func (a *RoundClientActor) handleGetState(ctx context.Context,
for keyStr, roundFSM := range a.rounds {
roundState, err := fsmState(scanCtx, roundFSM.FSM)
if err != nil {
a.log.WarnS(ctx, "Failed to get FSM state for round",
err,
a.log.DebugS(
ctx,
"Skipped round with unreadable FSM state",
slog.Any("err", err),
slog.String("key", string(keyStr)),
)

Expand Down Expand Up @@ -2923,12 +2925,13 @@ func (a *RoundClientActor) processOutbox(ctx context.Context,
inputSigState.CommitmentTx,
)

// Index for confirmation routing and register.
// Index the transaction before the confirmation request
// emitted by the FSM can be delivered. The FSM outbox
// owns the steady-state registration; registering again
// here creates two notifier subscriptions for the same
// tx. A restarted actor still re-registers active

@lightninglabs-gateway lightninglabs-gateway Bot Aug 27, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 Dismissed by @bhandras

🟡 F12 (Minor) — Restart re-registration uses current height as its height hint · round/actor.go:2932

The new comment asserts a restarted actor "still re-registers active rounds in Start", but that path goes through registerCommitmentConfirmation, which derives HeightHint from a live BestHeightRequest rather than the round's StartHeight — so PkScript and TargetConfs match the FSM-emitted request while the height hint does not, and it can sit above the height at which the commitment already confirmed. createRoundFSMFromDB documents exactly this hazard for the same restart path ("not the current height, which could miss confirmations if the tx was already mined"), and restart is precisely when the tx is most likely to have confirmed while the daemon was down. This is pre-existing code the diff does not touch, and I cannot confirm from the loaded context whether the notifier tolerates a hint above the confirmation height; passing round.StartHeight through to the registration would make the two paths agree on all three parameters.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/gateway dismiss

// rounds in Start.
a.commitmentTxIndex[txid] = keyStr

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 F1 (Major) — Checkpoint registration removed without proving the FSM's is equivalent · round/actor.go:2934

Deleting the registerCommitmentConfirmation call at the checkpoint leaves the FSM-emitted RegisterConfirmationRequest as the sole steady-state watch for the commitment tx, but nothing in the diff or the loaded context shows that request carries the same watch parameters. If it does not, the round's confirmation either never routes (funds sit in a checkpointed round that never finalizes) or finalizes on fewer confirmations than the operator requires.

Why this matters

The deleted path built its registration deliberately: registerCommitmentConfirmation derives pkScript from confirmationWatchScript(packet.UnsignedTx, vtxoTrees) — with an in-code comment stating it must "watch the validated batch output ... rather than assuming output 0" — and sets TargetConfs: a.cfg.OperatorTerms.MinConfirmations. The only visible representation of the FSM-side message is the one the new test hand-builds at round/actor_test.go:840-845, which carries TargetConfs: 1 and no PkScript at all. The FSM transition that emits the real message is not in file_contents[], so I cannot confirm which shape it uses; this is a major if the FSM request omits the tree-derived pkScript or hardcodes a conf target below OperatorTerms.MinConfirmations, and a non-issue if it mirrors the actor path.

The asymmetry the change introduces is worth naming regardless: restart recovery still registers through registerCommitmentConfirmation (see TestActorRecovery/single_active_round asserting one registration after Start), so a round now gets one watch shape in steady state and a different one after a restart. Those two shapes should be reconciled — either by having the FSM emit the same pkScript/conf-target, or by keeping the actor as the single registration owner and removing the FSM's emission instead.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 F1 (Major) — Commitment watch may register the wrong pkScript · round/actor.go:2934 · partially_addressed

Nothing in the diff or the loaded context shows what the FSM emits, so the deleted registration's tree-derived pkScript may not be reproduced. If it is not, rounds whose funds land outside output 0 register a confirmation watch on a pkScript the commitment tx never pays to, the confirmation never routes, and the round never finalizes.

Why this matters

registerCommitmentConfirmation passed inputSigState.VTXOTreePaths into confirmationWatchScript precisely so the watch lands on the validated batch output "rather than assuming output 0" (its own comment). The new test derives its expected script at round/actor_test.go:839-840 as confirmationWatchScript(commitmentTx.UnsignedTx, nil) — exactly the output-0 fallback — and then feeds that value into the outbox itself, so it neither exercises nor constrains the tree-derived path.

Second, concrete asymmetry: the caller IDs diverge. registerCommitmentConfirmation builds fmt.Sprintf("commitment-tx-%s", txid.String()); the FSM message the test mirrors uses "commitment-" + txid.String() (round/actor_test.go:846). chainsource keys registrations by caller ID, so if the Start-time recovery registration and an FSM-emitted one are ever live at the same time they cannot collapse — the double subscription this PR removes has no backstop on that path.

What closes it: a test that drives the real FSM transition with non-nil VTXOTreePaths and asserts the emitted request's PkScript equals confirmationWatchScript(tx, trees) and its CallerID matches the recovery path's format. Absent the FSM transition file I cannot confirm either way.

@lightninglabs-gateway lightninglabs-gateway Bot Aug 27, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 Dismissed by @bhandras

🟡 F11 (Minor) — Only the forfeit-bearing path is tested for the confirmation watch · round/actor.go:2934

Removing the checkpoint-path registration trades one registration that covered every checkpointed round for per-transition emissions, and only the forfeit-bearing transition (forfeitCollectionOutbox) is exercised by a test — a boarding-only round, which reaches InputSigSent without forfeits and so never runs that helper, has no test asserting it still gets a watch. round/actor_test.go's round_checkpointed case hand-builds the request rather than driving the transition, so it would pass even if the boarding-only path emitted nothing and the commitment tx were left unwatched. Worth a one-line confirmation that the PartialSigsSent → InputSigSent path emits the same request, and a test on it if so.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/gateway dismiss

a.registerCommitmentConfirmation(
ctx, txid, roundFSM.CommitmentTx,
inputSigState.VTXOTreePaths,
)

a.log.InfoS(ctx, "Round checkpoint processed",
slog.String("round_id", m.RoundID.String()),
Expand Down
22 changes: 21 additions & 1 deletion round/actor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -835,8 +835,20 @@ func TestActorProcessOutbox(t *testing.T) {
// Set up a round in InputSigSentState, simulating the state
// after the round has completed through partial sigs.
commitmentTx := h.setupRoundInInputSigSentState(roundID)
txid := commitmentTx.UnsignedTx.TxHash()
pkScript := confirmationWatchScript(
commitmentTx.UnsignedTx, nil,
)
targetConfs := h.actor.env.OperatorTerms.MinConfirmations

outbox := []ClientOutMsg{
&RegisterConfirmationRequest{
CallerID: "commitment-" + txid.String(),
Txid: &txid,
PkScript: pkScript,
TargetConfs: targetConfs,
HeightHint: h.actor.env.StartHeight,
},
&RoundCheckpointedNotification{
RoundID: roundID,
},
Expand All @@ -850,10 +862,18 @@ func TestActorProcessOutbox(t *testing.T) {
keyStr := RoundKeyStr(roundID.KeyString())
require.Contains(t, h.actor.rounds, keyStr)

txid := commitmentTx.UnsignedTx.TxHash()
indexedKeyStr, exists := h.actor.commitmentTxIndex[txid]
require.True(t, exists)
require.Equal(t, keyStr, indexedKeyStr)

// The FSM outbox registration is sufficient. Processing the
// checkpoint notification must not create a second notifier for
// the same commitment transaction.
require.Len(t, h.chainSource.registrations, 1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F2 (Minor) — Regression test cannot detect a divergent FSM registration · round/actor_test.go:867

The new assertions check len(h.chainSource.registrations) == 1 and that its Txid matches, but the RegisterConfirmationRequest under test is constructed by the test itself, so it proves only that processOutbox no longer double-registers — not that the real FSM emission is a viable substitute for the deleted call. Asserting PkScript and TargetConfs on registrations[0] against what registerCommitmentConfirmation would have produced would turn this into a test that actually locks in F1.

registration := h.chainSource.registrations[0]
require.True(t, registration.Txid.IsEqual(&txid))
require.Equal(t, pkScript, registration.PkScript)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F2 (Minor) — Registration assertions compare test-supplied values · round/actor_test.go:875 · partially_addressed

The PkScript / TargetConfs assertions were added, but the test computes both at :839-842 and feeds them into the outbox at :848-849, so require.Equal at :875-876 proves only that processOutbox passes the fields through. The require.Len(..., 1) check is the part that earns its keep; locking in F1 needs the request to originate from the FSM, not from the test body.

require.Equal(t, targetConfs, registration.TargetConfs)
})

t.Run("new_boarding_creates_round", func(t *testing.T) {
Expand Down
44 changes: 23 additions & 21 deletions round/transitions.go
Original file line number Diff line number Diff line change
Expand Up @@ -3097,6 +3097,9 @@ func confirmationWatchScript(commitmentTx *wire.MsgTx,
return commitmentTx.TxOut[idx].PkScript
}

// forfeitCollectionOutbox orders the reconciliation timeout before every
// fallible external effect, then preserves the server submission sequence
// before registering the commitment confirmation watch.
func (s *ForfeitSignaturesCollectingState) forfeitCollectionOutbox(
env *ClientEnvironment,
forfeitTxs map[wire.OutPoint]*types.ForfeitTxSig,
Expand All @@ -3115,17 +3118,6 @@ func (s *ForfeitSignaturesCollectingState) forfeitCollectionOutbox(
RoundKey: RoundKeyStr(s.RoundID.KeyString()),
Phase: TimeoutPhaseForfeitCollection,
},
&SubmitVTXOForfeitSigsToServer{
RoundID: s.RoundID,
ForfeitTxs: forfeitTxs,
},
&RegisterConfirmationRequest{
CallerID: callerID,
Txid: &txid,
PkScript: pkScript,
TargetConfs: env.OperatorTerms.MinConfirmations,
HeightHint: env.StartHeight,
},
}

// The forfeit signatures leave the box on this transition, opening the
Expand All @@ -3144,18 +3136,28 @@ func (s *ForfeitSignaturesCollectingState) forfeitCollectionOutbox(
})
}

if len(boardingInputSigs) == 0 {
return outboxMsgs
// The timeout must be armed before either fallible external effect.
// processOutbox stops on the first error, so placing it after the
// signature submission or chain registration could strand the round in
// forfeit collection with no reconciliation path.
outboxMsgs = append(outboxMsgs, &SubmitVTXOForfeitSigsToServer{
RoundID: s.RoundID,
ForfeitTxs: forfeitTxs,
})
if len(boardingInputSigs) > 0 {
outboxMsgs = append(outboxMsgs, &SubmitForfeitSigRequest{
RoundID: s.RoundID,
Signatures: boardingInputSigs,
})
}

return append(
outboxMsgs[:2], append([]ClientOutMsg{
&SubmitForfeitSigRequest{
RoundID: s.RoundID,
Signatures: boardingInputSigs,
},
}, outboxMsgs[2:]...)...,
)
return append(outboxMsgs, &RegisterConfirmationRequest{
CallerID: callerID,
Txid: &txid,
PkScript: pkScript,
TargetConfs: env.OperatorTerms.MinConfirmations,
HeightHint: env.StartHeight,
})
}

func (s *ForfeitSignaturesCollectingState) checkpointRound(
Expand Down
46 changes: 46 additions & 0 deletions round/vtxo_tree_binding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package round

import (
"testing"
"time"

"github.com/btcsuite/btcd/chainhash/v2"
"github.com/btcsuite/btcd/psbt/v2"
"github.com/btcsuite/btcd/txscript/v2"
"github.com/btcsuite/btcd/wire/v2"
"github.com/lightninglabs/wavelength/lib/tree"
Expand Down Expand Up @@ -254,6 +256,50 @@ func TestConfirmationWatchScriptUsesBatchOutput(t *testing.T) {
watch := confirmationWatchScript(tx, trees)
require.Equal(t, batchScript, watch)
require.NotEqual(t, fillerScript, watch)

// The real FSM outbox must carry the same validated script and
// operator confirmation target. This is the sole steady-state watch;
// restart recovery independently rebuilds the same parameters.
packet, err := psbt.NewFromUnsignedTx(tx)
require.NoError(t, err)
state := &ForfeitSignaturesCollectingState{
RoundID: testRoundIDTr("nonzero-batch-output"),
CommitmentTx: packet,
VTXOTreePaths: trees,
Intents: Intents{
Forfeits: []types.ForfeitRequest{
{},
},
},
}
const targetConfs = 6
const startHeight = 123
outbox := state.forfeitCollectionOutbox(
&ClientEnvironment{
OperatorTerms: &types.OperatorTerms{
MinConfirmations: targetConfs,
},
StartHeight: startHeight,
StatusReconcileTimeout: time.Minute,
},
nil, []*types.BoardingInputSignature{{}},
)

// Keep the timeout ahead of every fallible effect while preserving the
// server submission order: VTXO forfeits, boarding signatures, and then
// chain registration. This must not depend on an insertion index whose
// meaning changes when another outbox message is added.
require.Len(t, outbox, 5)
require.IsType(t, &CancelTimeoutReq{}, outbox[0])
require.IsType(t, &StartTimeoutReq{}, outbox[1])
require.IsType(t, &SubmitVTXOForfeitSigsToServer{}, outbox[2])
require.IsType(t, &SubmitForfeitSigRequest{}, outbox[3])
registration, ok := outbox[4].(*RegisterConfirmationRequest)
require.True(t, ok)
require.Equal(t, batchScript, registration.PkScript)
require.Equal(t, uint32(targetConfs), registration.TargetConfs)
require.Equal(t, uint32(startHeight), registration.HeightHint)
require.True(t, registration.Txid.IsEqual(&txid))
}

// TestCommitmentTxReceivedRejectsUnboundTree confirms the binding is wired into
Expand Down
Loading
Loading