From 5d77198e12617250b11e8aa1189c20fa45bc1f6f Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 8 Jul 2026 14:09:13 -0700 Subject: [PATCH 1/2] vtxo+oor: multi-parent lineage gate + OOR-received registration (C7) Squashed for the btcd v2 port. OOR registers every batch parent in the received-VTXO proof lineage with the canonicality manager, and the VTXO gate combines availability across all ancestry parents (worst-state AND) for multi-parent OOR VTXOs. --- oor/lineage_batch_canon_test.go | 165 ++++++++++++++++++++++++++ oor/session_actor.go | 9 ++ oor/session_actor_handlers.go | 102 ++++++++++++++++ vtxo/CLAUDE.md | 12 +- vtxo/manager.go | 56 +++++++-- vtxo/manager_multiparent_gate_test.go | 115 ++++++++++++++++++ 6 files changed, 442 insertions(+), 17 deletions(-) create mode 100644 oor/lineage_batch_canon_test.go create mode 100644 vtxo/manager_multiparent_gate_test.go diff --git a/oor/lineage_batch_canon_test.go b/oor/lineage_batch_canon_test.go new file mode 100644 index 000000000..2a2fc0c26 --- /dev/null +++ b/oor/lineage_batch_canon_test.go @@ -0,0 +1,165 @@ +package oor + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/batchcanon" + "github.com/lightninglabs/darepo-client/lib/tree" + "github.com/lightninglabs/darepo-client/vtxo" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// oorBCRef aliases the canonicality manager tell-ref to keep test literals +// within the line limit. +type oorBCRef = actor.TellOnlyRef[batchcanon.ManagerMsg] + +// lineageFragment builds an ancestry fragment anchored at the given commitment +// txid whose tree root carries the supplied batch-output pkScript. +func lineageFragment(txid chainhash.Hash, pkScript []byte) vtxo.Ancestry { + return vtxo.Ancestry{ + CommitmentTxID: txid, + TreePath: &tree.Tree{ + BatchOutput: &wire.TxOut{ + PkScript: pkScript, + }, + }, + } +} + +func lineageOutpoint(seed byte) wire.OutPoint { + var h chainhash.Hash + h[0] = seed + + return wire.OutPoint{Hash: h, Index: uint32(seed)} +} + +// TestRegisterLineageBatchesRegistersDistinctAncestors verifies that receiving +// OOR VTXOs registers one RegisterBatchRequest per distinct ancestor commitment +// tx, carrying the tree-root batch pkScript and the dependent VTXO outpoints, +// and that a batch shared across two received VTXOs accumulates both. +func TestRegisterLineageBatchesRegistersDistinctAncestors(t *testing.T) { + t.Parallel() + + ref := actor.NewChannelTellOnlyRef[batchcanon.ManagerMsg]( + "oor-batchcanon-test", 8, + ) + b := &sessionBehavior{ + cfg: SessionActorConfig{ + BatchCanonicality: fn.Some[oorBCRef](ref), + }, + log: btclog.Disabled, + } + + txA := chainhash.Hash{0xaa} + txB := chainhash.Hash{0xbb} + scriptA := []byte{0x51, 0x20, 0xaa} + scriptB := []byte{0x51, 0x20, 0xbb} + + vtxo1 := lineageOutpoint(1) + vtxo2 := lineageOutpoint(2) + + descs := []*vtxo.Descriptor{ + { + Outpoint: vtxo1, + Ancestry: []vtxo.Ancestry{ + lineageFragment(txA, scriptA), + }, + }, + { + // Multi-parent: shares txA and adds txB. + Outpoint: vtxo2, + Ancestry: []vtxo.Ancestry{ + lineageFragment(txA, scriptA), + lineageFragment(txB, scriptB), + }, + }, + } + + b.registerLineageBatches(t.Context(), descs) + + got := make(map[chainhash.Hash]*batchcanon.RegisterBatchRequest) + for range 2 { + msg, ok := ref.AwaitMessage(time.Second) + require.True(t, ok, "expected a RegisterBatchRequest") + req, ok := msg.(*batchcanon.RegisterBatchRequest) + require.True(t, ok) + got[req.BatchTxID] = req + } + + // No third registration. + _, extra := ref.AwaitMessage(100 * time.Millisecond) + require.False(t, extra, "expected exactly two distinct batches") + + require.Contains(t, got, txA) + require.Equal(t, scriptA, got[txA].ConfirmationPkScript) + require.ElementsMatch( + t, []wire.OutPoint{vtxo1, vtxo2}, got[txA].DependentVTXOs, + ) + + require.Contains(t, got, txB) + require.Equal(t, scriptB, got[txB].ConfirmationPkScript) + require.Equal(t, []wire.OutPoint{vtxo2}, got[txB].DependentVTXOs) +} + +// TestRegisterLineageBatchesDormantWhenUnwired verifies registration is a no-op +// when no canonicality manager ref is configured. +func TestRegisterLineageBatchesDormantWhenUnwired(t *testing.T) { + t.Parallel() + + b := &sessionBehavior{ + cfg: SessionActorConfig{ + BatchCanonicality: fn.None[oorBCRef](), + }, + log: btclog.Disabled, + } + + // Must not panic with a populated lineage and no ref. + b.registerLineageBatches(t.Context(), []*vtxo.Descriptor{ + { + Outpoint: lineageOutpoint(1), + Ancestry: []vtxo.Ancestry{ + lineageFragment( + chainhash.Hash{0xaa}, []byte{0x51}, + ), + }, + }, + }) +} + +// TestRegisterLineageBatchesSkipsIncompleteFragments verifies fragments with no +// tree path / batch output or a zero txid are skipped (they cannot be watched), +// leaving the gate permissive rather than registering an unwatchable batch. +func TestRegisterLineageBatchesSkipsIncompleteFragments(t *testing.T) { + t.Parallel() + + ref := actor.NewChannelTellOnlyRef[batchcanon.ManagerMsg]( + "oor-batchcanon-skip", 4, + ) + b := &sessionBehavior{ + cfg: SessionActorConfig{ + BatchCanonicality: fn.Some[oorBCRef](ref), + }, + log: btclog.Disabled, + } + + b.registerLineageBatches(t.Context(), []*vtxo.Descriptor{ + { + Outpoint: lineageOutpoint(1), + Ancestry: []vtxo.Ancestry{ + // Nil tree path: skipped. + {CommitmentTxID: chainhash.Hash{0xaa}}, + // Zero txid: skipped. + lineageFragment(chainhash.Hash{}, []byte{0x51}), + }, + }, + }) + + _, ok := ref.AwaitMessage(200 * time.Millisecond) + require.False(t, ok, "no registration expected for incomplete lineage") +} diff --git a/oor/session_actor.go b/oor/session_actor.go index 4d39fd854..beea7c00d 100644 --- a/oor/session_actor.go +++ b/oor/session_actor.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/batchcanon" "github.com/lightninglabs/darepo-client/build" clientdb "github.com/lightninglabs/darepo-client/db" "github.com/lightninglabs/darepo-client/ledger" @@ -64,6 +65,14 @@ type SessionActorConfig struct { // materialized so it can spawn monitoring actors. VTXOManager actor.TellOnlyRef[vtxo.ManagerMsg] + // BatchCanonicality, when set, receives a RegisterBatchRequest for + // each commitment batch in a received OOR VTXO's lineage so the + // reorg-safety availability gate (darepo#454) can govern the received + // VTXO: a reorg-out or invalidation of any ancestor batch marks the + // VTXO limbo. None disables registration (the gate stays dormant), + // matching the C5/C6 dormancy contract. + BatchCanonicality fn.Option[actor.TellOnlyRef[batchcanon.ManagerMsg]] + // SpendCompleter routes outgoing input-spend completion through the // VTXO manager. The manager's status write commits in the VTXO actor's // own transaction, so it does NOT join this actor's turn: the spend is diff --git a/oor/session_actor_handlers.go b/oor/session_actor_handlers.go index 5ce8e1ac2..51c9f2582 100644 --- a/oor/session_actor_handlers.go +++ b/oor/session_actor_handlers.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/darepo-client/batchcanon" clientdb "github.com/lightninglabs/darepo-client/db" "github.com/lightninglabs/darepo-client/ledger" libtypes "github.com/lightninglabs/darepo-client/lib/types" @@ -757,10 +758,111 @@ func (b *sessionBehavior) notifyMaterialized(ctx context.Context, event Event) { "observer failed", err) } } + + b.registerLineageBatches(ctx, descs) }() }) } +// registerLineageBatches registers every commitment batch in the received +// VTXOs' lineage with the canonicality manager so the multi-parent admission +// gate can govern these OOR-received VTXOs: a reorg-out or invalidation of any +// ancestor batch marks the dependent VTXO limbo, excluding it from selection +// until its lineage is canonical again (darepo#454, F4/F5). +// +// It is a no-op when no manager ref is wired (the gate stays dormant). It runs +// on the post-commit best-effort goroutine alongside the VTXO-manager +// notification: a dropped registration only leaves the gate permissive for +// these VTXOs (the safe default), and the manager's RegisterBatch is +// idempotent so a re-materialization re-registers harmlessly. +// +// The batch-output pkScript comes from each ancestry fragment's tree root +// (BatchOutput), which is what script-filtering light-client backends filter +// on, so confirmation detection of the ancestor batch works on Esplora/Neutrino +// receivers too. ConsumedInputs are left empty: the receiver does not hold the +// ancestor commitment txs' inputs at this seam, so per-input double-spend +// watches are a follow-up — a reorg-out of an ancestor batch is still detected +// via its confirmation watch and marks the VTXO limbo. +func (b *sessionBehavior) registerLineageBatches(ctx context.Context, + descs []*vtxo.Descriptor) { + + if b.cfg.BatchCanonicality.IsNone() { + return + } + ref := b.cfg.BatchCanonicality.UnsafeFromSome() + + // Collect, per distinct ancestor commitment tx, its batch-output + // pkScript and the deduped set of received VTXO outpoints that depend + // on it (a desc may carry the same commitment txid across more than one + // ancestry fragment). + type batchReg struct { + pkScript []byte + depSeen map[wire.OutPoint]struct{} + dependents []wire.OutPoint + } + batches := make(map[chainhash.Hash]*batchReg) + order := make([]chainhash.Hash, 0) + + for _, desc := range descs { + for i := range desc.Ancestry { + frag := desc.Ancestry[i] + if frag.TreePath == nil || + frag.TreePath.BatchOutput == nil { + + continue + } + txid := frag.CommitmentTxID + if txid == (chainhash.Hash{}) { + continue + } + + reg, ok := batches[txid] + if !ok { + reg = &batchReg{ + pkScript: frag.TreePath. + BatchOutput.PkScript, + depSeen: make( + map[wire.OutPoint]struct{}, + ), + } + batches[txid] = reg + order = append(order, txid) + } + if _, dup := reg.depSeen[desc.Outpoint]; !dup { + reg.depSeen[desc.Outpoint] = struct{}{} + reg.dependents = append( + reg.dependents, desc.Outpoint, + ) + } + } + } + + for _, txid := range order { + reg := batches[txid] + + // CSVExpiryDelta is intentionally left zero here: the + // admission gate consults only the batch State (reorged / + // conflict), never EffectiveExpiry, and the per-batch + // effective expiry is not consumed for OOR-registered ancestor + // batches (each received VTXO carries its own BatchExpiry). + // Threading the real per-fragment CSV delta is a follow-up + // alongside ConsumedInputs. + err := ref.Tell(ctx, &batchcanon.RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: reg.pkScript, + DependentVTXOs: reg.dependents, + }) + if err != nil { + b.log.WarnS(ctx, "Failed to register OOR lineage "+ + "batch canonicality", err, + slog.String("commitment_txid", txid.String()), + slog.Int("dependent_vtxos", + len(reg.dependents)), + ) + } + } +} + // queueVTXOsReceived stages one VTXOReceivedMsg per materialized incoming // VTXO for the durable outbox enqueue in commitAck. func (b *sessionBehavior) queueVTXOsReceived(ctx context.Context, diff --git a/vtxo/CLAUDE.md b/vtxo/CLAUDE.md index 64b910e50..31ccdea3b 100644 --- a/vtxo/CLAUDE.md +++ b/vtxo/CLAUDE.md @@ -31,11 +31,13 @@ when the local wallet owns the receive script. `BatchCanonicality` (optional `batchcanon.Store`), when set, gates coin selection on batch lineage canonicality: `selectAndReserveVTXOs` drops any candidate whose batch is in limbo (reorged-out) or invalidated - (conflict-finalized) state via `batchcanon.LineageBlocked`, reading the - candidate's direct commitment txid through `GetVTXO`. Nil disables the gate - (a complete no-op, the default until the batch producers register batches); - it is permissive otherwise (unseen / unregistered lineage does not block). - Full multi-parent ancestry gating is a follow-up. + (conflict-finalized) state via `batchcanon.LineageBlocked`. It gates on the + FULL lineage (`lineageCommitmentTxids`): the candidate's direct commitment + txid plus every cross-commitment ancestor batch in `Descriptor.Ancestry`, so + a multi-input OOR VTXO is blocked if ANY contributing batch is off the + canonical chain. Nil disables the gate (a complete no-op, the default until + the batch producers register batches); it is permissive otherwise (unseen / + unregistered lineage does not block). - `ExitOutcomeResolution` — Terminal result for an exiting VTXO: `Outcome` (`ExitOutcomeRecoverable` or `ExitOutcomeConfirmed`) and `Reason`. - `ExitOutcomeResolver` — Function type diff --git a/vtxo/manager.go b/vtxo/manager.go index e08efc347..1d5057e66 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -13,6 +13,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btclog/v2" "github.com/btcsuite/btcwallet/waddrmgr" @@ -1161,12 +1162,43 @@ type reserveParams struct { // total amount on success. // gateUnavailableLineage drops candidates whose batch lineage is in a limbo // (reorged-out) or invalidated (conflict-finalized) canonicality state, so a -// VTXO is never selected while its batch is off the canonical chain. It is a -// no-op when no canonicality store is configured (the gate stays dormant until -// the batch producers register their batches). It reads each candidate's -// direct commitment txid via the store; full multi-parent ancestry gating for -// cross-commitment OOR VTXOs is a follow-up. The gate is permissive: an -// unregistered or unseen batch does not block selection. +// VTXO is never selected while any batch in its lineage is off the canonical +// chain. It is a no-op when no canonicality store is configured (the gate +// stays dormant until the batch producers register their batches). It gates on +// the FULL lineage: a VTXO's direct commitment txid plus every cross-commitment +// ancestor batch (a multi-input OOR VTXO descends from more than one batch, and +// any single reorged-out/invalidated parent makes the leaf unspendable). The +// gate is permissive: an unregistered or unseen batch does not block selection. +// lineageCommitmentTxids returns the deduped set of commitment txids in a +// VTXO's lineage: its direct commitment tx plus every distinct ancestor +// commitment tx recorded in its ancestry. A round-direct or same-commitment +// OOR VTXO yields one txid; a cross-commitment multi-input OOR VTXO yields one +// per contributing batch. The direct commitment txid is included even when the +// ancestry slice is empty (e.g. incoming VTXOs materialized without their +// commitment tree) so the gate still governs the leaf by its batch. +func lineageCommitmentTxids(desc *Descriptor) []chainhash.Hash { + seen := make(map[chainhash.Hash]struct{}, len(desc.Ancestry)+1) + txids := make([]chainhash.Hash, 0, len(desc.Ancestry)+1) + + add := func(txid chainhash.Hash) { + if txid == (chainhash.Hash{}) { + return + } + if _, ok := seen[txid]; ok { + return + } + seen[txid] = struct{}{} + txids = append(txids, txid) + } + + add(desc.CommitmentTxID) + for i := range desc.Ancestry { + add(desc.Ancestry[i].CommitmentTxID) + } + + return txids +} + func (m *Manager) gateUnavailableLineage(ctx context.Context, candidates []*Descriptor) ([]*Descriptor, error) { @@ -1183,7 +1215,8 @@ func (m *Manager) gateUnavailableLineage(ctx context.Context, } blocked, avail, err := batchcanon.LineageBlocked( - ctx, m.cfg.BatchCanonicality, desc.CommitmentTxID, + ctx, m.cfg.BatchCanonicality, + lineageCommitmentTxids(desc)..., ) if err != nil { return nil, fmt.Errorf("lineage gate %s: %w", @@ -1932,10 +1965,9 @@ func (m *Manager) handleReserveForfeit(ctx context.Context, // limbo (reorged-out) or invalidated (conflict-finalized) canonicality state, // so an explicit forfeit reservation must be refused. It mirrors the // coin-selection gate (gateUnavailableLineage) for the explicit-outpoint -// reserve path: a no-op when no canonicality store is configured, and -// permissive for unseen / unregistered lineage. It reads the candidate's -// direct commitment txid; full multi-parent ancestry gating arrives with the -// selection gate's multi-parent extension. +// reserve path, gating on the full lineage (direct + ancestor commitment +// txids): a no-op when no canonicality store is configured, and permissive for +// unseen / unregistered lineage. func (m *Manager) forfeitLineageBlocked(ctx context.Context, op wire.OutPoint) ( bool, batchcanon.Availability, error) { @@ -1951,7 +1983,7 @@ func (m *Manager) forfeitLineageBlocked(ctx context.Context, op wire.OutPoint) ( } return batchcanon.LineageBlocked( - ctx, m.cfg.BatchCanonicality, desc.CommitmentTxID, + ctx, m.cfg.BatchCanonicality, lineageCommitmentTxids(desc)..., ) } diff --git a/vtxo/manager_multiparent_gate_test.go b/vtxo/manager_multiparent_gate_test.go new file mode 100644 index 000000000..db6a9d44b --- /dev/null +++ b/vtxo/manager_multiparent_gate_test.go @@ -0,0 +1,115 @@ +package vtxo + +import ( + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/lightninglabs/darepo-client/batchcanon" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestLineageCommitmentTxids verifies the helper collects the direct +// commitment txid plus every distinct ancestor commitment txid, dedupes, and +// skips the zero hash. +func TestLineageCommitmentTxids(t *testing.T) { + t.Parallel() + + direct := chainhash.Hash{0x01} + parentA := chainhash.Hash{0x02} + parentB := chainhash.Hash{0x03} + + desc := &Descriptor{ + CommitmentTxID: direct, + Ancestry: []Ancestry{ + { + CommitmentTxID: parentA, + }, + { + CommitmentTxID: parentB, + }, + // Duplicate of the direct txid: must be deduped. + { + CommitmentTxID: direct, + }, + // Zero hash: must be skipped. + { + CommitmentTxID: chainhash.Hash{}, + }, + }, + } + + got := lineageCommitmentTxids(desc) + require.Equal( + t, []chainhash.Hash{direct, parentA, parentB}, got, + ) +} + +// TestLineageCommitmentTxidsDirectOnly verifies a VTXO with no ancestry (e.g. +// an incoming VTXO materialized without its commitment tree) still yields its +// direct commitment txid so the gate governs it. +func TestLineageCommitmentTxidsDirectOnly(t *testing.T) { + t.Parallel() + + desc := &Descriptor{CommitmentTxID: chainhash.Hash{0x09}} + require.Equal( + t, []chainhash.Hash{{0x09}}, lineageCommitmentTxids(desc), + ) +} + +// TestSelectExcludesMultiParentLimboLineage verifies that a cross-commitment +// OOR VTXO is gated out when ANY of its ancestor batches is in limbo, even +// though its direct commitment batch is canonical. This is the multi-parent +// extension: the worst parent dominates. +func TestSelectExcludesMultiParentLimboLineage(t *testing.T) { + t.Parallel() + + good := makeDescriptor(t, 40000, 0) + multi := makeDescriptor(t, 50000, 1) + + good.CommitmentTxID = chainhash.Hash{0xaa} + + // multi descends from two batches: its direct commitment (canonical) + // and a cross-commitment ancestor that reorged out. + directBatch := chainhash.Hash{0xbb} + ancestorBatch := chainhash.Hash{0xcc} + multi.CommitmentTxID = directBatch + multi.Ancestry = []Ancestry{ + { + CommitmentTxID: directBatch, + }, + { + CommitmentTxID: ancestorBatch, + }, + } + + mgr, store := newTestManager(t, []*Descriptor{good, multi}) + mgr.cfg.BatchCanonicality = &fakeBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + good.CommitmentTxID: batchcanon.StateProvisional, + directBatch: batchcanon.StateProvisional, + ancestorBatch: batchcanon.StateReorgedOut, + }, + } + + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusLive, + ).Return([]*Descriptor{good, multi}, nil) + store.On("GetVTXO", mock.Anything, good.Outpoint).Return(good, nil) + store.On("GetVTXO", mock.Anything, multi.Outpoint).Return(multi, nil) + + result := mgr.Receive(t.Context(), &SelectAndReserveSpendRequest{ + TargetAmount: 40000, + }) + resp, err := result.Unpack() + require.NoError(t, err) + + spendResp, ok := resp.(*SelectAndReserveSpendResponse) + require.True(t, ok) + + // The 50000 multi-parent candidate is gated out for its reorged-out + // ANCESTOR batch despite its direct batch being canonical, so selection + // falls to the 40000 candidate. + require.Len(t, spendResp.SelectedVTXOs, 1) + require.Equal(t, good.Outpoint, spendResp.SelectedVTXOs[0].Outpoint) +} From 4bf4885d5978083f60dcf17db7920f246e799611 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 8 Jul 2026 14:12:05 -0700 Subject: [PATCH 2/2] unroll: gate admission on source-lineage canonicality (C8) Squashed for the btcd v2 port. Unroll gates fresh admission on the source VTXO's batch-lineage canonicality (blocks only Invalidated, fail-permissive). --- chainsource/finality.go | 40 ++--- unroll/registry.go | 120 +++++++++++++++ unroll/source_lineage_gate_test.go | 240 +++++++++++++++++++++++++++++ 3 files changed, 377 insertions(+), 23 deletions(-) create mode 100644 unroll/source_lineage_gate_test.go diff --git a/chainsource/finality.go b/chainsource/finality.go index 5bdc97ac6..2b8561b1f 100644 --- a/chainsource/finality.go +++ b/chainsource/finality.go @@ -21,16 +21,6 @@ var finalityBlockSubscriptionBackoffs = []time.Duration{ 2 * time.Second, } -// finalityBlockSubscriptionAttemptTimeout bounds each individual -// RegisterBlocks attempt. Without it a single hung RegisterBlocks call -// (e.g. a wedged lndclient gRPC stream) would block the conf/spend -// monitoring goroutine indefinitely — stalling Confirmed/Reorged/Done -// delivery on that watch — since the retry schedule only bounds the gaps -// between attempts, not the attempts themselves. 10s mirrors the per-call -// registration timeout used in conf_actor.go's handleRegisterConf so the -// whole file behaves consistently under a slow backend. -const finalityBlockSubscriptionAttemptTimeout = 10 * time.Second - // registerBlocksForFinality registers a block-epoch subscription used // to synthesize a Done signal at FinalityDepth past an observed // confirmation or spend. The call is retried with a short bounded @@ -39,11 +29,22 @@ const finalityBlockSubscriptionAttemptTimeout = 10 * time.Second // lndclient over gRPC); a one-shot RegisterBlocks attempt that // briefly hiccups would leak the per-watch sub-actor indefinitely. // -// The retries run in the calling sub-actor's monitoring goroutine, so -// brief blocking here is safe: more confirmation/spend events on this -// specific watch are not expected during the retry window (we already -// consumed the one that triggered the arm), and ctx cancellation -// breaks out promptly. +// The retries run in a dedicated arming goroutine (not the sub-actor's +// select loop), so brief blocking here is safe: more confirmation/spend +// events on this specific watch are not expected during the retry window +// (we already consumed the one that triggered the arm), and ctx +// cancellation breaks out promptly. +// +// The passed ctx MUST be the sub-actor's long-lived context, and it is +// handed to RegisterBlocks unwrapped: for in-process backends the +// block-epoch forwarder goroutine is tied to the ctx it receives, so +// bounding each attempt with a cancellable child ctx (and cancelling it +// once the call returns) would tear the subscription down the instant it +// was armed — starving finality synthesis of the very epochs it needs. +// A hung RegisterBlocks can therefore stall this arming goroutine, but +// that is contained: it is off the select loop (fix moved arming there +// precisely so a slow backend cannot wedge Confirmed/Reorged/Done +// delivery), and a genuinely wedged backend is a lost watch regardless. // // Returns the registration on success, or a non-nil error after // retries are exhausted. Callers should log the error at warn level @@ -54,14 +55,7 @@ func registerBlocksForFinality(ctx context.Context, backend ChainBackend, var lastErr error for attempt, backoff := range finalityBlockSubscriptionBackoffs { - // Bound each attempt so a hung RegisterBlocks cannot wedge the - // monitoring goroutine; the retry schedule only bounds the gaps - // between attempts, not a single stuck call. - attemptCtx, cancel := context.WithTimeout( - ctx, finalityBlockSubscriptionAttemptTimeout, - ) - reg, err := backend.RegisterBlocks(attemptCtx) - cancel() + reg, err := backend.RegisterBlocks(ctx) if err == nil { return reg, nil } diff --git a/unroll/registry.go b/unroll/registry.go index 881ab30c8..d9236142a 100644 --- a/unroll/registry.go +++ b/unroll/registry.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/batchcanon" "github.com/lightninglabs/darepo-client/chainsource" "github.com/lightninglabs/darepo-client/ledger" "github.com/lightninglabs/darepo-client/txconfirm" @@ -111,6 +112,17 @@ type RegistryConfig struct { // VTXOStore loads target descriptors for child actors. VTXOStore vtxo.VTXOStore + // BatchCanonicality, when set, gates fresh unroll admission on the + // target VTXO's source-lineage canonicality (darepo#454): an unroll is + // refused only when a batch in the VTXO's lineage is permanently + // invalidated (conflict-finalized), since the exit tree can then never + // confirm. Transient reorgs are admitted and the unroll self-reconciles + // its anchors (#410). Nil disables the gate (the default until the + // batch producers register batches); it is permissive otherwise (unseen + // / unregistered lineage does not block), and only fresh admissions are + // gated — an already-running unroll is never interrupted. + BatchCanonicality batchcanon.Store + // TxConfirmRef is the shared tx-confirmation actor. TxConfirmRef actor.ActorRef[txconfirm.Msg, txconfirm.Resp] @@ -398,6 +410,105 @@ func (r *registryBehavior) OnStop(context.Context) error { return nil } +// ErrSourceLineageUnavailable is returned by EnsureUnroll when the target +// VTXO's source lineage is permanently off the canonical chain (a batch in its +// lineage had a consumed input double-spent past finality), so its exit tree +// can never confirm. This is terminal — there is nothing to retry. +var ErrSourceLineageUnavailable = errors.New("vtxo source lineage " + + "unavailable for unroll") + +// refuseIfSourceLineageInvalidated returns ErrSourceLineageUnavailable (wrapped +// with the target outpoint) when the target VTXO's source lineage is +// permanently invalidated, so a fresh unroll must not be admitted. It is a +// no-op when the gate is dormant or the lineage is still recoverable. +func (r *registryBehavior) refuseIfSourceLineageInvalidated(ctx context.Context, + outpoint wire.OutPoint) error { + + if r.sourceLineageInvalidated(ctx, outpoint) { + return fmt.Errorf("%w: %s", ErrSourceLineageUnavailable, + outpoint) + } + + return nil +} + +// sourceLineageInvalidated reports whether the target VTXO's source lineage is +// PERMANENTLY invalidated (a batch in its lineage is conflict-finalized), in +// which case the exit tree can never confirm and a fresh unroll is pointless. +// +// It deliberately blocks ONLY the terminal Invalidated verdict, not the +// transient LimboReorg / LimboConflict states: a reorged-out batch (no input +// conflict) is expected to re-confirm on its own, and a not-yet-final conflict +// may still resolve in the batch's favor. Blocking those would risk dropping a +// needed critical-expiry / fraud-triggered exit during exactly the window it +// matters — and the critical-expiry safety net reaches this gate via a +// fire-and-forget Tell, so a refusal cannot be observed or retried. An +// already-admitted unroll tolerates a transiently-absent parent by reconciling +// its own chain anchors (#410), so a fresh safety exit should be admitted for +// the same transient condition rather than refused. +// +// It is a no-op (returns false) when no canonicality store is wired, and it is +// fail-permissive: any descriptor-load or canonicality lookup error logs and +// returns false rather than blocking an exit, mirroring the gate's permissive +// "unseen / unregistered lineage does not block" stance. +func (r *registryBehavior) sourceLineageInvalidated(ctx context.Context, + outpoint wire.OutPoint) bool { + + if r.cfg.BatchCanonicality == nil { + return false + } + + desc, err := r.cfg.VTXOStore.GetVTXO(ctx, outpoint) + if err != nil { + r.log.DebugS(ctx, "Unroll lineage gate: vtxo load failed, "+ + "permitting admission", err, + slog.String("outpoint", outpoint.String())) + + return false + } + + avail, err := batchcanon.LineageAvailability( + ctx, r.cfg.BatchCanonicality, + unrollLineageCommitmentTxids(desc)..., + ) + if err != nil { + r.log.DebugS(ctx, "Unroll lineage gate: availability lookup "+ + "failed, permitting admission", err, + slog.String("outpoint", outpoint.String())) + + return false + } + + return avail == batchcanon.Invalidated +} + +// unrollLineageCommitmentTxids returns the deduped commitment txids in a VTXO's +// lineage: its direct commitment tx plus every distinct ancestor commitment tx +// (zero-skipped). Mirrors the vtxo package's selection-gate helper for the +// unroll admission gate. +func unrollLineageCommitmentTxids(desc *vtxo.Descriptor) []chainhash.Hash { + seen := make(map[chainhash.Hash]struct{}, len(desc.Ancestry)+1) + txids := make([]chainhash.Hash, 0, len(desc.Ancestry)+1) + + add := func(txid chainhash.Hash) { + if txid == (chainhash.Hash{}) { + return + } + if _, ok := seen[txid]; ok { + return + } + seen[txid] = struct{}{} + txids = append(txids, txid) + } + + add(desc.CommitmentTxID) + for i := range desc.Ancestry { + add(desc.Ancestry[i].CommitmentTxID) + } + + return txids +} + // handleEnsure is the admission gate for new unroll jobs. It runs a // four-stage check to decide whether the caller is re-asking for an // already-tracked target or requesting a brand-new unroll, spawns and @@ -546,6 +657,15 @@ func (r *registryBehavior) handleEnsure(ctx context.Context, } } + // Refuse a fresh unroll only when the target's source lineage is + // permanently invalidated (see sourceLineageInvalidated); transient + // reorgs are admitted and self-reconcile. + if err := r.refuseIfSourceLineageInvalidated( + ctx, req.Outpoint, + ); err != nil { + return fn.Err[RegistryResp](err) + } + height, err := r.queryBestHeight(ctx) if err != nil { return fn.Err[RegistryResp](fmt.Errorf("best height: %w", err)) diff --git a/unroll/source_lineage_gate_test.go b/unroll/source_lineage_gate_test.go new file mode 100644 index 000000000..2ca34c951 --- /dev/null +++ b/unroll/source_lineage_gate_test.go @@ -0,0 +1,240 @@ +package unroll + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/batchcanon" + "github.com/lightninglabs/darepo-client/vtxo" + "github.com/stretchr/testify/require" +) + +// stubBatchCanon is a minimal batchcanon.Store for the unroll source-lineage +// gate tests: it answers GetBatch from a txid->state map and returns zero +// values for the methods the gate never calls. +type stubBatchCanon struct { + states map[chainhash.Hash]batchcanon.State +} + +func (s *stubBatchCanon) GetBatch(_ context.Context, txid chainhash.Hash) ( + *batchcanon.Record, error) { + + st, ok := s.states[txid] + if !ok { + return nil, batchcanon.ErrBatchNotFound + } + + return &batchcanon.Record{BatchTxID: txid, State: st}, nil +} + +func (s *stubBatchCanon) UpsertBatch(context.Context, + *batchcanon.Record) error { + + return nil +} + +func (s *stubBatchCanon) ListBatchesByState(context.Context, batchcanon.State) ( + []*batchcanon.Record, error) { + + return nil, nil +} + +func (s *stubBatchCanon) UpdateBatchState(context.Context, chainhash.Hash, + batchcanon.State) error { + + return nil +} + +func (s *stubBatchCanon) RecordConfirmation(context.Context, chainhash.Hash, + int32, chainhash.Hash) error { + + return nil +} + +func (s *stubBatchCanon) ClearConfirmation(context.Context, + chainhash.Hash) error { + + return nil +} + +func (s *stubBatchCanon) FindBatchesConsumingOutpoint(context.Context, + wire.OutPoint) ([]chainhash.Hash, error) { + + return nil, nil +} + +func (s *stubBatchCanon) AddProvisionalConsumer(context.Context, wire.OutPoint, + chainhash.Hash) error { + + return nil +} + +func (s *stubBatchCanon) ListProvisionalConsumersForBatch(context.Context, + chainhash.Hash) ([]wire.OutPoint, error) { + + return nil, nil +} + +func (s *stubBatchCanon) DeleteProvisionalConsumersForBatch(context.Context, + chainhash.Hash) error { + + return nil +} + +var _ batchcanon.Store = (*stubBatchCanon)(nil) + +// gateTarget builds a target outpoint + a descriptor whose lineage is the +// supplied commitment txids (first is the direct commitment, rest are +// cross-commitment ancestors). +func gateTarget(direct chainhash.Hash, + ancestors ...chainhash.Hash) (wire.OutPoint, *vtxo.Descriptor) { + + op := wire.OutPoint{Hash: chainhash.Hash{0xfe}, Index: 0} + desc := &vtxo.Descriptor{Outpoint: op, CommitmentTxID: direct} + for _, a := range ancestors { + desc.Ancestry = append(desc.Ancestry, vtxo.Ancestry{ + CommitmentTxID: a, + }) + } + + return op, desc +} + +func newGateBehavior(store batchcanon.Store, + desc *vtxo.Descriptor) *registryBehavior { + + return ®istryBehavior{ + cfg: RegistryConfig{ + BatchCanonicality: store, + VTXOStore: &mockVTXOStore{ + desc: desc, + }, + }, + } +} + +// TestSourceLineageBlockedOnInvalidatedAncestor verifies a fresh unroll is +// refused when any batch in the target's lineage is permanently invalidated +// (conflict-finalized), even if its direct commitment is canonical. +func TestSourceLineageBlockedOnInvalidatedAncestor(t *testing.T) { + t.Parallel() + + direct := chainhash.Hash{0xaa} + ancestor := chainhash.Hash{0xbb} + op, desc := gateTarget(direct, ancestor) + + b := newGateBehavior(&stubBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + direct: batchcanon.StateProvisional, + ancestor: batchcanon.StateConflictFinalized, + }, + }, desc) + + require.True(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestSourceLineagePermitsTransientReorg verifies a reorged-out (but not +// conflict-finalized) ancestor does NOT block a fresh unroll: the reorg is +// expected to self-heal, and blocking could drop a needed safety exit that +// cannot be retried. +func TestSourceLineagePermitsTransientReorg(t *testing.T) { + t.Parallel() + + direct := chainhash.Hash{0xaa} + ancestor := chainhash.Hash{0xbb} + op, desc := gateTarget(direct, ancestor) + + b := newGateBehavior(&stubBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + direct: batchcanon.StateProvisional, + ancestor: batchcanon.StateReorgedOut, + }, + }, desc) + + require.False(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestSourceLineageNotBlockedWhenCanonical verifies a fresh unroll is admitted +// when the whole lineage is canonical. +func TestSourceLineageNotBlockedWhenCanonical(t *testing.T) { + t.Parallel() + + direct := chainhash.Hash{0xaa} + ancestor := chainhash.Hash{0xbb} + op, desc := gateTarget(direct, ancestor) + + b := newGateBehavior(&stubBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + direct: batchcanon.StateFinalized, + ancestor: batchcanon.StateProvisional, + }, + }, desc) + + require.False(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestSourceLineagePermissiveWhenUnregistered verifies the gate does not block +// when the lineage batches are not registered (unseen), preserving the +// permissive default. +func TestSourceLineagePermissiveWhenUnregistered(t *testing.T) { + t.Parallel() + + op, desc := gateTarget(chainhash.Hash{0xaa}, chainhash.Hash{0xbb}) + + b := newGateBehavior(&stubBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{}, + }, desc) + + require.False(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestSourceLineagePermissiveWhenVTXOLoadFails verifies the gate fails +// permissive (admits) when the target descriptor cannot be loaded, rather than +// blocking an exit on a transient store error. +func TestSourceLineagePermissiveWhenVTXOLoadFails(t *testing.T) { + t.Parallel() + + op, _ := gateTarget(chainhash.Hash{0xaa}) + + b := ®istryBehavior{ + cfg: RegistryConfig{ + BatchCanonicality: &stubBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{}, + }, + VTXOStore: &mockVTXOStore{ + err: errors.New("boom"), + }, + }, + log: btclog.Disabled, + } + + require.False(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestSourceLineageGateDormantWhenNoStore verifies the gate is a no-op when no +// canonicality store is wired. +func TestSourceLineageGateDormantWhenNoStore(t *testing.T) { + t.Parallel() + + op, _ := gateTarget(chainhash.Hash{0xaa}) + + b := ®istryBehavior{cfg: RegistryConfig{}} + + require.False(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestErrSourceLineageUnavailableIsSentinel guards that the wrapped form +// handleEnsure returns is matchable via errors.Is, so RPC/chain-resolver +// callers can classify a lineage-refused unroll. +func TestErrSourceLineageUnavailableIsSentinel(t *testing.T) { + t.Parallel() + + wrapped := fmt.Errorf("%w: %s", ErrSourceLineageUnavailable, + "some-outpoint") + require.ErrorIs(t, wrapped, ErrSourceLineageUnavailable) +}