From dd6e65afe34c20c34f9fa4962680669507c450a2 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Mon, 13 Jul 2026 09:01:14 -0700 Subject: [PATCH 01/18] chainsource: reorg-aware conf/spend lifecycle + synthesized finality Add the reorg-aware chain-observation substrate the rest of the reorg-safety stack consumes: a seq-ordered conf/spend watch lifecycle (Confirmed -> Reorged -> re-Confirmed -> Done) with height-based finality synthesis for backends that cannot deliver a native Done (gRPC lndclient and lwwallet). Finality synthesis is armed off the select loop, tied to the sub-actor's long-lived context (a per-attempt timeout would tear the in-process block-epoch forwarder down the instant it armed), retried with a capped exponential backoff until it succeeds or the watch's context is cancelled (a single-confirmation tx has no later event to piggy-back a retry on), and evaluated against the best height captured at arm time so a tx already buried past FinalityDepth when the watch arms finalizes immediately instead of hanging for the next block. --- chainsource/backend.go | 91 +++- chainsource/chainsource.go | 28 +- chainsource/chainsource_test.go | 48 ++- chainsource/conf_actor.go | 419 ++++++++++++++++-- chainsource/epoch_drain_test.go | 76 ++++ chainsource/finality.go | 124 ++++++ chainsource/finality_test.go | 107 +++++ chainsource/messages.go | 99 +++++ chainsource/reorg_test.go | 735 ++++++++++++++++++++++++++++++++ chainsource/spend_actor.go | 335 ++++++++++++++- chainsource/transform.go | 38 ++ 11 files changed, 2043 insertions(+), 57 deletions(-) create mode 100644 chainsource/epoch_drain_test.go create mode 100644 chainsource/finality.go create mode 100644 chainsource/finality_test.go create mode 100644 chainsource/reorg_test.go diff --git a/chainsource/backend.go b/chainsource/backend.go index 3ef13b54e..7a6acfcaa 100644 --- a/chainsource/backend.go +++ b/chainsource/backend.go @@ -137,12 +137,41 @@ type ChainBackend interface { // ConfRegistration encapsulates the channels and control functions for a // confirmation registration. This mirrors lnd's chainntnfs.ConfirmationEvent // structure but provides a backend-agnostic interface. +// +// The registration is reorg-aware: after a confirmation has been delivered +// on Confirmed, a subsequent reorg that buries the original block can cause +// a fresh send on Reorged. Once the transaction re-confirms on the new +// canonical chain another event arrives on Confirmed. The lifecycle is +// therefore Confirmed -> Reorged -> Confirmed -> ... terminated by either a +// send on Done (the confirmation is past the backend's reorg-safety depth) +// or a caller-driven Cancel. Backends that cannot observe reorgs leave +// Reorged and Done as never-firing channels; callers must not assume either +// will ever fire. type ConfRegistration struct { - // Confirmed is a channel that fires once when the transaction reaches - // the target number of confirmations. The channel is buffered and will - // only send a single event. + // Confirmed fires every time the transaction reaches the target + // number of confirmations on the canonical chain. After a Reorged + // event it may fire again when the transaction re-confirms. The + // channel is buffered. Confirmed <-chan *TxConfirmation + // Reorged fires when a previously delivered confirmation is reorged + // out of the canonical chain. The payload is the backend forwarder's + // monotonic sequence number (see TxConfirmation.Seq) rather than + // reorg depth or block identity, which the lndclient gRPC transport + // cannot preserve; the sequence lets the consumer order this signal + // against confirmations sharing the same sequence space even though + // they arrive on a different channel. Backends that cannot observe + // reorgs leave this channel nil-equivalent (allocated but never + // written to); reading from it is always safe. + Reorged <-chan uint64 + + // Done fires once when the confirmation watch is past the backend's + // reorg-safety depth and will receive no further events. Callers + // must still invoke Cancel to release client-side resources. + // Backends that cannot synthesize a safety-depth signal leave this + // channel nil-equivalent (allocated but never written to). + Done <-chan struct{} + // Cancel is a function that can be called to cancel this registration // and clean up resources. After calling Cancel, no more events will be // sent on any channels. @@ -168,17 +197,54 @@ type TxConfirmation struct { // when the confirmation was registered with IncludeBlock=true. This // matches lnd's chainntnfs behavior. Block *wire.MsgBlock + + // Seq is a per-registration monotonic sequence number stamped by the + // backend forwarder in the order it observed lifecycle events. + // Confirmed and Reorged share one sequence space so the consumer can + // order them even though they arrive on separate channels (a select + // over two ready channels picks at random and cannot recover order). + // The consumer applies an event only when its Seq exceeds the highest + // Seq seen so far, discarding a stale event that lost a cross-channel + // race. Zero means the backend does not stamp sequences (it never + // reorgs, so ordering is moot); such events are always applied. + Seq uint64 } // SpendRegistration encapsulates the channels and control functions for a // spend registration. This mirrors lnd's chainntnfs.SpendEvent structure. +// +// The registration is reorg-aware: after a spend has been delivered on +// Spend, a subsequent reorg that buries the spending block can cause a +// fresh send on Reorged. If the outpoint is then re-spent on the new +// canonical chain (by the same or a different transaction) another event +// arrives on Spend. The lifecycle is therefore Spend -> Reorged -> Spend +// -> ... terminated by either a send on Done (the spend is past the +// backend's reorg-safety depth) or a caller-driven Cancel. Backends that +// cannot observe reorgs leave Reorged and Done as never-firing channels. type SpendRegistration struct { - // Spend is a channel that fires when the monitored outpoint is spent. - // The spending transaction must have at least one confirmation. The - // channel is buffered and will send an event for each spend (though - // typically only one unless reorgs occur). + // Spend fires every time the monitored outpoint is spent on the + // canonical chain. After a Reorged event it may fire again when a + // new spender confirms. The channel is buffered. Spend <-chan *SpendDetail + // Reorged fires when a previously delivered spend is reorged out of + // the canonical chain. The payload is the backend forwarder's + // monotonic sequence number (see SpendDetail.Seq) rather than reorg + // depth or block identity, which the lndclient gRPC transport cannot + // preserve; the sequence lets the consumer order this signal against + // spends sharing the same sequence space even though they arrive on a + // different channel. Backends that cannot observe spend reorgs leave + // this channel nil-equivalent (allocated but never written to); + // reading from it is always safe. + Reorged <-chan uint64 + + // Done fires once when the spend watch is past the backend's + // reorg-safety depth and will receive no further events. Callers + // must still invoke Cancel to release client-side resources. + // Backends that cannot synthesize a safety-depth signal leave this + // channel nil-equivalent (allocated but never written to). + Done <-chan struct{} + // Cancel is a function that can be called to cancel this registration // and clean up resources. Cancel func() @@ -203,6 +269,17 @@ type SpendDetail struct { // SpendingHeight is the block height where the spending transaction // was confirmed. SpendingHeight int32 + + // Seq is a per-registration monotonic sequence number stamped by the + // backend forwarder in the order it observed lifecycle events. Spend + // and Reorged share one sequence space so the consumer can order them + // even though they arrive on separate channels (a select over two + // ready channels picks at random and cannot recover order). The + // consumer applies an event only when its Seq exceeds the highest Seq + // seen so far, discarding a stale event that lost a cross-channel + // race. Zero means the backend does not stamp sequences (it never + // reorgs, so ordering is moot); such events are always applied. + Seq uint64 } // BlockRegistration encapsulates the channels and control functions for a diff --git a/chainsource/chainsource.go b/chainsource/chainsource.go index 47e0ee57d..86676dcd8 100644 --- a/chainsource/chainsource.go +++ b/chainsource/chainsource.go @@ -18,6 +18,16 @@ const ( // allows for buffering up to 10 blocks in transit, which should cover // normal block arrival patterns without blocking the backend. epochChannelSize = 10 + + // DefaultFinalityDepth is the conventional Bitcoin reorg-safety + // depth. After this many inclusive confirmations the ConfActor + // and SpendActor synthesize their Done events when the backend + // transport (notably lndclient over gRPC) does not surface one + // of its own. Six is the value the wider Lightning stack treats + // as final for the purposes of channel funding / settlement, so + // using it here keeps the unroll subsystem's finality threshold + // aligned with the rest of the daemon's chain assumptions. + DefaultFinalityDepth uint32 = 6 ) // ChainSourceConfig holds configuration for ChainSourceActor. @@ -32,6 +42,14 @@ type ChainSourceConfig struct { // falls back to extracting a logger from context via LoggerFromContext, // or uses btclog.Disabled if no logger is found. Log fn.Option[btclog.Logger] + + // FinalityDepth is forwarded to each spawned sub-actor as the + // number of confirmations past the first observed positive event + // that the actor uses to synthesize a Done signal when the backend + // transport (notably lndclient over gRPC) cannot deliver one. Zero + // disables height-based finality synthesis. See + // ConfActorConfig.FinalityDepth / SpendActorConfig.FinalityDepth. + FinalityDepth uint32 } // WithLogger returns a new config with the given logger set. @@ -318,8 +336,9 @@ func (a *ChainSourceActor) handleRegisterConf(ctx context.Context, ) confCfg := ConfActorConfig{ - Backend: a.cfg.Backend, - Log: fn.Some(a.logger(ctx)), + Backend: a.cfg.Backend, + Log: fn.Some(a.logger(ctx)), + FinalityDepth: a.cfg.FinalityDepth, } confActor := NewConfActor(confCfg) actorRef := serviceKey.Spawn(a.cfg.System, actorID, confActor) @@ -353,8 +372,9 @@ func (a *ChainSourceActor) handleRegisterSpend(ctx context.Context, ) spendCfg := SpendActorConfig{ - Backend: a.cfg.Backend, - Log: fn.Some(a.logger(ctx)), + Backend: a.cfg.Backend, + Log: fn.Some(a.logger(ctx)), + FinalityDepth: a.cfg.FinalityDepth, } spendActor := NewSpendActor(spendCfg) actorRef := serviceKey.Spawn(a.cfg.System, actorID, spendActor) diff --git a/chainsource/chainsource_test.go b/chainsource/chainsource_test.go index e039b4e9f..50a05a8c5 100644 --- a/chainsource/chainsource_test.go +++ b/chainsource/chainsource_test.go @@ -3,6 +3,7 @@ package chainsource import ( "context" "errors" + "sync/atomic" "testing" "time" @@ -17,12 +18,23 @@ import ( // mockBackend implements ChainBackend for testing. type mockBackend struct { - confChan chan *TxConfirmation - spendChan chan *SpendDetail - epochChan chan *BlockEpoch + confChan chan *TxConfirmation + confReorgedChan chan uint64 + confDoneChan chan struct{} + spendChan chan *SpendDetail + spendReorgedChan chan uint64 + spendDoneChan chan struct{} + epochChan chan *BlockEpoch epochCancel chan struct{} + // confCancelled / spendCancelled count Cancel invocations on the + // most recently issued registration. The reorg-aware lifecycle + // tests rely on these to assert that the actor released the + // registration after a Done event. + confCancelled atomic.Int32 + spendCancelled atomic.Int32 + feeRate btcutil.Amount bestHeight int32 bestHash chainhash.Hash @@ -42,12 +54,16 @@ type reconnectBlockBackend struct { // newMockBackend creates a new mock backend for testing. func newMockBackend() *mockBackend { return &mockBackend{ - confChan: make(chan *TxConfirmation, 1), - spendChan: make(chan *SpendDetail, 1), - epochChan: make(chan *BlockEpoch, 10), - epochCancel: make(chan struct{}, 10), - feeRate: 1000, - bestHeight: 100, + confChan: make(chan *TxConfirmation, 1), + confReorgedChan: make(chan uint64, 1), + confDoneChan: make(chan struct{}, 1), + spendChan: make(chan *SpendDetail, 1), + spendReorgedChan: make(chan uint64, 1), + spendDoneChan: make(chan struct{}, 1), + epochChan: make(chan *BlockEpoch, 10), + epochCancel: make(chan struct{}, 10), + feeRate: 1000, + bestHeight: 100, } } @@ -208,7 +224,11 @@ func (m *mockBackend) RegisterConf(ctx context.Context, txid *chainhash.Hash, return &ConfRegistration{ Confirmed: m.confChan, - Cancel: func() {}, + Reorged: m.confReorgedChan, + Done: m.confDoneChan, + Cancel: func() { + m.confCancelled.Add(1) + }, }, nil } @@ -217,8 +237,12 @@ func (m *mockBackend) RegisterSpend(ctx context.Context, *SpendRegistration, error) { return &SpendRegistration{ - Spend: m.spendChan, - Cancel: func() {}, + Spend: m.spendChan, + Reorged: m.spendReorgedChan, + Done: m.spendDoneChan, + Cancel: func() { + m.spendCancelled.Add(1) + }, }, nil } diff --git a/chainsource/conf_actor.go b/chainsource/conf_actor.go index 218005c45..75b6f53a1 100644 --- a/chainsource/conf_actor.go +++ b/chainsource/conf_actor.go @@ -22,6 +22,20 @@ type ConfActorConfig struct { // falls back to extracting a logger from context via LoggerFromContext, // or uses btclog.Disabled if no logger is found. Log fn.Option[btclog.Logger] + + // FinalityDepth is the number of confirmations past the first + // observed Confirmed event that the actor uses to synthesize a Done + // signal when the backend cannot deliver one. Zero disables + // height-based finality synthesis entirely; in that case the actor + // only fires ConfDoneEvent when the backend's own Done channel + // fires (e.g. an in-process lnd notifier). Non-zero values close + // the lndclient transport gap, where the gRPC layer does not + // surface lnd's internal "past reorg-safety depth" signal. + // + // The depth is counted inclusively (a tx confirmed at height H is + // at depth 1; height-based finality fires once the actor observes + // a block at H + FinalityDepth - 1). The conventional choice is 6. + FinalityDepth uint32 } // WithLogger returns a new config with the given logger set. @@ -67,9 +81,28 @@ type ConfActor struct { // mode. notifyActor fn.Option[actor.TellOnlyRef[ConfirmationEvent]] + // notifyReorged receives negative confirmation events in Actor mode. + notifyReorged fn.Option[actor.TellOnlyRef[ConfReorgedEvent]] + + // notifyDone receives finality events in Actor mode. + notifyDone fn.Option[actor.TellOnlyRef[ConfDoneEvent]] + // registration is the backend registration for this watch. registration *ConfRegistration + // blockReg is the block-epoch subscription used by height-based + // finality synthesis. Allocated lazily after the first Confirmed + // event when FinalityDepth > 0 and the registration is reorg-aware, + // torn down when the actor exits. + blockReg *BlockRegistration + + // confirmHeight records the block height the most recent + // Confirmed event arrived at. Used by the height-based finality + // synthesizer to compute current depth. Zero means there is no + // active confirmation to count from (either we have not yet seen + // one, or the last one was reorged out). + confirmHeight int32 + // ctx is the actor's internal context for cancellation, created from // context.Background() to ensure it outlives any request context. //nolint:containedctx @@ -143,6 +176,13 @@ func (a *ConfActor) handleRegisterConf(actorCtx context.Context, "than zero"), ) } + if req.NotifyActor.IsNone() && + (req.NotifyReorged.IsSome() || req.NotifyDone.IsSome()) { + return fn.Err[ConfResp]( + fmt.Errorf("confirmation reorg/done notifications " + + "require actor-mode NotifyActor"), + ) + } a.txid = req.Txid a.pkScript = req.PkScript @@ -150,6 +190,8 @@ func (a *ConfActor) handleRegisterConf(actorCtx context.Context, a.heightHint = req.HeightHint a.includeBlock = req.IncludeBlock a.notifyActor = req.NotifyActor + a.notifyReorged = req.NotifyReorged + a.notifyDone = req.NotifyDone // We're either in future or iterator mode, set the promise // accordingly. @@ -197,8 +239,8 @@ func (a *ConfActor) handleRegisterConf(actorCtx context.Context, } // monitorConfirmation runs in a background goroutine and waits for the target -// confirmation count to be reached. When reached, it delivers the event and -// exits. +// confirmation count to be reached. Legacy watches exit after confirmation, +// while reorg-aware actor-mode watches remain alive for reorg and done events. func (a *ConfActor) monitorConfirmation() { defer a.wg.Done() defer a.cancel() @@ -215,50 +257,324 @@ func (a *ConfActor) monitorConfirmation() { if a.registration != nil { a.registration.Cancel() } + if a.blockReg != nil { + a.blockReg.Cancel() + } }() - select { - case confDetails, ok := <-a.registration.Confirmed: - if !ok || confDetails == nil { - log.WarnS(a.ctx, "Confirmation subscription closed", - fmt.Errorf("channel closed or nil details"), + var lastEvent *ConfirmationEvent + reorgAware := a.notifyReorged.IsSome() || a.notifyDone.IsSome() + + // lastSeq is the highest backend forwarder sequence applied so far. + // Confirmed and Reorged signals arrive on separate channels and a + // select cannot order two ready channels, so we order them by the + // shared sequence instead: an event whose Seq does not exceed lastSeq + // lost a cross-channel race to a newer signal and is discarded. This + // makes the actor's view correct regardless of delivery interleaving + // — both reorg-then-reconfirm and confirm-then-reorg resolve to the + // highest-sequence outcome. Seq 0 means the backend does not stamp + // sequences (it never reorgs); those events are always applied. + var lastSeq uint64 + + // blockEpochs is rebound when height-based finality synthesis + // arms a block subscription. Until then a nil channel keeps the + // select arm parked. + var blockEpochs <-chan *BlockEpoch + + // blockRegCh hands a finality block subscription from the off-loop + // arming goroutine back to this loop; arming guards against launching + // more than one armer at a time. See armFinalityAsync for why arming + // runs off the select loop. + blockRegCh := make(chan finalityArmResult) + var arming bool + + for { + select { + case confDetails, ok := <-a.registration.Confirmed: + if !ok || confDetails == nil { + log.WarnS( + a.ctx, + "Confirmation subscription closed", + fmt.Errorf("channel closed or nil "+ + "details"), + ) + a.failConfirmation( + fmt.Errorf("confirmation " + + "subscription closed"), + ) + + return + } + + // Discard a confirmation that lost a cross-channel race + // to a newer reorg: an event whose sequence does not + // exceed the highest applied is stale. + if confDetails.Seq != 0 && confDetails.Seq <= lastSeq { + continue + } + if confDetails.Seq > lastSeq { + lastSeq = confDetails.Seq + } + + log.InfoS(a.ctx, "Received confirmation from backend", + "block_height", confDetails.BlockHeight, + "block_hash", confDetails.BlockHash, + ) + + event, err := buildConfirmationEvent(confDetails, a) + if err != nil { + log.WarnS( + a.ctx, + "Failed to build confirmation event", + err, + ) + a.failConfirmation(err) + + return + } + + log.InfoS(a.ctx, "Delivering confirmation event", + "txid", event.Txid, + "block_height", event.BlockHeight, ) - a.failConfirmation( - fmt.Errorf("confirmation subscription closed"), + a.deliverConfirmation(event) + lastEvent = &event + a.confirmHeight = event.BlockHeight + if !reorgAware || a.promise.IsSome() { + return + } + + // Arm height-based finality synthesis on the first + // confirmation if requested, off the select loop so + // the bounded RegisterBlocks retries cannot stall + // delivery of Reorged/Done/ctx.Done on this watch. A + // nil blockEpochs channel keeps the synthesis arm + // parked until the registration is handed back on + // blockRegCh. + if a.cfg.FinalityDepth > 0 && a.blockReg == nil && + !arming { + + arming = true + a.armFinalityAsync(blockRegCh, log) + } + + case armed := <-blockRegCh: + // Finality arming completed. Clear the flag; a nil reg + // only happens when the watch context was cancelled + // (arming otherwise retries until it succeeds). + arming = false + if armed.reg == nil { + continue + } + a.blockReg = armed.reg + blockEpochs = armed.reg.Epochs + + // The block-epoch subscription only delivers FUTURE + // epochs, but the confirmation that armed it may + // already be buried past FinalityDepth (it confirmed + // several blocks ago, or we re-armed after a restart). + // Use the tip observed at arm time to synthesize Done + // at once rather than hang until a fresh block is + // mined. The confirmHeight==0 / FinalityDepth==0 guards + // mirror the epoch handler below. + if a.confirmHeight == 0 || a.cfg.FinalityDepth == 0 { + continue + } + if armed.height-a.confirmHeight+1 < + int32(a.cfg.FinalityDepth) { + + continue + } + + log.InfoS(a.ctx, "Synthesizing confirmation done on arm "+ + "from height-based safety depth", + "confirm_height", a.confirmHeight, + "current_height", armed.height, + "finality_depth", int(a.cfg.FinalityDepth), ) + a.deliverConfDone(lastEvent) return - } - log.InfoS(a.ctx, "Received confirmation from backend", - "block_height", confDetails.BlockHeight, - "block_hash", confDetails.BlockHash, - ) + case seq, ok := <-a.registration.Reorged: + if !ok { + a.registration.Reorged = nil + continue + } + + // Discard a stale reorg that lost a cross-channel race + // to a newer confirmation. + if seq != 0 && seq <= lastSeq { + continue + } + if seq > lastSeq { + lastSeq = seq + } + + a.deliverConfReorged(lastEvent) + + // The previous confirmation is no longer on the + // canonical chain. Clear the cached event so a later + // Done cannot report the reorged-out txid, and reset + // the depth counter so the next re-confirmation starts + // a fresh window. + lastEvent = nil + a.confirmHeight = 0 + + case _, ok := <-a.registration.Done: + if !ok { + a.registration.Done = nil + continue + } + + a.deliverConfDone(lastEvent) - event, err := buildConfirmationEvent(confDetails, a) - if err != nil { - log.WarnS(a.ctx, "Failed to build confirmation event", - err, + return + + case epoch, ok := <-blockEpochs: + if !ok || epoch == nil { + blockEpochs = nil + continue + } + + // Coalesce any epochs already queued behind this one + // and evaluate finality against the most recent + // height only. With rapid-fire blocks (or a backend + // that re-delivers historical epochs) the channel can + // hold several epochs at once; processing them one per + // loop iteration would re-check the same monotonic + // Done condition repeatedly and risk synthesizing + // against a stale height. If the channel closed during + // the drain, park it so we stop selecting on it. + var closed bool + epoch, closed = drainToLatestEpoch(blockEpochs, epoch) + if closed { + blockEpochs = nil + } + + // The confirmHeight==0 guard is load-bearing: a reorg + // resets confirmHeight to 0 (see the Reorged case + // above), and a fresh epoch on the new tip would + // otherwise produce a negative depth (epoch.Height - 0 + // wraps to a large value); the depth comparison is + // meaningful only after a confirmation arms + // confirmHeight. FinalityDepth==0 disables synthesis + // entirely. + if a.confirmHeight == 0 || + a.cfg.FinalityDepth == 0 { + + continue + } + + depth := epoch.Height - a.confirmHeight + 1 + if depth < int32(a.cfg.FinalityDepth) { + continue + } + + log.InfoS(a.ctx, "Synthesizing confirmation done "+ + "from height-based safety depth", + "confirm_height", a.confirmHeight, + "current_height", epoch.Height, + "finality_depth", int(a.cfg.FinalityDepth), ) - a.failConfirmation(err) + + // Finality is terminal for the whole watch: deliver + // Done (a no-op when only NotifyReorged was set) and + // stop. A reorg-only watch therefore intentionally + // stops receiving reorg events once the confirmation is + // buried FinalityDepth deep — past that depth a reorg + // is beyond the safety threshold the watch was created + // to cover, so there is nothing left to observe. + a.deliverConfDone(lastEvent) return - } - log.InfoS(a.ctx, "Delivering confirmation event", - "txid", event.Txid, - "block_height", event.BlockHeight, - ) - a.deliverConfirmation(event) + case <-a.ctx.Done(): + log.InfoS(a.ctx, "ConfActor context cancelled") + a.failConfirmation(a.ctx.Err()) - case <-a.ctx.Done(): - log.InfoS(a.ctx, "ConfActor context cancelled") - a.failConfirmation(a.ctx.Err()) + return + } + } +} + +// drainToLatestEpoch non-blockingly consumes any block epochs already +// queued on ch and returns the most recent non-nil epoch, starting from +// cur. With rapid-fire blocks (or a backend that re-delivers historical +// epochs) several epochs can sit in the channel at once; height-based +// finality synthesis depends only on the highest observed height, so +// collapsing the backlog to the newest epoch avoids re-evaluating the +// same monotonic Done condition once per stale epoch. The returned bool +// reports whether the channel was observed closed during the drain so the +// caller can park its receive on a nil channel. +func drainToLatestEpoch(ch <-chan *BlockEpoch, + cur *BlockEpoch) (*BlockEpoch, bool) { + + latest := cur + for { + select { + case e, ok := <-ch: + if !ok { + return latest, true + } + if e != nil { + latest = e + } + + default: + return latest, false + } } } // deliverConfirmation sends the confirmation event to the subscriber and // completes the promise (Future mode) or sends to the actor (Actor mode). +// armFinalityAsync registers a block-epoch subscription for height-based +// finality synthesis off the actor's select loop. registerBlocksForFinality +// retries with a bounded backoff that can run for tens of seconds; doing it +// inline would block delivery of Reorged/Done/ctx.Done on this watch for the +// whole window. The registration (or nil on failure) is handed back on regCh, +// or cancelled if the actor exits before the loop reads it. The goroutine is +// tracked by the actor's wait group so Stop drains it. +func (a *ConfActor) armFinalityAsync(regCh chan<- finalityArmResult, + log btclog.Logger) { + + a.wg.Go(func() { + reg, err := registerBlocksForFinality(a.ctx, a.cfg.Backend, log) + if err != nil { + log.WarnS(a.ctx, "Giving up on height-based finality "+ + "synthesis; conf sub-actor will rely on "+ + "backend Done", err) + reg = nil + } + + // Capture the tip at arm time so the loop can finalize + // immediately when the arming confirmation is already buried + // past FinalityDepth (the block-epoch sub only delivers future + // epochs). A read failure is non-fatal: height stays zero and + // the loop falls back to waiting for the next epoch. + var height int32 + if reg != nil { + h, _, hErr := a.cfg.Backend.BestBlock(a.ctx) + if hErr != nil { + log.WarnS(a.ctx, "Failed to read best height "+ + "for on-arm finality check; will wait "+ + "for next epoch", hErr) + } else { + height = h + } + } + + select { + case regCh <- finalityArmResult{reg: reg, height: height}: + case <-a.ctx.Done(): + if reg != nil { + reg.Cancel() + } + } + }) +} + func (a *ConfActor) deliverConfirmation(event ConfirmationEvent) { a.promise.WhenSome(func(p actor.Promise[ConfirmationEvent]) { p.Complete(fn.Ok(event)) @@ -272,6 +588,53 @@ func (a *ConfActor) deliverConfirmation(event ConfirmationEvent) { }) } +// deliverConfReorged sends a reorg event to actor-mode subscribers. The +// correlation Txid is the registration's configured txid when set, since +// that is the identifier the caller asked us to watch; pkScript-only +// watches fall back to the txid carried on the most recent positive +// ConfirmationEvent. +func (a *ConfActor) deliverConfReorged(lastEvent *ConfirmationEvent) { + var event ConfReorgedEvent + switch { + case a.txid != nil: + event.Txid = *a.txid + + case lastEvent != nil: + event.Txid = lastEvent.Txid + } + + a.notifyReorged.WhenSome(func(ref actor.TellOnlyRef[ConfReorgedEvent]) { + log := a.logger(a.ctx) + if err := ref.Tell(a.ctx, event); err != nil { + log.WarnS(a.ctx, "Failed to deliver confirmation reorg", + err, + ) + } + }) +} + +// deliverConfDone sends a confirmation finality event to actor-mode +// subscribers. Txid follows the same precedence as deliverConfReorged. +func (a *ConfActor) deliverConfDone(lastEvent *ConfirmationEvent) { + var event ConfDoneEvent + switch { + case a.txid != nil: + event.Txid = *a.txid + + case lastEvent != nil: + event.Txid = lastEvent.Txid + } + + a.notifyDone.WhenSome(func(ref actor.TellOnlyRef[ConfDoneEvent]) { + log := a.logger(a.ctx) + if err := ref.Tell(a.ctx, event); err != nil { + log.WarnS(a.ctx, "Failed to deliver confirmation done", + err, + ) + } + }) +} + // failConfirmation completes the promise with an error (Future mode) or does // nothing (Actor mode - errors are not delivered in async mode). func (a *ConfActor) failConfirmation(err error) { diff --git a/chainsource/epoch_drain_test.go b/chainsource/epoch_drain_test.go new file mode 100644 index 000000000..9a439f84c --- /dev/null +++ b/chainsource/epoch_drain_test.go @@ -0,0 +1,76 @@ +package chainsource + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestDrainToLatestEpochCoalesces verifies that a backlog of queued block +// epochs collapses to the most recent one, so finality synthesis evaluates +// the highest observed height rather than re-checking once per stale epoch. +func TestDrainToLatestEpochCoalesces(t *testing.T) { + t.Parallel() + + ch := make(chan *BlockEpoch, 8) + cur := &BlockEpoch{Height: 100} + + // Queue several newer epochs behind the one already dequeued. + for h := int32(101); h <= 105; h++ { + ch <- &BlockEpoch{Height: h} + } + + got, closed := drainToLatestEpoch(ch, cur) + require.False(t, closed) + require.NotNil(t, got) + require.Equal(t, int32(105), got.Height) + + // The channel must be fully drained afterwards. + require.Len(t, ch, 0) +} + +// TestDrainToLatestEpochEmpty verifies that with nothing queued the helper +// returns the current epoch unchanged and reports the channel open. +func TestDrainToLatestEpochEmpty(t *testing.T) { + t.Parallel() + + ch := make(chan *BlockEpoch, 4) + cur := &BlockEpoch{Height: 42} + + got, closed := drainToLatestEpoch(ch, cur) + require.False(t, closed) + require.Same(t, cur, got) +} + +// TestDrainToLatestEpochSkipsNil verifies that nil epochs in the backlog are +// ignored: the most recent non-nil epoch wins. +func TestDrainToLatestEpochSkipsNil(t *testing.T) { + t.Parallel() + + ch := make(chan *BlockEpoch, 4) + cur := &BlockEpoch{Height: 10} + ch <- &BlockEpoch{Height: 11} + ch <- nil + + got, closed := drainToLatestEpoch(ch, cur) + require.False(t, closed) + require.NotNil(t, got) + require.Equal(t, int32(11), got.Height) +} + +// TestDrainToLatestEpochClosed verifies that a closed channel is reported so +// the caller can park its receive, while still returning the latest epoch +// observed before the close. +func TestDrainToLatestEpochClosed(t *testing.T) { + t.Parallel() + + ch := make(chan *BlockEpoch, 4) + cur := &BlockEpoch{Height: 7} + ch <- &BlockEpoch{Height: 8} + close(ch) + + got, closed := drainToLatestEpoch(ch, cur) + require.True(t, closed) + require.NotNil(t, got) + require.Equal(t, int32(8), got.Height) +} diff --git a/chainsource/finality.go b/chainsource/finality.go new file mode 100644 index 000000000..debed8eb5 --- /dev/null +++ b/chainsource/finality.go @@ -0,0 +1,124 @@ +package chainsource + +import ( + "context" + "time" + + "github.com/btcsuite/btclog/v2" +) + +const ( + // finalityArmInitialBackoff is the first delay between RegisterBlocks + // attempts when arming the block-epoch subscription that drives + // height-based finality synthesis. It doubles on each failure up to + // finalityArmMaxBackoff. + finalityArmInitialBackoff = 100 * time.Millisecond + + // finalityArmMaxBackoff caps the retry delay. Arming is retried + // indefinitely (until the watch's context is cancelled) rather than + // abandoned after a fixed count: for gRPC lndclient and lwwallet, + // height synthesis is the ONLY source of the terminal Done, so giving + // up would strand the round/exit in ProvisionallyConfirmed forever + // (a single-confirmation tx has no later event to trigger a re-arm). + // A brief backend outage at the arming moment therefore only delays + // finality, never permanently disables it. 30s stops a genuinely-down + // backend from spinning while bounding recovery latency once it heals. + finalityArmMaxBackoff = 30 * time.Second + + // finalityArmEscalateAfter is the consecutive-failure count past which + // the retry log escalates to an operator-visible "finality stalled" + // warning, so a persistently unarmable watch is detectable rather than + // silently stuck in provisional. + finalityArmEscalateAfter = 5 +) + +// finalityArmResult is handed from a conf/spend sub-actor's off-loop finality +// arming goroutine back to its select loop. reg is the block-epoch +// subscription (nil only when the watch context was cancelled). height is the +// best chain height observed at arm time (zero if it could not be read); the +// loop uses it to synthesize Done immediately when the arming +// confirmation/spend is already buried past FinalityDepth, rather than +// hanging until a fresh block epoch arrives (the subscription only delivers +// FUTURE epochs). +type finalityArmResult struct { + reg *BlockRegistration + height int32 +} + +// registerBlocksForFinality registers a block-epoch subscription used +// to synthesize a Done signal at FinalityDepth past an observed +// confirmation or spend. It retries RegisterBlocks indefinitely with a +// capped exponential backoff (until the passed context is cancelled) +// because finality synthesis is the only Done source for backends that +// do not write the upstream Done channel (notably lndclient over gRPC and +// lwwallet). Abandoning the arm after a fixed number of attempts would +// leak the per-watch sub-actor AND strand the round/exit in +// ProvisionallyConfirmed forever if the backend merely hiccups at the +// arming moment: a single-confirmation tx has no later event to trigger a +// re-arm, so the watch would only recover on a daemon restart. +// +// The retries run in a dedicated arming goroutine (not the sub-actor's +// select loop), so 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 goroutine is bounded by the watch's lifetime. +// +// 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. +// +// Returns the registration on success, or a non-nil error only when the +// context is cancelled (the watch is shutting down). +func registerBlocksForFinality(ctx context.Context, backend ChainBackend, + log btclog.Logger) (*BlockRegistration, error) { + + backoff := finalityArmInitialBackoff + for attempt := 1; ; attempt++ { + reg, err := backend.RegisterBlocks(ctx) + if err == nil { + if attempt > 1 { + log.InfoS(ctx, "Finality block subscription "+ + "armed after retries", + "attempts", attempt, + ) + } + + return reg, nil + } + + // Height synthesis is the only finality source for gRPC + // lndclient / lwwallet, so a persistent arming failure stalls + // the round/exit in provisional. Escalate the log once retries + // pass finalityArmEscalateAfter so the stall is + // operator-visible rather than silent; the capped backoff + // throttles it. + if attempt >= finalityArmEscalateAfter { + log.WarnS(ctx, "Finality block subscription arming "+ + "persistently failing; round/exit finality is "+ + "stalled until the backend recovers", err, + "attempts", attempt, + "backoff", backoff, + ) + } else { + log.WarnS(ctx, "RegisterBlocks for finality synthesis "+ + "failed; retrying", err, + "attempt", attempt, + "backoff", backoff, + ) + } + + select { + case <-time.After(backoff): + case <-ctx.Done(): + return nil, ctx.Err() + } + + backoff *= 2 + if backoff > finalityArmMaxBackoff { + backoff = finalityArmMaxBackoff + } + } +} diff --git a/chainsource/finality_test.go b/chainsource/finality_test.go new file mode 100644 index 000000000..f3101089b --- /dev/null +++ b/chainsource/finality_test.go @@ -0,0 +1,107 @@ +package chainsource + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/btcsuite/btclog/v2" + "github.com/stretchr/testify/require" +) + +// flakyArmBackend fails RegisterBlocks the first failCount times, then +// succeeds. It models a backend that is briefly unavailable at the exact +// moment finality arming is attempted (the failure mode that previously +// stranded a watch once the bounded retry schedule was exhausted). +type flakyArmBackend struct { + *mockBackend + + mu sync.Mutex + failLeft int + attempts int +} + +// RegisterBlocks fails until failLeft is drained, then returns a live +// registration. +func (b *flakyArmBackend) RegisterBlocks(ctx context.Context) ( + *BlockRegistration, error) { + + b.mu.Lock() + defer b.mu.Unlock() + + b.attempts++ + if b.failLeft > 0 { + b.failLeft-- + + return nil, errors.New("backend temporarily unavailable") + } + + return &BlockRegistration{ + Epochs: make(chan *BlockEpoch, 1), + Cancel: func() {}, + }, nil +} + +// attemptCount returns how many RegisterBlocks calls have been made. +func (b *flakyArmBackend) attemptCount() int { + b.mu.Lock() + defer b.mu.Unlock() + + return b.attempts +} + +// TestRegisterBlocksForFinalityRetriesUntilArmed asserts that finality arming +// retries past the old fixed three-attempt cap and eventually arms once a +// transiently-unavailable backend recovers. This is the reorg-safety +// robustness property: for gRPC lndclient / lwwallet, height synthesis is the +// only Done source, so abandoning the arm would strand the round/exit in +// provisional until a daemon restart. failLeft is five (past both the old cap +// and finalityArmEscalateAfter) so the escalation-warning branch is exercised. +func TestRegisterBlocksForFinalityRetriesUntilArmed(t *testing.T) { + t.Parallel() + + backend := &flakyArmBackend{ + mockBackend: newMockBackend(), + failLeft: 5, + } + + reg, err := registerBlocksForFinality( + context.Background(), backend, btclog.Disabled, + ) + require.NoError(t, err, "arming must succeed once the backend recovers") + require.NotNil(t, reg, "a live block registration must be returned") + require.NotNil(t, reg.Epochs) + require.GreaterOrEqual( + t, backend.attemptCount(), 6, + "arming must retry past the old three-attempt cap", + ) +} + +// TestRegisterBlocksForFinalityStopsOnContextCancel asserts that the otherwise +// unbounded arming retry exits promptly when the watch's context is cancelled +// (daemon shutdown), returning the context error rather than looping forever. +func TestRegisterBlocksForFinalityStopsOnContextCancel(t *testing.T) { + t.Parallel() + + // A backend that never recovers, so arming would loop indefinitely + // were it not bounded by the context. + backend := &flakyArmBackend{ + mockBackend: newMockBackend(), + failLeft: 1 << 30, + } + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(150 * time.Millisecond) + cancel() + }() + + reg, err := registerBlocksForFinality(ctx, backend, btclog.Disabled) + require.Error( + t, err, "arming must return once the context is cancelled", + ) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, reg) +} diff --git a/chainsource/messages.go b/chainsource/messages.go index 856276fa6..3c5c4aeaf 100644 --- a/chainsource/messages.go +++ b/chainsource/messages.go @@ -261,6 +261,15 @@ type RegisterConfRequest struct { // events will be sent to this actor asynchronously. If None, a Future // is returned in the response for blocking await. NotifyActor fn.Option[actor.TellOnlyRef[ConfirmationEvent]] + + // NotifyReorged is an optional actor reference for negative + // confirmation events. It is only used in async actor mode. + NotifyReorged fn.Option[actor.TellOnlyRef[ConfReorgedEvent]] + + // NotifyDone is an optional actor reference notified when the + // confirmation watch is beyond the backend's reorg tracking horizon. + // It is only used in async actor mode. + NotifyDone fn.Option[actor.TellOnlyRef[ConfDoneEvent]] } // MessageType returns the message type identifier for logging and debugging. @@ -332,6 +341,48 @@ func (m ConfirmationEvent) MessageType() string { return "ConfirmationEvent" } +// ConfReorgedEvent is sent when a previously reported confirmation is +// reorged out of the canonical chain. After receiving this event a consumer +// should consider the prior confirmation no longer valid; if the transaction +// re-confirms on the new canonical chain a fresh ConfirmationEvent will +// follow on the same registration. +// +// No block hash, height, or reorg depth is carried because the lndclient +// gRPC transport does not preserve that information and we do not want to +// expose fields that are always zero in production. Consumers that need to +// invalidate cached block metadata should match on Txid and use the data +// from the most recent ConfirmationEvent they observed on this watch. +type ConfReorgedEvent struct { + actor.BaseMessage + + // Txid is the transaction ID whose confirmation was reorged. This + // matches the Txid carried on the originating ConfirmationEvent. + Txid chainhash.Hash +} + +// MessageType returns the message type identifier for logging and debugging. +func (m ConfReorgedEvent) MessageType() string { + return "ConfReorgedEvent" +} + +// ConfDoneEvent is sent when a confirmation watch has matured past the +// backend's reorg-safety depth and will receive no further events. +// Consumers may use this signal to drop any reorg-recovery bookkeeping they +// were holding for the registration. Not all backends synthesize this +// event; consumers must treat its absence as a normal operating condition +// rather than an error. +type ConfDoneEvent struct { + actor.BaseMessage + + // Txid is the transaction ID whose registration matured. + Txid chainhash.Hash +} + +// MessageType returns the message type identifier for logging and debugging. +func (m ConfDoneEvent) MessageType() string { + return "ConfDoneEvent" +} + // UnregisterConfRequest requests cancellation of a confirmation subscription. // The ChainSource actor uses the fields to construct the service key and // cancel the dedicated actor. @@ -417,6 +468,15 @@ type RegisterSpendRequest struct { // will be sent to this actor asynchronously. If None, a Future is // returned for blocking await. NotifyActor fn.Option[actor.TellOnlyRef[SpendEvent]] + + // NotifyReorged is an optional actor reference for spend reorg events. + // It is only used in async actor mode. + NotifyReorged fn.Option[actor.TellOnlyRef[SpendReorgedEvent]] + + // NotifyDone is an optional actor reference notified when the spend + // watch is beyond the backend's reorg tracking horizon. It is only used + // in async actor mode. + NotifyDone fn.Option[actor.TellOnlyRef[SpendDoneEvent]] } // MessageType returns the message type identifier for logging and debugging. @@ -481,6 +541,45 @@ func (m SpendEvent) MessageType() string { return "SpendEvent" } +// SpendReorgedEvent is sent when a previously reported spend is reorged out +// of the canonical chain. After receiving this event a consumer should +// consider the prior spend no longer valid; if the outpoint is re-spent on +// the new canonical chain a fresh SpendEvent will follow on the same +// registration. +// +// No spending txid, height, or block hash is carried because the lndclient +// gRPC transport does not preserve that information and we do not want to +// expose fields that are always zero in production. Consumers that need to +// invalidate cached spending metadata should match on Outpoint and use the +// data from the most recent SpendEvent they observed on this watch. +type SpendReorgedEvent struct { + actor.BaseMessage + + // Outpoint is the output whose spend was reorged. + Outpoint wire.OutPoint +} + +// MessageType returns the message type identifier for logging and debugging. +func (m SpendReorgedEvent) MessageType() string { + return "SpendReorgedEvent" +} + +// SpendDoneEvent is sent when a spend watch has matured past the backend's +// reorg-safety depth and will receive no further events. Not all backends +// synthesize this event; consumers must treat its absence as a normal +// operating condition rather than an error. +type SpendDoneEvent struct { + actor.BaseMessage + + // Outpoint is the output whose spend registration matured. + Outpoint wire.OutPoint +} + +// MessageType returns the message type identifier for logging and debugging. +func (m SpendDoneEvent) MessageType() string { + return "SpendDoneEvent" +} + // UnregisterSpendRequest requests cancellation of a spend subscription. // The ChainSource actor uses the fields to construct the service key and // cancel the dedicated actor. diff --git a/chainsource/reorg_test.go b/chainsource/reorg_test.go new file mode 100644 index 000000000..35571adbe --- /dev/null +++ b/chainsource/reorg_test.go @@ -0,0 +1,735 @@ +package chainsource + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// awaitTimeout is the per-step wait used by the reorg lifecycle tests. It +// is generous enough to absorb scheduling jitter on overloaded CI machines +// but still short enough that a hung actor surfaces as a fast failure. +const awaitTimeout = 5 * time.Second + +// TestConfActorReorgAwareForwardsFullLifecycle drives the full +// Confirmed -> Reorged -> Confirmed -> Done sequence through a reorg-aware +// ConfActor and asserts each event is forwarded to the correct notify ref +// in order, and that the actor releases the backend registration after +// Done. +func TestConfActorReorgAwareForwardsFullLifecycle(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + confActor := NewConfActor(ConfActorConfig{Backend: backend}) + defer confActor.Stop() + + txHash := chainhash.Hash{0x01} + confNotifier := actor.NewChannelTellOnlyRef[ConfirmationEvent]( + "conf-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[ConfReorgedEvent]( + "conf-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[ConfDoneEvent]( + "conf-done", 10, + ) + + var confRef actor.TellOnlyRef[ConfirmationEvent] = confNotifier + var reorgRef actor.TellOnlyRef[ConfReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[ConfDoneEvent] = doneNotifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-reorg-lifecycle", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyActor: fn.Some(confRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + resp, err := result.Unpack() + require.NoError(t, err) + confResp, ok := resp.(*RegisterConfResponse) + require.True(t, ok) + + // Reorg-aware mode is actor-only, so the response must not carry a + // Future. + require.Nil(t, confResp.Future) + + // 1. First confirmation on the canonical chain. + blockHash1 := chainhash.Hash{0xaa} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash1, + BlockHeight: 100, + Tx: wire.NewMsgTx(2), + } + + event1, ok := confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for first ConfirmationEvent") + require.Equal(t, int32(100), event1.BlockHeight) + require.Equal(t, blockHash1, event1.BlockHash) + + // 2. Reorg evicts that confirmation. + backend.confReorgedChan <- uint64(0) + + reorgEvt, ok := reorgNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for ConfReorgedEvent") + require.Equal(t, txHash, reorgEvt.Txid) + + // 3. Transaction re-confirms in a different block on the new tip. + blockHash2 := chainhash.Hash{0xbb} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash2, + BlockHeight: 101, + Tx: wire.NewMsgTx(2), + } + + event2, ok := confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for re-ConfirmationEvent") + require.Equal(t, int32(101), event2.BlockHeight) + require.Equal(t, blockHash2, event2.BlockHash) + + // 4. Registration matures past reorg safety; backend fires Done. + backend.confDoneChan <- struct{}{} + + doneEvt, ok := doneNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for ConfDoneEvent") + require.Equal(t, txHash, doneEvt.Txid) + + // After Done the actor must release the registration. + require.Eventually(t, func() bool { + return backend.confCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "registration Cancel was never invoked after Done") +} + +// TestConfActorSynthesizesDoneFromFinalityDepth verifies that a +// reorg-aware ConfActor with a non-zero FinalityDepth fires +// ConfDoneEvent on its own once enough blocks have been observed past +// the first Confirmed event, even when the backend's Done channel +// never fires. This closes the lndclient gRPC gap, where lnd's +// internal "past reorg-safety depth" signal does not survive the +// transport. +func TestConfActorSynthesizesDoneFromFinalityDepth(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + const finalityDepth = 6 + confActor := NewConfActor(ConfActorConfig{ + Backend: backend, + FinalityDepth: finalityDepth, + }) + defer confActor.Stop() + + txHash := chainhash.Hash{0x01} + confNotifier := actor.NewChannelTellOnlyRef[ConfirmationEvent]( + "conf-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[ConfReorgedEvent]( + "conf-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[ConfDoneEvent]( + "conf-done", 10, + ) + + var confRef actor.TellOnlyRef[ConfirmationEvent] = confNotifier + var reorgRef actor.TellOnlyRef[ConfReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[ConfDoneEvent] = doneNotifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-finality-depth", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyActor: fn.Some(confRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + + // 1. First confirmation at height 100. This arms the height-based + // finality synthesizer. + blockHash1 := chainhash.Hash{0xaa} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash1, + BlockHeight: 100, + Tx: wire.NewMsgTx(2), + } + _, ok := confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for ConfirmationEvent") + + // 2. Push blocks up to height 104. Inclusive depth at that point + // is 5 (heights 100..104), one short of the finality threshold, + // so ConfDoneEvent MUST NOT have fired yet. + for height := int32(101); height <= + int32(100+finalityDepth-2); height++ { + + backend.epochChan <- &BlockEpoch{Height: height} + } + _, ok = doneNotifier.AwaitMessage(50 * time.Millisecond) + require.False(t, ok, "ConfDoneEvent fired before finality depth") + + // 3. One more block brings the inclusive depth to exactly + // FinalityDepth (heights 100..105). Done must fire now. + backend.epochChan <- &BlockEpoch{ + Height: 100 + int32(finalityDepth) - 1, + } + doneEvt, ok := doneNotifier.AwaitMessage(awaitTimeout) + require.True( + t, ok, + "ConfDoneEvent never fired despite reaching finality depth", + ) + require.Equal(t, txHash, doneEvt.Txid) + + // 4. After Done the actor exits and releases the registration. + require.Eventually(t, func() bool { + return backend.confCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "registration Cancel was never invoked after synthesized "+ + "Done") +} + +// TestConfActorDiscardsStaleReorgBySeq verifies that a reorg signal which +// lost a cross-channel race to a newer re-confirmation is discarded by +// sequence number. The re-confirmation (seq 3) is observed before the +// older reorg (seq 2); because Confirmed and Reorged arrive on separate +// channels the actor cannot order them by arrival, so it must order them +// by Seq and ignore the stale reorg. The proof is that height-based +// finality still fires against the re-confirmation height — if the stale +// reorg had been applied it would have reset confirmHeight to 0 and Done +// would never synthesize. +func TestConfActorDiscardsStaleReorgBySeq(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + const finalityDepth = 6 + confActor := NewConfActor(ConfActorConfig{ + Backend: backend, + FinalityDepth: finalityDepth, + }) + defer confActor.Stop() + + txHash := chainhash.Hash{0x04} + confNotifier := actor.NewChannelTellOnlyRef[ConfirmationEvent]( + "conf-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[ConfReorgedEvent]( + "conf-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[ConfDoneEvent]( + "conf-done", 10, + ) + + var confRef actor.TellOnlyRef[ConfirmationEvent] = confNotifier + var reorgRef actor.TellOnlyRef[ConfReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[ConfDoneEvent] = doneNotifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-stale-reorg-seq", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyActor: fn.Some(confRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + + // 1. First confirmation at height 100, seq 1. + blockHash1 := chainhash.Hash{0xaa} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash1, + BlockHeight: 100, + Tx: wire.NewMsgTx(2), + Seq: 1, + } + _, ok := confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for first ConfirmationEvent") + + // 2. The re-confirmation (seq 3) is delivered before the older reorg + // (seq 2) — the cross-channel race. It arms finality at height 101. + blockHash2 := chainhash.Hash{0xbb} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash2, + BlockHeight: 101, + Tx: wire.NewMsgTx(2), + Seq: 3, + } + _, ok = confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for re-ConfirmationEvent") + + // 3. The stale reorg (seq 2 <= 3) arrives late and must be discarded: + // no ConfReorgedEvent is delivered and confirmHeight is untouched. + backend.confReorgedChan <- uint64(2) + _, ok = reorgNotifier.AwaitMessage(50 * time.Millisecond) + require.False(t, ok, "stale reorg (seq 2) was not discarded") + + // 4. Drive blocks to the finality depth past height 101. Done must + // fire, proving confirmHeight survived the stale reorg. + for height := int32(102); height <= + int32(101+finalityDepth)-1; height++ { + + backend.epochChan <- &BlockEpoch{Height: height} + } + doneEvt, ok := doneNotifier.AwaitMessage(awaitTimeout) + require.True( + t, ok, "ConfDoneEvent never fired; stale reorg wrongly "+ + "reset confirmHeight", + ) + require.Equal(t, txHash, doneEvt.Txid) +} + +// TestConfActorDiscardsStaleConfirmBySeq verifies the opposite race: a +// re-confirmation that lost a cross-channel race to a newer reorg is +// discarded by sequence number. The reorg (seq 3) is observed before the +// stale confirmation (seq 2); the actor must ignore the confirmation and +// leave the watch unconfirmed, so height-based finality must NOT fire. +// Without sequence ordering the stale confirmation would set a non-zero +// confirmHeight and synthesize a false Done for a tx that is gone. +func TestConfActorDiscardsStaleConfirmBySeq(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + const finalityDepth = 6 + confActor := NewConfActor(ConfActorConfig{ + Backend: backend, + FinalityDepth: finalityDepth, + }) + defer confActor.Stop() + + txHash := chainhash.Hash{0x05} + confNotifier := actor.NewChannelTellOnlyRef[ConfirmationEvent]( + "conf-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[ConfReorgedEvent]( + "conf-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[ConfDoneEvent]( + "conf-done", 10, + ) + + var confRef actor.TellOnlyRef[ConfirmationEvent] = confNotifier + var reorgRef actor.TellOnlyRef[ConfReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[ConfDoneEvent] = doneNotifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-stale-confirm-seq", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyActor: fn.Some(confRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + + // 1. First confirmation at height 100, seq 1. + blockHash1 := chainhash.Hash{0xaa} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash1, + BlockHeight: 100, + Tx: wire.NewMsgTx(2), + Seq: 1, + } + _, ok := confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for first ConfirmationEvent") + + // 2. The newer reorg (seq 3) is observed first and resets the watch. + backend.confReorgedChan <- uint64(3) + _, ok = reorgNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for ConfReorgedEvent") + + // 3. The stale re-confirmation (seq 2 <= 3) arrives late and must be + // discarded: no ConfirmationEvent is delivered and the watch stays + // unconfirmed. + blockHash2 := chainhash.Hash{0xbb} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash2, + BlockHeight: 101, + Tx: wire.NewMsgTx(2), + Seq: 2, + } + _, ok = confNotifier.AwaitMessage(50 * time.Millisecond) + require.False(t, ok, "stale confirmation (seq 2) was not discarded") + + // 4. Drive many blocks well past any finality window. Done must NOT + // fire because confirmHeight was never re-armed. + for height := int32(101); height <= int32(120); height++ { + backend.epochChan <- &BlockEpoch{Height: height} + } + _, ok = doneNotifier.AwaitMessage(100 * time.Millisecond) + require.False( + t, ok, "ConfDoneEvent fired for a reorged-out tx; stale "+ + "confirmation was wrongly applied", + ) +} + +// TestConfActorReorgAwareRejectsWithoutNotifyActor verifies that opting in +// to reorg-aware mode without an actor-mode confirmation ref is rejected at +// admission. Allowing it would silently drop every re-confirmation after +// the first, since a Future can only complete once. +func TestConfActorReorgAwareRejectsWithoutNotifyActor(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + confActor := NewConfActor(ConfActorConfig{Backend: backend}) + defer confActor.Stop() + + txHash := chainhash.Hash{0x02} + reorgNotifier := actor.NewChannelTellOnlyRef[ConfReorgedEvent]( + "conf-reorged", 1, + ) + var reorgRef actor.TellOnlyRef[ConfReorgedEvent] = reorgNotifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-reorg-no-notify", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyReorged: fn.Some(reorgRef), + }) + require.True(t, result.IsErr()) + _, err := result.Unpack() + require.ErrorContains( + t, err, + "reorg/done notifications require actor-mode NotifyActor", + ) +} + +// TestConfActorLegacyExitsAfterFirstConfirm ensures legacy Actor-mode +// subscribers (no NotifyReorged / NotifyDone) keep their historical +// single-shot contract even when the backend would later emit Reorged or +// Done. The actor must cancel the registration after the first +// confirmation. +func TestConfActorLegacyExitsAfterFirstConfirm(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + confActor := NewConfActor(ConfActorConfig{Backend: backend}) + defer confActor.Stop() + + txHash := chainhash.Hash{0x03} + notifier := actor.NewChannelTellOnlyRef[ConfirmationEvent]( + "conf-notify", 10, + ) + var confRef actor.TellOnlyRef[ConfirmationEvent] = notifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-legacy-single-shot", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyActor: fn.Some(confRef), + }) + require.True(t, result.IsOk()) + + // First confirmation goes through. + blockHash := chainhash.Hash{0x10} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash, + BlockHeight: 200, + Tx: wire.NewMsgTx(2), + } + + event, ok := notifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for first ConfirmationEvent") + require.Equal(t, int32(200), event.BlockHeight) + + // Actor must have exited after the first confirmation, releasing the + // registration. + require.Eventually(t, func() bool { + return backend.confCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "legacy ConfActor did not cancel registration after first "+ + "confirmation") + + // No further events should reach the notifier. + _, ok = notifier.AwaitMessage(50 * time.Millisecond) + require.False( + t, ok, "legacy ConfActor delivered an unexpected second event", + ) +} + +// TestSpendActorReorgAwareForwardsFullLifecycle drives the spend lifecycle +// (Spend -> Reorged -> Spend -> Done) through a reorg-aware SpendActor and +// asserts every event is forwarded to the correct notify ref in order. +func TestSpendActorReorgAwareForwardsFullLifecycle(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + spendActor := NewSpendActor(SpendActorConfig{Backend: backend}) + defer spendActor.Stop() + + outpoint := wire.OutPoint{Hash: chainhash.Hash{0x11}, Index: 0} + + spendNotifier := actor.NewChannelTellOnlyRef[SpendEvent]( + "spend-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[SpendReorgedEvent]( + "spend-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[SpendDoneEvent]( + "spend-done", 10, + ) + + var spendRef actor.TellOnlyRef[SpendEvent] = spendNotifier + var reorgRef actor.TellOnlyRef[SpendReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[SpendDoneEvent] = doneNotifier + + result := spendActor.Receive(ctx, &RegisterSpendRequest{ + CallerID: "test-spend-reorg-lifecycle", + Outpoint: &outpoint, + PkScript: []byte{0x00, 0x14}, + NotifyActor: fn.Some(spendRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + resp, err := result.Unpack() + require.NoError(t, err) + spendResp, ok := resp.(*RegisterSpendResponse) + require.True(t, ok) + require.Nil(t, spendResp.Future) + + // 1. First spend confirms. + spendingTx1 := wire.NewMsgTx(2) + hash1 := spendingTx1.TxHash() + backend.spendChan <- &SpendDetail{ + SpentOutPoint: &outpoint, + SpenderTxHash: &hash1, + SpendingTx: spendingTx1, + SpendingHeight: 150, + } + + spend1, ok := spendNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for first SpendEvent") + require.Equal(t, outpoint, spend1.Outpoint) + require.Equal(t, int32(150), spend1.SpendingHeight) + require.Equal(t, hash1, spend1.SpendingTxid) + + // 2. Reorg evicts that spend. + backend.spendReorgedChan <- uint64(0) + + reorgEvt, ok := reorgNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for SpendReorgedEvent") + require.Equal(t, outpoint, reorgEvt.Outpoint) + + // 3. A different spender wins the new chain. + spendingTx2 := wire.NewMsgTx(2) + spendingTx2.AddTxIn(&wire.TxIn{Sequence: 1}) + hash2 := spendingTx2.TxHash() + require.NotEqual(t, hash1, hash2) + backend.spendChan <- &SpendDetail{ + SpentOutPoint: &outpoint, + SpenderTxHash: &hash2, + SpendingTx: spendingTx2, + SpendingHeight: 151, + } + + spend2, ok := spendNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for re-SpendEvent") + require.Equal(t, outpoint, spend2.Outpoint) + require.Equal(t, int32(151), spend2.SpendingHeight) + require.Equal(t, hash2, spend2.SpendingTxid) + + // 4. Registration matures past reorg safety. + backend.spendDoneChan <- struct{}{} + + doneEvt, ok := doneNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for SpendDoneEvent") + require.Equal(t, outpoint, doneEvt.Outpoint) + + require.Eventually(t, func() bool { + return backend.spendCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "registration Cancel was never invoked after Done") +} + +// TestSpendActorSynthesizesDoneFromFinalityDepth mirrors the conf-side +// height-based finality test for the spend watch. A reorg-aware +// SpendActor with non-zero FinalityDepth fires SpendDoneEvent on its +// own once enough blocks have been observed past the first Spend, +// closing the same lndclient gRPC gap that ConfActor closes for +// confirmations. +func TestSpendActorSynthesizesDoneFromFinalityDepth(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + const finalityDepth = 6 + spendActor := NewSpendActor(SpendActorConfig{ + Backend: backend, + FinalityDepth: finalityDepth, + }) + defer spendActor.Stop() + + outpoint := wire.OutPoint{Hash: chainhash.Hash{0x11}, Index: 0} + + spendNotifier := actor.NewChannelTellOnlyRef[SpendEvent]( + "spend-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[SpendReorgedEvent]( + "spend-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[SpendDoneEvent]( + "spend-done", 10, + ) + + var spendRef actor.TellOnlyRef[SpendEvent] = spendNotifier + var reorgRef actor.TellOnlyRef[SpendReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[SpendDoneEvent] = doneNotifier + + result := spendActor.Receive(ctx, &RegisterSpendRequest{ + CallerID: "test-spend-finality-depth", + Outpoint: &outpoint, + PkScript: []byte{0x00, 0x14}, + NotifyActor: fn.Some(spendRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + + // 1. First spend confirms at height 150. This arms the + // height-based finality synthesizer. + spendingTx := wire.NewMsgTx(2) + hash := spendingTx.TxHash() + backend.spendChan <- &SpendDetail{ + SpentOutPoint: &outpoint, + SpenderTxHash: &hash, + SpendingTx: spendingTx, + SpendingHeight: 150, + } + _, ok := spendNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for SpendEvent") + + // 2. Push blocks up to height 154. Inclusive depth at that point + // is 5 (heights 150..154), one short of the finality threshold. + // SpendDoneEvent MUST NOT have fired yet. + for height := int32(151); height <= + int32(150+finalityDepth-2); height++ { + + backend.epochChan <- &BlockEpoch{Height: height} + } + _, ok = doneNotifier.AwaitMessage(50 * time.Millisecond) + require.False(t, ok, "SpendDoneEvent fired before finality depth") + + // 3. One more block brings the inclusive depth to exactly + // FinalityDepth (heights 150..155). Done must fire now. + backend.epochChan <- &BlockEpoch{ + Height: 150 + int32(finalityDepth) - 1, + } + doneEvt, ok := doneNotifier.AwaitMessage(awaitTimeout) + require.True( + t, ok, + "SpendDoneEvent never fired despite reaching finality depth", + ) + require.Equal(t, outpoint, doneEvt.Outpoint) + + require.Eventually(t, func() bool { + return backend.spendCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "registration Cancel was never invoked after synthesized "+ + "Done") +} + +// TestSpendActorReorgAwareRejectsWithoutNotifyActor mirrors the conf-side +// admission check: opting in to reorg-aware spend forwarding without a +// NotifyActor must be rejected. +func TestSpendActorReorgAwareRejectsWithoutNotifyActor(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + spendActor := NewSpendActor(SpendActorConfig{Backend: backend}) + defer spendActor.Stop() + + outpoint := wire.OutPoint{Hash: chainhash.Hash{0x21}, Index: 0} + reorgNotifier := actor.NewChannelTellOnlyRef[SpendReorgedEvent]( + "spend-reorged", 1, + ) + var reorgRef actor.TellOnlyRef[SpendReorgedEvent] = reorgNotifier + + result := spendActor.Receive(ctx, &RegisterSpendRequest{ + CallerID: "test-spend-reorg-no-notify", + Outpoint: &outpoint, + PkScript: []byte{0x00, 0x14}, + NotifyReorged: fn.Some(reorgRef), + }) + require.True(t, result.IsErr()) + _, err := result.Unpack() + require.ErrorContains( + t, err, + "reorg/done notifications require actor-mode NotifyActor", + ) +} + +// TestSpendActorLegacyExitsAfterFirstSpend exercises the legacy Actor-mode +// path: a watch that did not opt into the reorg lifecycle +// (NotifyReorged/NotifyDone both unset) is single-shot, exiting and +// releasing its backend registration after the first spend. This mirrors +// ConfActor's legacy contract and the documented invariant in CLAUDE.md; +// without the reorgAware gate such a watch would run forever and, with +// FinalityDepth > 0, arm a block subscription it never requested. +func TestSpendActorLegacyExitsAfterFirstSpend(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + spendActor := NewSpendActor(SpendActorConfig{Backend: backend}) + defer spendActor.Stop() + + outpoint := wire.OutPoint{Hash: chainhash.Hash{0x31}, Index: 0} + notifier := actor.NewChannelTellOnlyRef[SpendEvent]( + "spend-notify", 10, + ) + var spendRef actor.TellOnlyRef[SpendEvent] = notifier + + result := spendActor.Receive(ctx, &RegisterSpendRequest{ + CallerID: "test-spend-legacy-single-shot", + Outpoint: &outpoint, + PkScript: []byte{0x00, 0x14}, + NotifyActor: fn.Some(spendRef), + }) + require.True(t, result.IsOk()) + + // First spend goes through. + spendingTx := wire.NewMsgTx(2) + hash := spendingTx.TxHash() + backend.spendChan <- &SpendDetail{ + SpentOutPoint: &outpoint, + SpenderTxHash: &hash, + SpendingTx: spendingTx, + SpendingHeight: 300, + } + + first, ok := notifier.AwaitMessage(awaitTimeout) + require.True(t, ok) + require.Equal(t, int32(300), first.SpendingHeight) + + // The actor must have exited after the first spend, releasing the + // registration. + require.Eventually(t, func() bool { + return backend.spendCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "legacy SpendActor did not cancel registration after first "+ + "spend") + + // No further events should reach the notifier. + _, ok = notifier.AwaitMessage(50 * time.Millisecond) + require.False( + t, ok, "legacy SpendActor delivered an unexpected second event", + ) +} diff --git a/chainsource/spend_actor.go b/chainsource/spend_actor.go index 1a5c314ca..b5df68f61 100644 --- a/chainsource/spend_actor.go +++ b/chainsource/spend_actor.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "sync" + "time" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" @@ -23,6 +24,15 @@ type SpendActorConfig struct { // falls back to extracting a logger from context via LoggerFromContext, // or uses btclog.Disabled if no logger is found. Log fn.Option[btclog.Logger] + + // FinalityDepth is the number of confirmations past the first + // observed Spend event that the actor uses to synthesize a Done + // signal when the backend cannot deliver one. See the matching + // field on ConfActorConfig for the rationale; the same constraint + // applies on the spend watch — lnd's chainntnfs.SpendEvent.Done + // does not survive the lndclient gRPC transport, so consumers + // that gate eviction on Done would otherwise leak per-spend state. + FinalityDepth uint32 } // WithLogger returns a new config with the given logger set. @@ -61,9 +71,26 @@ type SpendActor struct { // mode. notifyActor fn.Option[actor.TellOnlyRef[SpendEvent]] + // notifyReorged receives spend reorg events in Actor mode. + notifyReorged fn.Option[actor.TellOnlyRef[SpendReorgedEvent]] + + // notifyDone receives spend finality events in Actor mode. + notifyDone fn.Option[actor.TellOnlyRef[SpendDoneEvent]] + // registration is the backend registration for this watch. registration *SpendRegistration + // blockReg is the block-epoch subscription used by height-based + // finality synthesis. Allocated lazily after the first Spend + // event when FinalityDepth > 0, torn down when the actor exits. + blockReg *BlockRegistration + + // spendHeight records the block height the most recent Spend + // event arrived at. Zero means there is no active spend to count + // from (either we have not yet seen one, or the last one was + // reorged out). + spendHeight int32 + // ctx is the actor's internal context for cancellation, created from // context.Background() to ensure it outlives any request context. //nolint:containedctx @@ -132,12 +159,21 @@ func (a *SpendActor) handleRegisterSpend(actorCtx context.Context, "provided"), ) } + if req.NotifyActor.IsNone() && + (req.NotifyReorged.IsSome() || req.NotifyDone.IsSome()) { + return fn.Err[SpendResp]( + fmt.Errorf("spend reorg/done notifications require " + + "actor-mode NotifyActor"), + ) + } // Configure the actor with request parameters. a.outpoint = req.Outpoint a.pkScript = req.PkScript a.heightHint = req.HeightHint a.notifyActor = req.NotifyActor + a.notifyReorged = req.NotifyReorged + a.notifyDone = req.NotifyDone // Create promise for Future mode. var promise fn.Option[actor.Promise[SpendEvent]] @@ -152,10 +188,17 @@ func (a *SpendActor) handleRegisterSpend(actorCtx context.Context, // Register with the backend to receive spend notifications. We do this // before starting the goroutine so we can return an error to the - // caller if registration fails. + // caller if registration fails. The bounded timeout mirrors + // ConfActor.handleRegisterConf: a backend (LND) that is slow under + // heavy block processing load must not pin the parent Receive call + // indefinitely, since that would back-pressure the chainsource + // factory actor onto every other in-flight registration. + regCtx, regCancel := context.WithTimeout(a.ctx, 10*time.Second) + defer regCancel() + //nolint:contextcheck // actor root context owns registration lifetime registration, err := a.cfg.Backend.RegisterSpend( - a.ctx, a.outpoint, a.pkScript, a.heightHint, + regCtx, a.outpoint, a.pkScript, a.heightHint, ) if err != nil { return fn.Err[SpendResp]( @@ -191,10 +234,47 @@ func (a *SpendActor) monitorSpend() { if a.registration != nil { a.registration.Cancel() } + if a.blockReg != nil { + a.blockReg.Cancel() + } }() + log := a.logger(a.ctx) + // Monitor for spends indefinitely until cancelled or shutdown. // This allows us to catch re-org events where a spend is replaced. + var lastEvent *SpendEvent + + // reorgAware reports whether the caller opted into the multi-shot + // reorg lifecycle (at least one of NotifyReorged/NotifyDone). When + // false the watch is single-shot for backwards compatibility: the + // actor exits after the first spend, mirroring ConfActor. Without + // this gate a plain actor-mode spend watch would run forever and, with + // FinalityDepth > 0, arm a block subscription it never asked for. + reorgAware := a.notifyReorged.IsSome() || a.notifyDone.IsSome() + + // lastSeq is the highest backend forwarder sequence applied so far. + // Spend and Reorged signals arrive on separate channels and a select + // cannot order two ready channels, so we order them by the shared + // sequence instead: an event whose Seq does not exceed lastSeq lost a + // cross-channel race to a newer signal and is discarded. This makes + // the actor's view correct regardless of delivery interleaving. Seq 0 + // means the backend does not stamp sequences (it never reorgs); those + // events are always applied. + var lastSeq uint64 + + // blockEpochs is rebound when height-based finality synthesis + // arms a block subscription. Until then a nil channel keeps the + // select arm parked. + var blockEpochs <-chan *BlockEpoch + + // blockRegCh hands a finality block subscription from the off-loop + // arming goroutine back to this loop; arming guards against launching + // more than one armer at a time. See armFinalityAsync for why arming + // runs off the select loop. + blockRegCh := make(chan finalityArmResult) + var arming bool + for { select { case spend, ok := <-a.registration.Spend: @@ -206,6 +286,16 @@ func (a *SpendActor) monitorSpend() { return } + // Discard a spend that lost a cross-channel race to a + // newer reorg: an event whose sequence does not exceed + // the highest applied is stale. + if spend.Seq != 0 && spend.Seq <= lastSeq { + continue + } + if spend.Seq > lastSeq { + lastSeq = spend.Seq + } + event, err := buildSpendEvent(spend, a) if err != nil { a.failSpend(err) @@ -215,13 +305,151 @@ func (a *SpendActor) monitorSpend() { // Deliver the event. a.deliverSpend(event) - - // In Future mode, exit after first event. In Actor - // mode, continue monitoring for re-org events. - if a.promise.IsSome() { + lastEvent = &event + a.spendHeight = event.SpendingHeight + + // Exit after the first event in Future mode, and in + // actor mode that did not opt into the reorg lifecycle + // (single-shot backwards-compatible contract). Only a + // reorg-aware actor watch keeps monitoring. + if !reorgAware || a.promise.IsSome() { return } + // Arm height-based finality synthesis on the first + // spend if requested, off the select loop so the + // bounded RegisterBlocks retries cannot stall delivery + // of Reorged/Done/ctx.Done on this watch. A nil + // blockEpochs channel keeps the synthesis arm parked + // until the registration is handed back on blockRegCh. + if a.cfg.FinalityDepth > 0 && a.blockReg == nil && + !arming { + + arming = true + a.armFinalityAsync(blockRegCh, log) + } + + case armed := <-blockRegCh: + // Finality arming completed. Clear the flag; a nil reg + // only happens when the watch context was cancelled + // (arming otherwise retries until it succeeds). + arming = false + if armed.reg == nil { + continue + } + a.blockReg = armed.reg + blockEpochs = armed.reg.Epochs + + // The block-epoch subscription only delivers FUTURE + // epochs, but the spend that armed it may already be + // buried past FinalityDepth (it confirmed several + // blocks ago, or we re-armed after a restart). Use the + // tip observed at arm time to synthesize Done at once + // rather than hang until a fresh block is mined. The + // spendHeight==0 / FinalityDepth==0 guards mirror the + // epoch handler below. + if a.spendHeight == 0 || a.cfg.FinalityDepth == 0 { + continue + } + if armed.height-a.spendHeight+1 < + int32(a.cfg.FinalityDepth) { + + continue + } + + log.InfoS(a.ctx, "Synthesizing spend done on arm from "+ + "height-based safety depth", + "spend_height", a.spendHeight, + "current_height", armed.height, + "finality_depth", int(a.cfg.FinalityDepth), + ) + a.deliverSpendDone(lastEvent) + + return + + case seq, ok := <-a.registration.Reorged: + if !ok { + a.registration.Reorged = nil + continue + } + + // Discard a stale reorg that lost a cross-channel race + // to a newer spend. + if seq != 0 && seq <= lastSeq { + continue + } + if seq > lastSeq { + lastSeq = seq + } + + a.deliverSpendReorged(lastEvent) + + // The previous spend is no longer on the canonical + // chain. Clear the cached event so a later Done cannot + // report the reorged-out outpoint, and reset the depth + // counter so the next re-spend starts a fresh window. + lastEvent = nil + a.spendHeight = 0 + + case _, ok := <-a.registration.Done: + if !ok { + a.registration.Done = nil + continue + } + + a.deliverSpendDone(lastEvent) + + return + + case epoch, ok := <-blockEpochs: + if !ok || epoch == nil { + blockEpochs = nil + continue + } + + // Coalesce any epochs already queued behind this one + // and evaluate finality against the most recent height + // only. With rapid-fire blocks the channel can hold + // several epochs at once; processing them one per loop + // iteration would re-check the same monotonic Done + // condition repeatedly and risk synthesizing against a + // stale height. If the channel closed during the drain, + // park it so we stop selecting on it. + var closed bool + epoch, closed = drainToLatestEpoch(blockEpochs, epoch) + if closed { + blockEpochs = nil + } + + // The spendHeight==0 guard is load-bearing: a reorg + // resets spendHeight to 0 (the Reorged arm above), so + // a fresh epoch arriving before the re-spend would + // otherwise compute depth against a zero base and + // could synthesize Done prematurely. While + // spendHeight==0 there is no active spend to count + // from, so the depth comparison is meaningless. + // FinalityDepth==0 disables synthesis entirely. + if a.spendHeight == 0 || + a.cfg.FinalityDepth == 0 { + + continue + } + + depth := epoch.Height - a.spendHeight + 1 + if depth < int32(a.cfg.FinalityDepth) { + continue + } + + log.InfoS(a.ctx, "Synthesizing spend done from "+ + "height-based safety depth", + "spend_height", a.spendHeight, + "current_height", epoch.Height, + "finality_depth", int(a.cfg.FinalityDepth), + ) + a.deliverSpendDone(lastEvent) + + return + case <-a.ctx.Done(): // Actor was cancelled. a.failSpend(a.ctx.Err()) @@ -231,6 +459,52 @@ func (a *SpendActor) monitorSpend() { } } +// armFinalityAsync registers a block-epoch subscription for height-based +// finality synthesis off the actor's select loop. registerBlocksForFinality +// retries with a bounded backoff that can run for tens of seconds; doing it +// inline would block delivery of Reorged/Done/ctx.Done on this watch for the +// whole window. The registration (or nil on failure) is handed back on regCh, +// or cancelled if the actor exits before the loop reads it. The goroutine is +// tracked by the actor's wait group so Stop drains it. +func (a *SpendActor) armFinalityAsync(regCh chan<- finalityArmResult, + log btclog.Logger) { + + a.wg.Go(func() { + reg, err := registerBlocksForFinality(a.ctx, a.cfg.Backend, log) + if err != nil { + log.WarnS(a.ctx, "Giving up on height-based finality "+ + "synthesis; spend sub-actor will rely on "+ + "backend Done", err) + reg = nil + } + + // Capture the tip at arm time so the loop can finalize + // immediately when the arming spend is already buried past + // FinalityDepth (the block-epoch sub only delivers future + // epochs). A read failure is non-fatal: height stays zero and + // the loop falls back to waiting for the next epoch. + var height int32 + if reg != nil { + h, _, hErr := a.cfg.Backend.BestBlock(a.ctx) + if hErr != nil { + log.WarnS(a.ctx, "Failed to read best height "+ + "for on-arm finality check; will wait "+ + "for next epoch", hErr) + } else { + height = h + } + } + + select { + case regCh <- finalityArmResult{reg: reg, height: height}: + case <-a.ctx.Done(): + if reg != nil { + reg.Cancel() + } + } + }) +} + // deliverSpend delivers a spend event to the subscriber. In Future mode, it // completes the promise. In Actor mode, it sends to the registered actor. func (a *SpendActor) deliverSpend(event SpendEvent) { @@ -249,6 +523,55 @@ func (a *SpendActor) deliverSpend(event SpendEvent) { }) } +// deliverSpendReorged delivers a spend reorg event to actor-mode +// subscribers. The correlation Outpoint is the registration's configured +// outpoint when set, since that is the identifier the caller asked us to +// watch; pkScript-only watches fall back to the outpoint carried on the +// most recent positive SpendEvent. +func (a *SpendActor) deliverSpendReorged(lastEvent *SpendEvent) { + var event SpendReorgedEvent + switch { + case a.outpoint != nil: + event.Outpoint = *a.outpoint + + case lastEvent != nil: + event.Outpoint = lastEvent.Outpoint + } + + a.notifyReorged.WhenSome( + func(ref actor.TellOnlyRef[SpendReorgedEvent]) { + log := a.logger(a.ctx) + if err := ref.Tell(a.ctx, event); err != nil { + log.WarnS( + a.ctx, + "Failed to deliver spend reorg", + err, + ) + } + }, + ) +} + +// deliverSpendDone delivers a spend finality event to actor-mode subscribers. +// Outpoint follows the same precedence as deliverSpendReorged. +func (a *SpendActor) deliverSpendDone(lastEvent *SpendEvent) { + var event SpendDoneEvent + switch { + case a.outpoint != nil: + event.Outpoint = *a.outpoint + + case lastEvent != nil: + event.Outpoint = lastEvent.Outpoint + } + + a.notifyDone.WhenSome(func(ref actor.TellOnlyRef[SpendDoneEvent]) { + log := a.logger(a.ctx) + if err := ref.Tell(a.ctx, event); err != nil { + log.WarnS(a.ctx, "Failed to deliver spend done", err) + } + }) +} + // failSpend completes the promise with an error (Future mode) or does nothing // (Actor mode - errors are not delivered in async mode). func (a *SpendActor) failSpend(err error) { diff --git a/chainsource/transform.go b/chainsource/transform.go index 63b9df71a..f79abfdd5 100644 --- a/chainsource/transform.go +++ b/chainsource/transform.go @@ -33,6 +33,25 @@ func MapConfirmationEvent[Out actor.Message]( return actor.NewMapInputRef(targetRef, mapFn) } +// MapConfReorgedEvent creates a transformed TellOnlyRef that accepts +// ConfReorgedEvent and transforms it to the caller's desired output message +// type. +func MapConfReorgedEvent[Out actor.Message]( + targetRef actor.TellOnlyRef[Out], mapFn func(ConfReorgedEvent) Out, +) actor.TellOnlyRef[ConfReorgedEvent] { + + return actor.NewMapInputRef(targetRef, mapFn) +} + +// MapConfDoneEvent creates a transformed TellOnlyRef that accepts +// ConfDoneEvent and transforms it to the caller's desired output message type. +func MapConfDoneEvent[Out actor.Message]( + targetRef actor.TellOnlyRef[Out], mapFn func(ConfDoneEvent) Out, +) actor.TellOnlyRef[ConfDoneEvent] { + + return actor.NewMapInputRef(targetRef, mapFn) +} + // MapSpendEvent creates a transformed TellOnlyRef that accepts SpendEvent and // transforms it to the caller's desired output message type. This is a // convenience wrapper for the common pattern of adapting chainsource spend @@ -64,6 +83,25 @@ func MapSpendEvent[Out actor.Message]( return actor.NewMapInputRef(targetRef, mapFn) } +// MapSpendReorgedEvent creates a transformed TellOnlyRef that accepts +// SpendReorgedEvent and transforms it to the caller's desired output message +// type. +func MapSpendReorgedEvent[Out actor.Message]( + targetRef actor.TellOnlyRef[Out], mapFn func(SpendReorgedEvent) Out, +) actor.TellOnlyRef[SpendReorgedEvent] { + + return actor.NewMapInputRef(targetRef, mapFn) +} + +// MapSpendDoneEvent creates a transformed TellOnlyRef that accepts +// SpendDoneEvent and transforms it to the caller's desired output message type. +func MapSpendDoneEvent[Out actor.Message]( + targetRef actor.TellOnlyRef[Out], mapFn func(SpendDoneEvent) Out, +) actor.TellOnlyRef[SpendDoneEvent] { + + return actor.NewMapInputRef(targetRef, mapFn) +} + // MapBlockEpoch creates a transformed TellOnlyRef that accepts BlockEpoch // and transforms it to the caller's desired output message type. This is a // convenience wrapper for the common pattern of adapting chainsource block From e7b4a804ad97a0619d5d7d4b4080ae037f18a186 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Mon, 13 Jul 2026 09:01:14 -0700 Subject: [PATCH 02/18] txconfirm: reorg-aware confirmation watches with terminal seal Deliver the full TxConfirmed/TxReorged/re-TxConfirmed/TxFinalized/ TxFailed cycle on confirmation watches, with a terminal seal guarding the reversible fire-and-forget deliveries and a catch-up TxReorged when a reorg lands while a subscriber's initial TxConfirmed is still parked on the async notify path. --- txconfirm/actor.go | 711 +++++++++++++++++++++++++++++--- txconfirm/actor_test.go | 342 +++++++++++++-- txconfirm/broadcaster_test.go | 10 +- txconfirm/fsm_types.go | 33 +- txconfirm/funded_anchor_test.go | 24 +- txconfirm/messages.go | 83 +++- txconfirm/reorg_test.go | 147 +++++++ txconfirm/states.go | 66 ++- 8 files changed, 1296 insertions(+), 120 deletions(-) create mode 100644 txconfirm/reorg_test.go diff --git a/txconfirm/actor.go b/txconfirm/actor.go index 976f4b315..deef2b7aa 100644 --- a/txconfirm/actor.go +++ b/txconfirm/actor.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "sync/atomic" "time" "github.com/btcsuite/btcd/chainhash/v2" @@ -52,6 +53,14 @@ var ( // waiting longer only risks blocking unrelated confirmation work behind // a durable subscriber's DB writer. terminalNotifyTimeout = time.Second + + // reversibleNotifyTimeout bounds how long a fire-and-forget reversible + // notification goroutine (TxConfirmed, TxReorged) is willing to wait + // on a slow subscriber's mailbox before logging the drop and returning. + // Reversible deliveries are best-effort: the next state transition on + // the same tracked tx supersedes the missed event, so we trade a stale + // notification for keeping txconfirm's actor loop unblocked. + reversibleNotifyTimeout = time.Second ) // ErrEnsureParamsMismatch is returned by EnsureConfirmedReq when a second @@ -185,6 +194,24 @@ type TxBroadcasterActor struct { blockSubscriptionActive bool } +// trackedSubscriber is one attached subscriber to a tracked tx. Every +// subscriber receives the full reorg-aware lifecycle (TxConfirmed, +// TxReorged, re-TxConfirmed, TxFinalized, TxFailed) and remains +// attached until TxFinalized or TxFailed is acknowledged. +// +// pendingConfirmed reports whether the INITIAL TxConfirmed delivery +// is still owed to this subscriber. It is true at admission, flipped +// false the first time notifyOneConfirmed lands successfully, and +// drives retryConfirmedRedelivery's per-tick retry loop so the +// at-least-once initial-TxConfirmed contract holds even when the +// subscriber's mailbox is briefly slow. Re-confirmations after a +// reorg are delivered best-effort because the eventual TxFinalized +// reliably carries the final height/numConfs. +type trackedSubscriber struct { + Ref actor.TellOnlyRef[Notification] + pendingConfirmed bool +} + // trackedTx stores the actor-owned handle for one tracked txid. // // The struct is the actor's single source of truth about a tracked @@ -195,7 +222,7 @@ type trackedTx struct { data trackedTxData fsm *trackedTxStateMachine - subscribers map[string]actor.TellOnlyRef[Notification] + subscribers map[string]trackedSubscriber // escalateLog rate-limits the operator-facing escalation that fires // once a tx has failed to reach any mempool repeatedly. It @@ -219,6 +246,17 @@ type trackedTx struct { // subsequent interval-paced bumps fall back to the estimator. Zero // means "no override pending". pendingTargetFeeRate int64 + + // sealed is set true the moment terminal delivery (TxFinalized / + // TxFailed) begins for this entry. Reversible deliveries + // (TxReorged / re-TxConfirmed) run on detached fire-and-forget + // goroutines, so a reversible spawned just before finality could + // otherwise Tell its subscriber after the terminal notification and + // resurrect reorg-recovery bookkeeping the consumer already dropped. + // Each reversible goroutine checks this flag immediately before its + // Tell and skips if the entry has sealed. It is atomic because it is + // read off the actor goroutine; only the actor goroutine writes it. + sealed atomic.Bool } // confirmationObservedMsg routes a chainsource confirmation callback back into @@ -239,14 +277,53 @@ func (m *confirmationObservedMsg) MessageType() string { // set. func (m *confirmationObservedMsg) txConfirmMsgSealed() {} -// terminalNotifyResultMsg returns the result of a terminal notification that -// outlived the actor-path wait budget. +// confirmationReorgedMsg routes a chainsource ConfReorgedEvent back into +// the actor mailbox. +type confirmationReorgedMsg struct { + actor.BaseMessage + txid chainhash.Hash +} + +// MessageType returns the stable message type identifier. +func (m *confirmationReorgedMsg) MessageType() string { + return "confirmationReorgedMsg" +} + +// txConfirmMsgSealed seals confirmationReorgedMsg into the package message +// set. +func (m *confirmationReorgedMsg) txConfirmMsgSealed() {} + +// confirmationDoneMsg routes a chainsource ConfDoneEvent back into the +// actor mailbox. +type confirmationDoneMsg struct { + actor.BaseMessage + txid chainhash.Hash +} + +// MessageType returns the stable message type identifier. +func (m *confirmationDoneMsg) MessageType() string { + return "confirmationDoneMsg" +} + +// txConfirmMsgSealed seals confirmationDoneMsg into the package message +// set. +func (m *confirmationDoneMsg) txConfirmMsgSealed() {} + +// terminalNotifyResultMsg returns the result of a terminal-shape +// notification that outlived the actor-path wait budget. kind carries the +// original delivery kind ("confirmed" / "finalized" / "failed") so +// handleTerminalNotifyResult can distinguish a mid-lifecycle +// initial-TxConfirmed redelivery (where the subscriber must stay attached +// to receive later TxReorged / TxFinalized) from a truly terminal +// notification (where the subscriber should be removed and the entry can +// evict once empty). type terminalNotifyResultMsg struct { actor.BaseMessage txid chainhash.Hash subscriberID string inflightKey string + kind string err error } @@ -354,6 +431,22 @@ func (a *TxBroadcasterActor) Receive(ctx context.Context, State: TxStateConfirmed, }) + case *confirmationReorgedMsg: + a.handleConfirmationReorged(ctx, req) + + return fn.Ok[Resp](&EnsureConfirmedResp{ + Txid: req.txid, + State: TxStateAwaitingConfirmation, + }) + + case *confirmationDoneMsg: + a.handleConfirmationDone(ctx, req) + + return fn.Ok[Resp](&EnsureConfirmedResp{ + Txid: req.txid, + State: TxStateFinalized, + }) + case *blockEpochObservedMsg: a.handleBlockObserved(ctx, req) @@ -401,7 +494,23 @@ func (a *TxBroadcasterActor) OnStop(ctx context.Context) error { continue } - if state == TxStateConfirmed || state == TxStateFailed { + if isTerminalTxState(state) { + // A terminal entry can still hold a registered conf + // watch: a Failed entry never received Done, and a + // Finalized entry whose Done-driven release raced + // OnStop may not have torn it down yet. Release it so + // the chainsource sub-actor does not leak for the + // daemon's lifetime. confWatchRegistered guards the + // common case where Done already released the watch. + if entry.confWatchRegistered { + if err := a.unregisterConfWatch( + ctx, entry, + ); err != nil && firstErr == nil { + + firstErr = err + } + } + if entry.fsm != nil { entry.fsm.Stop() } @@ -476,7 +585,10 @@ func (a *TxBroadcasterActor) handleEnsure(ctx context.Context, } return a.attachExistingSubscriber( - ctx, existing, req.Subscriber, + ctx, existing, trackedSubscriber{ + Ref: req.Subscriber, + pendingConfirmed: true, + }, ), nil } @@ -700,7 +812,7 @@ func (a *TxBroadcasterActor) handleCancel(ctx context.Context, return nil, err } - if state == TxStateConfirmed || state == TxStateFailed { + if isTerminalTxState(state) { a.evictTerminal(ctx, entry) return resp, nil @@ -920,8 +1032,13 @@ func (a *TxBroadcasterActor) handleBumpNow(ctx context.Context, }, nil } -// handleConfirmationObserved marks a tracked txid as confirmed and fans the -// result out to all subscribers. +// handleConfirmationObserved advances a tracked txid into the reversible +// Confirmed state and fans TxConfirmed out to all subscribers. The +// confirmation watch is intentionally kept alive: a subsequent reorg +// would arrive on the same registration, and the entry must stay +// observable so the chainsource sub-actor's reorg/done channels reach +// this layer. The terminal Finalized state is reached separately when +// the backend emits a Done signal. func (a *TxBroadcasterActor) handleConfirmationObserved(ctx context.Context, msg *confirmationObservedMsg) { @@ -942,7 +1059,9 @@ func (a *TxBroadcasterActor) handleConfirmationObserved(ctx context.Context, return } - if state == TxStateConfirmed || state == TxStateFailed { + // Terminal entries (Failed / Finalized) are sticky; if delivery to + // any subscriber was deferred, retry it and evict on success. + if isTerminalTxState(state) { if a.retryTerminalNotifications(ctx, entry) { a.evictTerminal(ctx, entry) } @@ -950,6 +1069,17 @@ func (a *TxBroadcasterActor) handleConfirmationObserved(ctx context.Context, return } + // An already-Confirmed entry receiving another Confirmed event + // without an intervening Reorged is unexpected on a well-behaved + // backend (the chainsource sub-actor only re-fires Confirmed after + // a reorg), but we tolerate it by re-delivering TxConfirmed rather + // than failing the FSM. + if state == TxStateConfirmed { + a.notifyConfirmed(ctx, entry, msg.blockHeight, msg.numConfs) + + return + } + if err := a.advanceTrackedTxFSM(ctx, entry, &trackedTxConfirmed{ BlockHeight: msg.blockHeight, }); err != nil { @@ -960,13 +1090,153 @@ func (a *TxBroadcasterActor) handleConfirmationObserved(ctx context.Context, return } + a.notifyConfirmed(ctx, entry, msg.blockHeight, msg.numConfs) +} + +// handleConfirmationReorged moves a Confirmed tracked txid back into +// AwaitingConfirmation and fans TxReorged out to all subscribers. The +// confirmation watch stays alive on the chainsource side, so a +// subsequent re-confirmation arrives on the same registration and drives +// another handleConfirmationObserved. +func (a *TxBroadcasterActor) handleConfirmationReorged(ctx context.Context, + msg *confirmationReorgedMsg) { + + entry, ok := a.tracked[msg.txid] + if !ok { + return + } + + state, err := entry.currentTxState() + if err != nil { + a.log.WarnS(ctx, "Failed to read tracked tx state", + err, "txid", entry.data.Txid) + + return + } + + // Only Confirmed entries can be reorged. A reorg ping in any other + // state is benign and dropped: it may be a late notification for an + // entry the actor has already evicted or terminally failed. + if state != TxStateConfirmed { + return + } + + if err := a.advanceTrackedTxFSM( + ctx, entry, &trackedTxReorged{}, + ); err != nil { + + a.log.WarnS(ctx, "Failed to apply reorg to tracked tx", + err, "txid", entry.data.Txid) + + return + } + + a.notifyReorged(ctx, entry) +} + +// handleConfirmationDone moves a Confirmed tracked txid into the terminal +// Finalized state, fans TxFinalized out to all subscribers, releases the +// confirmation watch, and evicts the entry. +func (a *TxBroadcasterActor) handleConfirmationDone(ctx context.Context, + msg *confirmationDoneMsg) { + + entry, ok := a.tracked[msg.txid] + if !ok { + return + } + + state, err := entry.currentTxState() + if err != nil { + a.log.WarnS(ctx, "Failed to read tracked tx state", + err, "txid", entry.data.Txid) + + return + } + + // Only Confirmed entries can be finalized. A Done ping for an entry + // that is not Confirmed has three possible origins: + // + // - Idempotent re-delivery for a tx that is already Finalized: a + // benign no-op. + // + // - Late arrival while the entry is in AwaitingConfirmation + // after a reorg: structurally anomalous for the current + // backends — chainntnfs (in-process lnd) only writes Done + // after the tx has matured past the safety depth, and the + // lndclient adapter never writes the channel at all and + // relies on height-based synthesis (which is gated on + // confirmHeight, reset to 0 on reorg). A future backend that + // DID fire Done during the reorg gap would land here, the + // chainsource sub-actor would already have exited (Done is + // one-shot), and this txid would stop receiving new events. + // The watch is unrecoverable without a fresh RegisterConf + // from this layer. + // + // We log + drop rather than failing the entry: the realistic + // backends do not produce this state, and failing on a hypothetical + // edge case would break callers that rely on the FSM staying live + // for re-confirmation after a reorg. If the log starts firing in + // the wild, the right follow-up is to re-register the conf watch + // from here (see registerConfWatch) rather than relax the guard. + if state != TxStateConfirmed { + a.log.WarnS(ctx, "Dropping confirmation Done for non-Confirmed "+ + "entry; chainsource watch is gone, entry will not "+ + "receive further reorg/finality events", + fmt.Errorf("state %s", state), + "txid", entry.data.Txid) + + return + } + + // Snapshot the confirmation height BEFORE advancing the FSM so we + // can attach it to the outgoing TxFinalized event; the new state + // preserves it but reading from the FSM state directly avoids a + // second state-lookup roundtrip. + fsmState, err := entry.currentFSMState() + if err != nil { + a.log.WarnS(ctx, "Failed to read tracked tx FSM state", + err, "txid", entry.data.Txid) + + return + } + confirmHeight, _ := trackedTxConfirmHeight(fsmState) + + if err := a.advanceTrackedTxFSM( + ctx, entry, &trackedTxFinalized{}, + ); err != nil { + + a.log.WarnS(ctx, "Failed to finalize tracked tx", + err, "txid", entry.data.Txid) + + return + } + + // Only release the conf watch and evict once every subscriber has + // acknowledged the terminal TxFinalized notification. Failed + // deliveries leave the entry in place so retryTerminalNotifications + // can resend on a later actor tick. + if !a.notifyFinalized(ctx, entry, confirmHeight) { + return + } + if err := a.unregisterConfWatch(ctx, entry); err != nil { a.log.WarnS(ctx, "Failed to unregister confirmation watch", err, "txid", entry.data.Txid) } - if a.notifyConfirmed(ctx, entry, msg.blockHeight, msg.numConfs) { - a.evictTerminal(ctx, entry) + a.evictTerminal(ctx, entry) +} + +// isTerminalTxState reports whether a public TxState value represents a +// terminal lifecycle stage that the actor will not advance further on +// its own. +func isTerminalTxState(state TxState) bool { + switch state { + case TxStateFinalized, TxStateFailed: + return true + + default: + return false } } @@ -1003,7 +1273,7 @@ func (a *TxBroadcasterActor) handleBlockObserved(ctx context.Context, continue } - if state == TxStateConfirmed || state == TxStateFailed { + if isTerminalTxState(state) { if a.retryTerminalNotifications(ctx, entry) { a.evictTerminal(ctx, entry) } @@ -1057,13 +1327,13 @@ func (a *TxBroadcasterActor) handleBlockObserved(ctx context.Context, // txid or immediately replays a terminal result. func (a *TxBroadcasterActor) attachExistingSubscriber( ctx context.Context, entry *trackedTx, - subscriber actor.TellOnlyRef[Notification], + subscriber trackedSubscriber, ) *EnsureConfirmedResp { state, err := entry.currentFSMState() if err != nil { a.notifyOneFailed( - ctx, subscriber, entry.data.Txid, + ctx, subscriber.Ref, entry.data.Txid, fmt.Sprintf("tracked tx state: %v", err), ) @@ -1073,27 +1343,57 @@ func (a *TxBroadcasterActor) attachExistingSubscriber( } } + subID := subscriber.Ref.ID() switch state := state.(type) { case *trackedTxStateConfirmed: confirmHeight, _ := trackedTxConfirmHeight(state) - if !a.notifyOneConfirmed( - ctx, subscriber, entry.data.Txid, confirmHeight, - entry.data.TargetConfs, + txConfirmed := &TxConfirmed{ + Txid: entry.data.Txid, + BlockHeight: confirmHeight, + NumConfs: entry.data.TargetConfs, + } + + // Reliable replay of the at-least-once TxConfirmed contract. + // The subscriber is retained in the map regardless of + // delivery outcome so it also receives any later TxReorged + // or TxFinalized on this entry, and on timeout the per-tick + // retryTerminalNotifications path re-attempts delivery via + // the still-true pendingConfirmed flag until it lands. + if a.notifyOneConfirmed( + ctx, subscriber.Ref, entry.data.Txid, txConfirmed, + ) { + + subscriber.pendingConfirmed = false + } + entry.subscribers[subID] = subscriber + + case *trackedTxStateFinalized: + // Finalized is terminal. Deliver TxFinalized reliably and + // retain on timeout so the per-tick retry path can finish + // the handoff. Note that a late-attaching subscriber has + // not yet received TxConfirmed; TxFinalized carries the + // authoritative confirmation height so consumers that need + // it (sweep-finality gates etc.) can recover without an + // out-of-band lookup. + if !a.notifyOneFinalized( + ctx, subscriber.Ref, entry.data.Txid, + state.ConfirmHeight, entry.data.TargetConfs, ) { - entry.subscribers[subscriber.ID()] = subscriber + entry.subscribers[subID] = subscriber } case *trackedTxStateFailed: reason, _ := trackedTxFailureReason(state) - if !a.notifyOneFailed(ctx, subscriber, entry.data.Txid, - reason) { + if !a.notifyOneFailed( + ctx, subscriber.Ref, entry.data.Txid, reason, + ) { - entry.subscribers[subscriber.ID()] = subscriber + entry.subscribers[subID] = subscriber } default: - entry.subscribers[subscriber.ID()] = subscriber + entry.subscribers[subID] = subscriber } return a.ensureResp(entry, false) @@ -1150,8 +1450,11 @@ func (a *TxBroadcasterActor) newTrackedTx(ctx context.Context, return &trackedTx{ data: data, fsm: fsm, - subscribers: map[string]actor.TellOnlyRef[Notification]{ - req.Subscriber.ID(): req.Subscriber, + subscribers: map[string]trackedSubscriber{ + req.Subscriber.ID(): { + Ref: req.Subscriber, + pendingConfirmed: true, + }, }, escalateLog: rate.Sometimes{ First: 1, @@ -1289,6 +1592,9 @@ func (a *TxBroadcasterActor) ensureBlockSubscription( } // registerConfWatch registers a confirmation watch for one tracked txid. +// The watch is registered in reorg-aware mode so the tracked entry can +// observe a confirmation being reorged out and a confirmation maturing +// past the backend's reorg-safety depth. func (a *TxBroadcasterActor) registerConfWatch(ctx context.Context, entry *trackedTx) error { @@ -1303,6 +1609,18 @@ func (a *TxBroadcasterActor) registerConfWatch(ctx context.Context, } }, ) + reorgRef := chainsource.MapConfReorgedEvent( + a.selfRef, + func(event chainsource.ConfReorgedEvent) Msg { + return &confirmationReorgedMsg{txid: event.Txid} + }, + ) + doneRef := chainsource.MapConfDoneEvent( + a.selfRef, + func(event chainsource.ConfDoneEvent) Msg { + return &confirmationDoneMsg{txid: event.Txid} + }, + ) _, err := a.cfg.ChainSource.Ask( ctx, &chainsource.RegisterConfRequest{ @@ -1311,9 +1629,11 @@ func (a *TxBroadcasterActor) registerConfWatch(ctx context.Context, PkScript: append( []byte(nil), entry.data.ConfirmationPkScript..., ), - TargetConfs: entry.data.TargetConfs, - HeightHint: entry.data.HeightHint, - NotifyActor: fn.Some(notifyRef), + TargetConfs: entry.data.TargetConfs, + HeightHint: entry.data.HeightHint, + NotifyActor: fn.Some(notifyRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), }, ).Await(ctx).Unpack() if err != nil { @@ -1570,11 +1890,18 @@ func (a *TxBroadcasterActor) retryTerminalNotifications(ctx context.Context, switch state := state.(type) { case *trackedTxStateConfirmed: - confirmHeight, _ := trackedTxConfirmHeight(state) + // Retry deferred TxConfirmed deliveries to legacy + // subscribers whose first attempt timed out. Reorg-aware + // subscribers receive TxConfirmed fire-and-forget and have + // no retry tracking; they are skipped here. Returning false + // keeps the entry alive — the caller never evicts on + // Confirmed, only on terminal states. + a.retryConfirmedRedelivery(ctx, entry, state.ConfirmHeight) - return a.notifyConfirmed( - ctx, entry, confirmHeight, entry.data.TargetConfs, - ) + return false + + case *trackedTxStateFinalized: + return a.notifyFinalized(ctx, entry, state.ConfirmHeight) case *trackedTxStateFailed: reason, _ := trackedTxFailureReason(state) @@ -1586,8 +1913,57 @@ func (a *TxBroadcasterActor) retryTerminalNotifications(ctx context.Context, } } -// handleTerminalNotifyResult applies the result of a terminal subscriber -// notification that continued after txconfirm returned to its actor mailbox. +// retryConfirmedRedelivery retries TxConfirmed delivery to every +// subscriber whose initial delivery timed out. The at-least-once +// initial-TxConfirmed contract means a missed delivery must be +// landed before the lifecycle moves on, so this helper runs every +// actor tick from retryTerminalNotifications until every +// pendingConfirmed subscriber has been notified. On successful +// delivery the subscriber's pendingConfirmed flag is cleared and +// the subscriber stays in the map so it continues to receive any +// later TxReorged / TxFinalized on this entry — eviction is +// reserved for the truly terminal Finalized / Failed states. +func (a *TxBroadcasterActor) retryConfirmedRedelivery(ctx context.Context, + entry *trackedTx, confirmHeight int32) { + + for id, subscriber := range entry.subscribers { + if !subscriber.pendingConfirmed { + continue + } + + txConfirmed := &TxConfirmed{ + Txid: entry.data.Txid, + BlockHeight: confirmHeight, + NumConfs: entry.data.TargetConfs, + } + if a.notifyOneConfirmed( + ctx, subscriber.Ref, entry.data.Txid, txConfirmed, + ) { + + subscriber.pendingConfirmed = false + entry.subscribers[id] = subscriber + } + } +} + +// handleTerminalNotifyResult applies the result of a terminal-shape +// subscriber notification that continued after txconfirm returned to its +// actor mailbox. +// +// The kind field distinguishes mid-lifecycle from truly terminal +// deliveries: +// +// - kind == "confirmed": the late-landing delivery is the INITIAL +// TxConfirmed for this subscriber. Clear pendingConfirmed and KEEP +// the subscriber attached to the entry so later TxReorged / +// TxFinalized still reach it. Removing the subscriber here would +// silently drop the entire reorg-aware tail of its lifecycle — +// exactly the kind of failure the unified reliable-delivery path is +// supposed to prevent. +// +// - kind == "finalized" or "failed": the lifecycle is genuinely over +// for this subscriber. Remove it, and evict the entry once every +// subscriber has been notified. func (a *TxBroadcasterActor) handleTerminalNotifyResult(ctx context.Context, msg *terminalNotifyResultMsg) { @@ -1596,7 +1972,8 @@ func (a *TxBroadcasterActor) handleTerminalNotifyResult(ctx context.Context, if msg.err != nil { a.log.WarnS(ctx, "Terminal notification failed after "+ "actor-path timeout", msg.err, "txid", msg.txid, - "subscriber_id", msg.subscriberID) + "subscriber_id", msg.subscriberID, + "notification_kind", msg.kind) return } @@ -1606,6 +1983,40 @@ func (a *TxBroadcasterActor) handleTerminalNotifyResult(ctx context.Context, return } + if msg.kind == "confirmed" { + subscriber, ok := entry.subscribers[msg.subscriberID] + if !ok { + return + } + subscriber.pendingConfirmed = false + entry.subscribers[msg.subscriberID] = subscriber + + // If the tx reorged out of its confirmation while this + // subscriber's initial TxConfirmed was still parked on the + // async notify path, notifyReorged skipped it: a TxReorged must + // never precede the TxConfirmed it reverses. Now that the + // initial delivery has completed and pendingConfirmed is + // cleared, deliver the catch-up TxReorged so the subscriber is + // not left believing a reorged-out tx is confirmed. A tx that + // reorged and then re-confirmed during the window is back in + // Confirmed, so the delivered TxConfirmed is already accurate + // and no catch-up is owed; terminal states own their own + // notifications. + state, err := entry.currentTxState() + if err == nil && state != TxStateConfirmed && + !isTerminalTxState(state) { + + a.notifyReversibleAsync( + ctx, &entry.sealed, subscriber.Ref, + entry.data.Txid, "TxReorged", &TxReorged{ + Txid: entry.data.Txid, + }, + ) + } + + return + } + delete(entry.subscribers, msg.subscriberID) if len(entry.subscribers) != 0 { return @@ -1619,21 +2030,205 @@ func (a *TxBroadcasterActor) handleTerminalNotifyResult(ctx context.Context, return } - if state == TxStateConfirmed || state == TxStateFailed { + if isTerminalTxState(state) { a.evictTerminal(ctx, entry) } } -// notifyConfirmed fans a confirmation result out to all current subscribers. -// It returns true only after every subscriber accepted the terminal -// notification. Failed deliveries are left in the subscriber map so a later -// actor tick can retry instead of permanently losing the confirmation. +// notifyConfirmed fans a TxConfirmed notification out to every current +// subscriber. The initial TxConfirmed (subscriber.pendingConfirmed == +// true) goes through the reliable terminal-delivery path: on success +// pendingConfirmed flips false and the subscriber is retained so it +// keeps receiving the reorg-aware lifecycle (TxReorged / TxFinalized); +// on timeout pendingConfirmed stays true and retryConfirmedRedelivery +// re-attempts on the next actor tick, so the at-least-once initial- +// TxConfirmed guarantee holds even for slow durable subscribers. +// +// Re-confirmations (pendingConfirmed == false, i.e. this subscriber +// has already received the initial TxConfirmed and the chain reorged +// then re-confirmed) are best-effort. The subscriber already knows +// the tx confirmed at least once; the eventual TxFinalized carries +// the authoritative height / numConfs so a missed re-confirmation is +// not load-bearing. +// +// Subscribers are never deleted from the map by this function — that +// happens only on TxFinalized / TxFailed acknowledgment, which is +// when the reorg-aware lifecycle reaches a truly terminal state. func (a *TxBroadcasterActor) notifyConfirmed(ctx context.Context, - entry *trackedTx, blockHeight int32, numConfs uint32) bool { + entry *trackedTx, blockHeight int32, numConfs uint32) { + + for id, subscriber := range entry.subscribers { + txConfirmed := &TxConfirmed{ + Txid: entry.data.Txid, + BlockHeight: blockHeight, + NumConfs: numConfs, + } + + if !subscriber.pendingConfirmed { + a.notifyReversibleAsync( + ctx, &entry.sealed, subscriber.Ref, + entry.data.Txid, "TxConfirmed", txConfirmed, + ) + + continue + } + + if a.notifyOneConfirmed( + ctx, subscriber.Ref, entry.data.Txid, txConfirmed, + ) { + + subscriber.pendingConfirmed = false + entry.subscribers[id] = subscriber + } + } +} + +// notifyOneConfirmed delivers one TxConfirmed notification to a legacy +// (non-opt-in) subscriber via the reliable terminal-delivery path so a +// slow durable subscriber cannot block the actor while still +// preserving the pre-reorg-aware contract of guaranteed-at-least-once +// TxConfirmed delivery. +func (a *TxBroadcasterActor) notifyOneConfirmed(ctx context.Context, + subscriber actor.TellOnlyRef[Notification], txid chainhash.Hash, + notification *TxConfirmed) bool { + + return a.notifyOneTerminal( + ctx, subscriber, txid, "confirmed", + func(notifyCtx context.Context) error { + return subscriber.Tell(notifyCtx, notification) + }, + ) +} + +// notifyReorged fans a TxReorged notification out to every retained +// subscriber whose initial TxConfirmed has already landed. Delivery is +// fire-and-forget: a slow subscriber that misses the reorg is recovered +// by the next lifecycle event (re-TxConfirmed, TxFinalized, or TxFailed) +// since the actor stays attached until one of those terminal events lands. +// +// Subscribers still owed their initial TxConfirmed (pendingConfirmed) are +// skipped: that delivery is deferred to the reliable retry path while a +// reorg's TxReorged goes out fire-and-forget, so notifying them here would +// race the two and could surface TxReorged before — or without — the +// initial TxConfirmed, violating the at-least-once contract and leaving a +// consumer believing a reorged-out tx is confirmed. They observe the live +// state on their eventual initial delivery instead. +func (a *TxBroadcasterActor) notifyReorged(ctx context.Context, + entry *trackedTx) { + + for _, subscriber := range entry.subscribers { + if subscriber.pendingConfirmed { + continue + } + + a.notifyReversibleAsync( + ctx, &entry.sealed, subscriber.Ref, entry.data.Txid, + "TxReorged", + &TxReorged{ + Txid: entry.data.Txid, + }, + ) + } +} + +// notifyReversibleAsync delivers one reversible (non-terminal) +// Notification to a subscriber on a fresh goroutine so txconfirm's +// actor loop does not block on a slow durable subscriber's mailbox. +// Failures are logged but not retried: the next event in the tracked +// tx's lifecycle (re-TxConfirmed after a TxReorged, TxFinalized, or +// eventual TxFailed) carries the live state and supersedes any +// dropped reversible delivery. +// +// The delivery context is detached from the actor transaction (so the +// txconfirm goroutine's DB tx is not held open behind a subscriber +// roundtrip) and from ctx cancellation (so a per-message context +// expiring mid-flight does not race the actor releasing its mailbox). +// Each goroutine is bounded by reversibleNotifyTimeout so a stuck +// subscriber does not leak unbounded goroutines on every block. +func (a *TxBroadcasterActor) notifyReversibleAsync(ctx context.Context, + sealed *atomic.Bool, subscriber actor.TellOnlyRef[Notification], + txid chainhash.Hash, kind string, notification Notification) { + + notifyCtx := actor.WithoutTx(context.WithoutCancel(ctx)) + notifyCtx, cancel := context.WithTimeout( + notifyCtx, reversibleNotifyTimeout, + ) + + subscriberID := subscriber.ID() + go func() { + defer cancel() + + // Drop the reversible delivery if the entry has sealed (a + // terminal TxFinalized/TxFailed has begun). Checked here, just + // before the Tell, so a reversible spawned before finality + // cannot trail the terminal notification into the subscriber's + // mailbox and resurrect bookkeeping it already released. + if sealed != nil && sealed.Load() { + return + } + + if err := subscriber.Tell(notifyCtx, notification); err != nil { + a.log.WarnS(notifyCtx, + "Failed to deliver reversible notification", + err, "txid", txid, + "subscriber_id", subscriberID, + "notification_kind", kind) + } + }() +} + +// notifyFinalized fans a TxFinalized notification out to every +// retained subscriber. The map may still contain subscribers whose +// initial reliable TxConfirmed delivery timed out +// (pendingConfirmed=true) — those still need TxConfirmed before they +// see TxFinalized, otherwise the at-least-once TxConfirmed contract +// is violated. For each pending subscriber we make one TxConfirmed +// attempt, flip pendingConfirmed on success, and either way leave +// them attached for the next finalize retry (the per-tick retry path +// will keep cycling through this function until both deliveries +// land). +// +// On successful TxFinalized delivery the subscriber is removed and +// the caller may evict the tracked entry once every subscriber has +// acknowledged. Failed deliveries are left in the subscriber map for +// retry from the next actor tick. +// +// confirmHeight is the height observed at finalization time, replayed +// onto TxFinalized so consumers that dropped the fire-and-forget +// re-TxConfirmed can recover the authoritative confirmation height +// without an out-of-band lookup. +func (a *TxBroadcasterActor) notifyFinalized(ctx context.Context, + entry *trackedTx, confirmHeight int32) bool { + + // Seal the entry so any in-flight reversible delivery skips its Tell + // rather than trailing this terminal notification into a subscriber's + // mailbox. Idempotent across the per-tick finalize retries. + entry.sealed.Store(true) for id, subscriber := range entry.subscribers { - ok := a.notifyOneConfirmed( - ctx, subscriber, entry.data.Txid, blockHeight, numConfs, + if subscriber.pendingConfirmed { + if a.notifyOneConfirmed( + ctx, subscriber.Ref, entry.data.Txid, + &TxConfirmed{ + Txid: entry.data.Txid, + BlockHeight: confirmHeight, + NumConfs: entry.data.TargetConfs, + }, + ) { + + subscriber.pendingConfirmed = false + entry.subscribers[id] = subscriber + } else { + // Hold until the next tick — we must not + // deliver TxFinalized before the documented + // initial TxConfirmed has landed. + continue + } + } + + ok := a.notifyOneFinalized( + ctx, subscriber.Ref, entry.data.Txid, confirmHeight, + entry.data.TargetConfs, ) if !ok { continue @@ -1652,9 +2247,14 @@ func (a *TxBroadcasterActor) notifyConfirmed(ctx context.Context, func (a *TxBroadcasterActor) notifyFailed(ctx context.Context, entry *trackedTx, reason string) bool { + // Seal the entry so any in-flight reversible delivery skips its Tell + // rather than trailing this terminal failure into a subscriber's + // mailbox. Idempotent across the per-tick retries. + entry.sealed.Store(true) + for id, subscriber := range entry.subscribers { ok := a.notifyOneFailed( - ctx, subscriber, entry.data.Txid, reason, + ctx, subscriber.Ref, entry.data.Txid, reason, ) if !ok { continue @@ -1666,15 +2266,20 @@ func (a *TxBroadcasterActor) notifyFailed(ctx context.Context, entry *trackedTx, return len(entry.subscribers) == 0 } -// notifyOneConfirmed delivers one confirmation notification. -func (a *TxBroadcasterActor) notifyOneConfirmed(ctx context.Context, +// notifyOneFinalized delivers one TxFinalized notification through the +// terminal-delivery path so a slow subscriber cannot block the actor +// loop. blockHeight and numConfs are replayed onto the notification +// from the last observed confirmation so opt-in subscribers that +// dropped the (fire-and-forget) TxConfirmed event can still recover +// the authoritative confirmation height. +func (a *TxBroadcasterActor) notifyOneFinalized(ctx context.Context, subscriber actor.TellOnlyRef[Notification], txid chainhash.Hash, blockHeight int32, numConfs uint32) bool { return a.notifyOneTerminal( - ctx, subscriber, txid, "confirmed", + ctx, subscriber, txid, "finalized", func(notifyCtx context.Context) error { - return subscriber.Tell(notifyCtx, &TxConfirmed{ + return subscriber.Tell(notifyCtx, &TxFinalized{ Txid: txid, BlockHeight: blockHeight, NumConfs: numConfs, @@ -1735,7 +2340,7 @@ func (a *TxBroadcasterActor) notifyOneTerminal(ctx context.Context, a.terminalNotifyInflight[inflightKey] = struct{}{} //nolint:contextcheck // async result outlives ctx a.completeTerminalNotifyAsync( - inflightKey, txid, subscriberID, errChan, cancel, + inflightKey, txid, subscriberID, kind, errChan, cancel, ) a.log.DebugS(ctx, "Terminal tx notification deferred", @@ -1748,10 +2353,13 @@ func (a *TxBroadcasterActor) notifyOneTerminal(ctx context.Context, } } -// completeTerminalNotifyAsync reports a timed-out terminal delivery back to the -// txconfirm actor once the underlying Tell returns. +// completeTerminalNotifyAsync reports a timed-out terminal-shape delivery +// back to the txconfirm actor once the underlying Tell returns. kind +// records the notification variant ("confirmed" / "finalized" / "failed") +// so the actor-side handler can apply the correct post-delivery state +// transition. func (a *TxBroadcasterActor) completeTerminalNotifyAsync(inflightKey string, - txid chainhash.Hash, subscriberID string, errChan <-chan error, + txid chainhash.Hash, subscriberID, kind string, errChan <-chan error, cancel context.CancelFunc) { if a.selfRef == nil { @@ -1768,6 +2376,7 @@ func (a *TxBroadcasterActor) completeTerminalNotifyAsync(inflightKey string, txid: txid, subscriberID: subscriberID, inflightKey: inflightKey, + kind: kind, err: err, } bgCtx := context.Background() diff --git a/txconfirm/actor_test.go b/txconfirm/actor_test.go index f42055627..25b9856fe 100644 --- a/txconfirm/actor_test.go +++ b/txconfirm/actor_test.go @@ -29,6 +29,11 @@ import ( // testTimeout is the default timeout used by txconfirm actor tests. const testTimeout = time.Second +// confReorgedRef / confDoneRef are the reorg / done notification ref +// types used in the fake chain-source ref. +type confReorgedRef = actor.TellOnlyRef[chainsource.ConfReorgedEvent] +type confDoneRef = actor.TellOnlyRef[chainsource.ConfDoneEvent] + // confNotifyRef is the confirmation-event notification ref type used in // the fake chainsource test double. type confNotifyRef = actor.TellOnlyRef[chainsource.ConfirmationEvent] @@ -54,6 +59,8 @@ type fakeChainSourceRef struct { blockNotify actor.TellOnlyRef[chainsource.BlockEpoch] confNotify map[chainhash.Hash]confNotifyRef + confReorged map[chainhash.Hash]confReorgedRef + confDone map[chainhash.Hash]confDoneRef confConfs map[chainhash.Hash]uint32 alreadyConfirmed map[chainhash.Hash]chainsource.ConfirmationEvent @@ -148,6 +155,93 @@ func (b *blockingNotifyRef) attemptsCount() int { return b.attempts } +// deferringNotifyRef blocks Tell until released, then completes +// successfully (returns nil regardless of ctx state). Used to drive the +// async-completion path of notifyOneTerminal: Tell exceeds the actor-path +// budget, so the caller observes a timeout and parks the result on the +// completeTerminalNotifyAsync goroutine; the test then releases the Tell +// to simulate the underlying mailbox eventually accepting the +// notification, producing a terminalNotifyResultMsg{err: nil} that the +// actor mailbox processes via handleTerminalNotifyResult. +type deferringNotifyRef struct { + id string + + started chan struct{} + release chan struct{} + once sync.Once + + mu sync.Mutex + attempts int + msgs []Notification +} + +// newDeferringNotifyRef creates a subscriber that blocks on the first Tell +// until release is closed. +func newDeferringNotifyRef(id string) *deferringNotifyRef { + return &deferringNotifyRef{ + id: id, + started: make(chan struct{}), + release: make(chan struct{}), + } +} + +// ID returns the fake subscriber ID. +func (d *deferringNotifyRef) ID() string { + return d.id +} + +// Tell blocks until release is closed, records the notification, and +// returns nil. ctx cancellation is intentionally ignored so the test can +// drive the "Tell eventually succeeded" path even after the actor-side +// notifyCtx timed out. +func (d *deferringNotifyRef) Tell(_ context.Context, n Notification) error { + d.mu.Lock() + d.attempts++ + d.mu.Unlock() + + d.once.Do(func() { + close(d.started) + }) + + <-d.release + + d.mu.Lock() + d.msgs = append(d.msgs, n) + d.mu.Unlock() + + return nil +} + +// waitStarted blocks until the first Tell has begun, so the test can +// release after the actor-side timeout has fired. +func (d *deferringNotifyRef) waitStarted(t *testing.T) { + t.Helper() + + select { + case <-d.started: + case <-time.After(testTimeout): + t.Fatal("deferringNotifyRef Tell never started") + } +} + +// releaseTell unblocks every parked Tell call so the deferred deliveries +// complete and the test observes the async-completion path. +func (d *deferringNotifyRef) releaseTell() { + close(d.release) +} + +// snapshotMessages returns a defensive copy of every notification that +// has landed in the subscriber's mailbox so far. +func (d *deferringNotifyRef) snapshotMessages() []Notification { + d.mu.Lock() + defer d.mu.Unlock() + + out := make([]Notification, len(d.msgs)) + copy(out, d.msgs) + + return out +} + // ID returns the fake subscriber ID. func (r *contextInspectNotifyRef) ID() string { return r.id @@ -232,10 +326,12 @@ func (r *retryNotifyRef) awaitMessage(timeout time.Duration) (Notification, // newFakeChainSourceRef creates a new controllable chainsource test double. func newFakeChainSourceRef(bestHeight int32) *fakeChainSourceRef { return &fakeChainSourceRef{ - bestHeight: bestHeight, - feeRate: 5, - confNotify: make(map[chainhash.Hash]confNotifyRef), - confConfs: make(map[chainhash.Hash]uint32), + bestHeight: bestHeight, + feeRate: 5, + confNotify: make(map[chainhash.Hash]confNotifyRef), + confReorged: make(map[chainhash.Hash]confReorgedRef), + confDone: make(map[chainhash.Hash]confDoneRef), + confConfs: make(map[chainhash.Hash]uint32), alreadyConfirmed: make( map[chainhash.Hash]chainsource.ConfirmationEvent, ), @@ -344,6 +440,12 @@ func (f *fakeChainSourceRef) handleAsk(_ context.Context, if req.Txid != nil && req.NotifyActor.IsSome() { f.confNotify[*req.Txid] = req.NotifyActor.UnwrapOr(nil) f.confConfs[*req.Txid] = req.TargetConfs + req.NotifyReorged.WhenSome(func(r confReorgedRef) { + f.confReorged[*req.Txid] = r + }) + req.NotifyDone.WhenSome(func(r confDoneRef) { + f.confDone[*req.Txid] = r + }) if event, ok := f.alreadyConfirmed[*req.Txid]; ok { notifyRef := req.NotifyActor.UnwrapOr(nil) //nolint:contextcheck // fake backend @@ -358,6 +460,8 @@ func (f *fakeChainSourceRef) handleAsk(_ context.Context, if req.Txid != nil { delete(f.confNotify, *req.Txid) delete(f.confConfs, *req.Txid) + delete(f.confReorged, *req.Txid) + delete(f.confDone, *req.Txid) } return &chainsource.UnregisterConfResponse{}, nil @@ -417,6 +521,34 @@ func (f *fakeChainSourceRef) emitConfirmation(t *testing.T, txid chainhash.Hash, require.NoError(t, err) } +// emitConfReorged delivers a reorg event for one tracked txid. +func (f *fakeChainSourceRef) emitConfReorged(t *testing.T, + txid chainhash.Hash) { + + t.Helper() + + f.mu.Lock() + ref := f.confReorged[txid] + f.mu.Unlock() + + require.NotNil(t, ref) + err := ref.Tell(t.Context(), chainsource.ConfReorgedEvent{Txid: txid}) + require.NoError(t, err) +} + +// emitConfDone delivers a finality event for one tracked txid. +func (f *fakeChainSourceRef) emitConfDone(t *testing.T, txid chainhash.Hash) { + t.Helper() + + f.mu.Lock() + ref := f.confDone[txid] + f.mu.Unlock() + + require.NotNil(t, ref) + err := ref.Tell(t.Context(), chainsource.ConfDoneEvent{Txid: txid}) + require.NoError(t, err) +} + // emitBlock delivers a new block epoch to the shared block subscriber. func (f *fakeChainSourceRef) emitBlock(t *testing.T, height int32) { t.Helper() @@ -815,15 +947,27 @@ func TestEnsureConfirmedDedupesTwoSubscribers(t *testing.T) { require.IsType(t, &TxConfirmed{}, confirmedA) require.IsType(t, &TxConfirmed{}, confirmedB) + + // TxConfirmed alone does not release the chainsource conf watch in + // the reorg-aware model: the watch stays alive until the backend + // finalizes the confirmation. Driving Done evicts the tracked + // entry and unregisters. + chain.emitConfDone(t, tx.TxHash()) + mustAwaitNotification(t, subA) + mustAwaitNotification(t, subB) mustEventually(t, func() bool { return chain.unregisterConfCount() == 1 }) } -// TestConfirmationDeliveryRetriesAfterTellFailure verifies that a transient -// subscriber delivery failure does not permanently drop a terminal -// confirmation notification. -func TestConfirmationDeliveryRetriesAfterTellFailure(t *testing.T) { +// TestLifecycleDeliveryRetriesAfterTellFailure verifies that a transient +// subscriber delivery failure does not permanently drop a lifecycle +// notification. Both the initial TxConfirmed and the terminal +// TxFinalized are reliable: if the first Tell attempt fails, the +// per-tick retry path keeps re-attempting until delivery lands, and +// the entry only evicts after every retained subscriber has +// acknowledged both events. +func TestLifecycleDeliveryRetriesAfterTellFailure(t *testing.T) { chain := newFakeChainSourceRef(100) ref, _ := newTestActor(t, Config{ ChainSource: chain, @@ -831,6 +975,9 @@ func TestConfirmationDeliveryRetriesAfterTellFailure(t *testing.T) { tx := makeTestTx(false) txid := tx.TxHash() + // Fail the first Tell attempt: TxConfirmed delivery has to be + // retried before pendingConfirmed clears, after which TxFinalized + // can be attempted. sub := newRetryNotifyRef("sub-retry", 1) resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ @@ -844,31 +991,40 @@ func TestConfirmationDeliveryRetriesAfterTellFailure(t *testing.T) { return sub.attemptsCount() == 1 }) + // First TxConfirmed attempt failed; the entry stays alive with + // pendingConfirmed=true and no notification has reached the + // subscriber mailbox yet. msg, ok := sub.awaitMessage(100 * time.Millisecond) - require.False(t, ok, "unexpected notification: %v", msg) - mustEventually(t, func() bool { - return chain.unregisterConfCount() == 1 - }) - - chain.emitBlock(t, 102) - msg, ok = sub.awaitMessage(testTimeout) - require.True(t, ok, "expected retried notification") - - confirmed, ok := msg.(*TxConfirmed) - require.True(t, ok) + require.False(t, ok, "unexpected early notification: %v", msg) + require.Equal(t, 0, chain.unregisterConfCount()) + + // Finalization fires. notifyFinalized retries TxConfirmed first + // (the at-least-once initial-confirmation contract must land + // before TxFinalized is allowed through). The second Tell + // succeeds, so TxConfirmed lands and TxFinalized follows in the + // same notifyFinalized pass. + chain.emitConfDone(t, txid) + + first, ok := sub.awaitMessage(testTimeout) + require.True(t, ok, "expected retried TxConfirmed") + confirmed, ok := first.(*TxConfirmed) + require.True( + t, ok, "first notification must be TxConfirmed, got %T", first, + ) require.Equal(t, txid, confirmed.Txid) - require.Equal(t, int32(101), confirmed.BlockHeight) - require.Equal(t, uint32(1), confirmed.NumConfs) - require.Equal(t, 2, sub.attemptsCount()) - freshSub := actor.NewChannelTellOnlyRef[Notification]("sub-fresh", 4) - replayResp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ - Tx: tx, - Subscriber: freshSub, + second, ok := sub.awaitMessage(testTimeout) + require.True(t, ok, "expected TxFinalized") + finalized, ok := second.(*TxFinalized) + require.True( + t, ok, "second notification must be TxFinalized, got %T", + second, + ) + require.Equal(t, txid, finalized.Txid) + + mustEventually(t, func() bool { + return chain.unregisterConfCount() == 1 }) - require.True(t, replayResp.Created) - require.Equal(t, 2, chain.registerConfCount()) - require.Equal(t, 2, chain.broadcastCallCount()) } // TestTerminalNotificationsDoNotInheritCallerContext verifies that terminal @@ -884,17 +1040,17 @@ func TestTerminalNotificationsDoNotInheritCallerContext(t *testing.T) { cancel() txid := chainhash.Hash{1} - confirmedSub := &contextInspectNotifyRef{id: "confirmed-sub"} - ok := behavior.notifyOneConfirmed( - ctx, confirmedSub, txid, 101, 1, + finalizedSub := &contextInspectNotifyRef{id: "finalized-sub"} + ok := behavior.notifyOneFinalized( + ctx, finalizedSub, txid, 101, 1, ) require.True(t, ok) - hasTx, ctxErr, msgs := confirmedSub.snapshot() + hasTx, ctxErr, msgs := finalizedSub.snapshot() require.False(t, hasTx) require.NoError(t, ctxErr) require.Len(t, msgs, 1) - require.IsType(t, &TxConfirmed{}, msgs[0]) + require.IsType(t, &TxFinalized{}, msgs[0]) failedSub := &contextInspectNotifyRef{id: "failed-sub"} ok = behavior.notifyOneFailed(ctx, failedSub, txid, "boom") @@ -937,14 +1093,14 @@ func TestTerminalNotificationTimeoutDoesNotBlockActor(t *testing.T) { }() start := time.Now() - ok := behavior.notifyOneConfirmed( + ok := behavior.notifyOneFinalized( context.Background(), sub, txid, 101, 1, ) require.False(t, ok) require.Less(t, time.Since(start), testTimeout) require.True(t, <-started) - key := terminalNotifyKey(txid, sub.ID(), "confirmed") + key := terminalNotifyKey(txid, sub.ID(), "finalized") _, inflight := behavior.terminalNotifyInflight[key] require.True(t, inflight) require.Equal(t, 1, sub.attemptsCount()) @@ -1051,6 +1207,9 @@ func TestEnsureConfirmedAlreadyConfirmedUsesSuccessPath(t *testing.T) { subA := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) subB := actor.NewChannelTellOnlyRef[Notification]("sub-b", 4) + // Opt in to reorg-aware notifications so the tracked entry stays + // alive past TxConfirmed and the second EnsureConfirmedReq can + // attach to the existing tracking state. resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ Tx: tx, Subscriber: subA, @@ -1062,21 +1221,21 @@ func TestEnsureConfirmedAlreadyConfirmedUsesSuccessPath(t *testing.T) { require.True(t, ok) require.Equal(t, int32(99), confirmed.BlockHeight) - // Once subA's TxConfirmed has been delivered, terminal eviction drops - // the tracked entry. A subsequent EnsureConfirmedReq for the same - // txid therefore starts fresh tracking rather than replaying cached - // state. Chainsource immediately re-fires the confirmation for the - // already-confirmed tx, so subB still receives TxConfirmed. + // TxConfirmed is no longer terminal: the tracked entry remains alive + // (still subscribed to chainsource reorg/done) until a Done event + // fires. A subsequent EnsureConfirmedReq therefore attaches to the + // existing tracking state and replays TxConfirmed without + // re-broadcasting or re-registering with chainsource. replayResp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ Tx: tx, Subscriber: subB, }) - require.True(t, replayResp.Created) + require.False(t, replayResp.Created) replayed := mustAwaitNotification(t, subB) require.IsType(t, &TxConfirmed{}, replayed) - require.Equal(t, 2, chain.broadcastCallCount()) - require.Equal(t, 2, chain.registerConfCount()) + require.Equal(t, 1, chain.broadcastCallCount()) + require.Equal(t, 1, chain.registerConfCount()) } // TestEnsureConfirmedBroadcastFailureNotifiesFailure verifies that terminal @@ -1462,6 +1621,11 @@ func TestUnregisterConfMatchesRegisterServiceKey(t *testing.T) { confirmed := mustAwaitNotification(t, sub) require.IsType(t, &TxConfirmed{}, confirmed) + // The conf watch is only released once chainsource fires Done. + chain.emitConfDone(t, tx.TxHash()) + finalized := mustAwaitNotification(t, sub) + require.IsType(t, &TxFinalized{}, finalized) + mustEventually(t, func() bool { return chain.unregisterConfCount() == 1 }) @@ -1531,7 +1695,17 @@ func TestTerminalEntriesEvictedAfterConfirmation(t *testing.T) { require.IsType(t, &TxConfirmed{}, confirmed) } - // Every confirmation should have produced exactly one unregister. + // Finalization drives terminal eviction: TxConfirmed alone is now + // reversible and the tracked entry stays alive until the backend + // fires Done. + for i := 0; i < numTxs; i++ { + chain.emitConfDone(t, txids[i]) + + finalized := mustAwaitNotification(t, subs[i]) + require.IsType(t, &TxFinalized{}, finalized) + } + + // Every finalized entry should have produced exactly one unregister. mustEventually(t, func() bool { return chain.unregisterConfCount() == numTxs }) @@ -1907,3 +2081,81 @@ func TestEnsureConfirmedFailsPermanentBroadcastError(t *testing.T) { failed := mustAwaitNotification(t, sub) require.IsType(t, &TxFailed{}, failed) } + +// TestInitialConfirmedAsyncDeliveryRetainsSubscriber regression-tests an +// initial TxConfirmed delivery whose Tell exceeds terminalNotifyTimeout +// before landing successfully via the async-completion goroutine. The +// subscriber must remain attached to the entry afterwards so the +// reorg-aware tail of its lifecycle (TxReorged / TxFinalized) still +// reaches it; the previous handleTerminalNotifyResult deleted the +// subscriber on any successful async terminal completion, silently +// dropping every subsequent reorg event for slow mailboxes. +func TestInitialConfirmedAsyncDeliveryRetainsSubscriber(t *testing.T) { + // Shorten the actor-path budget so the test reliably exercises the + // async-completion code path within a normal test runtime. + oldTimeout := terminalNotifyTimeout + terminalNotifyTimeout = 20 * time.Millisecond + t.Cleanup(func() { + terminalNotifyTimeout = oldTimeout + }) + + chain := newFakeChainSourceRef(100) + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + txid := tx.TxHash() + sub := newDeferringNotifyRef("sub-async-confirmed") + + resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + require.True(t, resp.Created) + + // Fire the confirmation. notifyOneConfirmed -> notifyOneTerminal + // will park on sub.Tell, blow through terminalNotifyTimeout, and + // hand the in-flight Tell off to completeTerminalNotifyAsync. + chain.emitConfirmation(t, txid, 101) + sub.waitStarted(t) + + // Wait long enough that the actor-side notifyCtx is guaranteed to + // have timed out and the async-completion goroutine is in flight. + // Without this delay the Tell could land synchronously and the + // async path the regression targets would not be exercised. + time.Sleep(10 * terminalNotifyTimeout) + + // Release the parked Tell so the async goroutine reports back to + // the txconfirm actor with a successful terminalNotifyResultMsg. + sub.releaseTell() + + // Wait for TxConfirmed to actually land in the subscriber mailbox. + require.Eventually(t, func() bool { + for _, m := range sub.snapshotMessages() { + if _, ok := m.(*TxConfirmed); ok { + return true + } + } + + return false + }, testTimeout, 10*time.Millisecond, "TxConfirmed never delivered") + + // Drive a reorg. If the subscriber was wrongly evicted on the + // async-confirmed completion, notifyReorged has nobody to fan to + // and the TxReorged below never arrives. + chain.emitConfReorged(t, txid) + + require.Eventually(t, func() bool { + for _, m := range sub.snapshotMessages() { + if _, ok := m.(*TxReorged); ok { + return true + } + } + + return false + }, testTimeout, 10*time.Millisecond, + "TxReorged never reached subscriber whose initial TxConfirmed "+ + "completed via the async path — handleTerminalNotify"+ + "Result probably evicted it on completion") +} diff --git a/txconfirm/broadcaster_test.go b/txconfirm/broadcaster_test.go index 3485aa042..0d48bc21c 100644 --- a/txconfirm/broadcaster_test.go +++ b/txconfirm/broadcaster_test.go @@ -361,7 +361,7 @@ func newTrackedTxForState(t *testing.T, state trackedTxState) *trackedTx { return &trackedTx{ data: data, fsm: &fsm, - subscribers: make(map[string]actor.TellOnlyRef[Notification]), + subscribers: make(map[string]trackedSubscriber), } } @@ -1619,11 +1619,13 @@ func TestActorValidationAndCleanup(t *testing.T) { }, } entry := newTrackedTxForState(t, awaitConf) - entry.subscribers["fail"] = &failingNotifyRef{} + entry.subscribers["fail"] = trackedSubscriber{ + Ref: &failingNotifyRef{}, + } behavior.tracked[entry.data.Txid] = entry - behavior.notifyOneConfirmed( - t.Context(), &failingNotifyRef{}, entry.data.Txid, 1, 1, + behavior.notifyOneFinalized( + t.Context(), &failingNotifyRef{}, entry.data.Txid, 0, 0, ) behavior.notifyOneFailed( t.Context(), &failingNotifyRef{}, entry.data.Txid, diff --git a/txconfirm/fsm_types.go b/txconfirm/fsm_types.go index fbc1882d8..7f4edac17 100644 --- a/txconfirm/fsm_types.go +++ b/txconfirm/fsm_types.go @@ -168,6 +168,21 @@ type trackedTxFailed struct { // trackedTxEventSealed marks trackedTxFailed as a tracked-tx event. func (e *trackedTxFailed) trackedTxEventSealed() {} +// trackedTxReorged records that a previously delivered confirmation was +// reorged out of the canonical chain. Only valid from +// trackedTxStateConfirmed. +type trackedTxReorged struct{} + +// trackedTxEventSealed marks trackedTxReorged as a tracked-tx event. +func (e *trackedTxReorged) trackedTxEventSealed() {} + +// trackedTxFinalized records that a confirmation is past the backend's +// reorg-safety depth. Only valid from trackedTxStateConfirmed. +type trackedTxFinalized struct{} + +// trackedTxEventSealed marks trackedTxFinalized as a tracked-tx event. +func (e *trackedTxFinalized) trackedTxEventSealed() {} + // trackedTxErrorReporter reports tracked-tx FSM errors through the package // logger. type trackedTxErrorReporter struct { @@ -225,6 +240,9 @@ func txStateFromTrackedState(state trackedTxState) TxState { case *trackedTxStateConfirmed: return TxStateConfirmed + case *trackedTxStateFinalized: + return TxStateFinalized + case *trackedTxStateFailed: return TxStateFailed @@ -279,6 +297,9 @@ func trackedTxLastBroadcastHeight(state trackedTxState) fn.Option[int32] { case *trackedTxStateConfirmed: return s.LastBroadcastHeight + case *trackedTxStateFinalized: + return s.LastBroadcastHeight + case *trackedTxStateFailed: return s.LastBroadcastHeight @@ -302,12 +323,16 @@ func trackedTxBroadcastFailures(state trackedTxState) int { // trackedTxConfirmHeight returns the state's confirmation height if the // transaction has already confirmed. func trackedTxConfirmHeight(state trackedTxState) (int32, bool) { - confirmed, ok := state.(*trackedTxStateConfirmed) - if !ok { + switch s := state.(type) { + case *trackedTxStateConfirmed: + return s.ConfirmHeight, true + + case *trackedTxStateFinalized: + return s.ConfirmHeight, true + + default: return 0, false } - - return confirmed.ConfirmHeight, true } // trackedTxFailureReason returns the state's terminal failure reason when diff --git a/txconfirm/funded_anchor_test.go b/txconfirm/funded_anchor_test.go index 3dd375fb5..8a6f2c2c7 100644 --- a/txconfirm/funded_anchor_test.go +++ b/txconfirm/funded_anchor_test.go @@ -575,9 +575,12 @@ func TestBumpNowParentFeeSufficient(t *testing.T) { } // TestBumpNowUntrackedAndTerminal covers the remaining no-op guards: an -// untracked txid, and a transaction that confirmed and was evicted from -// tracking (a confirmed entry whose terminal notification is delivered is -// dropped from the map, so a late bump lands in the untracked branch). +// untracked txid, and a transaction that has reached its confirmation target. +// Under the reorg-aware lifecycle a confirmed entry is NOT evicted at its +// confirmation — it stays tracked in the reversible Confirmed state until the +// backend signals finality (Done), so it can observe a later reorg — so a bump +// of a confirmed-but-not-final tx lands in the "already confirmed" no-op +// branch rather than the untracked branch. func TestBumpNowUntrackedAndTerminal(t *testing.T) { t.Parallel() @@ -608,9 +611,10 @@ func TestBumpNowUntrackedAndTerminal(t *testing.T) { Subscriber: sub, }) - // Drain the confirmation callback the fake chain delivered to the - // self ref so the entry reaches its terminal state and, with its - // notification delivered, is evicted from tracking. + // Drain the confirmation callback the fake chain delivered to the self + // ref so the entry advances into the reversible Confirmed state and its + // TxConfirmed notification is delivered. It is NOT evicted: the entry + // stays tracked until finality so a reorg can still be observed. selfRef, ok := behavior.selfRef.(*actor.ChannelTellOnlyRef[Msg]) require.True(t, ok) for { @@ -622,11 +626,13 @@ func TestBumpNowUntrackedAndTerminal(t *testing.T) { } require.NotNil(t, mustAwaitNotification(t, sub)) - // A bump after eviction reports the untracked no-op: there is no - // entry left to bump, which is the correct answer for a confirmed tx. + // A bump of the confirmed (but not-yet-final) entry is the confirmed + // no-op: it is still tracked, so the handler reports "already + // confirmed" rather than the untracked branch. Either way a confirmed + // tx cannot be fee-bumped. bumpResp = mustReceiveBump(t, behavior, &BumpNowReq{ Txid: tx.TxHash(), }) require.False(t, bumpResp.Bumped) - require.Contains(t, bumpResp.Reason, "not tracked") + require.Contains(t, bumpResp.Reason, "already confirmed") } diff --git a/txconfirm/messages.go b/txconfirm/messages.go index 031bc6748..b4285d5b0 100644 --- a/txconfirm/messages.go +++ b/txconfirm/messages.go @@ -58,9 +58,15 @@ const ( TxStateFeeBumping // TxStateConfirmed indicates the tracked transaction reached its target - // confirmation count. + // confirmation count on the canonical chain. This state is reversible: + // a reorg moves the tracked entry back to TxStateAwaitingConfirmation, + // and finality moves it to TxStateFinalized. TxStateConfirmed + // TxStateFinalized indicates the tracked transaction confirmed and is + // past the backend's reorg-safety depth. Terminal. + TxStateFinalized + // TxStateFailed indicates the actor encountered a terminal // failure while // trying to confirm the transaction. @@ -85,6 +91,9 @@ func (s TxState) String() string { case TxStateConfirmed: return "confirmed" + case TxStateFinalized: + return "finalized" + case TxStateFailed: return "failed" @@ -126,8 +135,17 @@ type EnsureConfirmedReq struct { // parents and for callers that do not fee-bump. ParentFee btcutil.Amount - // Subscriber receives TxConfirmed or TxFailed notifications for this - // request. + // Subscriber receives the full reorg-aware notification lifecycle + // for this request: TxConfirmed when the tx reaches the + // confirmation target, TxReorged if a previously delivered + // TxConfirmed is reorged out, re-TxConfirmed on the new canonical + // chain, TxFinalized once the confirmation is past the backend's + // reorg-safety depth, and TxFailed on terminal failure. + // TxConfirmed and TxFailed deliveries are reliable (retry on + // timeout from the per-tick retry path); TxReorged is best-effort + // because the next lifecycle event (re-TxConfirmed / TxFinalized / + // TxFailed) re-establishes state. TxFinalized is reliable so the + // caller can free reorg-recovery bookkeeping deterministically. Subscriber actor.TellOnlyRef[Notification] } @@ -306,6 +324,65 @@ func (m *TxConfirmed) MessageType() string { // set. func (m *TxConfirmed) txConfirmNotificationSealed() {} +// TxReorged notifies a subscriber that a previously delivered TxConfirmed +// has been reorged out of the canonical chain. After receiving this event +// a consumer should consider the prior confirmation no longer valid; if +// the transaction re-confirms on the new canonical chain a fresh +// TxConfirmed will follow on the same subscription. +type TxReorged struct { + actor.BaseMessage + + // Txid identifies the reorged transaction. + Txid chainhash.Hash +} + +// MessageType returns the stable message type identifier. +func (m *TxReorged) MessageType() string { + return "TxReorged" +} + +// txConfirmNotificationSealed seals TxReorged into the package notification +// set. +func (m *TxReorged) txConfirmNotificationSealed() {} + +// TxFinalized notifies a subscriber that the tracked transaction is past +// the backend's reorg-safety depth and will receive no further events. +// Subscribers may use this signal to drop any reorg-recovery bookkeeping +// they were holding for the registration. Not all backends synthesize +// this event (the lndclient transport does not), so consumers must treat +// its absence as a normal operating condition rather than an error. +// +// BlockHeight and NumConfs replay the authoritative confirmation +// numbers carried by the last TxConfirmed before finalization. Because +// reversible TxConfirmed deliveries are fire-and-forget for opt-in +// subscribers and may be dropped on a momentarily-full mailbox, the +// finalization event must carry enough information for height-dependent +// consumers to recover without out-of-band lookups. +type TxFinalized struct { + actor.BaseMessage + + // Txid identifies the finalized transaction. + Txid chainhash.Hash + + // BlockHeight is the height of the block carrying the latest + // observed confirmation (post-any-reorg) at finalization time. + BlockHeight int32 + + // NumConfs is the confirmation count that triggered the + // finalization event — typically the EnsureConfirmedReq's + // TargetConfs. + NumConfs uint32 +} + +// MessageType returns the stable message type identifier. +func (m *TxFinalized) MessageType() string { + return "TxFinalized" +} + +// txConfirmNotificationSealed seals TxFinalized into the package +// notification set. +func (m *TxFinalized) txConfirmNotificationSealed() {} + // TxFailed notifies a subscriber that the actor encountered a terminal // failure while trying to confirm the tracked transaction. type TxFailed struct { diff --git a/txconfirm/reorg_test.go b/txconfirm/reorg_test.go new file mode 100644 index 000000000..ef6c4d512 --- /dev/null +++ b/txconfirm/reorg_test.go @@ -0,0 +1,147 @@ +package txconfirm + +import ( + "testing" + + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/stretchr/testify/require" +) + +// TestEnsureConfirmedReorgLifecycle drives the full reorg-aware lifecycle +// (Confirmed -> Reorged -> Confirmed -> Finalized) through the +// TxBroadcasterActor and asserts that: +// +// - Each chainsource event reaches the subscriber as a matching public +// notification, in order. +// - The conf watch is held open across the reorg-out / re-confirm +// bounce (no unregister fires before Done). +// - Finalization releases the conf watch and evicts the tracked entry. +func TestEnsureConfirmedReorgLifecycle(t *testing.T) { + chain := newFakeChainSourceRef(100) + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + txid := tx.TxHash() + sub := actor.NewChannelTellOnlyRef[Notification]("sub", 16) + + resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + require.True(t, resp.Created) + require.Equal(t, TxStateAwaitingConfirmation, resp.State) + require.Equal(t, 1, chain.registerConfCount()) + + // 1. First confirmation on the canonical chain. + chain.emitConfirmation(t, txid, 101) + first := mustAwaitNotification(t, sub) + confirmed, ok := first.(*TxConfirmed) + require.True(t, ok, "first event must be TxConfirmed") + require.Equal(t, txid, confirmed.Txid) + require.Equal(t, int32(101), confirmed.BlockHeight) + + // The conf watch must NOT have been released yet: the entry is + // still in the reversible Confirmed state. + require.Equal(t, 0, chain.unregisterConfCount()) + + // 2. Reorg evicts that confirmation. + chain.emitConfReorged(t, txid) + second := mustAwaitNotification(t, sub) + reorged, ok := second.(*TxReorged) + require.True(t, ok, "second event must be TxReorged") + require.Equal(t, txid, reorged.Txid) + require.Equal(t, 0, chain.unregisterConfCount()) + + // 3. Transaction re-confirms on the new tip. + chain.emitConfirmation(t, txid, 102) + third := mustAwaitNotification(t, sub) + reConfirmed, ok := third.(*TxConfirmed) + require.True(t, ok, "third event must be TxConfirmed") + require.Equal(t, int32(102), reConfirmed.BlockHeight) + require.Equal(t, 0, chain.unregisterConfCount()) + + // 4. Finality. After TxFinalized the entry evicts. + chain.emitConfDone(t, txid) + fourth := mustAwaitNotification(t, sub) + finalized, ok := fourth.(*TxFinalized) + require.True(t, ok, "fourth event must be TxFinalized") + require.Equal(t, txid, finalized.Txid) + + mustEventually(t, func() bool { + return chain.unregisterConfCount() == 1 + }) + + // Cancel for the now-evicted entry must observe an empty map: Removed + // is false because there is nothing to remove. + cancelResp := mustCancel(t, ref.Ref(), &CancelInterestReq{ + Txid: txid, + SubscriberID: sub.ID(), + }) + require.False(t, cancelResp.Removed) +} + +// TestEnsureConfirmedDoneDuringReorgGapDropped pins the documented +// edge-case semantics: if the chainsource backend fires Done while the +// tracked entry is in AwaitingConfirmation (post-reorg, pre-re-confirm), +// txconfirm drops the Done rather than advancing to Finalized. The +// realistic backends (chainntnfs, lndclient) do not fire Done during a +// reorg gap, but this test pins the guard so a future backend change +// cannot silently land the entry in Finalized off a non-Confirmed +// state, and so an accidental relaxation of the guard fails loudly. +func TestEnsureConfirmedDoneDuringReorgGapDropped(t *testing.T) { + chain := newFakeChainSourceRef(100) + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + txid := tx.TxHash() + sub := actor.NewChannelTellOnlyRef[Notification]("sub", 16) + + mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + + // Confirm, then reorg out — entry is now in AwaitingConfirmation. + chain.emitConfirmation(t, txid, 101) + first := mustAwaitNotification(t, sub) + _, ok := first.(*TxConfirmed) + require.True(t, ok, "first event must be TxConfirmed") + + chain.emitConfReorged(t, txid) + second := mustAwaitNotification(t, sub) + _, ok = second.(*TxReorged) + require.True(t, ok, "second event must be TxReorged") + + // Fire Done out-of-band, before any re-confirmation. txconfirm + // must NOT advance the entry to Finalized, must NOT deliver + // TxFinalized to the subscriber, and must NOT unregister the + // conf watch. + chain.emitConfDone(t, txid) + + // Re-issuing EnsureConfirmedReq for the same (txid, params) is a + // no-op attach that flushes the mailbox: by the time the response + // returns, the queued confirmationDoneMsg has been processed (or + // in this case, logged and dropped). The reported state must + // remain AwaitingConfirmation. + probe := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + require.False(t, probe.Created) + require.Equal( + t, TxStateAwaitingConfirmation, probe.State, + "Done during reorg gap must not promote entry to Finalized", + ) + + // No TxFinalized notification should have been delivered. + mustHaveNoNotification(t, sub) + + require.Equal( + t, 0, chain.unregisterConfCount(), + "dropped Done must not release the conf watch", + ) +} diff --git a/txconfirm/states.go b/txconfirm/states.go index 3555c5ce7..1b173fdad 100644 --- a/txconfirm/states.go +++ b/txconfirm/states.go @@ -249,7 +249,10 @@ func (s *trackedTxStateFeeBumping) ProcessEvent(_ context.Context, } } -// trackedTxStateConfirmed is the terminal confirmed state. +// trackedTxStateConfirmed is the reorg-reversible confirmed state. A +// transaction in this state has reached its target confirmation count on +// the canonical chain; a subsequent reorg moves it back to +// AwaitingConfirmation, and finality moves it to Finalized. type trackedTxStateConfirmed struct { trackedTxData trackedTxProgress @@ -263,19 +266,74 @@ func (s *trackedTxStateConfirmed) String() string { return "Confirmed" } -// IsTerminal returns true because confirmed is terminal. +// IsTerminal returns false because the confirmation is reversible until +// the backend reports finality via trackedTxFinalized. func (s *trackedTxStateConfirmed) IsTerminal() bool { - return true + return false } // trackedTxStateSealed marks trackedTxStateConfirmed as a tracked-tx state. func (s *trackedTxStateConfirmed) trackedTxStateSealed() {} -// ProcessEvent rejects unexpected events in the terminal confirmed state. +// ProcessEvent applies one event to the confirmed state. A reorg moves +// the FSM back to AwaitingConfirmation; finality moves it to the terminal +// Finalized state. func (s *trackedTxStateConfirmed) ProcessEvent(_ context.Context, event trackedTxEvent, _ *trackedTxEnvironment) ( *trackedTxStateTransition, error) { + switch event.(type) { + case *trackedTxReorged: + return &trackedTxStateTransition{ + NextState: &trackedTxStateAwaitingConfirmation{ + trackedTxData: s.trackedTxData, + trackedTxProgress: s.trackedTxProgress, + }, + }, nil + + case *trackedTxFinalized: + return &trackedTxStateTransition{ + NextState: &trackedTxStateFinalized{ + trackedTxData: s.trackedTxData, + trackedTxProgress: s.trackedTxProgress, + ConfirmHeight: s.ConfirmHeight, + }, + }, nil + + default: + return nil, fmt.Errorf("unexpected event %T in %s", event, s) + } +} + +// trackedTxStateFinalized is the terminal "confirmed past reorg safety +// depth" state. No further events are accepted. +type trackedTxStateFinalized struct { + trackedTxData + trackedTxProgress + + // ConfirmHeight is the block height where the tx confirmed before + // being finalized. + ConfirmHeight int32 +} + +// String returns a human-readable representation of the finalized state. +func (s *trackedTxStateFinalized) String() string { + return "Finalized" +} + +// IsTerminal returns true because finalized is terminal. +func (s *trackedTxStateFinalized) IsTerminal() bool { + return true +} + +// trackedTxStateSealed marks trackedTxStateFinalized as a tracked-tx state. +func (s *trackedTxStateFinalized) trackedTxStateSealed() {} + +// ProcessEvent rejects unexpected events in the terminal finalized state. +func (s *trackedTxStateFinalized) ProcessEvent(_ context.Context, + event trackedTxEvent, _ *trackedTxEnvironment) ( + *trackedTxStateTransition, error) { + return nil, fmt.Errorf("unexpected event %T in %s", event, s) } From 6205861190496bfbbf8a0ba48a0062ed138b7737 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Mon, 13 Jul 2026 09:01:14 -0700 Subject: [PATCH 03/18] chainbackends: forward lnd/lndclient reorg and finality signals Forward the lnd and lndclient chain-notifier reorg/finality signals (NegativeConf -> reorg) into the reorg-aware chainsource lifecycle with buffered forwarding. --- chainbackends/lnd.go | 203 ++++++++++++++---- chainbackends/lnd_reorg_test.go | 308 ++++++++++++++++++++++++++++ chainbackends/lndclient_adapters.go | 234 +++++++++++++++++++-- 3 files changed, 696 insertions(+), 49 deletions(-) create mode 100644 chainbackends/lnd_reorg_test.go diff --git a/chainbackends/lnd.go b/chainbackends/lnd.go index 8be668cf8..67f1ebbd6 100644 --- a/chainbackends/lnd.go +++ b/chainbackends/lnd.go @@ -27,6 +27,20 @@ import ( // unexported there and so must be matched by value. const lndNeutrinoBroadcastMsg = "broadcast-unverified" +// reorgSignalBufferSize bounds the buffered reorg notifications forwarded +// from lnd's notifier to a chainsource registration. The forwarder sends +// to this channel with a blocking send, so a full buffer head-of-line +// blocks delivery of unrelated Confirmed / Done events on the same +// forwarder goroutine. A reorg burst (e.g. a multi-block reorg emitting +// one NegativeConf per disconnected block) can produce several signals +// back-to-back; sizing the buffer to absorb a typical reorg depth keeps +// the forwarder moving. The signal is coalescing — the consumer +// re-queries chain state on any reorg — so exact depth need not be +// preserved; the buffer only needs to be deep enough to avoid stalling, +// not to record every event. Eight comfortably covers realistic reorg +// depths while staying negligible in memory. +const reorgSignalBufferSize = 8 + // TxBroadcaster is a minimal interface for broadcasting transactions. This // allows LNDBackend to work with both lnwallet.WalletController (in-process // lnd) and lndclient wrappers (remote lnd via gRPC). @@ -330,37 +344,98 @@ func (b *LNDBackend) RegisterConf(ctx context.Context, txid *chainhash.Hash, // context is released. notifyCtx, cancel := context.WithCancel(context.Background()) - // Create a channel to convert lnd's TxConfirmation to our type. + // Create channels to convert lnd's confirmation lifecycle to our + // backend-agnostic types. NegativeConf carries a reorg depth that + // the lndclient gRPC transport cannot preserve, so we forward the + // forwarder's sequence number instead (see below). confChan := make(chan *chainsource.TxConfirmation, 1) + reorgChan := make(chan uint64, reorgSignalBufferSize) + doneChan := make(chan struct{}, 1) go func() { + // seq is a per-registration monotonic counter stamped onto + // every Confirmed and Reorged signal in the order this single + // forwarder goroutine observes them. Confirmed and Reorged + // leave on separate channels, so a select over both at any + // downstream hop (here, and again in the chainsource conf + // actor) cannot recover their order; the shared sequence lets + // the final consumer apply highest-seq-wins and discard a + // stale signal that lost a cross-channel race. This goroutine + // is the single authoritative ordering point — whatever order + // it reads lnd's channels in is the order the consumer honors. + var seq uint64 + // Defers run in LIFO order. event.Cancel() must run first so + // the upstream notifier stops writing to its internal + // channels before we cancel notifyCtx (which any in-flight + // downstream sends are still using) and finally close the + // outgoing chans. Reversing this order would race the + // upstream notifier against closed channels. defer close(confChan) + defer close(reorgChan) + defer close(doneChan) defer cancel() defer event.Cancel() - select { - case lndConf, ok := <-event.Confirmed: - if !ok { - return - } + for { + select { + case lndConf, ok := <-event.Confirmed: + if !ok { + return + } - conf := &chainsource.TxConfirmation{ - BlockHash: lndConf.BlockHash, - BlockHeight: lndConf.BlockHeight, - TxIndex: lndConf.TxIndex, - Tx: lndConf.Tx, - Block: lndConf.Block, - } + seq++ + conf := &chainsource.TxConfirmation{ + BlockHash: lndConf.BlockHash, + BlockHeight: lndConf.BlockHeight, + TxIndex: lndConf.TxIndex, + Tx: lndConf.Tx, + Block: lndConf.Block, + Seq: seq, + } + + select { + case confChan <- conf: + case <-notifyCtx.Done(): + return + } - confChan <- conf + case _, ok := <-event.NegativeConf: + if !ok { + event.NegativeConf = nil + continue + } - case <-notifyCtx.Done(): - return + seq++ + select { + case reorgChan <- seq: + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.Done: + if !ok { + event.Done = nil + continue + } + + select { + case doneChan <- struct{}{}: + case <-notifyCtx.Done(): + return + } + + return + + case <-notifyCtx.Done(): + return + } } }() return &chainsource.ConfRegistration{ Confirmed: confChan, + Reorged: reorgChan, + Done: doneChan, Cancel: func() { cancel() event.Cancel() @@ -393,39 +468,97 @@ func (b *LNDBackend) RegisterSpend(ctx context.Context, outpoint *wire.OutPoint, // Keep spend delivery alive independently of the actor request context. notifyCtx, cancel := context.WithCancel(context.Background()) - // Create a channel to convert lnd's SpendDetail to our type. + // Create channels to convert lnd's spend lifecycle to our + // backend-agnostic types. lnd's spend Reorg carries no payload, so + // we forward the forwarder's sequence number instead (see below). spendChan := make(chan *chainsource.SpendDetail, 1) + reorgChan := make(chan uint64, reorgSignalBufferSize) + doneChan := make(chan struct{}, 1) - // Start a goroutine to convert and forward the spend. + // Start a goroutine to convert and forward spend lifecycle events. go func() { + // seq is a per-registration monotonic counter stamped onto + // every Spend and Reorged signal in the order this single + // forwarder observes them. Spend and Reorged leave on separate + // channels, so a downstream select cannot recover their order; + // the shared sequence lets the final consumer apply + // highest-seq-wins and discard a stale signal that lost a + // cross-channel race. This goroutine is the single + // authoritative ordering point. + var seq uint64 + // Defer order is LIFO: event.Cancel() stops the upstream + // notifier first, then cancel() ends notifyCtx, and only + // then are the outgoing channels closed. This avoids a race + // where the upstream notifier writes to a freshly-closed + // downstream channel. defer close(spendChan) + defer close(reorgChan) + defer close(doneChan) defer cancel() defer event.Cancel() - select { - case lndSpend, ok := <-event.Spend: - if !ok { - return - } + for { + select { + case lndSpend, ok := <-event.Spend: + if !ok { + return + } - // Convert to our type. - spend := &chainsource.SpendDetail{ - SpentOutPoint: lndSpend.SpentOutPoint, - SpenderTxHash: lndSpend.SpenderTxHash, - SpendingTx: lndSpend.SpendingTx, - SpenderInputIndex: lndSpend.SpenderInputIndex, - SpendingHeight: lndSpend.SpendingHeight, - } + // Convert to our type. + seq++ + spend := &chainsource.SpendDetail{ + SpentOutPoint: lndSpend.SpentOutPoint, + SpenderTxHash: lndSpend.SpenderTxHash, + SpendingTx: lndSpend.SpendingTx, + SpenderInputIndex: lndSpend. + SpenderInputIndex, + SpendingHeight: lndSpend.SpendingHeight, + Seq: seq, + } + + select { + case spendChan <- spend: + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.Reorg: + if !ok { + event.Reorg = nil + continue + } + + seq++ + select { + case reorgChan <- seq: + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.Done: + if !ok { + event.Done = nil + continue + } + + select { + case doneChan <- struct{}{}: + case <-notifyCtx.Done(): + return + } - spendChan <- spend + return - case <-notifyCtx.Done(): - return + case <-notifyCtx.Done(): + return + } } }() return &chainsource.SpendRegistration{ - Spend: spendChan, + Spend: spendChan, + Reorged: reorgChan, + Done: doneChan, Cancel: func() { cancel() event.Cancel() diff --git a/chainbackends/lnd_reorg_test.go b/chainbackends/lnd_reorg_test.go new file mode 100644 index 000000000..2028b9229 --- /dev/null +++ b/chainbackends/lnd_reorg_test.go @@ -0,0 +1,308 @@ +package chainbackends + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/chainsource" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/stretchr/testify/require" +) + +// reorgWaitTimeout is the per-step deadline used by the LNDBackend +// forwarder reorg tests. The forwarder is a tight goroutine, so the +// timeout exists only to make a hang surface as a fast failure on slow CI. +const reorgWaitTimeout = 2 * time.Second + +// TestRegisterConfForwardsReorgAndDone drives the full confirmation +// lifecycle through the chainntnfs notifier into the LNDBackend forwarder +// and asserts each event arrives on the matching chainsource registration +// channel. The lifecycle is: +// +// Confirmed -> NegativeConf -> Confirmed -> Done +// +// and the test additionally verifies the forwarder exits after Done by +// observing that the chainsource channels close. +func TestRegisterConfForwardsReorgAndDone(t *testing.T) { + t.Parallel() + + confChan := make(chan *chainntnfs.TxConfirmation, 2) + negChan := make(chan int32, 1) + doneChan := make(chan struct{}, 1) + notifier := &stubNotifier{ + confEvent: &chainntnfs.ConfirmationEvent{ + Confirmed: confChan, + NegativeConf: negChan, + Done: doneChan, + Cancel: func() {}, + }, + } + backend := NewLNDBackend( + notifier, &stubFeeEstimator{}, &stubBroadcaster{}, + ) + + reg, err := backend.RegisterConf( + t.Context(), &chainhash.Hash{0x42}, []byte{0x51}, 1, 100, false, + ) + require.NoError(t, err) + + // 1. First confirmation crosses the forwarder. + hash1 := chainhash.Hash{0xaa} + confChan <- &chainntnfs.TxConfirmation{ + BlockHash: &hash1, + BlockHeight: 123, + Tx: wire.NewMsgTx(2), + } + + conf1 := awaitConfForward(t, reg.Confirmed) + require.Equal(t, uint32(123), conf1.BlockHeight) + require.Equal(t, hash1, *conf1.BlockHash) + + // 2. Reorg ping is forwarded as a single struct{} on Reorged. The + // depth value carried on NegativeConf is intentionally dropped at + // this layer; consumers must not rely on it. + negChan <- 1 + + awaitSeq(t, reg.Reorged, "Reorged forward") + + // 3. Transaction re-confirms in a different block on the new tip. + hash2 := chainhash.Hash{0xbb} + confChan <- &chainntnfs.TxConfirmation{ + BlockHash: &hash2, + BlockHeight: 124, + Tx: wire.NewMsgTx(2), + } + + conf2 := awaitConfForward(t, reg.Confirmed) + require.Equal(t, uint32(124), conf2.BlockHeight) + require.Equal(t, hash2, *conf2.BlockHash) + + // 4. Done signal is forwarded; the forwarder then exits and the + // chainsource channels close. + doneChan <- struct{}{} + + awaitStruct(t, reg.Done, "Done forward") + + // All three forwarded channels must close once the forwarder exits. + requireConfClosedSoon(t, reg.Confirmed) + requireSeqClosedSoon(t, reg.Reorged) + requireStructClosedSoon(t, reg.Done) +} + +// TestRegisterSpendForwardsReorgAndDone is the spend-side equivalent of +// the confirmation lifecycle test above. +func TestRegisterSpendForwardsReorgAndDone(t *testing.T) { + t.Parallel() + + spendChan := make(chan *chainntnfs.SpendDetail, 2) + reorgChan := make(chan struct{}, 1) + doneChan := make(chan struct{}, 1) + notifier := &stubNotifier{ + spendEvent: &chainntnfs.SpendEvent{ + Spend: spendChan, + Reorg: reorgChan, + Done: doneChan, + Cancel: func() {}, + }, + } + backend := NewLNDBackend( + notifier, &stubFeeEstimator{}, &stubBroadcaster{}, + ) + + outpoint := &wire.OutPoint{Index: 1} + reg, err := backend.RegisterSpend( + t.Context(), outpoint, []byte{0x51}, 100, + ) + require.NoError(t, err) + + // 1. First spend. + hash1 := chainhash.Hash{0x10} + spendChan <- &chainntnfs.SpendDetail{ + SpentOutPoint: outpoint, + SpenderTxHash: &hash1, + SpendingTx: wire.NewMsgTx(2), + SpendingHeight: 150, + } + + spend1 := awaitSpendForward(t, reg.Spend) + require.Equal(t, int32(150), spend1.SpendingHeight) + require.Equal(t, hash1, *spend1.SpenderTxHash) + + // 2. Reorg evicts the spend. + reorgChan <- struct{}{} + + awaitSeq(t, reg.Reorged, "spend Reorged forward") + + // 3. A different spender wins the new chain. + hash2 := chainhash.Hash{0x20} + spendChan <- &chainntnfs.SpendDetail{ + SpentOutPoint: outpoint, + SpenderTxHash: &hash2, + SpendingTx: wire.NewMsgTx(2), + SpendingHeight: 151, + } + + spend2 := awaitSpendForward(t, reg.Spend) + require.Equal(t, int32(151), spend2.SpendingHeight) + require.Equal(t, hash2, *spend2.SpenderTxHash) + + // 4. Done. + doneChan <- struct{}{} + + awaitStruct(t, reg.Done, "spend Done forward") + + requireSpendClosedSoon(t, reg.Spend) + requireSeqClosedSoon(t, reg.Reorged) + requireStructClosedSoon(t, reg.Done) +} + +// awaitConfForward reads a single confirmation off the forwarded channel +// with a deadline, failing the test on timeout or unexpected close. +func awaitConfForward(t *testing.T, + ch <-chan *chainsource.TxConfirmation) *chainsource.TxConfirmation { + + t.Helper() + + select { + case conf, ok := <-ch: + if !ok { + t.Fatal("conf channel closed before delivery") + } + + return conf + + case <-time.After(reorgWaitTimeout): + t.Fatal("timeout waiting for conf forward") + + return nil + } +} + +// awaitSpendForward reads a single spend off the forwarded channel with a +// deadline, failing the test on timeout or unexpected close. +func awaitSpendForward(t *testing.T, + ch <-chan *chainsource.SpendDetail) *chainsource.SpendDetail { + + t.Helper() + + select { + case spend, ok := <-ch: + if !ok { + t.Fatal("spend channel closed before delivery") + } + + return spend + + case <-time.After(reorgWaitTimeout): + t.Fatal("timeout waiting for spend forward") + + return nil + } +} + +// awaitStruct reads a single struct{} off the forwarded channel with a +// deadline. Used for the Reorged and Done channels. +func awaitStruct(t *testing.T, ch <-chan struct{}, label string) { + t.Helper() + + select { + case _, ok := <-ch: + if !ok { + t.Fatalf("%s channel closed before delivery", label) + } + + case <-time.After(reorgWaitTimeout): + t.Fatalf("timeout waiting for %s", label) + } +} + +// requireConfClosedSoon asserts the confirmation channel closes within +// the reorg wait timeout. Used to verify the forwarder's defer-close +// chain runs after Done is delivered. +func requireConfClosedSoon(t *testing.T, + ch <-chan *chainsource.TxConfirmation) { + + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "conf channel did not close after Done") +} + +// requireSpendClosedSoon asserts the spend channel closes within the +// reorg wait timeout. +func requireSpendClosedSoon(t *testing.T, ch <-chan *chainsource.SpendDetail) { + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "spend channel did not close after Done") +} + +// requireStructClosedSoon asserts that a struct{} signal channel closes +// within the reorg wait timeout. +func requireStructClosedSoon(t *testing.T, ch <-chan struct{}) { + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "struct channel did not close after Done") +} + +// awaitSeq reads a single sequence number off the forwarded Reorged +// channel with a deadline. The reorg signal now carries the forwarder's +// monotonic sequence (see chainsource.TxConfirmation.Seq) rather than a +// bare struct{}. +func awaitSeq(t *testing.T, ch <-chan uint64, label string) { + t.Helper() + + select { + case _, ok := <-ch: + if !ok { + t.Fatalf("%s channel closed before delivery", label) + } + + case <-time.After(reorgWaitTimeout): + t.Fatalf("timeout waiting for %s", label) + } +} + +// requireSeqClosedSoon asserts that a sequence-carrying signal channel +// closes within the reorg wait timeout. +func requireSeqClosedSoon(t *testing.T, ch <-chan uint64) { + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "seq channel did not close after Done") +} diff --git a/chainbackends/lndclient_adapters.go b/chainbackends/lndclient_adapters.go index f0d4b1d31..c388fb644 100644 --- a/chainbackends/lndclient_adapters.go +++ b/chainbackends/lndclient_adapters.go @@ -17,6 +17,16 @@ import ( "github.com/lightningnetwork/lnd/lnwallet/chainfee" ) +// lndRegistrationTimeout bounds how long a conf/spend registration call into +// lndclient may block before we give up and return an error. lnd can be slow +// to answer a RegisterConfirmationsNtfn / RegisterSpendNtfn while it is busy +// processing a fresh block, and a registration that hangs forever would pin +// the chainsource sub-actor's Receive call and back-pressure the factory +// actor onto every other in-flight registration. Fifteen seconds is well +// above lnd's normal under-load response time yet short enough that a +// genuinely wedged backend surfaces as an error rather than a silent hang. +const lndRegistrationTimeout = 15 * time.Second + // LndClientTxBroadcaster implements TxBroadcaster using // lndclient.WalletKitClient. type LndClientTxBroadcaster struct { @@ -131,7 +141,16 @@ func (n *LndClientChainNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, opt(notifierOpts) } - var lndOpts []lndclient.NotifierOption + // Ask lndclient to keep the confirmation stream alive past the first + // Confirmed event and forward any subsequent reorg signal on a + // dedicated channel. Without WithReOrgChan, lndclient's receive + // loop tears the stream down after one delivery and any later + // reorg is silently dropped at the gRPC layer. + reorgPing := make(chan struct{}, 1) + + lndOpts := []lndclient.NotifierOption{ + lndclient.WithReOrgChan(reorgPing), + } if notifierOpts.IncludeBlock { lndOpts = append(lndOpts, lndclient.WithIncludeBlock()) } @@ -177,11 +196,11 @@ func (n *LndClientChainNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, confChan = r.confChan errChan = r.errChan - case <-time.After(15 * time.Second): + case <-time.After(lndRegistrationTimeout): cancel() - return nil, fmt.Errorf("register confirmations timed out " + - "after 15s") + return nil, fmt.Errorf("register confirmations timed out "+ + "after %s", lndRegistrationTimeout) } go func() { @@ -199,26 +218,147 @@ func (n *LndClientChainNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, } }() + // Forward the confirmation lifecycle through a SINGLE goroutine so the + // reorg ping and the (re-)confirmation reach the downstream chainntnfs + // channels in lndclient's emission order. lndclient drives both off one + // ordered gRPC receive loop and writes the reorg ping before the + // replacement Confirmed, but it splits them across two channels + // (confChan and reorgPing); if we forwarded each on its own goroutine, + // the downstream ConfActor's select could consume a re-Confirmed before + // the Reorged, reset confirmHeight to 0 with no further Confirmed + // coming, and strand the watch. Draining a pending reorg with priority + // on each iteration, and handing every event off with a blocking send, + // makes the ConfActor observe exactly the forwarder's (lndclient's) + // order. + // + // lndclient does not preserve the reorg depth across the gRPC boundary, + // so a sentinel value of 0 is forwarded on NegativeConf; callers over + // this transport must not rely on the integer value. + orderedConfirmed := make(chan *chainntnfs.TxConfirmation, 1) + negativeConf := make(chan int32, 1) + go func() { + defer close(orderedConfirmed) + defer close(negativeConf) + + forwardOrderedReorg( + ctx, reorgPing, confChan, orderedConfirmed, + negativeConf, + ) + }() + + // Done is allocated but never written to because lnd's internal + // "past reorg-safety depth" signal is not surfaced through the + // lndclient gRPC transport. Consumers needing such a gate must + // compute it themselves from block height. return &chainntnfs.ConfirmationEvent{ - Confirmed: confChan, - Cancel: cancel, - Done: make(chan struct{}, 1), + Confirmed: orderedConfirmed, + NegativeConf: negativeConf, + Cancel: cancel, + Done: make(chan struct{}, 1), }, nil } +// forwardOrderedReorg copies confirmations and reorg pings from lndclient's two +// source channels onto the downstream Confirmed / NegativeConf channels, +// forwarding each event in the order this single goroutine observes it. It does +// not bias either channel: the authoritative lifecycle ordering is +// re-established downstream by the per-registration sequence number the +// LNDBackend forwarder stamps (see chainsource.TxConfirmation.Seq), so the +// consumer applies highest-seq-wins regardless of how near-simultaneous events +// interleave here. lndclient's two-channel split makes a perfectly ordered +// merge impossible at this layer, so forwarding in natural arrival order keeps +// the stamped sequence faithful to what was actually observed rather than +// injecting an artificial reorg-first bias. lndclient does not preserve the +// reorg depth across the gRPC boundary, so a sentinel value of 0 is forwarded +// on NegativeConf; callers over this transport must not rely on the value. +func forwardOrderedReorg(ctx context.Context, reorgPing <-chan struct{}, + confChan <-chan *chainntnfs.TxConfirmation, + outConfirmed chan<- *chainntnfs.TxConfirmation, + outNegConf chan<- int32) { + + for confChan != nil || reorgPing != nil { + select { + case _, ok := <-reorgPing: + if !ok { + reorgPing = nil + + continue + } + + select { + case outNegConf <- 0: + case <-ctx.Done(): + return + } + + case c, ok := <-confChan: + if !ok { + confChan = nil + + continue + } + + select { + case outConfirmed <- c: + case <-ctx.Done(): + return + } + + case <-ctx.Done(): + return + } + } +} + // RegisterSpendNtfn registers for spend notifications using lndclient's // ChainNotifier. func (n *LndClientChainNotifier) RegisterSpendNtfn(outpoint *wire.OutPoint, pkScript []byte, heightHint uint32) (*chainntnfs.SpendEvent, error) { + // Ask lndclient to keep the spend stream alive past the first + // Spend event so it can forward reorg pings. Without WithReOrgChan + // the stream is torn down after the first delivery and any later + // spend reorg is silently dropped at the gRPC layer. + reorgPing := make(chan struct{}, 1) + ctx, cancel := context.WithCancel(context.Background()) - spendChan, errChan, err := n.cfg.LND.ChainNotifier.RegisterSpendNtfn( - ctx, outpoint, pkScript, int32(heightHint), - ) - if err != nil { + + // Run the registration in a goroutine with a timeout to prevent + // hanging when LND is slow under block load, mirroring the conf path. + type regResult struct { + spendChan chan *chainntnfs.SpendDetail + errChan chan error + err error + } + + resultCh := make(chan regResult, 1) + go func() { + sc, ec, err := n.cfg.LND.ChainNotifier.RegisterSpendNtfn( + ctx, outpoint, pkScript, int32(heightHint), + lndclient.WithReOrgChan(reorgPing), + ) + resultCh <- regResult{sc, ec, err} + }() + + var spendChan chan *chainntnfs.SpendDetail + var errChan chan error + + select { + case r := <-resultCh: + if r.err != nil { + cancel() + + return nil, fmt.Errorf("register spend: %w", r.err) + } + + spendChan = r.spendChan + errChan = r.errChan + + case <-time.After(lndRegistrationTimeout): cancel() - return nil, fmt.Errorf("register spend: %w", err) + return nil, fmt.Errorf("register spend timed out after %s", + lndRegistrationTimeout) } go func() { @@ -236,14 +376,80 @@ func (n *LndClientChainNotifier) RegisterSpendNtfn(outpoint *wire.OutPoint, } }() + // Forward the spend lifecycle through a SINGLE goroutine so the reorg + // ping and the (re-)spend reach the downstream chainntnfs channels in + // lndclient's emission order, for the same reason as the conf path: the + // two-channel split (spendChan and reorgPing) would otherwise let the + // downstream SpendActor's select consume a re-Spend before the Reorged + // and strand the watch. Draining a pending reorg with priority and + // using blocking hand-offs makes the SpendActor observe lndclient's + // order. + orderedSpend := make(chan *chainntnfs.SpendDetail, 1) + reorgChan := make(chan struct{}, 1) + go func() { + defer close(orderedSpend) + defer close(reorgChan) + + forwardOrderedSpendReorg( + ctx, reorgPing, spendChan, orderedSpend, reorgChan, + ) + }() + + // Done is allocated but never written to because lnd's "past + // reorg-safety depth" signal is not surfaced through the lndclient + // gRPC transport. return &chainntnfs.SpendEvent{ - Spend: spendChan, - Reorg: make(chan struct{}, 1), + Spend: orderedSpend, + Reorg: reorgChan, Done: make(chan struct{}, 1), Cancel: cancel, }, nil } +// forwardOrderedSpendReorg is the spend-path analogue of forwardOrderedReorg: +// it copies spends and reorg pings from lndclient's two source channels onto +// the downstream Spend / Reorg channels in the order this single goroutine +// observes them, without biasing either channel. The authoritative lifecycle +// ordering is re-established downstream by the per-registration sequence number +// the LNDBackend forwarder stamps (see chainsource.SpendDetail.Seq). +func forwardOrderedSpendReorg(ctx context.Context, reorgPing <-chan struct{}, + spendChan <-chan *chainntnfs.SpendDetail, + outSpend chan<- *chainntnfs.SpendDetail, outReorg chan<- struct{}) { + + for spendChan != nil || reorgPing != nil { + select { + case _, ok := <-reorgPing: + if !ok { + reorgPing = nil + + continue + } + + select { + case outReorg <- struct{}{}: + case <-ctx.Done(): + return + } + + case sp, ok := <-spendChan: + if !ok { + spendChan = nil + + continue + } + + select { + case outSpend <- sp: + case <-ctx.Done(): + return + } + + case <-ctx.Done(): + return + } + } +} + // RegisterBlockEpochNtfn registers for block epoch notifications using // lndclient's ChainNotifier. func (n *LndClientChainNotifier) RegisterBlockEpochNtfn( From bda120eb62b9f53a0ade6d66c3c70cba2af3507b Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Mon, 13 Jul 2026 09:01:14 -0700 Subject: [PATCH 04/18] lwwallet: reorg-aware Esplora chain backend (TipPoller) Reorg-aware lwwallet/Esplora backend: TipPoller same-height and deeper reorg detection via PrevBlock continuity, a unified ChainEvent stream, and BlockDisconnected emission to btcwallet before connecting the replacement tip. --- lwwallet/chain_backend.go | 706 +++++++++++++--- lwwallet/chain_backend_reorg_test.go | 1136 ++++++++++++++++++++++++++ lwwallet/chain_backend_test.go | 59 +- lwwallet/esplora_chain.go | 99 ++- lwwallet/esplora_chain_reorg_test.go | 175 ++++ lwwallet/esplora_chain_test.go | 11 + lwwallet/tip_poller.go | 824 ++++++++++++++++++- lwwallet/tip_poller_reorg_test.go | 274 +++++++ lwwallet/tip_poller_test.go | 95 ++- 9 files changed, 3167 insertions(+), 212 deletions(-) create mode 100644 lwwallet/chain_backend_reorg_test.go create mode 100644 lwwallet/esplora_chain_reorg_test.go create mode 100644 lwwallet/tip_poller_reorg_test.go diff --git a/lwwallet/chain_backend.go b/lwwallet/chain_backend.go index 1c5c65d41..1f98dd83e 100644 --- a/lwwallet/chain_backend.go +++ b/lwwallet/chain_backend.go @@ -16,6 +16,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/wavelength/chainsource" + fn "github.com/lightningnetwork/lnd/fn/v2" "golang.org/x/sync/singleflight" ) @@ -42,6 +43,29 @@ const ( // raise Esplora load over the default poll cadence. const recheckHeartbeatInterval = 60 * time.Second +// regState is the lifecycle state of a conf/spend registration. +// +// stateWatching: no positive event has been delivered yet (or the +// last positive event was reorged out). The next checkSingle... that +// finds the tx confirmed / outpoint spent will emit Confirmed / +// Spend and transition to statePositive. +// +// statePositive: a Confirmed / Spend event has been delivered and the +// associated block-hash is cached on the registration. The next +// checkSingle... that does NOT find the tx confirmed / outpoint +// spent in the same block leaves the registration alone (the +// chainsource actor handles Done synthesis at FinalityDepth via +// block epochs). A reorg event that names the cached block-hash in +// its Disconnected set fires Reorged, resets cached state, and +// transitions back to stateWatching so the next re-check can fire +// Confirmed / Spend again on the new chain. +type regState uint8 + +const ( + stateWatching regState = iota + statePositive +) + // confRegistration tracks a pending confirmation registration within the // polling loop. type confRegistration struct { @@ -63,8 +87,37 @@ type confRegistration struct { // confChan is the channel to send the confirmation on. confChan chan *chainsource.TxConfirmation + // reorgChan is the channel that fires when a previously delivered + // confirmation is reorged out of the canonical chain. Buffered to + // 1; never written by the backend before stateWatching has been + // re-entered. The value is the chainsource ordering sequence; this + // backend does not stamp sequences (it emits conf/reorg from a + // single ordered handler goroutine), so it always sends 0, which + // the chainsource actor treats as always-apply. + reorgChan chan uint64 + + // doneChan is allocated for API symmetry with the chainsource + // contract. The Esplora-backed backend does not write to it: the + // chainsource ConfActor synthesizes Done at FinalityDepth from + // block epochs once the backend stops re-firing events. + doneChan chan struct{} + // cancelCh signals that this registration has been cancelled. cancelCh chan struct{} + + // regMu guards the fields below that mutate over the + // registration's lifetime. It is held only briefly during state + // transitions; channel sends are performed without it. + regMu sync.Mutex + + // state is the current lifecycle state. + state regState + + // lastBlockHash is the BlockHash of the last delivered + // confirmation when state == statePositive. Used to detect when + // a reorg's Disconnected hash list invalidates this + // registration's last event. + lastBlockHash chainhash.Hash } // spendRegistration tracks a pending spend registration within the @@ -82,8 +135,34 @@ type spendRegistration struct { // spendChan is the channel to send the spend detail on. spendChan chan *chainsource.SpendDetail + // reorgChan is the channel that fires when a previously delivered + // spend is reorged out of the canonical chain. Buffered to 1. + reorgChan chan uint64 + + // doneChan is allocated for API symmetry with the chainsource + // contract. The Esplora-backed backend does not write to it: the + // chainsource SpendActor synthesizes Done at FinalityDepth. + doneChan chan struct{} + // cancelCh signals that this registration has been cancelled. cancelCh chan struct{} + + // regMu guards the fields below. + regMu sync.Mutex + + // state is the current lifecycle state. + state regState + + // lastSpenderHash is the SpenderTxHash of the last delivered + // spend when state == statePositive. + lastSpenderHash chainhash.Hash + + // lastSpendingBlockHash is the block hash that the last + // delivered spend confirmed in, parsed from the same + // outspend response that confirmed the spend. Reorg + // comparison against ReorgEvent.Disconnected uses this so + // the spend-watch path does not need an extra HTTP round-trip. + lastSpendingBlockHash chainhash.Hash } // blockRegistration tracks a block epoch subscription. @@ -205,7 +284,8 @@ func (b *ChainBackend) Start() error { } } - height, hash, _, sub, err := b.tipPoller.BestBlockAndSubscribe() + height, hash, _, chainSub, err := + b.tipPoller.BestBlockAndSubscribeChain() if err != nil { return fmt.Errorf("subscribe to tip poller: %w", err) } @@ -216,7 +296,7 @@ func (b *ChainBackend) Start() error { b.mu.Unlock() b.wg.Add(1) - go b.handleTipEvents(sub) + go b.handleChainEvents(chainSub) b.log.InfoS(context.Background(), "Chain backend started", slog.Int("tip_height", int(height)), @@ -388,11 +468,20 @@ func (b *ChainBackend) SubmitPackage(ctx context.Context, parents []*wire.MsgTx, } // RegisterConf registers for confirmation notifications of a transaction. +// The registration is reorg-aware: the returned ConfRegistration's +// Reorged channel fires when a previously delivered confirmation is +// reorged out of the canonical chain, and the Confirmed channel may +// fire again when the tx re-confirms on the new chain. The Done +// channel is allocated but never written by this backend; the +// chainsource ConfActor synthesizes Done at its configured +// FinalityDepth using block epochs. func (b *ChainBackend) RegisterConf(ctx context.Context, txid *chainhash.Hash, pkScript []byte, numConfs uint32, heightHint uint32, includeBlock bool) (*chainsource.ConfRegistration, error) { confChan := make(chan *chainsource.TxConfirmation, 1) + reorgChan := make(chan uint64, 1) + doneChan := make(chan struct{}, 1) cancelCh := make(chan struct{}) reg := &confRegistration{ @@ -402,6 +491,8 @@ func (b *ChainBackend) RegisterConf(ctx context.Context, txid *chainhash.Hash, heightHint: heightHint, includeBlock: includeBlock, confChan: confChan, + reorgChan: reorgChan, + doneChan: doneChan, cancelCh: cancelCh, } @@ -424,11 +515,7 @@ func (b *ChainBackend) RegisterConf(ctx context.Context, txid *chainhash.Hash, ) cancelFn := func() { - close(cancelCh) - - b.mu.Lock() - delete(b.confRegs, id) - b.mu.Unlock() + b.cancelConfReg(id, reg) } // Run an immediate single-shot check scoped to JUST this @@ -450,16 +537,41 @@ func (b *ChainBackend) RegisterConf(ctx context.Context, txid *chainhash.Hash, return &chainsource.ConfRegistration{ Confirmed: confChan, + Reorged: reorgChan, + Done: doneChan, Cancel: cancelFn, }, nil } +// cancelConfReg tears down a confirmation registration. It is safe +// to invoke from any state; the close(cancelCh) is guarded by a +// once-style check to make double-Cancel a no-op. +func (b *ChainBackend) cancelConfReg(id uint64, reg *confRegistration) { + reg.regMu.Lock() + select { + case <-reg.cancelCh: + // Already cancelled. + reg.regMu.Unlock() + + return + + default: + } + close(reg.cancelCh) + reg.regMu.Unlock() + + b.mu.Lock() + delete(b.confRegs, id) + b.mu.Unlock() +} + // runConfOneShot performs the per-registration confirmation check // triggered at RegisterConf time. It snapshots the current best // height under the chain backend's lock, asks Esplora for this one // registration's status, and delivers the result if confirmed. The -// goroutine exits on any of: cancellation, stopCh closure, a -// successful delivery, or a non-confirmed status. +// registration is NOT deleted after delivery: a reorg may later +// reset it to stateWatching and the next re-check must fire +// Confirmed again on the new chain. func (b *ChainBackend) runConfOneShot(id uint64, reg *confRegistration) { defer b.wg.Done() @@ -477,11 +589,49 @@ func (b *ChainBackend) runConfOneShot(id uint64, reg *confRegistration) { currentHeight := b.bestHeight b.mu.Unlock() + b.deliverConfIfNew(id, reg, currentHeight) +} + +// deliverConfIfNew runs a confirmation re-check and fires the +// Confirmed channel iff the registration is in stateWatching and a +// confirmation is now available. It is the single delivery path +// shared by the one-shot at registration time and the broad +// re-check driven by tip / reorg events. The registration's regMu +// is held only across the state read and the state transition; the +// channel send happens outside the lock so a slow consumer never +// blocks the broad re-check goroutine. +func (b *ChainBackend) deliverConfIfNew(id uint64, reg *confRegistration, + currentHeight int32) { + + reg.regMu.Lock() + if reg.state == statePositive { + reg.regMu.Unlock() + + return + } + reg.regMu.Unlock() + conf := b.checkSingleConf(reg, currentHeight) if conf == nil { return } + // Re-check state under the lock: a concurrent reorg handler + // could have flipped us back to stateWatching after the check + // started, but it could not have flipped us forward to + // statePositive (that's exclusively this function's job). + reg.regMu.Lock() + if reg.state == statePositive { + reg.regMu.Unlock() + + return + } + reg.state = statePositive + if conf.BlockHash != nil { + reg.lastBlockHash = *conf.BlockHash + } + reg.regMu.Unlock() + select { case reg.confChan <- conf: case <-reg.cancelCh: @@ -493,22 +643,26 @@ func (b *ChainBackend) runConfOneShot(id uint64, reg *confRegistration) { b.log.DebugS( context.Background(), - "Confirmation registration fulfilled (one-shot)", + "Confirmation registration fulfilled", slog.Uint64("reg_id", id), slog.Int("block_height", int(conf.BlockHeight)), ) - - b.mu.Lock() - delete(b.confRegs, id) - b.mu.Unlock() } // RegisterSpend registers for spend notifications of a transaction output. +// The registration is reorg-aware: the returned SpendRegistration's +// Reorged channel fires when a previously delivered spend is reorged +// out of the canonical chain, and the Spend channel may fire again +// when the outpoint is re-spent on the new chain. The Done channel +// is allocated but never written by this backend; the chainsource +// SpendActor synthesizes Done at its configured FinalityDepth. func (b *ChainBackend) RegisterSpend(ctx context.Context, outpoint *wire.OutPoint, pkScript []byte, heightHint uint32) ( *chainsource.SpendRegistration, error) { spendChan := make(chan *chainsource.SpendDetail, 1) + reorgChan := make(chan uint64, 1) + doneChan := make(chan struct{}, 1) cancelCh := make(chan struct{}) reg := &spendRegistration{ @@ -516,6 +670,8 @@ func (b *ChainBackend) RegisterSpend(ctx context.Context, pkScript: pkScript, heightHint: heightHint, spendChan: spendChan, + reorgChan: reorgChan, + doneChan: doneChan, cancelCh: cancelCh, } @@ -537,11 +693,7 @@ func (b *ChainBackend) RegisterSpend(ctx context.Context, ) cancelFn := func() { - close(cancelCh) - - b.mu.Lock() - delete(b.spendRegs, id) - b.mu.Unlock() + b.cancelSpendReg(id, reg) } // Per-registration one-shot to handle outpoints that are @@ -552,16 +704,37 @@ func (b *ChainBackend) RegisterSpend(ctx context.Context, go b.runSpendOneShot(id, reg) return &chainsource.SpendRegistration{ - Spend: spendChan, - Cancel: cancelFn, + Spend: spendChan, + Reorged: reorgChan, + Done: doneChan, + Cancel: cancelFn, }, nil } +// cancelSpendReg tears down a spend registration. Idempotent: a +// double-Cancel is treated as a no-op. +func (b *ChainBackend) cancelSpendReg(id uint64, reg *spendRegistration) { + reg.regMu.Lock() + select { + case <-reg.cancelCh: + reg.regMu.Unlock() + + return + + default: + } + close(reg.cancelCh) + reg.regMu.Unlock() + + b.mu.Lock() + delete(b.spendRegs, id) + b.mu.Unlock() +} + // runSpendOneShot performs the per-registration spend check -// triggered at RegisterSpend time. It exits on cancellation, stopCh -// closure, a successful delivery, or any non-spent / unconfirmed -// status; the broad checkSpends called from processTipEvent re-runs -// it on every tip advance. +// triggered at RegisterSpend time. The registration is NOT deleted +// after delivery: a reorg may later reset it to stateWatching and +// the next re-check must fire Spend again on the new chain. func (b *ChainBackend) runSpendOneShot(id uint64, reg *spendRegistration) { defer b.wg.Done() @@ -579,11 +752,46 @@ func (b *ChainBackend) runSpendOneShot(id uint64, reg *spendRegistration) { return } - detail := b.checkSingleSpend(reg) + b.deliverSpendIfNew(id, reg) +} + +// deliverSpendIfNew runs a spend re-check and fires the Spend +// channel iff the registration is in stateWatching and a spend is +// now available. Mirror of deliverConfIfNew; see that function's +// comment for the regMu / channel-send ordering rationale. +func (b *ChainBackend) deliverSpendIfNew(id uint64, reg *spendRegistration) { + reg.regMu.Lock() + if reg.state == statePositive { + reg.regMu.Unlock() + + return + } + reg.regMu.Unlock() + + // checkSingleSpend returns the spending block hash alongside + // the detail (parsed from the same /outspend response that + // confirmed the spend). A zero hash here means the response + // did not parse and reorgSpendReg will fall back to a + // conservative re-check rather than try to match against + // ReorgEvent.Disconnected. + detail, spendingBlockHash := b.checkSingleSpend(reg) if detail == nil { return } + reg.regMu.Lock() + if reg.state == statePositive { + reg.regMu.Unlock() + + return + } + reg.state = statePositive + if detail.SpenderTxHash != nil { + reg.lastSpenderHash = *detail.SpenderTxHash + } + reg.lastSpendingBlockHash = spendingBlockHash + reg.regMu.Unlock() + select { case reg.spendChan <- detail: case <-reg.cancelCh: @@ -594,15 +802,11 @@ func (b *ChainBackend) runSpendOneShot(id uint64, reg *spendRegistration) { } b.log.DebugS(context.Background(), - "Spend registration fulfilled (one-shot)", + "Spend registration fulfilled", slog.Uint64("reg_id", id), slog.String("outpoint", reg.outpoint.String()), slog.String("spender_txid", detail.SpenderTxHash.String())) - - b.mu.Lock() - delete(b.spendRegs, id) - b.mu.Unlock() } // RegisterBlocks registers for new block notifications. @@ -637,17 +841,258 @@ func (b *ChainBackend) RegisterBlocks(_ context.Context) ( }, nil } -// handleTipEvents drains TipBlock events from the shared poller and -// translates them into chain backend work: emit a BlockEpoch to each -// block-registration subscriber, advance the cached tip, and re-check -// pending confirmation/spend registrations. -// -// On stopCh the loop exits and cancels its subscription so the -// poller does not waste effort fanning to a dead consumer. The -// subscription's Quit channel covers the inverse direction: if the -// poller is shut down externally, we exit promptly without waiting -// for stopCh. -func (b *ChainBackend) handleTipEvents(sub *TipSubscription) { +// processReorgEvent reconciles all active registrations with one +// ReorgEvent. It is invoked from the unified handleChainEvents +// goroutine in producer order: a ReorgEvent is fully processed +// (registration state reset, Reorged channels fired) before any +// subsequent TipBlock dispatches block epochs or runs broad +// re-check on the replacement chain. Registrations whose last +// positive event names a disconnected hash are reset and +// re-checked; all others are left alone (the broad tip-driven +// re-check still runs separately). +func (b *ChainBackend) processReorgEvent(event *ReorgEvent) { + if event == nil { + return + } + + b.log.InfoS(context.Background(), "Processing reorg", + slog.Int("fork_height", int(event.ForkHeight)), + slog.Int("disconnected", len(event.Disconnected)), + slog.Int("connected", len(event.Connected)), + ) + + disconnectedSet := make( + map[chainhash.Hash]struct{}, len(event.Disconnected), + ) + for _, hash := range event.Disconnected { + disconnectedSet[hash] = struct{}{} + } + + // Snapshot the registration sets under the chain backend + // lock so we don't hold it across the per-registration + // channel sends below. + b.mu.Lock() + confRegs := make(map[uint64]*confRegistration, len(b.confRegs)) + for id, reg := range b.confRegs { + confRegs[id] = reg + } + spendRegs := make(map[uint64]*spendRegistration, len(b.spendRegs)) + for id, reg := range b.spendRegs { + spendRegs[id] = reg + } + currentHeight := b.bestHeight + b.mu.Unlock() + + for id, reg := range confRegs { + b.reorgConfReg(id, reg, disconnectedSet, currentHeight) + } + for id, reg := range spendRegs { + b.reorgSpendReg(id, reg, disconnectedSet) + } +} + +// reorgConfReg processes one confirmation registration against a +// reorg event. If the registration is in statePositive and its +// last-known block hash is in disconnectedSet, fire Reorged, reset +// to stateWatching, and run a fresh check that may immediately +// fire Confirmed against the new chain. +func (b *ChainBackend) reorgConfReg(id uint64, reg *confRegistration, + disconnectedSet map[chainhash.Hash]struct{}, currentHeight int32) { + + select { + case <-reg.cancelCh: + return + + default: + } + + reg.regMu.Lock() + if reg.state != statePositive { + reg.regMu.Unlock() + + return + } + cachedHash := reg.lastBlockHash + reg.regMu.Unlock() + + // Fast path: the registration's cached block hash appears in + // the reorg event's disconnected set. This covers every + // reorg of a block we previously broadcast. + _, fastHit := disconnectedSet[cachedHash] + + if !fastHit { + // Fallback: the registration may have delivered against + // a block older than the poller's seeded hash history + // (e.g. on a fresh daemon where RegisterConf landed an + // immediate historical positive). The reorg's + // disconnected set is bounded by recentHashes, so a + // reorg deep enough to invalidate that historical block + // would not appear here. Re-query canonical status and + // compare against the cached block hash. + // + // confirmedBlockHash returns a tri-state so we never + // fire a spurious reorg: Some(h) means the tx is still + // confirmed in block h (independent of the numConfs + // threshold), None means it is definitively unconfirmed, + // and a non-nil error means canonical status could not + // be determined right now. + gotHash, err := b.confirmedBlockHash(reg) + switch { + // Transient backend failure: we cannot tell whether this + // is a reorg. Leave the registration in statePositive and + // bail; a later tip or reorg event re-evaluates once the + // backend recovers. Firing Reorged here would strand the + // consumer on a false alarm. + case err != nil: + b.log.DebugS( + context.Background(), + "Skipping conf reorg eval; canonical "+ + "status undeterminable", + slog.Uint64("reg_id", id), + btclog.Fmt("err", "%v", err), + ) + + return + + // Still confirmed in the same block: definitively not a + // reorg of this registration. + case gotHash.IsSome() && + gotHash.UnsafeFromSome() == cachedHash: + return + } + + // Otherwise the tx is confirmed elsewhere or definitively + // unconfirmed: fall through and fire Reorged. + } + + reg.regMu.Lock() + if reg.state != statePositive { + reg.regMu.Unlock() + + return + } + reg.state = stateWatching + reg.lastBlockHash = chainhash.Hash{} + reg.regMu.Unlock() + + // Fire Reorged non-blocking: the channel is buffered to 1 + // and the consumer (chainsource conf actor) is single- + // threaded so a missed coalesced reorg is correct — the + // consumer will re-query state anyway. + select { + case reg.reorgChan <- uint64(0): + case <-reg.cancelCh: + return + + default: + b.log.DebugS( + context.Background(), + "Conf reorged signal coalesced", + slog.Uint64("reg_id", id), + ) + } + + b.log.InfoS( + context.Background(), + "Conf registration reorged; re-checking", + slog.Uint64("reg_id", id), + ) + + // Re-check now so a re-confirmation on the new chain fires + // Confirmed in the same reorg-handler turn. + b.deliverConfIfNew(id, reg, currentHeight) +} + +// reorgSpendReg is the spend-side equivalent of reorgConfReg. +func (b *ChainBackend) reorgSpendReg(id uint64, reg *spendRegistration, + disconnectedSet map[chainhash.Hash]struct{}) { + + select { + case <-reg.cancelCh: + return + + default: + } + + reg.regMu.Lock() + if reg.state != statePositive { + reg.regMu.Unlock() + + return + } + cachedBlockHash := reg.lastSpendingBlockHash + cachedSpenderHash := reg.lastSpenderHash + reg.regMu.Unlock() + + // Fast path: the cached spending-block hash appears in the + // reorg event's disconnected set. Empty cachedBlockHash + // (e.g. delivered before the outspend response was parseable) + // falls through to the fallback re-query rather than being + // treated as an automatic hit. + fastHit := false + if cachedBlockHash != (chainhash.Hash{}) { + _, fastHit = disconnectedSet[cachedBlockHash] + } + + if !fastHit { + // Fallback: re-query canonical chain. Covers (a) regs + // delivered against a block older than the poller's + // seeded hash history, and (b) regs whose + // cachedBlockHash is zero because the outspend response + // was unparseable at delivery time. If the outpoint is + // still spent by the same spender in the same block, + // no reorg for this registration. + current, currentBlock := b.checkSingleSpend(reg) + if current != nil && + current.SpenderTxHash != nil && + *current.SpenderTxHash == cachedSpenderHash && + cachedBlockHash != (chainhash.Hash{}) && + currentBlock == cachedBlockHash { + return + } + } + + reg.regMu.Lock() + if reg.state != statePositive { + reg.regMu.Unlock() + + return + } + reg.state = stateWatching + reg.lastSpenderHash = chainhash.Hash{} + reg.lastSpendingBlockHash = chainhash.Hash{} + reg.regMu.Unlock() + + select { + case reg.reorgChan <- uint64(0): + case <-reg.cancelCh: + return + + default: + b.log.DebugS( + context.Background(), + "Spend reorged signal coalesced", + slog.Uint64("reg_id", id), + ) + } + + b.log.InfoS( + context.Background(), + "Spend registration reorged; re-checking", + slog.Uint64("reg_id", id), + ) + + b.deliverSpendIfNew(id, reg) +} + +// handleChainEvents drains the unified chain stream and dispatches +// each event in producer order. Using a single subscription + single +// goroutine guarantees that a ReorgEvent is fully processed +// (registration state reset, Reorged channels fired) before any +// post-reorg TipBlock drives block-epoch dispatch or broad re-check +// — otherwise a block-epoch driven Confirmed could land on a stale +// registration before reorgConfReg has a chance to reset it. +func (b *ChainBackend) handleChainEvents(sub *ChainSubscription) { defer b.wg.Done() defer sub.Cancel() @@ -661,18 +1106,15 @@ func (b *ChainBackend) handleTipEvents(sub *TipSubscription) { return } - b.processTipEvent(event) + switch { + case event.Reorg != nil: + b.processReorgEvent(event.Reorg) + + case event.Tip != nil: + b.processTipEvent(event.Tip) + } case <-heartbeat.C: - // Re-run the broad checks even when the tip - // hasn't moved. Esplora's status indexer lags - // the block-found event by 1-3 seconds, so a - // processTipEvent that ran before the indexer - // caught up would otherwise not retry until - // the next block lands. Coalesced via the same - // singleflight keys used by processTipEvent so - // a tip event arriving on the same tick does - // not produce two parallel scans. b.runRecheckHeartbeat() case <-sub.Quit(): @@ -774,8 +1216,13 @@ func (b *ChainBackend) processTipEvent(event *TipBlock) { }) } -// checkConfirmations iterates over all pending confirmation registrations -// and checks their status via the Esplora API. +// checkConfirmations iterates over all pending confirmation +// registrations and re-checks their status via the Esplora API. +// Registrations already in statePositive are skipped: the reorg +// handler is responsible for transitioning them back to +// stateWatching, and an unconditional re-check would just burn +// Esplora calls for no behavior change (the chainsource actor's +// FinalityDepth synthesis handles Done from block epochs). func (b *ChainBackend) checkConfirmations() { b.mu.Lock() regs := make(map[uint64]*confRegistration, len(b.confRegs)) @@ -794,28 +1241,7 @@ func (b *ChainBackend) checkConfirmations() { default: } - conf := b.checkSingleConf(reg, currentHeight) - if conf == nil { - continue - } - - // Send the confirmation. - select { - case reg.confChan <- conf: - case <-reg.cancelCh: - continue - } - - b.log.DebugS(context.Background(), - "Confirmation registration fulfilled", - slog.Uint64("reg_id", id), - slog.Int("block_height", - int(conf.BlockHeight))) - - // Remove the fulfilled registration. - b.mu.Lock() - delete(b.confRegs, id) - b.mu.Unlock() + b.deliverConfIfNew(id, reg, currentHeight) } } @@ -948,8 +1374,73 @@ func (b *ChainBackend) checkConfByScript(reg *confRegistration, return nil } -// checkSpends iterates over all pending spend registrations and checks -// their status via the Esplora API. +// confirmedBlockHash reports the block hash a registration's transaction is +// currently confirmed in, independent of the registration's numConfs +// threshold. It exists for the reorg-detection fallback, which must +// distinguish three states that checkSingleConf collapses into a single nil: +// +// - Some(hash), nil: the tx is confirmed in block hash (any depth); +// - None, nil: the tx is definitively unconfirmed (genuine reorg); +// - None, err: canonical status could not be determined right now +// (transient backend error) — callers MUST NOT treat this as a reorg. +// +// Unlike checkSingleConf it never gates on numConfs, because a reorg that +// merely reduces a tx's confirmation depth without moving it out of its block +// is not a reorg of that registration. +func (b *ChainBackend) confirmedBlockHash(reg *confRegistration) ( + fn.Option[chainhash.Hash], error) { + + none := fn.None[chainhash.Hash]() + + // Explicit-txid registrations resolve via a direct status lookup. + if reg.txid != nil { + status, err := b.esplora.GetTxStatus( + context.Background(), *reg.txid, + ) + if err != nil { + return none, err + } + if !status.Confirmed { + return none, nil + } + + hash, err := chainhash.NewHashFromStr(status.BlockHash) + if err != nil { + return none, err + } + + return fn.Some(*hash), nil + } + + // Script registrations resolve via the first confirmed UTXO paying + // the watched script. + utxos, err := b.esplora.GetScriptUtxos( + context.Background(), reg.pkScript, + ) + if err != nil { + return none, err + } + + for _, utxo := range utxos { + if !utxo.Status.Confirmed { + continue + } + + hash, err := chainhash.NewHashFromStr(utxo.Status.BlockHash) + if err != nil { + return none, err + } + + return fn.Some(*hash), nil + } + + return none, nil +} + +// checkSpends iterates over all pending spend registrations and +// re-checks their status via the Esplora API. Registrations in +// statePositive are skipped — see checkConfirmations for the +// rationale. func (b *ChainBackend) checkSpends() { b.mu.Lock() regs := make(map[uint64]*spendRegistration, len(b.spendRegs)) @@ -971,68 +1462,59 @@ func (b *ChainBackend) checkSpends() { continue } - detail := b.checkSingleSpend(reg) - if detail == nil { - continue - } - - // Send the spend detail. - select { - case reg.spendChan <- detail: - case <-reg.cancelCh: - continue - } - - b.log.DebugS(context.Background(), - "Spend registration fulfilled", - slog.Uint64("reg_id", id), - slog.String("outpoint", - reg.outpoint.String()), - slog.String( - "spender_txid", detail.SpenderTxHash.String(), - )) - - // Remove the fulfilled registration. - b.mu.Lock() - delete(b.spendRegs, id) - b.mu.Unlock() + b.deliverSpendIfNew(id, reg) } } // checkSingleSpend resolves the spend status of a single spend // registration via Esplora. Returns nil when the outpoint is not yet // confirmed-spent, when any HTTP / parse error occurs, or when the -// registration has no outpoint. The caller is responsible for +// registration has no outpoint. The second return value is the block +// hash containing the spending tx (parsed from outspend.Status); the +// zero hash indicates the spending block hash was unparseable but the +// spend itself is still valid. The caller is responsible for // delivery, logging, and removing the fulfilled registration; this // helper only resolves the on-chain question. -func (b *ChainBackend) checkSingleSpend( - reg *spendRegistration) *chainsource.SpendDetail { +func (b *ChainBackend) checkSingleSpend(reg *spendRegistration) ( + *chainsource.SpendDetail, chainhash.Hash) { if reg.outpoint == nil { - return nil + return nil, chainhash.Hash{} } outspend, err := b.esplora.GetOutspend( context.Background(), reg.outpoint.Hash, reg.outpoint.Index, ) if err != nil { - return nil + return nil, chainhash.Hash{} } if !outspend.Spent || !outspend.Status.Confirmed { - return nil + return nil, chainhash.Hash{} } spenderHash, err := chainhash.NewHashFromStr(outspend.Txid) if err != nil { - return nil + return nil, chainhash.Hash{} } spendingTx, err := b.esplora.GetRawTx( context.Background(), *spenderHash, ) if err != nil { - return nil + return nil, chainhash.Hash{} + } + + // outspend.Status.BlockHash is hex from the same /outspend + // response that confirmed the spend; an unparseable value is + // not fatal — the spend itself is valid and the reorg path can + // fall back to a conservative re-check (see reorgSpendReg). + var spendingBlockHash chainhash.Hash + if outspend.Status.BlockHash != "" { + h, err := chainhash.NewHashFromStr(outspend.Status.BlockHash) + if err == nil { + spendingBlockHash = *h + } } return &chainsource.SpendDetail{ @@ -1041,7 +1523,7 @@ func (b *ChainBackend) checkSingleSpend( SpendingTx: spendingTx, SpenderInputIndex: outspend.Vin, SpendingHeight: int32(outspend.Status.BlockHeight), - } + }, spendingBlockHash } // Compile-time check that ChainBackend implements diff --git a/lwwallet/chain_backend_reorg_test.go b/lwwallet/chain_backend_reorg_test.go new file mode 100644 index 000000000..f7d875e1f --- /dev/null +++ b/lwwallet/chain_backend_reorg_test.go @@ -0,0 +1,1136 @@ +package lwwallet + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "runtime" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/chainsource" + "github.com/stretchr/testify/require" +) + +// reorgTestTimeout is the per-step wait used by the lwwallet reorg +// tests. Generous enough to absorb scheduling jitter on overloaded +// CI machines but short enough that a hung backend surfaces as a +// fast failure. +const reorgTestTimeout = 3 * time.Second + +// fakeChain is a mutable chain fixture for the reorg tests. Each +// height maps to a block whose hash, raw header, and contents the +// embedded HTTP handler will serve. Hashes are computable so the +// EsploraClient's content-hash verification passes. +// +// fakeChain is intentionally orthogonal to stubChain (used by the +// existing tip-poller tests): it supports same-height hash +// replacement (reorg), per-tx status overrides, per-outpoint +// outspend overrides, and serves /block//header so the tip +// poller's continuity check can resolve PrevBlock. +type fakeChain struct { + t *testing.T + mu sync.Mutex + + // tip is the current tip height. + tip int32 + + // blocks holds the per-height block model. Replacing the entry + // at height h simulates a same-height reorg; appending new + // heights simulates chain advance. + blocks map[int32]*fakeBlock + + // txStatus is the response to /tx//status. + txStatus map[chainhash.Hash]esploraTxStatus + + // rawTx is the response to /tx//raw. + rawTx map[chainhash.Hash][]byte + + // outspends is the response to /tx//outspend/. + outspends map[wire.OutPoint]esploraOutspend + + // failRawHeader holds block hashes for which the + // /block//header endpoint should return 500. Used to + // simulate a transient Esplora flake during continuity + // checking. + failRawHeader map[chainhash.Hash]struct{} +} + +// fakeBlock describes one height's block in the fakeChain. The hash +// is derived from a synthetic 80-byte header so the EsploraClient's +// content-hash verification accepts the raw-header response. +type fakeBlock struct { + height int32 + hash chainhash.Hash + prevHash chainhash.Hash + header *wire.BlockHeader + timestamp int64 +} + +// newFakeChain seeds a fakeChain with a single block at the given +// tip height. tag is mixed into the block hash so independent +// fakeChains in parallel tests produce distinct hashes. +func newFakeChain(t *testing.T, tip int32, tag string) *fakeChain { + t.Helper() + + c := &fakeChain{ + t: t, + tip: tip, + blocks: make(map[int32]*fakeBlock), + txStatus: make(map[chainhash.Hash]esploraTxStatus), + rawTx: make(map[chainhash.Hash][]byte), + outspends: make(map[wire.OutPoint]esploraOutspend), + failRawHeader: make(map[chainhash.Hash]struct{}), + } + + c.blocks[tip] = c.mintBlock(tip, chainhash.Hash{}, tag) + + return c +} + +// mintBlock builds a fakeBlock at height with the given prev hash +// and a tag that varies the resulting block hash. We synthesize a +// minimal 80-byte header with a unique nonce so BlockHash() varies +// reliably across invocations. +func (c *fakeChain) mintBlock(height int32, prev chainhash.Hash, + tag string) *fakeBlock { + + c.t.Helper() + + hdr := &wire.BlockHeader{ + Version: 1, + PrevBlock: prev, + MerkleRoot: chainhash.HashH([]byte(tag + "-merkle")), + Timestamp: time.Unix(int64(height)*600, 0), + Bits: 0x207fffff, + Nonce: uint32(height) ^ + uint32(chainhash.HashH([]byte(tag)).String()[0])<<16, + } + + // Make the nonce truly unique per (height, tag) so two + // adjacent tags do not collide on PrevBlock=zero replays. + salt := chainhash.HashH([]byte(fmt.Sprintf("%d-%s", height, tag))) + hdr.Nonce = uint32(salt[0])<<24 | uint32(salt[1])<<16 | + uint32(salt[2])<<8 | uint32(salt[3]) + + return &fakeBlock{ + height: height, + hash: hdr.BlockHash(), + prevHash: prev, + header: hdr, + timestamp: hdr.Timestamp.Unix(), + } +} + +// replaceTip swaps in a brand-new block at the current tip height +// (same height, different hash) and returns the new block. Used to +// drive a same-height reorg. +func (c *fakeChain) replaceTip(tag string) *fakeBlock { + c.mu.Lock() + defer c.mu.Unlock() + + prev, ok := c.blocks[c.tip-1] + var prevHash chainhash.Hash + if ok { + prevHash = prev.hash + } + + blk := c.mintBlock(c.tip, prevHash, tag) + c.blocks[c.tip] = blk + + return blk +} + +// extend appends a new block on top of the current tip and returns +// it. tag varies the resulting hash for parallel tests. +func (c *fakeChain) extend(tag string) *fakeBlock { + c.mu.Lock() + defer c.mu.Unlock() + + tipBlk := c.blocks[c.tip] + blk := c.mintBlock(c.tip+1, tipBlk.hash, tag) + c.tip++ + c.blocks[c.tip] = blk + + return blk +} + +// rewriteFrom rebuilds the chain from height start upward, giving +// each new block a different hash than the previous occupant at +// the same height. Used to simulate a deeper reorg where multiple +// heights diverge at once. +func (c *fakeChain) rewriteFrom(start int32, tagPrefix string) { + c.mu.Lock() + defer c.mu.Unlock() + + var prev chainhash.Hash + if prior, ok := c.blocks[start-1]; ok { + prev = prior.hash + } + for h := start; h <= c.tip; h++ { + blk := c.mintBlock(h, prev, + fmt.Sprintf("%s-%d", tagPrefix, h)) + c.blocks[h] = blk + prev = blk.hash + } +} + +// setTxStatus pins the response for /tx//status. +func (c *fakeChain) setTxStatus(txid chainhash.Hash, status esploraTxStatus) { + c.mu.Lock() + defer c.mu.Unlock() + + c.txStatus[txid] = status +} + +// setRawTx pins the response for /tx//raw. +func (c *fakeChain) setRawTx(txid chainhash.Hash, raw []byte) { + c.mu.Lock() + defer c.mu.Unlock() + + c.rawTx[txid] = raw +} + +// setOutspend pins the response for /tx//outspend/. +func (c *fakeChain) setOutspend(op wire.OutPoint, outspend esploraOutspend) { + c.mu.Lock() + defer c.mu.Unlock() + + c.outspends[op] = outspend +} + +// handler returns an http.HandlerFunc that serves the routes the +// chain backend / tip poller exercise. Anything else returns 404. +func (c *fakeChain) handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + c.mu.Lock() + defer c.mu.Unlock() + + path := r.URL.Path + switch { + case path == "/blocks/tip/height": + _, _ = fmt.Fprint(w, c.tip) + + case path == "/blocks/tip/hash": + blk, ok := c.blocks[c.tip] + if !ok { + http.Error(w, "no tip", http.StatusNotFound) + + return + } + + _, _ = fmt.Fprint(w, blk.hash.String()) + + case strings.HasPrefix(path, "/block-height/"): + heightStr := strings.TrimPrefix( + path, "/block-height/", + ) + height, err := strconv.ParseInt(heightStr, 10, 32) + if err != nil { + http.Error( + w, "bad height", http.StatusBadRequest, + ) + + return + } + blk, ok := c.blocks[int32(height)] + if !ok { + http.Error(w, "not found", + http.StatusNotFound) + + return + } + + _, _ = fmt.Fprint(w, blk.hash.String()) + + case strings.HasPrefix(path, "/block/"): + c.serveBlockReq(w, r, path) + + case strings.HasPrefix(path, "/tx/"): + c.serveTxReq(w, r, path) + + default: + http.Error(w, "not found", http.StatusNotFound) + } + } +} + +// serveBlockReq handles /block/ and /block//header. +// Caller holds c.mu. +func (c *fakeChain) serveBlockReq(w http.ResponseWriter, _ *http.Request, + path string) { + + rest := strings.TrimPrefix(path, "/block/") + hashStr := rest + suffix := "" + if idx := strings.Index(rest, "/"); idx >= 0 { + hashStr = rest[:idx] + suffix = rest[idx:] + } + + hash, err := chainhash.NewHashFromStr(hashStr) + if err != nil { + http.Error(w, "bad hash", http.StatusBadRequest) + + return + } + + var found *fakeBlock + for _, b := range c.blocks { + if b.hash == *hash { + found = b + + break + } + } + if found == nil { + http.Error(w, "not found", http.StatusNotFound) + + return + } + + switch suffix { + case "": + // JSON header. + _, _ = fmt.Fprintf( + w, `{"id":%q,"height":%d,"timestamp":%d}`, + found.hash.String(), found.height, found.timestamp, + ) + + case "/header": + // Raw 80-byte header, hex-encoded. + if _, fail := c.failRawHeader[found.hash]; fail { + http.Error( + w, "raw header unavailable", + http.StatusInternalServerError, + ) + + return + } + var buf bytes.Buffer + require.NoError(c.t, found.header.Serialize(&buf)) + _, _ = fmt.Fprint(w, hex.EncodeToString(buf.Bytes())) + + default: + http.Error(w, "not implemented", + http.StatusNotImplemented) + } +} + +// serveTxReq handles /tx//status, /tx//raw, and +// /tx//outspend/. Caller holds c.mu. +func (c *fakeChain) serveTxReq(w http.ResponseWriter, _ *http.Request, + path string) { + + rest := strings.TrimPrefix(path, "/tx/") + parts := strings.SplitN(rest, "/", 3) + if len(parts) < 2 { + http.Error(w, "not found", http.StatusNotFound) + + return + } + + txid, err := chainhash.NewHashFromStr(parts[0]) + if err != nil { + http.Error(w, "bad txid", http.StatusBadRequest) + + return + } + + switch parts[1] { + case "status": + status, ok := c.txStatus[*txid] + if !ok { + // Default to unconfirmed when no override exists. + status = esploraTxStatus{Confirmed: false} + } + + err := json.NewEncoder(w).Encode(status) + require.NoError(c.t, err) + + case "raw": + raw, ok := c.rawTx[*txid] + if !ok { + http.Error(w, "not found", + http.StatusNotFound) + + return + } + _, err := w.Write(raw) + require.NoError(c.t, err) + + case "outspend": + if len(parts) < 3 { + http.Error(w, "not found", + http.StatusNotFound) + + return + } + vout, err := strconv.ParseUint(parts[2], 10, 32) + if err != nil { + http.Error(w, "bad vout", + http.StatusBadRequest) + + return + } + op := wire.OutPoint{ + Hash: *txid, Index: uint32(vout), + } + outspend, ok := c.outspends[op] + if !ok { + outspend = esploraOutspend{Spent: false} + } + err = json.NewEncoder(w).Encode(outspend) + require.NoError(c.t, err) + + default: + http.Error(w, "not found", http.StatusNotFound) + } +} + +// fakeChainServer wraps a fakeChain with an httptest.Server for +// drop-in use by the backend tests. The server is auto-closed via +// t.Cleanup. +func fakeChainServer(t *testing.T, chain *fakeChain) *httptest.Server { + t.Helper() + + srv := httptest.NewServer(chain.handler()) + t.Cleanup(srv.Close) + + return srv +} + +// awaitConf reads one TxConfirmation with a deadline. +func awaitConf(t *testing.T, ch <-chan *chainsourceConf) *chainsourceConf { + t.Helper() + + select { + case c, ok := <-ch: + require.True(t, ok, "conf channel closed unexpectedly") + + return c + + case <-time.After(reorgTestTimeout): + t.Fatal("timeout waiting for confirmation") + + return nil + } +} + +// awaitSpend reads one SpendDetail with a deadline. +func awaitSpend(t *testing.T, ch <-chan *chainsourceSpend) *chainsourceSpend { + t.Helper() + + select { + case s, ok := <-ch: + require.True(t, ok, "spend channel closed unexpectedly") + + return s + + case <-time.After(reorgTestTimeout): + t.Fatal("timeout waiting for spend") + + return nil + } +} + +// awaitSeqSignal is awaitSignal for the sequence-carrying Reorged +// channel (chainsource retyped it from struct{} to the ordering +// sequence; this backend always sends 0). +func awaitSeqSignal(t *testing.T, ch <-chan uint64, label string) { + t.Helper() + + select { + case _, ok := <-ch: + require.True(t, ok, + "%s channel closed unexpectedly", label) + + case <-time.After(reorgTestTimeout): + t.Fatalf("timeout waiting for %s", label) + } +} + +// requireQuiet asserts that ch has no event for a short window. +// Used to ensure registrations are NOT double-firing. +func requireQuiet(t *testing.T, ch <-chan struct{}, label string, + dur time.Duration) { + + t.Helper() + + select { + case <-ch: + t.Fatalf("unexpected %s signal", label) + + case <-time.After(dur): + } +} + +// chainsourceConf / chainsourceSpend are type aliases to keep the +// test helper signatures short. +type ( + chainsourceConf = chainsource.TxConfirmation + chainsourceSpend = chainsource.SpendDetail +) + +// TestChainBackendSameHeightHashDrift verifies that a same-height +// hash replacement is detected by the tip poller and routed to the +// chain backend as a reorg, even when the chain height does not +// advance. This is the core "same-height reorgs invisible" gap the +// PR closes. +func TestChainBackendSameHeightHashDrift(t *testing.T) { + t.Parallel() + + chain := newFakeChain(t, 100, "drift-init") + srv := fakeChainServer(t, chain) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + backend := NewChainBackend( + esplora, 20*time.Millisecond, btclog.Disabled, + ) + require.NoError(t, backend.Start()) + t.Cleanup(func() { + require.NoError(t, backend.Stop()) + }) + + // Subscribe directly to the poller's reorg stream to verify + // detection. The backend also subscribes; both subscribers + // must observe the reorg. + reorgSub, err := backend.tipPoller.SubscribeReorgs() + require.NoError(t, err) + defer reorgSub.Cancel() + + // Replace the tip block at the SAME height with a new hash. + chain.replaceTip("drift-new") + + select { + case ev := <-reorgSub.Updates(): + require.NotNil(t, ev) + require.Equal(t, int32(99), ev.ForkHeight) + require.Len(t, ev.Disconnected, 1) + require.Len(t, ev.Connected, 1) + require.Equal(t, int32(100), ev.Connected[0].Height) + + case <-time.After(reorgTestTimeout): + t.Fatal("timed out waiting for same-height reorg") + } +} + +// TestChainBackendConfReorgRoundTrip drives a registration through +// Confirmed -> Reorged -> Confirmed by replacing the block that +// confirmed the tx with a new block at the same height that still +// confirms the tx (different BlockHash). +func TestChainBackendConfReorgRoundTrip(t *testing.T) { + t.Parallel() + + chain := newFakeChain(t, 100, "conf-init") + srv := fakeChainServer(t, chain) + + // Use minimalRawTx so the EsploraClient's content-hash + // verification accepts the raw-tx response. + rawTx := minimalRawTx() + txid := minimalRawTxID(t) + chain.setRawTx(txid, rawTx) + + confBlock := chain.blocks[100] + chain.setTxStatus(txid, esploraTxStatus{ + Confirmed: true, + BlockHeight: 100, + BlockHash: confBlock.hash.String(), + }) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + backend := NewChainBackend( + esplora, 20*time.Millisecond, btclog.Disabled, + ) + require.NoError(t, backend.Start()) + t.Cleanup(func() { + require.NoError(t, backend.Stop()) + }) + + reg, err := backend.RegisterConf( + t.Context(), &txid, nil, 1, 99, false, + ) + require.NoError(t, err) + defer reg.Cancel() + + conf1 := awaitConf(t, reg.Confirmed) + require.Equal(t, uint32(100), conf1.BlockHeight) + require.Equal(t, confBlock.hash, *conf1.BlockHash) + + // Same-height reorg: replace the confirming block with a + // different one at height 100, and pin the tx status to + // the new block hash so the post-reorg re-check finds the + // tx confirmed in the new block. + newBlock := chain.replaceTip("conf-replaced") + chain.setTxStatus(txid, esploraTxStatus{ + Confirmed: true, + BlockHeight: 100, + BlockHash: newBlock.hash.String(), + }) + + awaitSeqSignal(t, reg.Reorged, "conf Reorged") + + conf2 := awaitConf(t, reg.Confirmed) + require.Equal(t, uint32(100), conf2.BlockHeight) + require.Equal(t, newBlock.hash, *conf2.BlockHash) + require.NotEqual( + t, conf1.BlockHash.String(), conf2.BlockHash.String(), + "reorg should surface a different block hash", + ) +} + +// TestChainBackendConfReorgEvictedDoesNotReConfirm verifies that +// when the post-reorg chain no longer contains the tx, Reorged +// fires but Confirmed does not re-fire. +func TestChainBackendConfReorgEvictedDoesNotReConfirm(t *testing.T) { + t.Parallel() + + chain := newFakeChain(t, 100, "evict-init") + srv := fakeChainServer(t, chain) + + rawTx := minimalRawTx() + txid := minimalRawTxID(t) + chain.setRawTx(txid, rawTx) + + confBlock := chain.blocks[100] + chain.setTxStatus(txid, esploraTxStatus{ + Confirmed: true, + BlockHeight: 100, + BlockHash: confBlock.hash.String(), + }) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + backend := NewChainBackend( + esplora, 20*time.Millisecond, btclog.Disabled, + ) + require.NoError(t, backend.Start()) + t.Cleanup(func() { + require.NoError(t, backend.Stop()) + }) + + reg, err := backend.RegisterConf( + t.Context(), &txid, nil, 1, 99, false, + ) + require.NoError(t, err) + defer reg.Cancel() + + awaitConf(t, reg.Confirmed) + + // Reorg evicts the confirming block AND the tx is no longer + // found on the new chain. + chain.replaceTip("evict-new") + chain.setTxStatus(txid, esploraTxStatus{Confirmed: false}) + + awaitSeqSignal(t, reg.Reorged, "conf Reorged") + + // No re-confirmation: assert silence. + requireQuiet( + t, structOnlyChan(reg.Confirmed), + "unexpected re-Confirmed", 500*time.Millisecond, + ) +} + +// structOnlyChan adapts a TxConfirmation channel to a struct{} +// channel for use with requireQuiet. The forwarder goroutine is +// short-lived; once the test ends both channels go out of scope. +func structOnlyChan(in <-chan *chainsourceConf) <-chan struct{} { + out := make(chan struct{}, 4) + + go func() { + for c := range in { + if c == nil { + continue + } + + select { + case out <- struct{}{}: + default: + } + } + }() + + return out +} + +// TestChainBackendSpendReorgRoundTrip drives a spend registration +// through Spend -> Reorged -> Spend by reorging the block that +// confirmed the spending tx and pinning a different (still spent) +// outspend on the new chain. +func TestChainBackendSpendReorgRoundTrip(t *testing.T) { + t.Parallel() + + chain := newFakeChain(t, 100, "spend-init") + srv := fakeChainServer(t, chain) + + rawTx := minimalRawTx() + spenderTxid := minimalRawTxID(t) + chain.setRawTx(spenderTxid, rawTx) + + fundingTxid := chainhash.HashH([]byte("funding")) + outpoint := wire.OutPoint{Hash: fundingTxid, Index: 0} + + chain.setOutspend(outpoint, esploraOutspend{ + Spent: true, Txid: spenderTxid.String(), Vin: 0, + Status: esploraStatus{ + Confirmed: true, BlockHeight: 100, + }, + }) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + backend := NewChainBackend( + esplora, 20*time.Millisecond, btclog.Disabled, + ) + require.NoError(t, backend.Start()) + t.Cleanup(func() { + require.NoError(t, backend.Stop()) + }) + + reg, err := backend.RegisterSpend( + t.Context(), &outpoint, nil, 90, + ) + require.NoError(t, err) + defer reg.Cancel() + + spend1 := awaitSpend(t, reg.Spend) + require.Equal(t, spenderTxid, *spend1.SpenderTxHash) + require.Equal(t, int32(100), spend1.SpendingHeight) + + // Replace the confirming block AND swap the spender on the + // new chain. The spender tx id is the same (we're reusing + // minimalRawTx) but the block hash differs, which is what + // the reorg handler keys off. + chain.replaceTip("spend-replaced") + chain.setOutspend(outpoint, esploraOutspend{ + Spent: true, Txid: spenderTxid.String(), Vin: 0, + Status: esploraStatus{ + Confirmed: true, BlockHeight: 100, + }, + }) + + awaitSeqSignal(t, reg.Reorged, "spend Reorged") + + spend2 := awaitSpend(t, reg.Spend) + require.Equal(t, spenderTxid, *spend2.SpenderTxHash) + require.Equal(t, int32(100), spend2.SpendingHeight) +} + +// TestTipPollerSeedsHashHistoryOnStart pins the property that the +// poller seeds its recent-hash ring back through historySize-1 +// heights at Start. Without this, a fresh poller would only ever +// cache the initial tip, leaving any reorg-event consumer with an +// incomplete disconnected set for reorgs deeper than 1 block but +// within the configured history. The chain.Interface consumer +// (EsploraChainService) depends on a complete disconnected set to +// emit a BlockDisconnected for every height btcwallet must roll +// back. +func TestTipPollerSeedsHashHistoryOnStart(t *testing.T) { + t.Parallel() + + // Build a chain at tip 100 plus three lower heights. The poller + // starts at tip 100; with seed-history, recentHashes will be + // pre-populated with {97, 98, 99, 100}. + chain := newFakeChain(t, 100, "seed-100") + // fakeChain only populates the tip block; the test needs lower + // heights in the response. Extend backwards by minting blocks + // at 99, 98, 97 with the correct PrevBlock chain so the + // poller's seed walk resolves each height. + chain.mu.Lock() + var prev chainhash.Hash + for h := int32(97); h <= 100; h++ { + blk := chain.mintBlock(h, prev, fmt.Sprintf("seed-%d", h)) + chain.blocks[h] = blk + prev = blk.hash + } + chain.mu.Unlock() + + srv := fakeChainServer(t, chain) + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + + // Cap historySize at 4 so the seed walk fills exactly the + // heights we care about. + tipPoller := NewTipPollerWithConfig( + esplora, 20*time.Millisecond, 4, btclog.Disabled, + ) + require.NoError(t, tipPoller.Start()) + t.Cleanup(tipPoller.Stop) + + reorgSub, err := tipPoller.SubscribeReorgs() + require.NoError(t, err) + defer reorgSub.Cancel() + + // Reorg the deepest 3 heights (98, 99, 100). With seed-history + // the poller's recentHashes contains 97, 98, 99, 100 so the + // walk-back terminates at fork point 97 and disconnects 3 + // hashes. Without the seed, recentHashes would only contain + // 100; walk-back would stop at 99 (no cached hash) and the + // disconnected list would carry only 1 hash. + chain.rewriteFrom(98, "seed-rewritten") + + select { + case ev := <-reorgSub.Updates(): + require.NotNil(t, ev) + require.Equal( + t, int32(97), ev.ForkHeight, "fork point should be "+ + "the deepest unchanged height; "+ + "seed-history failed if it lands above 97", + ) + require.Len( + t, ev.Disconnected, 3, "all three reorged heights "+ + "must appear in Disconnected; seed-history "+ + "is what makes this property hold for "+ + "reorgs of depth > 1 against a freshly "+ + "started poller", + ) + + case <-time.After(reorgTestTimeout): + t.Fatal("timed out waiting for reorg event") + } +} + +// TestChainBackendDeeperReorgDetected verifies that a multi-block +// reorg (where height advances AND PrevBlock continuity is broken) +// produces a ReorgEvent with the correct fork point. +// +// The test first advances the chain past the eventual fork point so +// the poller's recent-hash ring buffer contains the to-be-rewritten +// heights. Without that, the walk-back would terminate early on the +// first !haveCached probe and report a shallower fork than the test +// intends to exercise. +func TestChainBackendDeeperReorgDetected(t *testing.T) { + t.Parallel() + + chain := newFakeChain(t, 100, "deep-init") + + srv := fakeChainServer(t, chain) + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + tp := NewTipPoller(esplora, 20*time.Millisecond, btclog.Disabled) + require.NoError(t, tp.Start()) + t.Cleanup(tp.Stop) + + // Subscribe BEFORE the rewrite so we receive the reorg + // event live. + reorgSub, err := tp.SubscribeReorgs() + require.NoError(t, err) + defer reorgSub.Cancel() + + tipSub, err := tp.Subscribe() + require.NoError(t, err) + defer tipSub.Cancel() + + // Advance the chain through the poller so it caches + // heights 101 and 102 in its ring buffer. + chain.extend("init-101") + chain.extend("init-102") + + for expected := int32(101); expected <= 102; expected++ { + select { + case ev := <-tipSub.Updates(): + require.Equal(t, expected, ev.Height) + + case <-time.After(reorgTestTimeout): + t.Fatalf("timed out waiting for height %d to be cached", + expected) + } + } + + // Now rewrite from height 101 upward (heights 101 and 102 + // get new hashes) and extend by one to push the new tip + // past the old. + chain.rewriteFrom(101, "fork") + chain.extend("fork-103") + + // Wait for ReorgEvent. ForkHeight should be 100 because + // the rewrite started at 101 (so 100 is the last height + // that agrees between old and new chains). + select { + case ev := <-reorgSub.Updates(): + require.NotNil(t, ev) + require.Equal(t, int32(100), ev.ForkHeight) + require.GreaterOrEqual(t, len(ev.Connected), 2) + require.GreaterOrEqual(t, len(ev.Disconnected), 2) + + case <-time.After(reorgTestTimeout): + t.Fatal("timed out waiting for deeper reorg") + } +} + +// TestChainBackendCancelCleanup verifies that Cancel on a +// conf/spend registration removes it from internal maps and does +// not leak goroutines. +func TestChainBackendCancelCleanup(t *testing.T) { + t.Parallel() + + chain := newFakeChain(t, 100, "cancel-init") + srv := fakeChainServer(t, chain) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + backend := NewChainBackend( + esplora, time.Hour, btclog.Disabled, + ) + require.NoError(t, backend.Start()) + t.Cleanup(func() { + require.NoError(t, backend.Stop()) + }) + + txid := minimalRawTxID(t) + outpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte("funding")), Index: 0, + } + + // Sample the baseline goroutine count AFTER the backend is + // started so the poller / tip handler / reorg handler + // goroutines are already included. + time.Sleep(50 * time.Millisecond) + baseline := runtime.NumGoroutine() + + const iterations = 20 + for i := 0; i < iterations; i++ { + confReg, err := backend.RegisterConf( + t.Context(), &txid, nil, 1, 99, false, + ) + require.NoError(t, err) + + spendReg, err := backend.RegisterSpend( + t.Context(), &outpoint, nil, 90, + ) + require.NoError(t, err) + + confReg.Cancel() + spendReg.Cancel() + + // Double Cancel must be a safe no-op. + confReg.Cancel() + spendReg.Cancel() + } + + // Give the spawned one-shot goroutines time to exit. + require.Eventually(t, func() bool { + + // Allow a small tolerance for scheduling jitter; the + // upper bound here is generous (20) versus the + // per-iteration spawn count (2) to absorb any + // in-flight Esplora request goroutines that have not + // yet returned. The key invariant is that the count + // does not GROW unbounded. + return runtime.NumGoroutine() <= baseline+5 + }, 2*time.Second, 50*time.Millisecond, + "goroutines leaked after Cancel: baseline=%d current=%d", + baseline, runtime.NumGoroutine()) + + // After Cancel, internal maps should be empty. + backend.mu.Lock() + confLen := len(backend.confRegs) + spendLen := len(backend.spendRegs) + backend.mu.Unlock() + require.Equal(t, 0, confLen, + "confRegs not cleaned up after Cancel") + require.Equal(t, 0, spendLen, + "spendRegs not cleaned up after Cancel") +} + +// TestChainBackendConfStaysAliveAfterFirstFire verifies that the +// confirmation registration is NOT deleted after the first +// Confirmed delivery, since the chainsource ConfActor needs the +// registration to remain alive to receive future Reorged events +// and finally a synthesized Done. +func TestChainBackendConfStaysAliveAfterFirstFire(t *testing.T) { + t.Parallel() + + chain := newFakeChain(t, 100, "alive-init") + srv := fakeChainServer(t, chain) + + rawTx := minimalRawTx() + txid := minimalRawTxID(t) + chain.setRawTx(txid, rawTx) + chain.setTxStatus(txid, esploraTxStatus{ + Confirmed: true, + BlockHeight: 100, + BlockHash: chain.blocks[100].hash.String(), + }) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + backend := NewChainBackend( + esplora, 50*time.Millisecond, btclog.Disabled, + ) + require.NoError(t, backend.Start()) + t.Cleanup(func() { + require.NoError(t, backend.Stop()) + }) + + reg, err := backend.RegisterConf( + t.Context(), &txid, nil, 1, 99, false, + ) + require.NoError(t, err) + defer reg.Cancel() + + awaitConf(t, reg.Confirmed) + + // Give the polling loop time to fire several heartbeats. + // The registration must still be present. + time.Sleep(200 * time.Millisecond) + + backend.mu.Lock() + confLen := len(backend.confRegs) + backend.mu.Unlock() + + require.Equal( + t, 1, confLen, "reg deleted after first fire; reorg-aware "+ + "contract requires it to stay alive", + ) +} + +// TestChainBackendConfReorgBelowSeededHistory pins the property that a +// reorg deeper than the poller's seeded hash history still surfaces +// Reorged for a registration that delivered against the +// now-out-of-window block. The poller's disconnected set is bounded +// by the cached history, so without a canonical re-query fallback the +// reorg signal would be silently dropped. +func TestChainBackendConfReorgBelowSeededHistory(t *testing.T) { + t.Parallel() + + // Seed a chain at height 100 and extend up to 105 so the + // walk-back has live block hashes to compare against. Use a + // tiny history size so the registration's conf block falls + // outside the seeded window from the poller's perspective. + chain := newFakeChain(t, 100, "below-100") + chain.extend("below-101") + chain.extend("below-102") + chain.extend("below-103") + chain.extend("below-104") + chain.extend("below-105") + + confBlock := chain.blocks[100] + srv := fakeChainServer(t, chain) + + rawTx := minimalRawTx() + txid := minimalRawTxID(t) + chain.setRawTx(txid, rawTx) + chain.setTxStatus(txid, esploraTxStatus{ + Confirmed: true, + BlockHeight: 100, + BlockHash: confBlock.hash.String(), + }) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + // historySize=3 means the poller only retains the last three + // canonical heights it has observed. The conf block at height + // 100 will be five heights below the seeded tip (105) and is + // guaranteed never to enter recentHashes. + tipPoller := NewTipPollerWithConfig( + esplora, 20*time.Millisecond, 3, btclog.Disabled, + ) + require.NoError(t, tipPoller.Start()) + t.Cleanup(tipPoller.Stop) + backend, err := NewChainBackendWithPoller( + esplora, tipPoller, btclog.Disabled, + ) + require.NoError(t, err) + require.NoError(t, backend.Start()) + t.Cleanup(func() { + require.NoError(t, backend.Stop()) + }) + + reg, err := backend.RegisterConf( + t.Context(), &txid, nil, 1, 99, false, + ) + require.NoError(t, err) + defer reg.Cancel() + + conf1 := awaitConf(t, reg.Confirmed) + require.Equal(t, confBlock.hash, *conf1.BlockHash) + + // Deep reorg: rewrite every height from 100 upward with + // different hashes. The new block at height 100 has a new + // hash, but the tx is still pinned there so the canonical + // re-query in reorgConfReg can fire a fresh Confirmed. + chain.rewriteFrom(100, "below-new") + newConfBlock := chain.blocks[100] + chain.setTxStatus(txid, esploraTxStatus{ + Confirmed: true, + BlockHeight: 100, + BlockHash: newConfBlock.hash.String(), + }) + + awaitSeqSignal(t, reg.Reorged, "conf Reorged on below-history reorg") + + conf2 := awaitConf(t, reg.Confirmed) + require.Equal(t, newConfBlock.hash, *conf2.BlockHash) + require.NotEqual( + t, conf1.BlockHash.String(), conf2.BlockHash.String(), + "re-confirmation must surface the new block hash", + ) +} + +// TestChainBackendAbortsOnRawHeaderFailure pins the property that a +// failed raw-header fetch during continuity check aborts the poll +// cycle rather than optimistically advancing. The optimistic path +// would permanently hide a reorg that crossed the old-tip boundary +// if the raw-header fetch flaked at exactly the wrong moment. +func TestChainBackendAbortsOnRawHeaderFailure(t *testing.T) { + t.Parallel() + + chain := newFakeChain(t, 100, "abort-init") + srv := fakeChainServer(t, chain) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + tipPoller := NewTipPoller( + esplora, 20*time.Millisecond, btclog.Disabled, + ) + require.NoError(t, tipPoller.Start()) + t.Cleanup(tipPoller.Stop) + + tipSub, err := tipPoller.Subscribe() + require.NoError(t, err) + defer tipSub.Cancel() + reorgSub, err := tipPoller.SubscribeReorgs() + require.NoError(t, err) + defer reorgSub.Cancel() + + // Add a height-101 block whose PrevBlock does NOT chain off + // the seeded tip at height 100. Then poison the raw-header + // endpoint for that block so the continuity check cannot + // resolve. The poller must abort the cycle: not broadcast + // the new block, not update its cached tip, not fire a reorg. + chain.mu.Lock() + stranger := chain.mintBlock( + 101, chainhash.Hash{0xde, 0xad}, "abort-stranger", + ) + chain.blocks[101] = stranger + chain.tip = 101 + chain.failRawHeader[stranger.hash] = struct{}{} + chain.mu.Unlock() + + // Sleep long enough for a few poll cycles to fire; verify + // neither tip advance nor reorg ever fires. + select { + case ev := <-tipSub.Updates(): + t.Fatalf("poller broadcast a tip event despite raw-header "+ + "failure: height=%d hash=%s", ev.Height, ev.Hash) + + case ev := <-reorgSub.Updates(): + t.Fatalf("poller broadcast a reorg event despite raw-header "+ + "failure: fork=%d", ev.ForkHeight) + + case <-time.After(200 * time.Millisecond): + // Expected: silent. + } + + height, hash, _ := tipPoller.BestBlock() + require.Equal( + t, int32(100), height, + "cached tip height advanced despite aborted cycle", + ) + require.Equal( + t, chain.blocks[100].hash, hash, + "cached tip hash advanced despite aborted cycle", + ) +} diff --git a/lwwallet/chain_backend_test.go b/lwwallet/chain_backend_test.go index bcea6be99..6ee39c5ad 100644 --- a/lwwallet/chain_backend_test.go +++ b/lwwallet/chain_backend_test.go @@ -2,6 +2,7 @@ package lwwallet import ( "bytes" + "encoding/hex" "encoding/json" "fmt" "net/http" @@ -80,15 +81,20 @@ func TestChainBackendBlockNotification(t *testing.T) { mu sync.Mutex tipHeight int32 = 100 blockHashes = make(map[int32]chainhash.Hash) + blockHdrs = make(map[int32]*wire.BlockHeader) ) - // Pre-generate block hashes. + // Pre-generate real chained block headers so the tip poller's + // PrevBlock continuity check during advance can resolve the raw + // header endpoint with a header that actually links back to the + // previous height. + var prevHash chainhash.Hash for h := int32(100); h <= 103; h++ { - blockHashes[h] = chainhash.HashH( - []byte( - fmt.Sprintf("block-%d", h), - ), - ) + hdr := mintStubHeader(h, prevHash) + blockHdrs[h] = hdr + hash := hdr.BlockHash() + blockHashes[h] = hash + prevHash = hash } srv := mockEsploraServer( @@ -109,7 +115,7 @@ func TestChainBackendBlockNotification(t *testing.T) { default: handleBlockReqs( - t, w, r, &mu, blockHashes, + t, w, r, &mu, blockHashes, blockHdrs, ) } }, @@ -147,10 +153,13 @@ func TestChainBackendBlockNotification(t *testing.T) { } } -// handleBlockReqs handles /block-height/:height and /block/:hash -// requests in the mock Esplora server. +// handleBlockReqs handles /block-height/:height, /block/:hash, and +// /block/:hash/header requests in the mock Esplora server. The +// blockHdrs map supplies real wire.BlockHeaders so the raw-header +// continuity check the tip poller runs on advance can succeed. func handleBlockReqs(t *testing.T, w http.ResponseWriter, r *http.Request, - mu *sync.Mutex, blockHashes map[int32]chainhash.Hash) { + mu *sync.Mutex, blockHashes map[int32]chainhash.Hash, + blockHdrs map[int32]*wire.BlockHeader) { t.Helper() @@ -176,20 +185,40 @@ func handleBlockReqs(t *testing.T, w http.ResponseWriter, r *http.Request, return } - // Handle /block/:hash (block header). - for _, h := range blockHashes { + // Handle /block/:hash and /block/:hash/header. + mu.Lock() + defer mu.Unlock() + for height, h := range blockHashes { hashStr := h.String() path := "/block/" + hashStr - if r.URL.Path == path { + headerPath := path + "/header" + + switch r.URL.Path { + case path: resp := esploraBlock{ ID: hashStr, - Height: 100, + Height: height, Timestamp: 1700000000, } - err := json.NewEncoder(w).Encode(resp) require.NoError(t, err) + return + + case headerPath: + hdr := blockHdrs[height] + if hdr == nil { + http.Error(w, "no header", + http.StatusNotFound) + + return + } + var buf bytes.Buffer + require.NoError(t, hdr.Serialize(&buf)) + _, _ = fmt.Fprint( + w, hex.EncodeToString(buf.Bytes()), + ) + return } } diff --git a/lwwallet/esplora_chain.go b/lwwallet/esplora_chain.go index bd37534ee..2d1c944fc 100644 --- a/lwwallet/esplora_chain.go +++ b/lwwallet/esplora_chain.go @@ -110,12 +110,15 @@ func NewEsploraChainService(esplora *EsploraClient, tipPoller *TipPoller, // Start seeds the initial chain tip from the configured TipPoller // (which the caller must have started already) and spawns the -// goroutine that translates each TipBlock event into btcwallet -// chain notifications. +// goroutine that translates each chain event into btcwallet chain +// notifications. The service subscribes to the unified chain stream +// so reorg and tip updates arrive on a single producer-ordered +// channel; this is what lets chain.BlockDisconnected reliably precede +// the replacement chain.BlockConnected events for the new tip. func (s *EsploraChainService) Start(ctx context.Context) error { //nolint:contextcheck // tip subscription lifecycle is owned by Stop - tipHeight, tipHash, tipTime, sub, err := - s.tipPoller.BestBlockAndSubscribe() + tipHeight, tipHash, tipTime, chainSub, err := + s.tipPoller.BestBlockAndSubscribeChain() if err != nil { return fmt.Errorf("subscribe to tip poller: %w", err) } @@ -134,7 +137,7 @@ func (s *EsploraChainService) Start(ctx context.Context) error { s.notifications <- chain.ClientConnected{} s.wg.Add(1) - go s.handleTipEvents(ctx, sub) + go s.handleChainEvents(ctx, chainSub) s.log.InfoS(ctx, "Esplora chain service started", slog.Int("tip_height", int(tipHeight)), @@ -685,26 +688,35 @@ func (s *EsploraChainService) MapRPCErr(err error) error { return err } -// handleTipEvents drains TipBlock events from the shared poller and -// translates each event into the FilteredBlockConnected + -// BlockConnected notification pair that btcwallet's wallet syncer -// expects. The loop exits when the chain service is stopped, when -// the poller signals shutdown via Quit, or when the subscription's -// Updates channel is closed by Cancel. -func (s *EsploraChainService) handleTipEvents(ctx context.Context, - sub *TipSubscription) { +// handleChainEvents drains the unified chain stream and translates +// each event into btcwallet chain notifications. Using a single +// subscription delivers ReorgEvent and TipBlock updates in producer +// order through one channel, which is what guarantees +// BlockDisconnected lands on btcwallet's notification queue before +// the BlockConnected events for the replacement chain (btcwallet's +// disconnectBlock would otherwise refuse the rollback if the cached +// hash at that height had already been overwritten by a stale +// BlockConnected). +func (s *EsploraChainService) handleChainEvents(ctx context.Context, + sub *ChainSubscription) { defer s.wg.Done() defer sub.Cancel() for { select { - case event, ok := <-sub.Updates(): + case ev, ok := <-sub.Updates(): if !ok { return } - s.processTipEvent(ctx, event) + switch { + case ev.Reorg != nil: + s.processReorgEvent(ctx, ev.Reorg) + + case ev.Tip != nil: + s.processTipEvent(ctx, ev.Tip) + } case <-sub.Quit(): return @@ -729,6 +741,63 @@ func (s *EsploraChainService) handleTipEvents(ctx context.Context, // per-event cap branch without producing 256+ heights of traffic. const defaultMaxGapFillPerTipEvent int32 = 256 +// processReorgEvent translates a ReorgEvent into chain.BlockDisconnected +// notifications. The replacement Connected blocks arrive separately on +// the tip stream and are processed by processTipEvent in the usual way; +// emitting Disconnected here is what lets btcwallet roll back the +// reorged-out heights via its disconnectBlock path before it re-sees +// the canonical chain. Newest height is disconnected first so btcwallet +// walks back from its own cached tip. +// +// After emitting the disconnects, s.bestBlock is rolled back to +// event.ForkHeight so the subsequent replacement-chain TipBlock events +// (which arrive at heights forkHeight+1..newTipHeight) pass +// processTipEvent's "event.Height <= lastDelivered" duplicate guard. +// Without this rollback, the replacement BlockConnected events would +// silently be dropped because s.bestBlock still points at the old tip. +// The Hash is intentionally cleared because the reorg event does not +// carry the fork-point hash; the next BlockConnected fully repopulates +// s.bestBlock and only the Height is load-bearing for the duplicate +// guard. +func (s *EsploraChainService) processReorgEvent(ctx context.Context, + event *ReorgEvent) { + + if event == nil { + return + } + + for i := len(event.Disconnected) - 1; i >= 0; i-- { + hash := event.Disconnected[i] + height := event.ForkHeight + int32(i) + 1 + + meta := wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Hash: hash, + Height: height, + }, + } + + select { + case s.notifications <- chain.BlockDisconnected(meta): + case <-s.quit: + return + } + } + + s.mu.Lock() + if s.bestBlock.Height > event.ForkHeight { + s.bestBlock = waddrmgr.BlockStamp{ + Height: event.ForkHeight, + } + } + s.mu.Unlock() + + s.log.InfoS(ctx, "Chain service emitted BlockDisconnected", + slog.Int("fork_height", int(event.ForkHeight)), + slog.Int("disconnected", len(event.Disconnected)), + ) +} + // processTipEvent applies one TipBlock to btcwallet's notification // channel. The chain service owns its own delivery cursor // (s.bestBlock); this function first walks any gap between diff --git a/lwwallet/esplora_chain_reorg_test.go b/lwwallet/esplora_chain_reorg_test.go new file mode 100644 index 000000000..a4b22c1c1 --- /dev/null +++ b/lwwallet/esplora_chain_reorg_test.go @@ -0,0 +1,175 @@ +package lwwallet + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btclog/v2" + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/require" +) + +// awaitNotification pulls the next notification from the chain +// service or fails the test on timeout. +func awaitNotification(t *testing.T, s *EsploraChainService) interface{} { + t.Helper() + + select { + case n := <-s.Notifications(): + return n + + case <-time.After(reorgTestTimeout): + t.Fatalf("timed out waiting for chain notification") + + return nil + } +} + +// drainUntilConnected drains notifications until a BlockConnected +// event at the given height is observed (or the test times out). +// Used to skip over the startup ClientConnected and initial connected +// events so the reorg-specific assertions can run on a known cursor. +func drainUntilConnected(t *testing.T, s *EsploraChainService, height int32) { + t.Helper() + + deadline := time.After(reorgTestTimeout) + + for { + select { + case n := <-s.Notifications(): + conn, ok := n.(chain.BlockConnected) + if !ok { + continue + } + if conn.Block.Height == height { + return + } + + case <-deadline: + t.Fatalf("timed out waiting for BlockConnected at %d", + height) + } + } +} + +// TestEsploraChainServiceReorgEmitsBlockDisconnected pins the property +// that a chain reorg surfaces chain.BlockDisconnected notifications +// before btcwallet sees the BlockConnected events that announce the +// new canonical chain, AND that the disconnect events arrive in +// newest-height-first order (the order btcwallet's disconnectBlock +// expects when walking back from its cached tip). Both ordering +// properties are load-bearing: without disconnect-before-connect, +// btcwallet would refuse the rollback because the cached hash at +// each height would already be overwritten; without +// newest-first, btcwallet's per-step rollback could trip on a +// height it hasn't seen disconnected yet. +func TestEsploraChainServiceReorgEmitsBlockDisconnected(t *testing.T) { + t.Parallel() + + chainModel := newFakeChain(t, 100, "svc-reorg-100") + chainModel.extend("svc-reorg-101") + chainModel.extend("svc-reorg-102") + + srv := fakeChainServer(t, chainModel) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + tipPoller := NewTipPoller( + esplora, 20*time.Millisecond, btclog.Disabled, + ) + require.NoError(t, tipPoller.Start()) + t.Cleanup(tipPoller.Stop) + + service := NewEsploraChainService( + esplora, tipPoller, btclog.Disabled, + ) + require.NoError(t, service.Start(t.Context())) + t.Cleanup(func() { + service.Stop() + service.WaitForShutdown() + }) + + // First notification is always ClientConnected. + first := awaitNotification(t, service) + _, ok := first.(chain.ClientConnected) + require.True(t, ok, "expected ClientConnected first, got %T", first) + + // Drain forward to a known tip + a couple of new blocks so the + // service has a known set of recently-emitted connected blocks + // before the reorg fires. + chainModel.extend("svc-reorg-103") + drainUntilConnected(t, service, 103) + + // Stash hashes of the blocks the reorg is about to invalidate. + oldHash102 := chainModel.blocks[102].hash + oldHash103 := chainModel.blocks[103].hash + + // Rewrite heights 102..103 with a new chain. The tip stays at + // 103 but with a different hash, so the poller detects a + // same-height drift at 103 and walks back to fork point 101. + chainModel.rewriteFrom(102, "svc-reorg-new") + newHash102 := chainModel.blocks[102].hash + newHash103 := chainModel.blocks[103].hash + + // Drain notifications and pin the strict ordering. Each + // BlockConnected for a new-chain hash must arrive AFTER both + // BlockDisconnected events for the old chain — the unified + // chain stream guarantees this by serializing reorg + tip + // events through a single producer-ordered channel. + disconnectsSeen := 0 + gotDisconnected := []chainhash.Hash{} + gotConnectedNew := map[chainhash.Hash]struct{}{} + deadline := time.After(reorgTestTimeout) + for len(gotConnectedNew) < 2 { + select { + case n := <-service.Notifications(): + switch ev := n.(type) { + case chain.BlockDisconnected: + gotDisconnected = append( + gotDisconnected, ev.Block.Hash, + ) + disconnectsSeen++ + + case chain.BlockConnected: + meta := wtxmgr.BlockMeta(ev) + if meta.Hash != newHash102 && + meta.Hash != newHash103 { + + continue + } + + require.Equal( + t, 2, disconnectsSeen, "BlockConnect"+ + "ed for replacement chain "+ + "arrived before all "+ + "BlockDisconnected events: "+ + "saw %d disconnects so far", + disconnectsSeen, + ) + gotConnectedNew[meta.Hash] = struct{}{} + } + + case <-deadline: + t.Fatalf("timed out: disconnects=%v connected_new=%v", + gotDisconnected, gotConnectedNew) + } + } + + require.Equal( + t, oldHash103, gotDisconnected[0], "first "+ + "BlockDisconnected should be the newest old-chain "+ + "height (103) for btcwallet's per-step walk-back", + ) + require.Equal( + t, oldHash102, gotDisconnected[1], + "second BlockDisconnected should be height 102", + ) + + _, sawConn102 := gotConnectedNew[newHash102] + _, sawConn103 := gotConnectedNew[newHash103] + require.True(t, sawConn102, + "BlockConnected for new 102 not emitted") + require.True(t, sawConn103, + "BlockConnected for new 103 not emitted") +} diff --git a/lwwallet/esplora_chain_test.go b/lwwallet/esplora_chain_test.go index ee43b022f..151363cc0 100644 --- a/lwwallet/esplora_chain_test.go +++ b/lwwallet/esplora_chain_test.go @@ -2,6 +2,7 @@ package lwwallet import ( "bytes" + "encoding/hex" "encoding/json" "fmt" "net/http" @@ -299,6 +300,16 @@ func (c *rawBlockStubChain) serveBlockRoute(t *testing.T, w http.ResponseWriter, require.NoError(t, block.Serialize(&buf)) _, _ = w.Write(buf.Bytes()) + case "/header": + // Hex-encoded 80-byte block header. The TipPoller fetches + // this per new tip to verify PrevBlock continuity against + // the cached tip hash; without it the poller cannot + // distinguish a clean tip advance from a reorg crossing the + // boundary and aborts the cycle. + var buf bytes.Buffer + require.NoError(t, block.Header.Serialize(&buf)) + _, _ = fmt.Fprint(w, hex.EncodeToString(buf.Bytes())) + default: http.Error(w, "not implemented", http.StatusNotImplemented) diff --git a/lwwallet/tip_poller.go b/lwwallet/tip_poller.go index dbce6c97d..f2fd3557f 100644 --- a/lwwallet/tip_poller.go +++ b/lwwallet/tip_poller.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "sort" "sync" "time" @@ -11,6 +12,13 @@ import ( "github.com/btcsuite/btclog/v2" ) +// DefaultHashHistorySize is the default upper bound on entries retained +// in the TipPoller's bounded height -> hash history map. It is sized to +// at least twice the conventional Bitcoin reorg-safety depth (6) so a +// reorg at finality depth still has its disconnected hashes available +// for walk-back, with headroom. Configurable via NewTipPollerWithConfig. +const DefaultHashHistorySize = 100 + // TipBlock describes a newly detected block emitted by TipPoller. // Each subscriber receives one TipBlock per advance: when the tip // moves from oldHeight to newHeight the poller fans out (newHeight - @@ -38,6 +46,62 @@ type TipBlock struct { // for ergonomic call-site naming. type TipSubscription = Subscription[*TipBlock] +// ReorgEvent describes a chain reorganization observed by the +// TipPoller. The poller emits one ReorgEvent every time it detects +// that one or more previously broadcast blocks are no longer in the +// canonical chain. Disconnected hashes are listed in canonical +// (low-height first) order; Connected blocks are the new tip's path +// from ForkHeight+1 onward, also in canonical order. Either slice may +// be empty: a same-height hash-replacement reorg has Disconnected of +// length 1 and Connected of length 1. +type ReorgEvent struct { + // ForkHeight is the highest height at which the old chain and the + // new chain agree on the block hash. The first disconnected / + // connected block is at ForkHeight+1. + ForkHeight int32 + + // Disconnected lists block hashes that were previously broadcast + // as part of the canonical chain and are no longer on it, in + // ascending height order (ForkHeight+1 first). + Disconnected []chainhash.Hash + + // Connected lists blocks now on the canonical chain starting at + // ForkHeight+1, in ascending height order. The poller will also + // fan these out individually as TipBlock events on the standard + // Subscribe channel after the ReorgEvent is delivered. + Connected []*TipBlock +} + +// ReorgSubscription is the typed handle returned by +// TipPoller.SubscribeReorgs. +type ReorgSubscription = Subscription[*ReorgEvent] + +// ChainEvent is the unified update delivered on the ordered chain +// stream returned by SubscribeChain. Exactly one of Reorg and Tip is +// non-nil per event: +// +// - Reorg-only events announce a reorg's disconnected range and +// precede the replacement tip events on the same stream. +// - Tip-only events announce a new block (either a forward advance +// or a post-reorg connected block). +// +// Cross-event ordering on this stream is producer-ordered: the +// embedded EventServer delivers updates in SendUpdate order to a +// single subscriber goroutine, so a downstream consumer that +// dispatches on event type from one channel observes the same order +// the TipPoller emitted. That is the load-bearing property +// downstream needs to emit BlockDisconnected before BlockConnected +// for the replacement chain (btcwallet's disconnectBlock requires +// it; chainsource finality synthesis requires it too). +type ChainEvent struct { + Reorg *ReorgEvent + Tip *TipBlock +} + +// ChainSubscription is the typed handle returned by +// TipPoller.SubscribeChain. It carries the unified ChainEvent stream. +type ChainSubscription = Subscription[*ChainEvent] + // TipPoller is the single source of truth for the lwwallet chain // tip. Exactly one polling goroutine periodically asks the Esplora // backend for the current best height. When new blocks are detected @@ -58,18 +122,46 @@ type TipPoller struct { pollInterval time.Duration log btclog.Logger + // historySize caps the bounded height -> hash history map that + // lets the poller resolve old-chain hashes during reorg walk-back. + historySize int + // events is the typed event server that fans TipBlock updates // out to all active subscribers. Its Start/Stop are driven by // TipPoller.Start/Stop. events *EventServer[*TipBlock] + // reorgs is the typed event server that fans ReorgEvent updates + // out to subscribers that opt in via SubscribeReorgs. Reorgs are + // rare enough that a separate server is cheaper than re-fitting + // every TipBlock consumer with reorg-aware logic. + reorgs *EventServer[*ReorgEvent] + + // chain is the unified event server that fans both tip and reorg + // updates out on a single ordered stream. Consumers that need + // strict reorg-before-replacement-tip ordering (chain.Interface + // adapter, chainsource backend) subscribe here rather than to + // the separate events / reorgs servers, which would race across + // two independent translator goroutines. + chain *EventServer[*ChainEvent] + // mu guards the cached tip so BestBlock readers see a - // consistent height/hash/timestamp triple. + // consistent height/hash/timestamp triple. It also guards + // recentHashes, which is sized small enough that linear scans + // under the lock are cheap. mu sync.Mutex tipHeight int32 tipHash chainhash.Hash tipTime time.Time + // recentHashes maps a recent canonical-chain height to the hash + // the poller broadcast for it. It is bounded to historySize + // entries: on every insert we prune any entry whose height is + // more than historySize below the current tip. The buffer lets + // reorg detection walk back to the fork point using the cached + // hashes rather than re-fetching the entire pre-fork chain. + recentHashes map[int32]chainhash.Hash + // started gates re-entrant Start calls; the underlying // EventServer is also idempotent on Start. started bool @@ -82,15 +174,35 @@ type TipPoller struct { // NewTipPoller constructs a TipPoller bound to the given Esplora // client. The poll interval controls how often the goroutine asks // Esplora for the latest tip height; subscribers do not influence -// the cadence. +// the cadence. The bounded hash-history map is sized to +// DefaultHashHistorySize. func NewTipPoller(esplora *EsploraClient, pollInterval time.Duration, log btclog.Logger) *TipPoller { + return NewTipPollerWithConfig( + esplora, pollInterval, DefaultHashHistorySize, log, + ) +} + +// NewTipPollerWithConfig is the explicit-history-size constructor. +// historySize <= 0 falls back to DefaultHashHistorySize so callers +// cannot accidentally disable reorg walk-back by passing a zero value. +func NewTipPollerWithConfig(esplora *EsploraClient, pollInterval time.Duration, + historySize int, log btclog.Logger) *TipPoller { + + if historySize <= 0 { + historySize = DefaultHashHistorySize + } + return &TipPoller{ esplora: esplora, pollInterval: pollInterval, + historySize: historySize, log: log, events: NewEventServer[*TipBlock](log), + reorgs: NewEventServer[*ReorgEvent](log), + chain: NewEventServer[*ChainEvent](log), + recentHashes: make(map[int32]chainhash.Hash), quit: make(chan struct{}), } } @@ -170,12 +282,60 @@ func (t *TipPoller) Start() error { return fmt.Errorf("start event server: %w", err) } + if err := t.reorgs.Start(); err != nil { + // Roll back the tip-event server so a partial Start does + // not leave a half-running poller behind. + _ = t.events.Stop() + resetStarted() + + return fmt.Errorf("start reorg event server: %w", err) + } + + if err := t.chain.Start(); err != nil { + _ = t.events.Stop() + _ = t.reorgs.Stop() + resetStarted() + + return fmt.Errorf("start chain event server: %w", err) + } + + // Seed recentHashes by walking back historySize-1 heights so a + // reorg whose disconnected range extends below the seeded tip + // but within the configured history can still resolve every + // disconnected hash from the cache. Without this, a fresh + // poller would only ever cache the initial tip, leaving a + // downstream chain.Interface consumer unable to enumerate every + // hash btcwallet must roll back on a multi-block reorg below + // the seeded tip. The walk-back is best-effort: a single + // per-height fetch failure ends the seed loop early; the next + // poll tick still drives the cache forward as the chain grows. t.mu.Lock() t.tipHeight = height t.tipHash = hash t.tipTime = tipTime + t.recordHashLocked(height, hash) t.mu.Unlock() + for h := height - 1; h > height-int32(t.historySize) && h >= 0; h-- { + liveHash, err := t.esplora.GetBlockHashByHeight( + context.Background(), h, + ) + if err != nil { + t.log.WarnS( + context.Background(), + "Tip poller history seed fetch failed", + err, + slog.Int("height", int(h)), + ) + + break + } + + t.mu.Lock() + t.recordHashLocked(h, liveHash) + t.mu.Unlock() + } + t.wg.Add(1) go t.pollLoop() @@ -197,7 +357,7 @@ func (t *TipPoller) Stop() { t.wg.Wait() - // Stop the event server after the poll loop has exited so + // Stop the event servers after the poll loop has exited so // that no SendUpdate is in flight when the server tears down // its subscriber handler. if err := t.events.Stop(); err != nil { @@ -207,6 +367,22 @@ func (t *TipPoller) Stop() { err, ) } + + if err := t.reorgs.Stop(); err != nil { + t.log.WarnS( + context.Background(), + "Tip poller reorg event server stop returned error", + err, + ) + } + + if err := t.chain.Stop(); err != nil { + t.log.WarnS( + context.Background(), + "Tip poller chain event server stop returned error", + err, + ) + } } // BestBlock returns a snapshot of the currently cached tip. Callers @@ -233,6 +409,18 @@ func (t *TipPoller) Subscribe() (*TipSubscription, error) { return t.events.Subscribe() } +// SubscribeReorgs returns a typed subscription that receives a +// ReorgEvent every time the poller detects that one or more +// previously broadcast blocks have been replaced on the canonical +// chain. Callers that need both tip events and reorg events should +// subscribe to both streams; reorg events are delivered BEFORE the +// connected blocks are fanned out on the TipBlock stream so that +// consumers can mark their registrations dirty before the +// re-confirmation arrives. +func (t *TipPoller) SubscribeReorgs() (*ReorgSubscription, error) { + return t.reorgs.Subscribe() +} + // BestBlockAndSubscribe atomically reads the current cached tip and // registers a new subscription. The poll goroutine holds t.mu // during the {update tip + SendUpdate} pair, and this function @@ -257,6 +445,85 @@ func (t *TipPoller) BestBlockAndSubscribe() (int32, chainhash.Hash, time.Time, return t.tipHeight, t.tipHash, t.tipTime, sub, nil } +// BestBlockAndSubscribeAll atomically reads the cached tip and +// registers both a TipBlock subscription and a ReorgEvent +// subscription. Reorg-aware consumers (e.g. ChainBackend) use this +// to avoid the small window where a reorg could land between +// independently registering on the two streams. +func (t *TipPoller) BestBlockAndSubscribeAll() (int32, chainhash.Hash, + time.Time, *TipSubscription, *ReorgSubscription, error) { + + t.mu.Lock() + defer t.mu.Unlock() + + sub, err := t.events.Subscribe() + if err != nil { + return 0, chainhash.Hash{}, time.Time{}, nil, nil, + fmt.Errorf("subscribe to tip events: %w", err) + } + + reorgSub, err := t.reorgs.Subscribe() + if err != nil { + sub.Cancel() + + return 0, chainhash.Hash{}, time.Time{}, nil, nil, + fmt.Errorf("subscribe to reorg events: %w", err) + } + + return t.tipHeight, t.tipHash, t.tipTime, sub, reorgSub, nil +} + +// SubscribeChain returns a typed subscription on the unified chain +// event stream. Updates arrive in producer order through a single +// channel; consumers that need strict reorg-before-replacement-tip +// ordering must subscribe here rather than to the separate tip / +// reorg streams (which race across two independent translator +// goroutines). +func (t *TipPoller) SubscribeChain() (*ChainSubscription, error) { + return t.chain.Subscribe() +} + +// BestBlockAndSubscribeChain atomically reads the cached tip and +// registers a ChainSubscription. Same atomicity guarantee as +// BestBlockAndSubscribe, applied to the unified stream. +func (t *TipPoller) BestBlockAndSubscribeChain() (int32, chainhash.Hash, + time.Time, *ChainSubscription, error) { + + t.mu.Lock() + defer t.mu.Unlock() + + sub, err := t.chain.Subscribe() + if err != nil { + return 0, chainhash.Hash{}, time.Time{}, nil, + fmt.Errorf("subscribe to chain events: %w", err) + } + + return t.tipHeight, t.tipHash, t.tipTime, sub, nil +} + +// recordHashLocked inserts a (height, hash) pair into the recent- +// hash history map and prunes any entry whose height has fallen out +// of the historySize window relative to the new entry. Caller must +// hold t.mu. +func (t *TipPoller) recordHashLocked(height int32, hash chainhash.Hash) { + t.recentHashes[height] = hash + + cutoff := height - int32(t.historySize) + for h := range t.recentHashes { + if h <= cutoff { + delete(t.recentHashes, h) + } + } +} + +// hashAtHeightLocked returns the cached hash for a height plus +// whether it was present. Caller must hold t.mu. +func (t *TipPoller) hashAtHeightLocked(height int32) (chainhash.Hash, bool) { + h, ok := t.recentHashes[height] + + return h, ok +} + // pollLoop is the single tip-polling goroutine. It ticks at // pollInterval, asks Esplora for the latest tip, and walks the // gap from the cached tip to the new tip emitting one TipBlock per @@ -278,12 +545,26 @@ func (t *TipPoller) pollLoop() { } } -// poll performs one tip-detection cycle. On detected progress it -// fetches the hash and header for each new height, broadcasts a -// TipBlock to every subscriber, and advances the cached tip -// monotonically. A failure to fetch any single block aborts the -// remainder of the cycle so subscribers never see an out-of-order -// event; the next tick re-attempts from the same starting point. +// poll performs one tip-detection cycle. The cycle is reorg-aware: +// +// 1. Query (tip height, tip hash). +// 2. If the tip is at the same height as the cached tip but the hash +// differs, walk back through cached hashes to find the fork point +// and emit a ReorgEvent + a re-broadcast TipBlock at the same +// height. This is the "same-height reorg" case that earlier +// versions of the poller could not detect. +// 3. If the new height is strictly greater than the cached height, +// fetch each intervening hash; if the first new block's PrevBlock +// header field does not point at the cached tip hash, walk back +// to find the fork point, emit a ReorgEvent for the disconnected +// range, and then fan TipBlock events for the new chain in +// ascending order. If the new chain extends the old tip cleanly +// (the common case), no ReorgEvent fires and TipBlock events are +// dispatched as before. +// +// A failure to fetch any single block aborts the remainder of the +// cycle so subscribers never see an out-of-order event; the next +// tick re-attempts from the same starting point. func (t *TipPoller) poll() { newHeight, err := t.esplora.GetTipHeight(context.Background()) if err != nil { @@ -298,23 +579,123 @@ func (t *TipPoller) poll() { t.mu.Lock() oldHeight := t.tipHeight + oldHash := t.tipHash t.mu.Unlock() - // Known limitation: a same-height reorg (block at height N - // replaced by a different block at height N) is invisible to - // this loop until the chain advances to N+1, because we gate - // progress on height alone rather than (height, hash). This - // matches the behavior of the per-component pollers that - // preceded the unified TipPoller and has historically been - // acceptable for lwwallet's confirmation-target use case - // (downstream callers re-check status against Esplora on every - // tip event, so a stale hash at height N converges within one - // extra tip advance). Documented here so it is not filed as a - // regression by a future reader. - if newHeight <= oldHeight { + switch { + case newHeight < oldHeight: + // The remote reports fewer blocks than we have cached. + // Two distinct cases hide behind a lower height: + // + // 1. A transient indexer hiccup where the remote + // momentarily lags our cached tip but is still on + // the same chain (our tip remains canonical). + // 2. A genuine reorg onto a SHORTER but higher-work + // chain: blocks above newHeight were orphaned and + // the surviving chain has fewer total blocks. Bitcoin + // follows most-work, not most-blocks, so a shorter + // chain can legitimately win. + // + // Disambiguate by comparing the live hash at newHeight to + // our cached hash for that height. Agreement means the + // remote is merely behind (case 1, no-op); divergence + // means the chain reorged out from under us (case 2) and + // we must walk back to the fork point. Without this an + // orphaning reorg to a shorter chain would be silently + // ignored until the new chain grew past our stale tip. + newTipHash, err := t.esplora.GetBlockHashByHeight( + context.Background(), newHeight, + ) + if err != nil { + t.log.WarnS( + context.Background(), + "Tip poller shorter-chain hash check failed", + err, + slog.Int("height", int(newHeight)), + ) + + return + } + + t.mu.Lock() + cachedHash, haveCached := t.hashAtHeightLocked(newHeight) + t.mu.Unlock() + + switch { + // newHeight predates our retained history floor, so we + // cannot prove a reorg by hash comparison. A reorg this + // deep exceeds historySize and is an operator-level + // problem rather than a productionally recoverable one + // (mirrors handleReorg's own deep-reorg guard); log and + // re-check next tick rather than walk a pruned history. + case !haveCached: + t.log.WarnS( + context.Background(), + "Tip poller remote tip below retained "+ + "history floor; ignoring", + fmt.Errorf("history floor exceeded"), + slog.Int("new_height", int(newHeight)), + slog.Int("old_height", int(oldHeight)), + ) + + return + + // Same hash at newHeight: the remote is merely lagging + // our cached tip on the same chain. No-op. + case newTipHash == cachedHash: + return + } + + // Divergent hash at newHeight: a reorg onto a shorter, + // higher-work chain. handleReorg builds Disconnected up + // to our old cached tip (covering the orphaned blocks + // above newHeight) and Connected up to newHeight. + t.handleReorg(newHeight, newTipHash) + + return + + case newHeight == oldHeight: + // Same-height: query the hash at this height (which + // the BlockHashByHeight endpoint resolves directly). + // If it differs from the cached hash at the same + // height we have a same-height reorg; if it matches + // the chain has not moved. + newTipHash, err := t.esplora.GetBlockHashByHeight( + context.Background(), newHeight, + ) + if err != nil { + t.log.WarnS( + context.Background(), + "Tip poller same-height hash check failed", + err, + slog.Int("height", int(newHeight)), + ) + + return + } + + if newTipHash == oldHash { + return + } + + t.handleReorg(newHeight, newTipHash) + return + + default: + // newHeight > oldHeight: forward advance, possibly with + // a deeper reorg if the first new height's predecessor + // is not the cached tip hash. + t.advance(oldHeight, newHeight) } +} +// advance walks the chain forward from oldHeight+1 to newHeight, +// detecting reorgs along the way. The very first new height's raw +// header is consulted to verify its PrevBlock matches the cached +// tip hash; mismatch triggers a walk-back to the fork point and a +// reorg emission before any new TipBlock events fire. +func (t *TipPoller) advance(oldHeight, newHeight int32) { t.log.DebugS(context.Background(), "Tip poller advancing", slog.Int("old_height", int(oldHeight)), slog.Int("new_height", int(newHeight)), @@ -349,44 +730,397 @@ func (t *TipPoller) poll() { return } + // On the FIRST new height, verify continuity against + // the cached tip. We use the raw 80-byte header (which + // carries PrevBlock) because the JSON header endpoint + // does not include the previous-block hash. If + // PrevBlock does not match the cached tip hash a reorg + // crossed the boundary; walk back to the fork point + // and emit a ReorgEvent before continuing. + if height == oldHeight+1 { + rawHdr, err := t.esplora.GetRawBlockHeader( + context.Background(), hash, + ) + if err != nil { + // Without the raw header we cannot verify + // that the new block connects to the cached + // tip. Abort the cycle without broadcasting + // or updating the cached tip; the next tick + // will retry. Optimistically advancing here + // would permanently hide a reorg that + // crossed the oldHeight boundary if the + // raw-header fetch flaked exactly when the + // reorg happened. + t.log.WarnS( + context.Background(), + "Tip poller raw header fetch "+ + "failed; aborting cycle", + err, + slog.String("hash", hash.String()), + ) + + return + } + + t.mu.Lock() + cachedOldHash, ok := t.hashAtHeightLocked( + oldHeight, + ) + t.mu.Unlock() + + if ok && rawHdr.PrevBlock != cachedOldHash { + // Deeper reorg: handle it as a new-tip + // reorg from oldHeight, pointing at the + // actual new tip at newHeight. handleReorg + // emits ReorgEvent + TipBlock events + // itself. + tipHash, tipErr := t.esplora. + GetBlockHashByHeight( + context.Background(), newHeight, + ) + if tipErr != nil { + t.log.WarnS( + context.Background(), + "Tip poller "+ + "reorg tip "+ + "fetch failed", + tipErr, + slog.Int( + "height", + int(newHeight), + ), + ) + + return + } + + t.handleReorg(newHeight, tipHash) + + return + } + } + event := &TipBlock{ Height: height, Hash: hash, Header: header, } - // Hold t.mu across the {update tip + SendUpdate} - // pair so BestBlockAndSubscribe can serialize against - // it: a subscriber that acquires t.mu before us reads - // the OLD tip and is guaranteed to receive THIS event - // once it Subscribes (subscribe.Server's handler is - // single-threaded over Subscribe and SendUpdate, so - // our SendUpdate enqueues behind their Subscribe). A - // subscriber that acquires t.mu after us reads the - // NEW tip and will see only events strictly newer - // than this one. Without holding t.mu here a tip - // reader+subscriber pair has a small window where it - // can read the new tip but miss this event entirely. + if !t.broadcastTipBlock(event) { + return + } + } +} + +// broadcastTipBlock updates the cached tip, records the hash in the +// history map, and fans the TipBlock out to subscribers. Returns +// false if the send failed (server shutting down), telling the +// caller to abort the cycle so the cached tip is not advanced past +// an event subscribers did not receive. +func (t *TipPoller) broadcastTipBlock(event *TipBlock) bool { + // Hold t.mu across the {update tip + SendUpdate} pair so + // BestBlockAndSubscribe can serialize against it: a subscriber + // that acquires t.mu before us reads the OLD tip and is + // guaranteed to receive THIS event once it Subscribes + // (subscribe.Server's handler is single-threaded over + // Subscribe and SendUpdate, so our SendUpdate enqueues behind + // their Subscribe). A subscriber that acquires t.mu after us + // reads the NEW tip and will see only events strictly newer + // than this one. + t.mu.Lock() + t.tipHeight = event.Height + t.tipHash = event.Hash + if event.Header != nil { + t.tipTime = time.Unix(event.Header.Timestamp, 0) + } + t.recordHashLocked(event.Height, event.Hash) + tipErr := t.events.SendUpdate(event) + chainErr := t.chain.SendUpdate(&ChainEvent{Tip: event}) + t.mu.Unlock() + + if tipErr != nil { + t.log.WarnS( + context.Background(), + "Tip poller send update failed", + tipErr, + slog.Int("height", int(event.Height)), + ) + + return false + } + + if chainErr != nil { + t.log.WarnS( + context.Background(), + "Tip poller chain stream send failed", + chainErr, + slog.Int("height", int(event.Height)), + ) + + return false + } + + return true +} + +// handleReorg walks back through the recent-hash history map until it +// finds the fork point between the cached chain and the new chain +// whose tip is (newTipHeight, newTipHash). It then constructs a +// ReorgEvent listing the disconnected hashes and connected blocks, +// broadcasts the reorg, and finally fans out the connected blocks +// as TipBlock events in ascending height order so consumers can +// re-check registrations against each new block. +func (t *TipPoller) handleReorg(newTipHeight int32, newTipHash chainhash.Hash) { + t.log.InfoS(context.Background(), "Tip poller detected reorg", + slog.Int("new_tip_height", int(newTipHeight)), + slog.String("new_tip_hash", newTipHash.String()), + ) + + // Walk back to find the fork point. We probe heights from + // newTipHeight downwards, comparing the live Esplora hash at + // each height to the cached hash. The first height at which + // they agree is the fork point. We bound the search by the + // retained history depth so a misbehaving Esplora cannot drag + // us into an unbounded loop. + t.mu.Lock() + cachedTipHeight := t.tipHeight + cutoff := cachedTipHeight - int32(t.historySize) + t.mu.Unlock() + + if cutoff < 0 { + cutoff = 0 + } + + // connectedByHeight collects new-chain blocks discovered + // during walk-back so we can re-broadcast them in ascending + // order after the fork point is found. + connectedByHeight := make(map[int32]chainhash.Hash) + connectedByHeight[newTipHeight] = newTipHash + + forkHeight := int32(-1) + probeHeight := newTipHeight + for probeHeight > cutoff { + probeHeight-- + + // Heights strictly above our cached tip cannot be a fork + // point: we never broadcast a block there, so the live + // block is a NEW connected block on a longer chain (a + // forward reorg that both reorganized old blocks AND + // extended past our tip). Record it and keep walking + // down toward the real fork. Without this, the + // !haveCached branch below would mistake the first such + // height for the fork point and compute a forkHeight + // above the cached tip, underflowing the Disconnected + // slice capacity. + if probeHeight > cachedTipHeight { + liveHash, err := t.esplora.GetBlockHashByHeight( + context.Background(), probeHeight, + ) + if err != nil { + t.log.WarnS( + context.Background(), + "Tip poller reorg walk-back failed", + err, + slog.Int("height", int(probeHeight)), + ) + + return + } + + connectedByHeight[probeHeight] = liveHash + + continue + } + t.mu.Lock() - t.tipHeight = height - t.tipHash = hash - t.tipTime = time.Unix(header.Timestamp, 0) - sendErr := t.events.SendUpdate(event) + cachedHash, haveCached := t.hashAtHeightLocked(probeHeight) t.mu.Unlock() - // SendUpdate failures only happen when the embedded - // subscribe.Server is shutting down; log and exit so - // we do not advance the cached tip past an event we - // failed to fan out. - if sendErr != nil { + if !haveCached { + // We never broadcast a block at this height + // (typically because it's older than our + // retained history's start, e.g. on a fresh + // poller that only ever cached the initial + // tip). Treat probeHeight as the fork point: + // anything at or below it is not part of our + // old broadcast set, so it cannot be + // "disconnected" from a downstream consumer's + // point of view. The live hash at this height + // is not a new connected block we owe + // subscribers either; only blocks strictly + // above probeHeight that we previously + // broadcast (and that have now changed) form + // the reorg boundary. + forkHeight = probeHeight + + break + } + + liveHash, err := t.esplora.GetBlockHashByHeight( + context.Background(), probeHeight, + ) + if err != nil { t.log.WarnS( context.Background(), - "Tip poller send update failed", - sendErr, - slog.Int("height", int(height)), + "Tip poller reorg walk-back failed", + err, + slog.Int("height", int(probeHeight)), + ) + + return + } + + if liveHash == cachedHash { + // Genuine fork point: the new chain agrees with + // our cached canonical chain at this height. + forkHeight = probeHeight + + break + } + + // haveCached && liveHash != cachedHash: this height is + // part of the reorg. Record the live hash so we can + // rebroadcast it after the fork point is found. + connectedByHeight[probeHeight] = liveHash + } + + if forkHeight < 0 { + // We exhausted the retained history without finding a + // fork point. Be conservative: log and bail. A future + // tick will re-attempt with the now-pruned history; in + // practice a reorg deeper than DefaultHashHistorySize + // is a developer / operator problem, not a + // productionally recoverable condition. + t.log.WarnS( + context.Background(), + "Tip poller reorg deeper than retained history; "+ + "giving up walk-back", + fmt.Errorf("history exhausted"), + slog.Int("new_tip_height", int(newTipHeight)), + slog.Int("history_size", t.historySize), + ) + + return + } + + // Build the Disconnected slice from cached hashes between + // forkHeight+1 and the cached tipHeight in one critical + // section. The walk is already in ascending order so no sort + // is needed; we cap at the history size to bound the worst + // case under t.mu. + t.mu.Lock() + cachedTipHeight = t.tipHeight + disconnected := make( + []chainhash.Hash, 0, int(cachedTipHeight-forkHeight), + ) + for h := forkHeight + 1; h <= cachedTipHeight; h++ { + if hash, ok := t.hashAtHeightLocked(h); ok { + disconnected = append(disconnected, hash) + } + } + t.mu.Unlock() + + // Build the Connected slice. Heights range from forkHeight+1 + // to newTipHeight; for each, fetch the header so the + // TipBlock carries a populated Header field for downstream + // consumers. + connectedHeights := make([]int32, 0, len(connectedByHeight)) + for h := range connectedByHeight { + if h <= forkHeight { + continue + } + connectedHeights = append(connectedHeights, h) + } + sort.Slice(connectedHeights, func(i, j int) bool { + return connectedHeights[i] < connectedHeights[j] + }) + + connected := make([]*TipBlock, 0, len(connectedHeights)) + for _, h := range connectedHeights { + hash := connectedByHeight[h] + header, err := t.esplora.GetBlockHeader( + context.Background(), hash, + ) + if err != nil { + t.log.WarnS( + context.Background(), + "Tip poller reorg header fetch failed", + err, + slog.String("hash", hash.String()), ) return } + + connected = append(connected, &TipBlock{ + Height: h, + Hash: hash, + Header: header, + }) + } + + // Prune the now-stale hashes from the history before we + // broadcast the reorg so a subscriber that immediately calls + // back into BestBlock sees the new tip. + t.mu.Lock() + for h := forkHeight + 1; h <= cachedTipHeight; h++ { + delete(t.recentHashes, h) + } + t.mu.Unlock() + + reorgEvent := &ReorgEvent{ + ForkHeight: forkHeight, + Disconnected: disconnected, + Connected: connected, + } + + t.log.InfoS(context.Background(), "Tip poller emitting reorg", + slog.Int("fork_height", int(forkHeight)), + slog.Int("disconnected", len(disconnected)), + slog.Int("connected", len(connected)), + ) + + if err := t.reorgs.SendUpdate(reorgEvent); err != nil { + t.log.WarnS( + context.Background(), + "Tip poller reorg send failed", + err, + ) + + return + } + + // Also emit the reorg on the unified chain stream BEFORE any + // connected-block tip events land on it, so a single consumer + // reading the chain subscription sees Reorged before any of + // the replacement Connected blocks. This is the load-bearing + // ordering property the chain.Interface adapter needs to emit + // BlockDisconnected before BlockConnected, and the chainsource + // backend needs to reset registrations before block-epoch + // driven re-checks run on the replacement chain. + if err := t.chain.SendUpdate( + &ChainEvent{Reorg: reorgEvent}, + ); err != nil { + + t.log.WarnS( + context.Background(), + "Tip poller chain reorg send failed", + err, + ) + + return + } + + // Finally, fan the connected blocks out on the standard + // TipBlock stream so existing consumers re-check on each + // new block exactly as they would on a non-reorg advance. + // broadcastTipBlock also pushes each block onto the unified + // chain stream so the single-channel consumer observes the + // connected blocks immediately after the reorg event in the + // same producer-ordered sequence. + for _, block := range connected { + if !t.broadcastTipBlock(block) { + return + } } } diff --git a/lwwallet/tip_poller_reorg_test.go b/lwwallet/tip_poller_reorg_test.go new file mode 100644 index 000000000..0b9dd2cbc --- /dev/null +++ b/lwwallet/tip_poller_reorg_test.go @@ -0,0 +1,274 @@ +package lwwallet + +import ( + "fmt" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/stretchr/testify/require" +) + +// mintStubHeaderGen is like mintStubHeader but mixes a fork generation +// into the salt so re-minting the same height on a forked chain yields +// a distinct BlockHash. This lets a test rebuild a height range on a +// different chain whose hashes provably diverge from the original. +func mintStubHeaderGen(height, gen int32, + prev chainhash.Hash) *wire.BlockHeader { + + salt := chainhash.HashH( + fmt.Appendf(nil, "stub-block-%d-gen-%d", height, gen), + ) + + return &wire.BlockHeader{ + Version: 1, + PrevBlock: prev, + MerkleRoot: chainhash.HashH( + fmt.Appendf(nil, "merkle-%d-gen-%d", height, gen), + ), + Timestamp: time.Unix(int64(height)*600, 0), + Bits: 0x207fffff, + Nonce: uint32(salt[0])<<24 | uint32(salt[1])<<16 | + uint32(salt[2])<<8 | uint32(salt[3]), + } +} + +// reorgTo rewrites the chain onto a fork that branches at forkHeight and +// extends to newTip. newTip may be lower than the current tip (a shorter +// but higher-work chain), equal to it (a same-height hash replacement), +// or higher (a deeper forward reorg). Heights above forkHeight are +// replaced with generation-salted blocks whose hashes diverge from the +// old chain; any old heights above newTip are orphaned (removed) so the +// stubbed backend no longer serves them. +func (c *stubChain) reorgTo(t *testing.T, forkHeight, newTip, gen int32) { + t.Helper() + + c.mu.Lock() + defer c.mu.Unlock() + + // Drop every height above the fork point; the new chain rebuilds + // forkHeight+1 .. newTip and orphans anything beyond newTip. + for h := range c.hashAt { + if h > forkHeight { + delete(c.hashAt, h) + delete(c.blocks, h) + } + } + + prev := c.hashAt[forkHeight] + for h := forkHeight + 1; h <= newTip; h++ { + hdr := mintStubHeaderGen(h, gen, prev) + c.blocks[h] = hdr + hash := hdr.BlockHash() + c.hashAt[h] = hash + prev = hash + } + + c.tipHeight = newTip +} + +// setTipHeight lowers (or raises) the reported tip height WITHOUT +// touching any cached hashes. It models a transient indexer hiccup +// where the remote momentarily reports fewer blocks than it served a +// moment ago, but is still on the same chain. +func (c *stubChain) setTipHeight(h int32) { + c.mu.Lock() + defer c.mu.Unlock() + + c.tipHeight = h +} + +// requireTipEventually blocks until the poller's BestBlock height +// reaches want, failing the test if it does not within the deadline. +func requireTipEventually(t *testing.T, tp *TipPoller, want int32) { + t.Helper() + + require.Eventually(t, func() bool { + h, _, _ := tp.BestBlock() + + return h == want + }, 2*time.Second, 5*time.Millisecond, + "poller never reached tip height %d", want) +} + +// newReorgTestPoller spins up a TipPoller over a stubChain seeded at the +// given height and returns both so a test can drive reorgs. +func newReorgTestPoller(t *testing.T, + seedHeight int32) (*TipPoller, *stubChain) { + + t.Helper() + + chain := newStubChain(seedHeight) + srv := mockEsploraServer(t, stubEsploraHandler(t, chain)) + + tp := NewTipPoller( + NewEsploraClient(srv.URL, btclog.Disabled), 10*time.Millisecond, + btclog.Disabled, + ) + require.NoError(t, tp.Start()) + t.Cleanup(tp.Stop) + + return tp, chain +} + +// TestTipPollerShorterChainReorg covers the case Roasbeef flagged: a +// reorg onto a SHORTER but higher-work chain, where blocks above the new +// tip are orphaned. The poller must detect this even though the remote +// reports a lower height than the cached tip, and roll the tip back to +// the shorter chain's tip. +func TestTipPollerShorterChainReorg(t *testing.T) { + t.Parallel() + + tp, chain := newReorgTestPoller(t, 100) + + reorgSub, err := tp.SubscribeReorgs() + require.NoError(t, err) + defer reorgSub.Cancel() + + // Grow to 110 so the poller caches 101..110, then confirm it + // observed the advance before we reorg out from under it. + chain.advance(t, 10) + requireTipEventually(t, tp, 110) + + // Fork at 105 onto a shorter chain that tips at 108 (< 110). + chain.reorgTo(t, 105, 108, 1) + + select { + case ev := <-reorgSub.Updates(): + require.NotNil(t, ev) + require.Equal(t, int32(105), ev.ForkHeight) + + // Old chain 106..110 disconnects (5 blocks), even though + // the new chain only carries 106..108. + require.Len(t, ev.Disconnected, 5) + + // New chain connects 106..108 (3 blocks) in ascending + // order, tipping at 108. + require.Len(t, ev.Connected, 3) + require.Equal( + t, int32(106), ev.Connected[0].Height, + ) + require.Equal( + t, int32(108), ev.Connected[len(ev.Connected)-1].Height, + ) + + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for shorter-chain reorg event") + } + + // The poller's tip must now be the shorter chain's tip. + requireTipEventually(t, tp, 108) +} + +// TestTipPollerShorterHeightSameChainNoReorg verifies the no-op half of +// the shorter-height branch: a transient indexer hiccup where the remote +// reports fewer blocks but is still on the same chain (the hash at the +// reported height matches our cache). No reorg must fire and the cached +// tip must NOT roll back. +func TestTipPollerShorterHeightSameChainNoReorg(t *testing.T) { + t.Parallel() + + tp, chain := newReorgTestPoller(t, 100) + + reorgSub, err := tp.SubscribeReorgs() + require.NoError(t, err) + defer reorgSub.Cancel() + + chain.advance(t, 10) + requireTipEventually(t, tp, 110) + + // Remote briefly reports height 108 with the SAME hash history + // (no fork). This is lag, not a reorg. + chain.setTipHeight(108) + + select { + case ev := <-reorgSub.Updates(): + t.Fatalf("unexpected reorg on transient lag: %+v", ev) + + case <-time.After(300 * time.Millisecond): + // No reorg fired: correct. + } + + // The tip must remain at 110: we never roll back on a lagging + // remote that is still on our chain. + h, _, _ := tp.BestBlock() + require.Equal(t, int32(110), h) +} + +// TestTipPollerSameHeightReorg covers a same-height hash replacement: the +// tip height does not change but the block at the tip is replaced by a +// different block on a competing chain. +func TestTipPollerSameHeightReorg(t *testing.T) { + t.Parallel() + + tp, chain := newReorgTestPoller(t, 100) + + reorgSub, err := tp.SubscribeReorgs() + require.NoError(t, err) + defer reorgSub.Cancel() + + chain.advance(t, 10) + requireTipEventually(t, tp, 110) + + oldHash := chain.hashAt[110] + + // Replace block 110 with a different block at the same height. + chain.reorgTo(t, 109, 110, 1) + + select { + case ev := <-reorgSub.Updates(): + require.NotNil(t, ev) + require.Equal(t, int32(109), ev.ForkHeight) + require.Len(t, ev.Disconnected, 1) + require.Equal(t, oldHash, ev.Disconnected[0]) + require.Len(t, ev.Connected, 1) + require.Equal(t, int32(110), ev.Connected[0].Height) + + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for same-height reorg event") + } + + requireTipEventually(t, tp, 110) +} + +// TestTipPollerDeeperForwardReorg covers a reorg that also extends the +// chain: the new chain forks below the old tip yet ends higher than it. +// The forward-advance path must notice the broken PrevBlock continuity +// at the old tip boundary and emit a reorg before the new tip events. +func TestTipPollerDeeperForwardReorg(t *testing.T) { + t.Parallel() + + tp, chain := newReorgTestPoller(t, 100) + + reorgSub, err := tp.SubscribeReorgs() + require.NoError(t, err) + defer reorgSub.Cancel() + + chain.advance(t, 10) + requireTipEventually(t, tp, 110) + + // Fork at 107 onto a longer chain that tips at 113. + chain.reorgTo(t, 107, 113, 1) + + select { + case ev := <-reorgSub.Updates(): + require.NotNil(t, ev) + require.Equal(t, int32(107), ev.ForkHeight) + + // Old chain 108..110 disconnects (3 blocks). + require.Len(t, ev.Disconnected, 3) + + // New chain connects 108..113 (6 blocks). + require.Len(t, ev.Connected, 6) + require.Equal( + t, int32(113), ev.Connected[len(ev.Connected)-1].Height, + ) + + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for deeper forward reorg event") + } + + requireTipEventually(t, tp, 113) +} diff --git a/lwwallet/tip_poller_test.go b/lwwallet/tip_poller_test.go index e4aed4a9c..cf75fa16b 100644 --- a/lwwallet/tip_poller_test.go +++ b/lwwallet/tip_poller_test.go @@ -1,6 +1,8 @@ package lwwallet import ( + "bytes" + "encoding/hex" "fmt" "net/http" "sync" @@ -9,6 +11,7 @@ import ( "time" "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btclog/v2" "github.com/stretchr/testify/require" ) @@ -16,28 +19,59 @@ import ( // stubChain is a tiny test fixture that simulates an Esplora chain // where the tip height can be advanced under test control. It is // independent of the larger mockEsploraServer helper so tip-poller -// tests can drive the chain forward synchronously. +// tests can drive the chain forward synchronously. Each height holds +// a real wire.BlockHeader whose PrevBlock chains back to the previous +// height; the poller's PrevBlock continuity check on advance therefore +// passes naturally rather than aborting. type stubChain struct { mu sync.Mutex tipHeight int32 - // hashAt[h] is the hash for height h. We pre-populate as the - // tip advances so GetBlockHashByHeight resolves consistently. + // blocks[h] holds the wire.BlockHeader for height h. Pre- + // populated through 0..tipHeight at construction and grown + // monotonically by advance. + blocks map[int32]*wire.BlockHeader + + // hashAt[h] is the chainhash.Hash for height h. hashAt map[int32]chainhash.Hash } +// mintStubHeader builds a deterministic wire.BlockHeader for a given +// height, chaining off the supplied previous hash. The salted nonce +// makes the resulting BlockHash stable per (height, prev) and unique +// across heights. +func mintStubHeader(height int32, prev chainhash.Hash) *wire.BlockHeader { + salt := chainhash.HashH([]byte(fmt.Sprintf("stub-block-%d", height))) + + return &wire.BlockHeader{ + Version: 1, + PrevBlock: prev, + MerkleRoot: chainhash.HashH( + []byte( + fmt.Sprintf("merkle-%d", height), + ), + ), + Timestamp: time.Unix(int64(height)*600, 0), + Bits: 0x207fffff, + Nonce: uint32(salt[0])<<24 | uint32(salt[1])<<16 | + uint32(salt[2])<<8 | uint32(salt[3]), + } +} + func newStubChain(tipHeight int32) *stubChain { c := &stubChain{ tipHeight: tipHeight, + blocks: make(map[int32]*wire.BlockHeader), hashAt: make(map[int32]chainhash.Hash), } + var prev chainhash.Hash for h := int32(0); h <= tipHeight; h++ { - c.hashAt[h] = chainhash.HashH( - []byte( - fmt.Sprintf("block-%d", h), - ), - ) + hdr := mintStubHeader(h, prev) + c.blocks[h] = hdr + hash := hdr.BlockHash() + c.hashAt[h] = hash + prev = hash } return c @@ -49,13 +83,14 @@ func (c *stubChain) advance(t *testing.T, n int32) { c.mu.Lock() defer c.mu.Unlock() + prev := c.hashAt[c.tipHeight] for i := int32(1); i <= n; i++ { h := c.tipHeight + i - c.hashAt[h] = chainhash.HashH( - []byte( - fmt.Sprintf("block-%d", h), - ), - ) + hdr := mintStubHeader(h, prev) + c.blocks[h] = hdr + hash := hdr.BlockHash() + c.hashAt[h] = hash + prev = hash } c.tipHeight += n @@ -147,19 +182,29 @@ func stubEsploraHandler(t *testing.T, chain *stubChain) http.HandlerFunc { h.String(), height, int64(height)*600) + case "/header": + // Raw 80-byte header, hex-encoded. + chain.mu.Lock() + hdr := chain.blocks[height] + chain.mu.Unlock() + if hdr == nil { + http.Error( + w, "not found", + http.StatusNotFound, + ) + + return + } + var buf bytes.Buffer + require.NoError(t, hdr.Serialize(&buf)) + _, _ = fmt.Fprint( + w, + hex.EncodeToString( + buf.Bytes(), + ), + ) + default: - // Raw header / raw block — synthesize a - // header whose serialized bytes hash to - // h. We take the simple route of using - // h's bytes themselves: the header - // hash-verifier compares header.BlockHash() - // to the requested h, so any header that - // happens to round-trip works. Synthesizing - // such a header from a target hash is - // effectively impossible, so for these - // suffix variants we return 501 — tests - // that need them must use the cache - // pre-fill path. http.Error( w, "not implemented", http.StatusNotImplemented, From 34c26644a8321d12c344d02ffd8fcff4fa3a8f66 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Mon, 13 Jul 2026 09:01:14 -0700 Subject: [PATCH 05/18] btcwbackend: reorg-aware Neutrino chain notifier forwarding Forward Neutrino chain-notifier reorg signals into the reorg-aware chainsource lifecycle. --- btcwbackend/chain_backend.go | 171 ++++++++--- btcwbackend/chain_backend_reorg_test.go | 359 ++++++++++++++++++++++++ 2 files changed, 493 insertions(+), 37 deletions(-) create mode 100644 btcwbackend/chain_backend_reorg_test.go diff --git a/btcwbackend/chain_backend.go b/btcwbackend/chain_backend.go index 6d631daac..adf29bf4f 100644 --- a/btcwbackend/chain_backend.go +++ b/btcwbackend/chain_backend.go @@ -38,8 +38,13 @@ type ChainBackend struct { neutrinoCS *neutrino.ChainService // notifier provides chain notification services backed by - // neutrino's compact block filter scanning. - notifier *neutrinonotify.NeutrinoNotifier + // neutrino's compact block filter scanning. Stored under the + // chainntnfs interface (concrete type is + // *neutrinonotify.NeutrinoNotifier in production) so reorg-aware + // forwarder tests can substitute a stub that drives the full + // Confirmed / NegativeConf / Done lifecycle without spinning up a + // neutrino chain service. + notifier chainntnfs.ChainNotifier // feeEstimator provides fee estimation from a web API since // neutrino has no mempool visibility. @@ -571,40 +576,89 @@ func (b *ChainBackend) RegisterConf(ctx context.Context, txid *chainhash.Hash, cancelOnce.Do(event.Cancel) } + // Create channels to convert neutrino's confirmation lifecycle + // (which already drives chainntnfs's NegativeConf/Done channels + // directly — neutrino emits these natively from its compact- + // block-filter scanner) into our backend-agnostic types. + // NegativeConf carries a reorg depth that the cross-backend + // chainsource layer intentionally drops, so we forward a bare + // struct{} signal on reorgChan instead. confChan := make(chan *chainsource.TxConfirmation, 1) + reorgChan := make(chan uint64, 1) + doneChan := make(chan struct{}, 1) go func() { + // Defers run in LIFO order. event.Cancel() must run first + // so the upstream notifier stops writing to its internal + // channels before we cancel notifyCtx (which any in-flight + // downstream sends are still using) and finally close the + // outgoing chans. Reversing this order would race the + // upstream notifier against closed channels. defer close(confChan) + defer close(reorgChan) + defer close(doneChan) defer cancel() defer safeCancel() - select { - case lndConf, ok := <-event.Confirmed: - if !ok { - return - } + for { + select { + case lndConf, ok := <-event.Confirmed: + if !ok { + return + } - conf := &chainsource.TxConfirmation{ - BlockHash: lndConf.BlockHash, - BlockHeight: lndConf.BlockHeight, - TxIndex: lndConf.TxIndex, - Tx: lndConf.Tx, - Block: lndConf.Block, - } + conf := &chainsource.TxConfirmation{ + BlockHash: lndConf.BlockHash, + BlockHeight: lndConf.BlockHeight, + TxIndex: lndConf.TxIndex, + Tx: lndConf.Tx, + Block: lndConf.Block, + } + + select { + case confChan <- conf: + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.NegativeConf: + if !ok { + event.NegativeConf = nil + + continue + } + + select { + case reorgChan <- uint64(0): + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.Done: + if !ok { + event.Done = nil + + continue + } + + select { + case doneChan <- struct{}{}: + case <-notifyCtx.Done(): + return + } + + return - select { - case confChan <- conf: case <-notifyCtx.Done(): return } - - case <-notifyCtx.Done(): - return } }() return &chainsource.ConfRegistration{ Confirmed: confChan, + Reorged: reorgChan, + Done: doneChan, Cancel: func() { cancel() safeCancel() @@ -641,40 +695,83 @@ func (b *ChainBackend) RegisterSpend(ctx context.Context, cancelOnce.Do(event.Cancel) } + // Create channels to convert neutrino's spend lifecycle into our + // backend-agnostic types. neutrino's Reorg carries no payload, so + // we forward a bare struct{} signal. spendChan := make(chan *chainsource.SpendDetail, 1) + reorgChan := make(chan uint64, 1) + doneChan := make(chan struct{}, 1) go func() { + // LIFO defer: event.Cancel() first so the upstream notifier + // stops writing, then cancel notifyCtx so in-flight downstream + // sends unblock, and finally close the outgoing chans. defer close(spendChan) + defer close(reorgChan) + defer close(doneChan) defer cancel() defer safeCancel() - select { - case lndSpend, ok := <-event.Spend: - if !ok { - return - } + for { + select { + case lndSpend, ok := <-event.Spend: + if !ok { + return + } - spend := &chainsource.SpendDetail{ - SpentOutPoint: lndSpend.SpentOutPoint, - SpenderTxHash: lndSpend.SpenderTxHash, - SpendingTx: lndSpend.SpendingTx, - SpenderInputIndex: lndSpend.SpenderInputIndex, - SpendingHeight: lndSpend.SpendingHeight, - } + spend := &chainsource.SpendDetail{ + SpentOutPoint: lndSpend.SpentOutPoint, + SpenderTxHash: lndSpend.SpenderTxHash, + SpendingTx: lndSpend.SpendingTx, + SpenderInputIndex: lndSpend. + SpenderInputIndex, + SpendingHeight: lndSpend.SpendingHeight, + } + + select { + case spendChan <- spend: + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.Reorg: + if !ok { + event.Reorg = nil + + continue + } + + select { + case reorgChan <- uint64(0): + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.Done: + if !ok { + event.Done = nil + + continue + } + + select { + case doneChan <- struct{}{}: + case <-notifyCtx.Done(): + return + } + + return - select { - case spendChan <- spend: case <-notifyCtx.Done(): return } - - case <-notifyCtx.Done(): - return } }() return &chainsource.SpendRegistration{ - Spend: spendChan, + Spend: spendChan, + Reorged: reorgChan, + Done: doneChan, Cancel: func() { cancel() safeCancel() diff --git a/btcwbackend/chain_backend_reorg_test.go b/btcwbackend/chain_backend_reorg_test.go new file mode 100644 index 000000000..a3fcb89ad --- /dev/null +++ b/btcwbackend/chain_backend_reorg_test.go @@ -0,0 +1,359 @@ +package btcwbackend + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/chainsource" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/stretchr/testify/require" +) + +// reorgWaitTimeout is the per-step deadline used by the btcwbackend +// forwarder reorg tests. The forwarder is a tight goroutine, so the +// timeout exists only to make a hang surface as a fast failure on +// slow CI. +const reorgWaitTimeout = 2 * time.Second + +// fakeNotifier is a minimal chainntnfs.ChainNotifier whose Register* +// methods hand back a caller-supplied event struct. The neutrino-side +// of the real chainntnfs implementation produces events with all four +// channels populated (Confirmed/NegativeConf/Done for conf, +// Spend/Reorg/Done for spend), so the stub lets us drive a synthetic +// upstream lifecycle through the chainbackend forwarder without +// standing up a real neutrino chain service. +type fakeNotifier struct { + confEvent *chainntnfs.ConfirmationEvent + spendEvent *chainntnfs.SpendEvent +} + +// RegisterConfirmationsNtfn returns the stub's pre-built confirmation +// event. +func (n *fakeNotifier) RegisterConfirmationsNtfn(_ *chainhash.Hash, _ []byte, _, + _ uint32, _ ...chainntnfs.NotifierOption) ( + *chainntnfs.ConfirmationEvent, error) { + + return n.confEvent, nil +} + +// RegisterSpendNtfn returns the stub's pre-built spend event. +func (n *fakeNotifier) RegisterSpendNtfn(_ *wire.OutPoint, _ []byte, _ uint32) ( + *chainntnfs.SpendEvent, error) { + + return n.spendEvent, nil +} + +// RegisterBlockEpochNtfn returns an empty block-epoch event. The +// reorg lifecycle tests do not exercise block epochs, so the channels +// are intentionally never written to. +func (n *fakeNotifier) RegisterBlockEpochNtfn(_ *chainntnfs.BlockEpoch) ( + *chainntnfs.BlockEpochEvent, error) { + + return &chainntnfs.BlockEpochEvent{ + Epochs: make(chan *chainntnfs.BlockEpoch), + Cancel: func() {}, + }, nil +} + +// Start satisfies the ChainNotifier interface; the stub has no real +// lifecycle. +func (n *fakeNotifier) Start() error { return nil } + +// Started satisfies the ChainNotifier interface; the stub always +// reports started. +func (n *fakeNotifier) Started() bool { return true } + +// Stop satisfies the ChainNotifier interface; the stub has no real +// lifecycle. +func (n *fakeNotifier) Stop() error { return nil } + +// TestRegisterConfForwardsReorgAndDone drives the full confirmation +// lifecycle through neutrino's chainntnfs notifier into the btcwbackend +// forwarder and asserts each event arrives on the matching chainsource +// registration channel. The lifecycle is: +// +// Confirmed -> NegativeConf -> Confirmed -> Done +// +// and the test additionally verifies the forwarder exits after Done +// by observing that the chainsource channels close. +func TestRegisterConfForwardsReorgAndDone(t *testing.T) { + t.Parallel() + + confChan := make(chan *chainntnfs.TxConfirmation, 2) + negChan := make(chan int32, 1) + doneChan := make(chan struct{}, 1) + notifier := &fakeNotifier{ + confEvent: &chainntnfs.ConfirmationEvent{ + Confirmed: confChan, + NegativeConf: negChan, + Done: doneChan, + Cancel: func() {}, + }, + } + backend := &ChainBackend{notifier: notifier} + + reg, err := backend.RegisterConf( + t.Context(), &chainhash.Hash{0x42}, []byte{0x51}, 1, 100, false, + ) + require.NoError(t, err) + + // 1. First confirmation crosses the forwarder. + hash1 := chainhash.Hash{0xaa} + confChan <- &chainntnfs.TxConfirmation{ + BlockHash: &hash1, + BlockHeight: 123, + Tx: wire.NewMsgTx(2), + } + + conf1 := awaitConfForward(t, reg.Confirmed) + require.Equal(t, uint32(123), conf1.BlockHeight) + require.Equal(t, hash1, *conf1.BlockHash) + + // 2. Reorg ping is forwarded as a single struct{} on Reorged. The + // depth value carried on NegativeConf is intentionally dropped at + // this layer so neutrino and LND backends present the same wire + // shape to chainsource consumers. + negChan <- 1 + + awaitSeq(t, reg.Reorged, "Reorged forward") + + // 3. Transaction re-confirms in a different block on the new tip. + hash2 := chainhash.Hash{0xbb} + confChan <- &chainntnfs.TxConfirmation{ + BlockHash: &hash2, + BlockHeight: 124, + Tx: wire.NewMsgTx(2), + } + + conf2 := awaitConfForward(t, reg.Confirmed) + require.Equal(t, uint32(124), conf2.BlockHeight) + require.Equal(t, hash2, *conf2.BlockHash) + + // 4. Done signal is forwarded; the forwarder then exits and the + // chainsource channels close. + doneChan <- struct{}{} + + awaitStruct(t, reg.Done, "Done forward") + + // All three forwarded channels must close once the forwarder + // exits. + requireConfClosedSoon(t, reg.Confirmed) + requireSeqClosedSoon(t, reg.Reorged) + requireStructClosedSoon(t, reg.Done) +} + +// TestRegisterSpendForwardsReorgAndDone is the spend-side equivalent of +// the confirmation lifecycle test above. +func TestRegisterSpendForwardsReorgAndDone(t *testing.T) { + t.Parallel() + + spendChan := make(chan *chainntnfs.SpendDetail, 2) + reorgChan := make(chan struct{}, 1) + doneChan := make(chan struct{}, 1) + notifier := &fakeNotifier{ + spendEvent: &chainntnfs.SpendEvent{ + Spend: spendChan, + Reorg: reorgChan, + Done: doneChan, + Cancel: func() {}, + }, + } + backend := &ChainBackend{notifier: notifier} + + outpoint := &wire.OutPoint{Index: 1} + reg, err := backend.RegisterSpend( + t.Context(), outpoint, []byte{0x51}, 100, + ) + require.NoError(t, err) + + // 1. First spend. + hash1 := chainhash.Hash{0x10} + spendChan <- &chainntnfs.SpendDetail{ + SpentOutPoint: outpoint, + SpenderTxHash: &hash1, + SpendingTx: wire.NewMsgTx(2), + SpendingHeight: 150, + } + + spend1 := awaitSpendForward(t, reg.Spend) + require.Equal(t, int32(150), spend1.SpendingHeight) + require.Equal(t, hash1, *spend1.SpenderTxHash) + + // 2. Reorg evicts the spend. + reorgChan <- struct{}{} + + awaitSeq(t, reg.Reorged, "spend Reorged forward") + + // 3. A different spender wins the new chain. + hash2 := chainhash.Hash{0x20} + spendChan <- &chainntnfs.SpendDetail{ + SpentOutPoint: outpoint, + SpenderTxHash: &hash2, + SpendingTx: wire.NewMsgTx(2), + SpendingHeight: 151, + } + + spend2 := awaitSpendForward(t, reg.Spend) + require.Equal(t, int32(151), spend2.SpendingHeight) + require.Equal(t, hash2, *spend2.SpenderTxHash) + + // 4. Done. + doneChan <- struct{}{} + + awaitStruct(t, reg.Done, "spend Done forward") + + requireSpendClosedSoon(t, reg.Spend) + requireSeqClosedSoon(t, reg.Reorged) + requireStructClosedSoon(t, reg.Done) +} + +// awaitConfForward reads a single confirmation off the forwarded +// channel with a deadline, failing the test on timeout or unexpected +// close. +func awaitConfForward(t *testing.T, + ch <-chan *chainsource.TxConfirmation) *chainsource.TxConfirmation { + + t.Helper() + + select { + case conf, ok := <-ch: + if !ok { + t.Fatal("conf channel closed before delivery") + } + + return conf + + case <-time.After(reorgWaitTimeout): + t.Fatal("timeout waiting for conf forward") + + return nil + } +} + +// awaitSpendForward reads a single spend off the forwarded channel +// with a deadline, failing the test on timeout or unexpected close. +func awaitSpendForward(t *testing.T, + ch <-chan *chainsource.SpendDetail) *chainsource.SpendDetail { + + t.Helper() + + select { + case spend, ok := <-ch: + if !ok { + t.Fatal("spend channel closed before delivery") + } + + return spend + + case <-time.After(reorgWaitTimeout): + t.Fatal("timeout waiting for spend forward") + + return nil + } +} + +// awaitStruct reads a single struct{} off the forwarded channel with a +// deadline. Used for the Reorged and Done channels. +func awaitStruct(t *testing.T, ch <-chan struct{}, label string) { + t.Helper() + + select { + case _, ok := <-ch: + if !ok { + t.Fatalf("%s channel closed before delivery", label) + } + + case <-time.After(reorgWaitTimeout): + t.Fatalf("timeout waiting for %s", label) + } +} + +// requireConfClosedSoon asserts the confirmation channel closes within +// the reorg wait timeout. Used to verify the forwarder's defer-close +// chain runs after Done is delivered. +func requireConfClosedSoon(t *testing.T, + ch <-chan *chainsource.TxConfirmation) { + + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "conf channel did not close after Done") +} + +// requireSpendClosedSoon asserts the spend channel closes within the +// reorg wait timeout. +func requireSpendClosedSoon(t *testing.T, ch <-chan *chainsource.SpendDetail) { + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "spend channel did not close after Done") +} + +// requireStructClosedSoon asserts that a struct{} signal channel +// closes within the reorg wait timeout. +func requireStructClosedSoon(t *testing.T, ch <-chan struct{}) { + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "struct channel did not close after Done") +} + +// awaitSeq reads a single sequence value off the forwarded Reorged +// channel with a deadline. chainsource retyped Reorged from struct{} +// to the ordering sequence; this backend always sends 0. +func awaitSeq(t *testing.T, ch <-chan uint64, label string) { + t.Helper() + + select { + case _, ok := <-ch: + if !ok { + t.Fatalf("%s channel closed before delivery", label) + } + + case <-time.After(reorgWaitTimeout): + t.Fatalf("timeout waiting for %s", label) + } +} + +// requireSeqClosedSoon asserts that a sequence-carrying signal channel +// closes within the reorg wait timeout. +func requireSeqClosedSoon(t *testing.T, ch <-chan uint64) { + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "seq channel did not close after Done") +} From 3412852f81ec0fd7a7236da130bcfc24e8bf0b84 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Mon, 13 Jul 2026 09:01:14 -0700 Subject: [PATCH 06/18] multi: wire reorg-aware lifecycle into wallet, harness, systests Consume the reorg-aware substrate at the edges: make the wallet boarding sweep reorg-aware, enable height-based finality on the darepod chainsource actor, add the harness GetRawTransaction / SignedV3Tx helpers, and add the end-to-end reorg systests for chainsource and txconfirm. --- harness/harness.go | 169 ++++++++++++++++ systest/reorg_test.go | 244 +++++++++++++++++++++++ systest/systest.go | 7 + systest/txconfirm_reorg_test.go | 221 +++++++++++++++++++++ wallet/boarding_sweep_actor.go | 294 +++++++++++++++++++++++----- wallet/boarding_sweep_actor_test.go | 271 +++++++++++++++++++++++-- waved/server.go | 7 + 7 files changed, 1158 insertions(+), 55 deletions(-) create mode 100644 systest/reorg_test.go create mode 100644 systest/txconfirm_reorg_test.go diff --git a/harness/harness.go b/harness/harness.go index 5bdf2a970..3c421f10d 100644 --- a/harness/harness.go +++ b/harness/harness.go @@ -9,6 +9,7 @@ import ( "bytes" "context" crand "crypto/rand" + "encoding/hex" "encoding/json" "flag" "fmt" @@ -26,8 +27,13 @@ import ( "testing" "time" + btcaddr "github.com/btcsuite/btcd/address/v2" "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/taproot-assets/taprpc" "github.com/lightninglabs/wavelength/chain" @@ -1773,6 +1779,169 @@ func (h *Harness) BlockHeader(hash string) BlockHeader { return hdr } +// GetRawTransaction fetches the raw transaction body for txid from +// bitcoind and deserializes it into a wire.MsgTx. The transaction must +// exist either in the current chain or in bitcoind's mempool; +// `getrawtransaction` returns it via the wallet's lookup path in either +// case for a faucet-sent tx, so callers do not need to mine first. +func (h *Harness) GetRawTransaction(txid string) *wire.MsgTx { + h.T.Helper() + + res, err := h.bitcoinRPCCall("getrawtransaction", txid) + require.NoError(h.T, err, "getrawtransaction rpc failed") + + var hexStr string + err = json.Unmarshal(res, &hexStr) + require.NoError(h.T, err, "getrawtransaction unmarshal failed") + + rawBytes, err := hex.DecodeString(hexStr) + require.NoError(h.T, err, "decode raw tx hex failed") + + tx := wire.NewMsgTx(2) + err = tx.Deserialize(bytes.NewReader(rawBytes)) + require.NoError(h.T, err, "deserialize raw tx failed") + + return tx +} + +// SignedV3Tx builds a TRUC (v3) transaction that sends `amount` to +// `destPkScript`, uses one of bitcoind's wallet UTXOs as input, and +// returns it fully signed but NOT broadcast. The change output goes +// back to a fresh address from bitcoind's wallet. +// +// This helper exists for reorg systests that need to feed a v3 tx into +// txconfirm: bitcoind's `sendtoaddress` faucet path produces v2 txs, +// and txconfirm's CPFP broadcaster enforces v3/TRUC at the version +// gate. Building the v3 tx ourselves and letting txconfirm broadcast +// it is the cleanest way to exercise the full tracked-tx lifecycle on +// a freshly-mined chain entry. +// +// Fee rate is fixed at 5 sat/vB — well above any regtest min-relay-fee +// floor and small enough that the leftover change is reusable. +func (h *Harness) SignedV3Tx(destPkScript []byte, + amount btcutil.Amount) *wire.MsgTx { + + h.T.Helper() + + h.bitcoindEnsureWallet() + + // Find a confirmed wallet UTXO with enough value to cover the + // destination + change + a generous fee. + utxoTxid, utxoVout, utxoValueBTC := h.bitcoindFirstSpendableUTXO() + utxoValue := btcutil.Amount(utxoValueBTC * btcutil.SatoshiPerBitcoin) + + feeSat := btcutil.Amount(2_000) // ~5 sat/vB on a ~400 vB tx. + require.Greater( + h.T, utxoValue, amount+feeSat, + "selected UTXO not large enough for destination + fee", + ) + changeSat := utxoValue - amount - feeSat + + // Fresh change address. + changeAddrRes, err := h.bitcoinRPCCall("getnewaddress") + require.NoError(h.T, err, "getnewaddress for change failed") + var changeAddrStr string + require.NoError( + h.T, json.Unmarshal(changeAddrRes, &changeAddrStr), + "getnewaddress unmarshal failed", + ) + changeAddr, err := btcaddr.DecodeAddress( + changeAddrStr, &chaincfg.RegressionNetParams, + ) + require.NoError(h.T, err, "decode change address failed") + changePkScript, err := txscript.PayToAddrScript(changeAddr) + require.NoError(h.T, err, "derive change pkScript failed") + + // Build the unsigned v3 tx. + tx := wire.NewMsgTx(3) + prevHash, err := chainhash.NewHashFromStr(utxoTxid) + require.NoError(h.T, err, "parse selected UTXO txid") + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: *prevHash, Index: utxoVout, + }, + Sequence: wire.MaxTxInSequenceNum - 1, + }) + tx.AddTxOut(&wire.TxOut{ + Value: int64(amount), + PkScript: destPkScript, + }) + tx.AddTxOut(&wire.TxOut{ + Value: int64(changeSat), + PkScript: changePkScript, + }) + + // Hex-encode and ask bitcoind to sign. + var unsignedBuf bytes.Buffer + require.NoError( + h.T, tx.Serialize(&unsignedBuf), + "serialize unsigned v3 tx", + ) + unsignedHex := hex.EncodeToString(unsignedBuf.Bytes()) + + signRes, err := h.bitcoinRPCCall( + "signrawtransactionwithwallet", unsignedHex, + ) + require.NoError(h.T, err, "signrawtransactionwithwallet failed") + + var signResult struct { + Hex string `json:"hex"` + Complete bool `json:"complete"` + } + require.NoError( + h.T, json.Unmarshal(signRes, &signResult), + "signrawtransactionwithwallet unmarshal failed", + ) + require.True( + h.T, signResult.Complete, "tx signing incomplete: %s", + signResult.Hex, + ) + + signedBytes, err := hex.DecodeString(signResult.Hex) + require.NoError(h.T, err, "decode signed v3 tx hex failed") + + signed := wire.NewMsgTx(3) + require.NoError( + h.T, + signed.Deserialize( + bytes.NewReader(signedBytes), + ), + "deserialize signed v3 tx failed", + ) + require.Equal( + h.T, int32(3), signed.Version, + "signing must preserve v3 version", + ) + + return signed +} + +// bitcoindFirstSpendableUTXO picks the first confirmed wallet UTXO via +// `listunspent` and returns its txid, vout, and amount in BTC. +func (h *Harness) bitcoindFirstSpendableUTXO() (string, uint32, float64) { + h.T.Helper() + + // Restrict to confirmed and spendable; bitcoind defaults are + // already minconf=1, maxconf=9999999. + res, err := h.bitcoinRPCCall("listunspent") + require.NoError(h.T, err, "listunspent rpc failed") + + var utxos []struct { + Txid string `json:"txid"` + Vout uint32 `json:"vout"` + Amount float64 `json:"amount"` + } + require.NoError( + h.T, json.Unmarshal(res, &utxos), + "listunspent unmarshal failed", + ) + require.NotEmpty(h.T, utxos, "no spendable wallet UTXOs") + + first := utxos[0] + + return first.Txid, first.Vout, first.Amount +} + // Faucet funds a test address by sending the specified amount from bitcoind's // default wallet, creating unconfirmed UTXOs for tests to spend. This mimics // external funding without requiring manual transaction construction. diff --git a/systest/reorg_test.go b/systest/reorg_test.go new file mode 100644 index 000000000..b4ada12e6 --- /dev/null +++ b/systest/reorg_test.go @@ -0,0 +1,244 @@ +//go:build systest + +package systest + +import ( + "crypto/sha256" + "testing" + "time" + + btcaddr "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/chainsource" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// reorgSystestEventTimeout is the per-step deadline used by the +// chainsource reorg systests. The chain-notification pipeline runs +// over gRPC to a real lnd instance, so the timeout is generous enough +// to absorb container startup variance and notifier wake-up latency. +const reorgSystestEventTimeout = 30 * time.Second + +// TestChainSourceConfReorgRoundTrip drives a real bitcoind reorg +// through the full chainsource pipeline: +// +// lnd chainntnfs (in-process) +// -> lndclient gRPC (WithReOrgChan) +// -> chainbackends.LndClientChainNotifier (bridge) +// -> chainbackends.LNDBackend (multi-shot forwarder) +// -> chainsource.ConfActor (reorg-aware mode) +// -> test actor refs +// +// The flow is: +// +// 1. Register a reorg-aware confirmation watch on a synthetic P2WPKH +// pkScript whose txid we know once we faucet to it. +// 2. Faucet + mine one block. Assert ConfirmationEvent arrives with +// the expected (txid, blockHeight, blockHash). +// 3. Drive a 1-block reorg via the harness helper, which invalidates +// the confirmation block and mines a strictly longer (2-block) +// replacement branch. Bitcoind preserves the original tx in its +// mempool, so the transaction re-confirms in the new chain at a +// different block hash (and potentially the same height). +// 4. Assert ConfReorgedEvent arrives. +// 5. Assert a fresh ConfirmationEvent arrives with the new chain's +// block hash (NOT the original one), demonstrating that lnd's +// chainntnfs dispatched a re-confirmation after the reorg and +// that every layer above it propagated the multi-shot signal +// correctly. +// +// This is the systest-level oracle for "the reorg-aware pipeline +// actually works over the real gRPC transport". The unit tests in +// chainsource/reorg_test.go and chainbackends/lnd_reorg_test.go prove +// the same lifecycle against mocks, but they cannot prove that +// lndclient.WithReOrgChan fires when wired to real lnd. Only this +// test does. +func TestChainSourceConfReorgRoundTrip(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + // Spawn a real chainsource actor over the harness's LND. + chainSource := h.NewChainSourceActor() + + // Build a synthetic P2WPKH address from a deterministic per-test + // pubkey hash. The address does not need a controllable key (we + // never spend it in this test); we just need a known pkScript so + // we can register a confirmation watch. + pubKeyHash := sha256.Sum256([]byte(t.Name())) + addr, err := btcaddr.NewAddressWitnessPubKeyHash( + pubKeyHash[:20], &chaincfg.RegressionNetParams, + ) + require.NoError(t, err, "build synthetic P2WPKH address") + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err, "derive pkScript for synthetic address") + + // Wire test refs for each event variant. The Reorged ref also + // has to be set for NotifyDone-or-NotifyReorged admission to flip + // the sub-actor into multi-shot mode; we leave Done unwired + // because the lndclient transport does not synthesize a Done + // signal so it would never fire over a real lnd run anyway. + confRef := actor.NewChannelTellOnlyRef[chainsource.ConfirmationEvent]( + "systest-conf", 8, + ) + reorgRef := actor.NewChannelTellOnlyRef[chainsource.ConfReorgedEvent]( + "systest-conf-reorged", 8, + ) + + // Register the watch BEFORE the tx hits the mempool so we + // exercise the live-detection path rather than the + // historical-backfill path. + heightHint := h.Harness.BlockCount() + amount := btcutil.Amount(btcutil.SatoshiPerBitcoin / 100) + + // We need the txid up front, which means faucet first, then + // register, then mine. The watch is on the txid + pkScript pair + // so the ordering between mempool entry and registration is OK; + // what must NOT happen is that we mine the block before + // registering, because lnd's notifier would then dispatch + // historical confirmation state and our test would be racing two + // delivery paths. + txidStr := h.Harness.Faucet(addr.String(), amount) + txid, err := chainhash.NewHashFromStr(txidStr) + require.NoError(t, err, "parse faucet txid") + + confNotify := actor.TellOnlyRef[chainsource.ConfirmationEvent]( + confRef, + ) + reorgNotify := actor.TellOnlyRef[chainsource.ConfReorgedEvent]( + reorgRef, + ) + + regResp := chainSource.Ask(ctx, &chainsource.RegisterConfRequest{ + CallerID: "test-reorg-conf-" + txidStr, + Txid: txid, + PkScript: pkScript, + TargetConfs: 1, + HeightHint: heightHint, + NotifyActor: fn.Some(confNotify), + NotifyReorged: fn.Some(reorgNotify), + }).Await(ctx) + require.True(t, regResp.IsOk(), "register reorg-aware conf watch") + resp, err := regResp.Unpack() + require.NoError(t, err) + _, ok := resp.(*chainsource.RegisterConfResponse) + require.True(t, ok, "unexpected register response type") + + // 1. Mine the block that confirms the faucet tx. + originalBlocks := h.Harness.Generate(1) + require.Len(t, originalBlocks, 1) + originalBlock := originalBlocks[0] + + originalHash, err := chainhash.NewHashFromStr(originalBlock.Hash) + require.NoError(t, err, "parse original block hash") + + // 2. Assert the first ConfirmationEvent. + firstConf := awaitConfEvent(t, confRef) + require.Equal(t, *txid, firstConf.Txid, "first conf txid mismatch") + require.Equal( + t, int32(originalBlock.Height), firstConf.BlockHeight, + "first conf block height should match the mined block", + ) + require.Equal( + t, *originalHash, firstConf.BlockHash, + "first conf block hash should match the mined block", + ) + t.Logf( + "first ConfirmationEvent: txid=%s height=%d hash=%s", + firstConf.Txid, firstConf.BlockHeight, firstConf.BlockHash, + ) + + // 3. Drive a reorg: invalidate the conf block, mine a strictly + // longer replacement branch. The harness Reorg helper waits for + // lnd's chain sync to catch up before returning. + reorg := h.Harness.Reorg(1, 2) + require.Equal( + t, originalBlock.Hash, reorg.Disconnected[0].Hash, + "the reorg should have disconnected the conf block", + ) + require.Len(t, reorg.Connected, 2) + t.Logf( + "reorg: disconnected=%d connected=%d fork=%d", + len(reorg.Disconnected), len(reorg.Connected), + reorg.ForkPoint.Height, + ) + + // 4. Assert the ConfReorgedEvent. Lnd's chainntnfs notifier + // dispatches NegativeConf for the disconnected confirmation + // asynchronously after processing the disconnect; the gRPC + // transport adds a further hop, so this can take longer than + // the initial confirmation event. + reorgEvt := awaitReorgEvent(t, reorgRef) + require.Equal(t, *txid, reorgEvt.Txid, "reorg event txid mismatch") + t.Logf("ConfReorgedEvent: txid=%s", reorgEvt.Txid) + + // 5. Assert a fresh ConfirmationEvent for the same tx, now in + // the replacement chain. Bitcoind keeps the tx in mempool across + // the invalidate, so generatetoaddress picks it back up on the + // first new block. The new block hash MUST differ from the + // original; the height may or may not match depending on where + // the tx landed in the replacement branch. + secondConf := awaitConfEvent(t, confRef) + require.Equal(t, *txid, secondConf.Txid, "re-conf txid mismatch") + require.NotEqual( + t, firstConf.BlockHash, secondConf.BlockHash, + "re-confirmation must arrive in a new block", + ) + t.Logf( + "second ConfirmationEvent: txid=%s height=%d hash=%s", + secondConf.Txid, secondConf.BlockHeight, secondConf.BlockHash, + ) + + // Sanity: the new block hash must be one of the harness-reported + // connected blocks. chainhash.Hash.String() renders the + // canonical big-endian hex form that bitcoind's RPCs emit, so + // the strings can be compared directly. + connectedHashes := make(map[string]struct{}, len(reorg.Connected)) + for _, blk := range reorg.Connected { + connectedHashes[blk.Hash] = struct{}{} + } + require.Contains( + t, connectedHashes, secondConf.BlockHash.String(), + "re-confirmation block must belong to the replacement branch", + ) +} + +// awaitConfEvent reads a single ConfirmationEvent from the test ref +// with a generous deadline, failing the test on timeout. +func awaitConfEvent(t *testing.T, + ref *actor.ChannelTellOnlyRef[chainsource.ConfirmationEvent], +) chainsource.ConfirmationEvent { + + t.Helper() + + evt, ok := ref.AwaitMessage(reorgSystestEventTimeout) + require.True( + t, ok, "timeout waiting for ConfirmationEvent (%s)", + reorgSystestEventTimeout, + ) + + return evt +} + +// awaitReorgEvent reads a single ConfReorgedEvent from the test ref +// with a generous deadline, failing the test on timeout. +func awaitReorgEvent(t *testing.T, + ref *actor.ChannelTellOnlyRef[chainsource.ConfReorgedEvent], +) chainsource.ConfReorgedEvent { + + t.Helper() + + evt, ok := ref.AwaitMessage(reorgSystestEventTimeout) + require.True( + t, ok, "timeout waiting for ConfReorgedEvent (%s)", + reorgSystestEventTimeout, + ) + + return evt +} diff --git a/systest/systest.go b/systest/systest.go index be5e58e37..b485b3801 100644 --- a/systest/systest.go +++ b/systest/systest.go @@ -212,6 +212,13 @@ func (h *SysTestHarness) NewChainSourceActor() actor.ActorRef[ chainsource.ChainSourceConfig{ Backend: backend, System: h.actorSystem, + // Mirror the production wiring so systests that + // register reorg-aware conf/spend watches do not + // leak per-watch sub-actors past test teardown: + // the lndclient backend never writes the upstream + // Done channel, so without height-based synthesis + // reorg-aware watches would stay open forever. + FinalityDepth: chainsource.DefaultFinalityDepth, }.WithLogger( h.SubLogger(chainsource.Subsystem), ), diff --git a/systest/txconfirm_reorg_test.go b/systest/txconfirm_reorg_test.go new file mode 100644 index 000000000..ad376e18e --- /dev/null +++ b/systest/txconfirm_reorg_test.go @@ -0,0 +1,221 @@ +//go:build systest + +package systest + +import ( + "context" + "crypto/sha256" + "testing" + "time" + + btcaddr "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/txconfirm" + "github.com/stretchr/testify/require" +) + +// txConfirmSystestEventTimeout is the per-step deadline used by the +// txconfirm reorg systest. Generous because the chain-notification +// pipeline runs over gRPC to a real lnd instance plus this test waits +// for txconfirm's tracked-tx FSM to forward each event. +const txConfirmSystestEventTimeout = 30 * time.Second + +// recordingNotificationRef captures every Notification delivered to a +// txconfirm subscriber so the test can assert on the order and shape +// of the lifecycle without racing concurrent delivery. +type recordingNotificationRef struct { + id string + msgs chan txconfirm.Notification +} + +func newRecordingNotificationRef(id string) *recordingNotificationRef { + return &recordingNotificationRef{ + id: id, + msgs: make(chan txconfirm.Notification, 16), + } +} + +// ID returns the subscriber identifier. +func (r *recordingNotificationRef) ID() string { + return r.id +} + +// Tell records the inbound notification on the channel for the test to +// consume. +func (r *recordingNotificationRef) Tell(_ context.Context, + msg txconfirm.Notification) error { + + r.msgs <- msg + + return nil +} + +// await pulls the next notification or fails the test on timeout. +func (r *recordingNotificationRef) await(t *testing.T) txconfirm.Notification { + t.Helper() + + select { + case msg := <-r.msgs: + return msg + + case <-time.After(txConfirmSystestEventTimeout): + t.Fatalf("timeout waiting for txconfirm notification (%s)", + txConfirmSystestEventTimeout) + + return nil + } +} + +// TestTxConfirmReorgRoundTrip drives a real bitcoind reorg through +// the full txconfirm pipeline: +// +// lnd chainntnfs (in-process) +// -> lndclient gRPC (WithReOrgChan) +// -> chainbackends.LndClientChainNotifier (bridge) +// -> chainbackends.LNDBackend (multi-shot forwarder) +// -> chainsource.ConfActor (reorg-aware mode + finality synth) +// -> txconfirm.TxBroadcasterActor (tracked-tx FSM) +// -> recording subscriber +// +// This is the systest-level oracle for the layer that unroll consumes. +// The chainsource-level systest (TestChainSourceConfReorgRoundTrip) +// proves the chain-event plumbing; this one proves the tracked-tx FSM +// transitions Confirmed -> AwaitingConfirmation -> Confirmed correctly +// on real reorgs and that subscribers see TxConfirmed -> TxReorged -> +// TxConfirmed in order, with TxFinalized arriving once the +// height-based safety depth is reached. +// +// Full daemon-level end-to-end coverage (the VTXOUnrollActor walking +// a real proof through a real wallet under a real reorg) belongs in +// itest; the unroll FSM's reducer behavior on these events is already +// covered by unit tests in unroll/reorg_safety_test.go. +func TestTxConfirmReorgRoundTrip(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + // Spawn a txconfirm actor over the real chainsource. Wallet is + // nil because the faucet tx is non-anchor and we never trigger + // CPFP fee-input selection in this test. + txconfBehavior := txconfirm.NewTxBroadcasterActor(txconfirm.Config{ + ChainSource: chainSource, + }) + txconfInstance := actor.NewActor(actor.ActorConfig[ + txconfirm.Msg, txconfirm.Resp, + ]{ + ID: "txconfirm-systest", + Behavior: txconfBehavior, + MailboxSize: 64, + }) + txconfBehavior.SetSelfRef(txconfInstance.TellRef()) + txconfInstance.Start() + t.Cleanup(txconfInstance.Stop) + + // Synthetic watched address: deterministic P2WPKH derived from + // the test name. We faucet to it so we have a known txid and + // pkScript for txconfirm to track. The address is never spent. + pubKeyHash := sha256.Sum256([]byte(t.Name())) + addr, err := btcaddr.NewAddressWitnessPubKeyHash( + pubKeyHash[:20], &chaincfg.RegressionNetParams, + ) + require.NoError(t, err, "build synthetic P2WPKH address") + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err, "derive pkScript for synthetic address") + + heightHint := h.Harness.BlockCount() + + // txconfirm's CPFP broadcaster enforces v3/TRUC at the version + // gate, and the standard bitcoind faucet returns v2 txs. Build a + // v3 tx that pays our synthetic address from bitcoind's wallet, + // sign it, but do NOT broadcast — txconfirm will handle the + // first broadcast on EnsureConfirmedReq. + signedTx := h.Harness.SignedV3Tx( + pkScript, btcutil.Amount(btcutil.SatoshiPerBitcoin/100), + ) + txidVal := signedTx.TxHash() + txid := &txidVal + t.Logf("constructed v3 tx: txid=%s", txid) + + subscriber := newRecordingNotificationRef("txconfirm-sub") + var subRef actor.TellOnlyRef[txconfirm.Notification] = subscriber + + // Register the EnsureConfirmedReq BEFORE mining so we exercise + // the live-detection path and the tracked-tx FSM walks the full + // Broadcasting -> AwaitingConfirmation -> Confirmed transitions. + ensureResp, err := txconfInstance.Ref().Ask( + ctx, &txconfirm.EnsureConfirmedReq{ + Tx: signedTx, + ConfirmationPkScript: pkScript, + Label: "systest-reorg", + HeightHint: heightHint, + TargetConfs: 1, + Subscriber: subRef, + }, + ).Await(ctx).Unpack() + require.NoError(t, err, "EnsureConfirmedReq failed") + require.IsType(t, &txconfirm.EnsureConfirmedResp{}, ensureResp) + + // 1. Mine the block that confirms the faucet tx. + originalBlocks := h.Harness.Generate(1) + require.Len(t, originalBlocks, 1) + originalBlock := originalBlocks[0] + + // 2. Expect TxConfirmed. + first := subscriber.await(t) + firstConfirmed, ok := first.(*txconfirm.TxConfirmed) + require.True( + t, ok, "first notification must be TxConfirmed, got %T", first, + ) + require.Equal(t, *txid, firstConfirmed.Txid) + require.Equal( + t, int32(originalBlock.Height), firstConfirmed.BlockHeight, + "first conf block height should match the mined block", + ) + t.Logf( + "first TxConfirmed: txid=%s height=%d", firstConfirmed.Txid, + firstConfirmed.BlockHeight, + ) + + // 3. Reorg the conf block out, mine a strictly longer + // replacement branch, wait for lnd to chain-sync. + reorg := h.Harness.Reorg(1, 2) + require.Equal( + t, originalBlock.Hash, reorg.Disconnected[0].Hash, + "the reorg should have disconnected the conf block", + ) + require.Len(t, reorg.Connected, 2) + t.Logf( + "reorg: disconnected=%d connected=%d fork=%d", + len(reorg.Disconnected), len(reorg.Connected), + reorg.ForkPoint.Height, + ) + + // 4. Expect TxReorged. + second := subscriber.await(t) + reorgedMsg, ok := second.(*txconfirm.TxReorged) + require.True( + t, ok, "second notification must be TxReorged, got %T", second, + ) + require.Equal(t, *txid, reorgedMsg.Txid) + t.Logf("TxReorged: txid=%s", reorgedMsg.Txid) + + // 5. Expect a fresh TxConfirmed on the replacement chain. The + // faucet tx stays in mempool across the invalidate so it + // re-confirms in the first new block. + third := subscriber.await(t) + secondConfirmed, ok := third.(*txconfirm.TxConfirmed) + require.True( + t, ok, "third notification must be TxConfirmed, got %T", third, + ) + require.Equal(t, *txid, secondConfirmed.Txid) + t.Logf( + "second TxConfirmed: txid=%s height=%d", secondConfirmed.Txid, + secondConfirmed.BlockHeight, + ) +} diff --git a/wallet/boarding_sweep_actor.go b/wallet/boarding_sweep_actor.go index 6759d789e..58cf3b223 100644 --- a/wallet/boarding_sweep_actor.go +++ b/wallet/boarding_sweep_actor.go @@ -233,29 +233,154 @@ func (m BoardingSweepSpendNotification) MessageType() string { func (m BoardingSweepSpendNotification) walletMsgSealed() {} -// BoardingSweepTxNotification is a Tell carrying a txconfirm terminal -// notification (confirmation or failure) for a tracked sweep tx, -// re-wrapped from txconfirm.TxConfirmed / txconfirm.TxFailed via +// BoardingSweepTxStatus identifies which point in the txconfirm +// reorg-aware lifecycle drove this notification. Splitting the status +// out (instead of a single `Confirmed bool`) makes it impossible for +// the handler to confuse a reorg-out with a terminal failure: txconfirm +// fan-outs a TxReorged whenever a previously delivered TxConfirmed is +// rolled back on chain, and we must NOT treat that as a sweep failure. +type BoardingSweepTxStatus int + +const ( + // BoardingSweepTxStatusUnknown is the zero value; receiving it + // indicates a programmer error (MapNotification missed a kind). + BoardingSweepTxStatusUnknown BoardingSweepTxStatus = iota + + // BoardingSweepTxStatusConfirmed reports that the sweep + // transaction has been observed on the canonical chain at + // BlockHeight with NumConfs confirmations. The observation is + // provisional until BoardingSweepTxStatusFinalized arrives — a + // reorg can roll it back via BoardingSweepTxStatusReorged. + BoardingSweepTxStatusConfirmed + + // BoardingSweepTxStatusReorged reports that a previously + // delivered TxConfirmed for this sweep was reorged out. The + // handler must NOT call MarkBoardingSweepFailed; instead it + // leaves the spend watches and pending state armed so the next + // TxConfirmed on the new canonical chain (or an eventual + // TxFailed) drives the terminal decision. + BoardingSweepTxStatusReorged + + // BoardingSweepTxStatusFinalized reports that the sweep + // confirmation is past the chainsource backend's reorg-safety + // depth and is no longer reversible. The handler can release + // any reorg-recovery bookkeeping for this sweep. + BoardingSweepTxStatusFinalized + + // BoardingSweepTxStatusFailed reports a terminal failure from + // txconfirm (broadcast rejected, retry budget exhausted, etc.). + // The handler marks the sweep failed in the store and cleans up + // pending state. + BoardingSweepTxStatusFailed +) + +// classifyTxconfirmNotificationForBoardingSweep maps one event from +// the txconfirm reorg-aware lifecycle into the wallet-domain +// BoardingSweepTxNotification shape consumed by +// handleSweepTxNotification. Pulled out of submitSweepConfirmer so the +// systest (TestBoardingSweepReorgRoundTrip) can reuse the exact same +// classifier the production wiring uses; if the production wiring +// drifts from the systest classifier, the test stops validating +// production behavior. +func classifyTxconfirmNotificationForBoardingSweep( + n txconfirm.Notification) BoardingSweepTxNotification { + + switch ev := n.(type) { + case *txconfirm.TxConfirmed: + return BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusConfirmed, + + Txid: ev.Txid, + BlockHeight: ev.BlockHeight, + NumConfs: ev.NumConfs, + } + + case *txconfirm.TxReorged: + // Reorg-out: do NOT mark the sweep as failed in the + // handler. Leave spend watches and pending state armed so + // the next TxConfirmed on the new canonical chain (or an + // eventual TxFailed) drives the terminal decision. + return BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusReorged, + Txid: ev.Txid, + } + + case *txconfirm.TxFinalized: + // Reorg-safety horizon reached. The sweep observation is + // no longer reversible; the handler may release reorg- + // recovery bookkeeping. Audit/ledger emission already + // fired on the original Confirmed (idempotent on replay). + return BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusFinalized, + + Txid: ev.Txid, + BlockHeight: ev.BlockHeight, + NumConfs: ev.NumConfs, + } + + case *txconfirm.TxFailed: + return BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusFailed, + Txid: ev.Txid, + Reason: ev.Reason, + } + } + + return BoardingSweepTxNotification{} +} + +// NewBoardingSweepTxconfirmSubscriber wires the production boarding- +// sweep classifier onto a txconfirm.MapNotification anchored at +// selfRef. The returned ref can be set as the Subscriber field on a +// txconfirm.EnsureConfirmedReq; every TxConfirmed / TxReorged / +// TxFinalized / TxFailed delivered for that sweep will be classified +// into a BoardingSweepTxNotification and Tell'd to selfRef as a +// WalletMsg, which the wallet actor's Receive arm dispatches to +// handleSweepTxNotification. +// +// Exported so the systest can construct the exact same subscriber +// chain that submitSweepConfirmer uses in production. +func NewBoardingSweepTxconfirmSubscriber( + selfRef actor.TellOnlyRef[WalletMsg], +) actor.TellOnlyRef[txconfirm.Notification] { + + walletNotif := actor.NewMapInputRef[ + BoardingSweepTxNotification, WalletMsg, + ]( + selfRef, + func(n BoardingSweepTxNotification) WalletMsg { + return n + }, + ) + + return txconfirm.MapNotification( + walletNotif, classifyTxconfirmNotificationForBoardingSweep, + ) +} + +// BoardingSweepTxNotification is a Tell carrying one event from the +// txconfirm reorg-aware lifecycle for a tracked sweep tx, re-wrapped +// from txconfirm.{TxConfirmed,TxReorged,TxFinalized,TxFailed} via // txconfirm.MapNotification. type BoardingSweepTxNotification struct { actor.BaseMessage - // Confirmed is true when the underlying txconfirm.TxConfirmed event - // fired; false when it was txconfirm.TxFailed. - Confirmed bool + // Status identifies which lifecycle event this notification + // carries. See BoardingSweepTxStatus. + Status BoardingSweepTxStatus // Txid identifies the tracked sweep transaction. Txid chainhash.Hash // BlockHeight is the height at which the sweep confirmed when - // Confirmed=true; zero otherwise. + // Status=Confirmed or Finalized; zero otherwise. BlockHeight int32 - // NumConfs is the confirmation count when Confirmed=true; zero - // otherwise. + // NumConfs is the confirmation count when Status=Confirmed or + // Finalized; zero otherwise. NumConfs uint32 - // Reason is the human-readable failure reason when Confirmed=false. + // Reason is the human-readable failure reason when Status=Failed. Reason string } @@ -430,6 +555,16 @@ func (a *Ark) loadSweepCandidates(ctx context.Context, return intents, nil } +// isTerminalSuccessSweepStatus reports whether a persisted boarding-sweep +// status represents a resolved sweep whose accounting is already booked and +// must not be rolled back to failed: confirmed (our sweep landed) or +// external_resolved (the input was spent by another path). A spurious +// TxFailed for such a sweep is ignored. +func isTerminalSuccessSweepStatus(status string) bool { + return status == BoardingSweepStatusConfirmed || + status == BoardingSweepStatusExternalResolved +} + // defaultBoardingSweepStatuses are the boarding-intent statuses considered // candidates for an aggregate timeout sweep when no outpoint set is // supplied. @@ -868,37 +1003,11 @@ func (a *Ark) cancelSweepSpendWatches(ctx context.Context, func (a *Ark) submitSweepConfirmer(ctx context.Context, tx *wire.MsgTx, pkScript []byte, heightHint uint32) error { - walletNotif := actor.NewMapInputRef[ - BoardingSweepTxNotification, WalletMsg, - ]( - a.selfRef, - func(n BoardingSweepTxNotification) WalletMsg { - return n - }, - ) - - subscriber := txconfirm.MapNotification(walletNotif, - func(n txconfirm.Notification) BoardingSweepTxNotification { - switch ev := n.(type) { - case *txconfirm.TxConfirmed: - return BoardingSweepTxNotification{ - Confirmed: true, - Txid: ev.Txid, - BlockHeight: ev.BlockHeight, - NumConfs: ev.NumConfs, - } - - case *txconfirm.TxFailed: - return BoardingSweepTxNotification{ - Confirmed: false, - Txid: ev.Txid, - Reason: ev.Reason, - } - } + if a.actorSystem == nil { + return fmt.Errorf("actor system unavailable") + } - return BoardingSweepTxNotification{} - }, - ) + subscriber := NewBoardingSweepTxconfirmSubscriber(a.selfRef) return a.submitSweepToConfirm( ctx, tx, pkScript, heightHint, boardingSweepBroadcastLabel, @@ -1061,8 +1170,8 @@ func (a *Ark) handleSweepTxNotification(ctx context.Context, return fn.Ok[WalletResp](&BoardingSweepNotificationAck{}) } - switch { - case notif.Confirmed: + switch notif.Status { + case BoardingSweepTxStatusConfirmed: a.logger(ctx).DebugS( ctx, "Boarding sweep confirmation observed by broadcaster", @@ -1074,7 +1183,74 @@ func (a *Ark) handleSweepTxNotification(ctx context.Context, a.emitSweepConfirmedLedger(ctx, notif) - default: + case BoardingSweepTxStatusReorged: + // A previously delivered TxConfirmed for this sweep was + // reorged out. Do NOT mark the sweep as failed and do NOT + // tear down the pending entry: txconfirm keeps the watch + // alive, so a subsequent TxConfirmed on the new canonical + // chain will re-run reconcileSweepInputsOnConfirm with the + // new height. MarkBoardingSweepInputSpent is idempotent on + // (outpoint, txid), and the ledger emissions use txid-keyed + // idempotency, so the second confirmation will not produce + // duplicate audit/balance entries. + // + // Best-effort backstop only: the per-input chainsource + // spend watch fires handleSweepSpendNotification if some + // other party spends an input. That spend-watch path is + // not itself reorg-symmetric — MapSpendEvent collapses the + // chainsource SpendEvent lifecycle to a single + // BoardingSweepSpendNotification without surfacing + // Reorged / Done — so a reorged-out external spender will + // leave the input row marked external_spent until manual + // reconciliation. Closing that gap is tracked separately. + a.logger(ctx).WarnS( + ctx, + "Boarding sweep confirmation reorged out; waiting "+ + "for re-confirmation or external spend", + nil, + slog.String("txid", notif.Txid.String()), + ) + + case BoardingSweepTxStatusFinalized: + // Confirmation is past the backend's reorg-safety depth. + // No further reversible events will fire for this sweep — + // reorg-recovery bookkeeping can drop. Audit/ledger + // emission already ran on Confirmed and is idempotent on + // replay, so we do not re-emit here. + a.logger(ctx).DebugS( + ctx, + "Boarding sweep confirmation finalized", + nil, + slog.String("txid", notif.Txid.String()), + slog.Int("block_height", int(notif.BlockHeight)), + ) + + case BoardingSweepTxStatusFailed: + // Defensive guard against a failure arriving for a sweep that + // already confirmed. txconfirm's contract is that TxFailed + // never follows a real TxConfirmed, but the confirmed sweep's + // ledger legs (fee + per-input + destination) are irreversible + // and txid-keyed, so rolling the intents back to failed here + // would diverge the store from the ledger. If the persisted + // record shows the sweep already reached a terminal-success + // status, ignore the failure rather than undo a booked sweep. + if rec, ok := a.lookupSweepRecord(ctx, notif.Txid); ok && + isTerminalSuccessSweepStatus(rec.Status) { + + a.logger(ctx).WarnS( + ctx, + "Ignoring boarding sweep failure for an "+ + "already-resolved sweep", + errors.New(notif.Reason), + slog.String("txid", notif.Txid.String()), + slog.String("status", rec.Status), + ) + + return fn.Ok[WalletResp]( + &BoardingSweepNotificationAck{}, + ) + } + a.logger(ctx).WarnS( ctx, "Boarding sweep broadcaster reported failure", @@ -1104,6 +1280,14 @@ func (a *Ark) handleSweepTxNotification(ctx context.Context, if pending != nil { a.cancelSweepSpendWatches(ctx, pending) } + + default: + a.logger(ctx).WarnS( + ctx, + "Boarding sweep tx notification with unknown status", + fmt.Errorf("status=%d", notif.Status), + slog.String("txid", notif.Txid.String()), + ) } return fn.Ok[WalletResp](&BoardingSweepNotificationAck{}) @@ -1116,6 +1300,18 @@ func (a *Ark) handleSweepTxNotification(ctx context.Context, // the spending txid is the sweep's own txid. MarkBoardingSweepInputSpent // is idempotent, so inputs already resolved via the spend-notification path // are left untouched. +// +// Under the reorg-aware lifecycle a second TxConfirmed can land after a +// TxReorged / re-confirmation cycle. The store's status guard rejects +// the redundant transition with sql.ErrNoRows; that signals "input row +// already advanced past pending/published" and is treated as a benign +// no-op, mirroring how handleSweepSpendNotification classifies the +// same error. Other errors still log at warn because they indicate a +// real persistence problem. Note: the input row's confirmed_height +// stays pinned to the FIRST Confirmed observation — a deliberate +// first-observation-wins audit policy that survives reorg-reconfirm +// at a different height; the reorg-safety horizon is reported +// separately via the Finalized lifecycle event. func (a *Ark) reconcileSweepInputsOnConfirm(ctx context.Context, notif BoardingSweepTxNotification) { @@ -1128,7 +1324,17 @@ func (a *Ark) reconcileSweepInputsOnConfirm(ctx context.Context, _, err := a.sweepStore.MarkBoardingSweepInputSpent( ctx, op, notif.Txid, notif.BlockHeight, ) - if err != nil { + switch { + case err == nil: + // Success. + + case errors.Is(err, sql.ErrNoRows): + // Row already past pending/published — likely + // resolved via handleSweepSpendNotification or a + // previous Confirmed in a reorg-reconfirm cycle. + // Idempotent no-op. + + default: a.logger(ctx).WarnS( ctx, "Failed to mark sweep input spent on confirm", diff --git a/wallet/boarding_sweep_actor_test.go b/wallet/boarding_sweep_actor_test.go index 1a1f190e2..29493ba8d 100644 --- a/wallet/boarding_sweep_actor_test.go +++ b/wallet/boarding_sweep_actor_test.go @@ -599,7 +599,7 @@ func TestSweepTxNotificationConfirmedEmitsLedger(t *testing.T) { result := a.handleSweepTxNotification( t.Context(), BoardingSweepTxNotification{ - Confirmed: true, + Status: BoardingSweepTxStatusConfirmed, Txid: swept, BlockHeight: 800_650, NumConfs: 1, @@ -719,7 +719,7 @@ func TestSweepTxNotificationConfirmedExternalDestSkipsCreated(t *testing.T) { result := a.handleSweepTxNotification( t.Context(), BoardingSweepTxNotification{ - Confirmed: true, + Status: BoardingSweepTxStatusConfirmed, Txid: swept, BlockHeight: 800_700, }, @@ -854,12 +854,13 @@ func TestSweepLedgerClearingNetsToZero(t *testing.T) { ), ) + notif := BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusConfirmed, + Txid: swept, + BlockHeight: 800_800, + } result := a.handleSweepTxNotification( - t.Context(), BoardingSweepTxNotification{ - Confirmed: true, - Txid: swept, - BlockHeight: 800_800, - }, + t.Context(), notif, ) require.True(t, result.IsOk()) @@ -934,7 +935,7 @@ func TestSweepTxNotificationMissingTxSkipsLegs(t *testing.T) { result := a.handleSweepTxNotification( t.Context(), BoardingSweepTxNotification{ - Confirmed: true, + Status: BoardingSweepTxStatusConfirmed, Txid: swept, BlockHeight: 800_900, }, @@ -962,6 +963,12 @@ func TestSweepTxNotificationFailedMarksFailed(t *testing.T) { mock.Anything, ).Return(nil) + // The Failed arm looks the sweep up first to ignore failures for an + // already-resolved sweep; absent record means the failure proceeds. + store.On( + "GetBoardingSweep", mock.Anything, failedTxid, + ).Return(nil, nil) + a := newSweepTestArk(t, store, nil, 0, 0) a.pendingSweeps[failedTxid] = &pendingSweepState{ txid: failedTxid, @@ -970,9 +977,9 @@ func TestSweepTxNotificationFailedMarksFailed(t *testing.T) { result := a.handleSweepTxNotification( t.Context(), BoardingSweepTxNotification{ - Confirmed: false, - Txid: failedTxid, - Reason: "test failure", + Status: BoardingSweepTxStatusFailed, + Txid: failedTxid, + Reason: "test failure", }, ) require.True(t, result.IsOk()) @@ -980,3 +987,245 @@ func TestSweepTxNotificationFailedMarksFailed(t *testing.T) { store.AssertExpectations(t) } + +// TestSweepTxNotificationReorgedDoesNotMarkFailed verifies that a +// TxReorged event from txconfirm — which arrives whenever a previously +// observed confirmation is rolled back on chain — must NOT be treated +// as a sweep failure. The handler should leave pendingSweeps and the +// persistent sweep record intact so that the next TxConfirmed on the +// new canonical chain (or, in the worst case, a chainsource spend +// notification for some other spender of the inputs) drives the +// terminal decision. +func TestSweepTxNotificationReorgedDoesNotMarkFailed(t *testing.T) { + t.Parallel() + + reorgedTxid := chainhash.Hash{0xa1} + store := &MockBoardingSweepStore{} + // CRITICAL: MarkBoardingSweepFailed must NOT be called on reorg. + // testify/mock will fail the test if any unexpected method is + // invoked, so we simply do not register MarkBoardingSweepFailed + // here and rely on AssertExpectations to verify the negative. + + a := newSweepTestArk(t, store, nil, 0, 0) + pending := &pendingSweepState{ + txid: reorgedTxid, + inputs: map[wire.OutPoint]string{}, + } + a.pendingSweeps[reorgedTxid] = pending + + result := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusReorged, + Txid: reorgedTxid, + }, + ) + require.True(t, result.IsOk()) + + // Pending state must remain intact: a re-confirmation on the new + // canonical chain has to find the same entry to drive its + // reconcileSweepInputsOnConfirm pass. + require.Same( + t, pending, a.pendingSweeps[reorgedTxid], + "reorg must not evict the pending sweep entry", + ) + + // Mock did not register MarkBoardingSweepFailed; AssertExpectations + // passes vacuously, but any call would have failed the mock. + store.AssertExpectations(t) +} + +// TestSweepTxNotificationFinalizedIsBenign verifies that a TxFinalized +// event (the chainsource reorg-safety horizon is reached) does not +// mark the sweep failed, does not re-emit ledger entries, and does +// not tear down pending state. Pending state remains because the +// terminal release path is the same as the legacy confirmation path — +// post-finalization cleanup is a no-op since reconcileSweepInputsOnConfirm +// already ran on the original TxConfirmed. +func TestSweepTxNotificationFinalizedIsBenign(t *testing.T) { + t.Parallel() + + finalizedTxid := chainhash.Hash{0xa2} + store := &MockBoardingSweepStore{} + // No expectations registered — finalized must not call any + // store mutation method. + + a := newSweepTestArk(t, store, nil, 0, 0) + pending := &pendingSweepState{ + txid: finalizedTxid, + inputs: map[wire.OutPoint]string{}, + } + a.pendingSweeps[finalizedTxid] = pending + + result := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusFinalized, + Txid: finalizedTxid, + BlockHeight: 800_750, + NumConfs: 6, + }, + ) + require.True(t, result.IsOk()) + + // Pending state should still be present; finalized is informational. + require.Same(t, pending, a.pendingSweeps[finalizedTxid]) + + store.AssertExpectations(t) +} + +// TestSweepTxNotificationReorgedAfterPendingCleared verifies the +// Reorged handler arm survives a missing pendingSweeps entry (which +// can happen when every per-input spend notification has already +// resolved and the entry was cleaned up by handleSweepSpendNotification +// before the tx-level reorg notification arrives). +func TestSweepTxNotificationReorgedAfterPendingCleared(t *testing.T) { + t.Parallel() + + reorgedTxid := chainhash.Hash{0xa3} + store := &MockBoardingSweepStore{} + // No store expectations — reorg with no pending entry must touch + // nothing. + + a := newSweepTestArk(t, store, nil, 0, 0) + // Note: pendingSweeps is intentionally empty. + + result := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusReorged, + Txid: reorgedTxid, + }, + ) + require.True(t, result.IsOk()) + require.Empty(t, a.pendingSweeps) + + store.AssertExpectations(t) +} + +// TestSweepTxNotificationFailedAfterReorgedStillTerminates verifies +// the Reorged arm does NOT suppress a subsequent terminal Failed +// notification: a sequence of Reorged then Failed must still call +// MarkBoardingSweepFailed and drop the pending entry, otherwise a +// reorg followed by a hard broadcast failure would be silently +// stranded. +func TestSweepTxNotificationFailedAfterReorgedStillTerminates(t *testing.T) { + t.Parallel() + + txid := chainhash.Hash{0xa4} + store := &MockBoardingSweepStore{} + store.On( + "MarkBoardingSweepFailed", mock.Anything, txid, + mock.Anything, + ).Return(nil) + + // The Failed arm now looks the sweep up first to ignore a spurious + // failure for an already-resolved sweep. Here the record is absent + // (not terminal-success), so the failure path proceeds as before. + store.On( + "GetBoardingSweep", mock.Anything, txid, + ).Return(nil, nil) + + a := newSweepTestArk(t, store, nil, 0, 0) + pending := &pendingSweepState{ + txid: txid, + inputs: map[wire.OutPoint]string{}, + } + a.pendingSweeps[txid] = pending + + // Step 1: Reorged — pending should remain, store should not be + // touched. + reorgResult := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusReorged, + Txid: txid, + }, + ) + require.True(t, reorgResult.IsOk()) + require.Same(t, pending, a.pendingSweeps[txid]) + + // Step 2: Failed — terminal path must still fire even though we + // passed through Reorged first. + failResult := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusFailed, + Txid: txid, + Reason: "post-reorg broadcast failure", + }, + ) + require.True(t, failResult.IsOk()) + require.Empty( + t, a.pendingSweeps, + "Failed after Reorged must still tear down pendingSweeps", + ) + + store.AssertExpectations(t) +} + +// TestSweepTxNotificationFailedIgnoredForConfirmedSweep verifies the +// defensive guard on the Failed arm: a spurious Failed notification for a +// sweep whose persisted record already shows a terminal-success status +// (confirmed) must NOT roll the sweep back to failed, because the +// confirmed sweep's ledger legs are irreversible and txid-keyed. +func TestSweepTxNotificationFailedIgnoredForConfirmedSweep(t *testing.T) { + t.Parallel() + + txid := chainhash.Hash{0xa6} + store := &MockBoardingSweepStore{} + + // The record is already confirmed, so the guard must short-circuit + // before MarkBoardingSweepFailed; no expectation is set for it, so a + // call would fail the test. + store.On( + "GetBoardingSweep", mock.Anything, txid, + ).Return(&BoardingSweepRecord{ + Status: BoardingSweepStatusConfirmed, + }, nil) + + a := newSweepTestArk(t, store, nil, 0, 0) + pending := &pendingSweepState{ + txid: txid, + inputs: map[wire.OutPoint]string{}, + } + a.pendingSweeps[txid] = pending + + failResult := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusFailed, + Txid: txid, + Reason: "spurious failure after confirmation", + }, + ) + require.True(t, failResult.IsOk()) + + store.AssertNotCalled(t, "MarkBoardingSweepFailed") + store.AssertExpectations(t) +} + +// TestSweepTxNotificationUnknownStatusIsBenign verifies the default +// arm of handleSweepTxNotification handles an unrecognised status +// without touching the store or pendingSweeps. This guards against a +// future txconfirm lifecycle event being added without a matching +// MapNotification arm. +func TestSweepTxNotificationUnknownStatusIsBenign(t *testing.T) { + t.Parallel() + + txid := chainhash.Hash{0xa5} + store := &MockBoardingSweepStore{} + // No expectations — unknown must touch nothing. + + a := newSweepTestArk(t, store, nil, 0, 0) + pending := &pendingSweepState{ + txid: txid, + inputs: map[wire.OutPoint]string{}, + } + a.pendingSweeps[txid] = pending + + result := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusUnknown, + Txid: txid, + }, + ) + require.True(t, result.IsOk()) + require.Same(t, pending, a.pendingSweeps[txid]) + + store.AssertExpectations(t) +} diff --git a/waved/server.go b/waved/server.go index 724edb25e..b70440f9a 100644 --- a/waved/server.go +++ b/waved/server.go @@ -2082,6 +2082,13 @@ func (s *Server) registerChainSourceActor( chainsource.ChainSourceConfig{ Backend: s.chainBackend, System: s.actorSystem, + // Enable height-based Done synthesis at the default + // safety depth. Without this, conf/spend sub-actors + // driven through lndclient (whose Done channel is + // allocated-but-never-written) would never receive a + // finality signal, and reorg-aware consumers like + // txconfirm would leak watch state forever. + FinalityDepth: chainsource.DefaultFinalityDepth, }, ) From 39f521c139c3d3d0ea35c29920bf44acd07e8aab Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 8 Jul 2026 13:54:34 -0700 Subject: [PATCH 07/18] multi: batch canonicality data model (C2) Squashed for the btcd v2 port. Durable batch-canonicality data model: batchcanon package (State/Record/EffectiveExpiry/ProvisionalConsumer, behavior-free Store), DB migration + persistence store + BackfillFromVTXOs, and the expiry-as-terminal audit. No manager, chain watching, or admission (those are C3+). --- batchcanon/AGENTS.md | 75 ++ batchcanon/CLAUDE.md | 75 ++ batchcanon/doc.go | 26 + batchcanon/record.go | 82 +++ batchcanon/record_test.go | 70 ++ batchcanon/state.go | 101 +++ batchcanon/state_test.go | 75 ++ batchcanon/store.go | 77 +++ db/batch_canonicality_store.go | 648 ++++++++++++++++++ db/batch_canonicality_store_test.go | 467 +++++++++++++ db/migrations.go | 2 +- db/sqlc/batch_canonicality.sql.go | 503 ++++++++++++++ .../000014_batch_canonicality.down.sql | 4 + .../000014_batch_canonicality.up.sql | 134 ++++ db/sqlc/models.go | 32 + db/sqlc/querier.go | 59 ++ db/sqlc/queries/batch_canonicality.sql | 138 ++++ db/sqlc/schemas/generated_schema.sql | 106 +++ db/store.go | 23 + 19 files changed, 2696 insertions(+), 1 deletion(-) create mode 100644 batchcanon/AGENTS.md create mode 100644 batchcanon/CLAUDE.md create mode 100644 batchcanon/doc.go create mode 100644 batchcanon/record.go create mode 100644 batchcanon/record_test.go create mode 100644 batchcanon/state.go create mode 100644 batchcanon/state_test.go create mode 100644 batchcanon/store.go create mode 100644 db/batch_canonicality_store.go create mode 100644 db/batch_canonicality_store_test.go create mode 100644 db/sqlc/batch_canonicality.sql.go create mode 100644 db/sqlc/migrations/000014_batch_canonicality.down.sql create mode 100644 db/sqlc/migrations/000014_batch_canonicality.up.sql create mode 100644 db/sqlc/queries/batch_canonicality.sql diff --git a/batchcanon/AGENTS.md b/batchcanon/AGENTS.md new file mode 100644 index 000000000..47ea46e0f --- /dev/null +++ b/batchcanon/AGENTS.md @@ -0,0 +1,75 @@ +# batchcanon + +## Purpose + +Client-side **batch canonicality data model** for the reorg-safety epic +(darepo#454, task C2). Holds the durable, reorg-aware record of how each batch +(commitment) transaction is faring against the best chain: its canonicality +state, current confirmation observation, recompute inputs for effective +expiry, the inputs it consumes, the VTXOs it anchors, and the reverse +dependencies needed to restore a provisionally consumed VTXO. + +This package is **data + query/update interface only**. It contains no +interpretation, no chain watching, and no admission behavior — those belong to +the (later) `BatchCanonicalityManager` and the VTXO manager. Keeping the model +in its own package, separate from `chainsource` (raw observation) and `vtxo` +(admission), preserves the epic's observation → interpretation → action split. + +## Key Types + +- `State` — canonicality state enum: `StateUnseen`, `StateProvisional`, + `StateFinalized`, `StateReorgedOut`, `StateConflictProvisional`, + `StateConflictFinalized`. Reorg-reversible; **no state is a terminal + verdict** at this layer. Persisted as an append-only typed INTEGER column — + values must never be renumbered. +- `PolicyState` — reserved policy classification slot (`PolicyStateDefault` + only); persisted and round-tripped, no business meaning yet. +- `Record` — per-batch record keyed by `BatchTxID`. Identity is by **txid**, + never `(txid, block hash)`; `ConfirmationBlock` is an observation attribute + only. `EffectiveExpiry()` derives the absolute expiry as + `ConfirmationHeight + CSVExpiryDelta`, returning `None` when unconfirmed — + the structural guarantee that expiry is recomputed on every + reconfirmation rather than frozen. +- `ProvisionalConsumer` — reverse-dependency edge (consumed VTXO → consumer + batch) enabling VTXO restore if a consumer batch never becomes canonical. +- `Store` — behavior-free durable query/update interface. Implemented by + `db.BatchCanonicalityPersistenceStore` over the `000020` schema; backfilled + from existing VTXOs via `db.BatchCanonicalityPersistenceStore.BackfillFromVTXOs`. + +## Relationships + +- **Depends on**: `btcd/chaincfg/chainhash`, `btcd/wire`, `lnd/fn/v2` only. +- **Depended on by**: `db` (concrete store), and — in later tasks — the + batch canonicality manager and `vtxo` admission. + +## Invariants + +- Identity is by txid / outpoint, never by `(txid, block hash)`. +- Expiry is never persisted as a standalone or terminal value; it is always + derived from `CSVExpiryDelta` + the current confirmation observation. +- State enum integer values are append-only (persisted column). + +## Expiry-as-terminal audit (darepo#454 C2) + +C2 requires auditing every site that treats `BatchExpiry`/`Expired` as a +one-way terminal fact. These are flagged for rework when the +BatchCanonicalityManager (task C3/C4) rewires expiry consumers onto +`Record.EffectiveExpiry()`; **no behavior is changed by C2**: + +- `vtxo/transitions.go` (`ExpiryStatusExpired → FailedState{Recoverable: + false}`, and the Critical/Expired escalations) — the primary offender: a + reorg that lowers the confirmation height could otherwise push a VTXO + permanently into non-recoverable `Failed`. +- `vtxo/expiry.go` (`CheckExpiry`, `BlocksUntilExpiry`) — compute from the + frozen absolute `vtxo.BatchExpiry`; must consume effective (recomputable) + expiry instead. +- `vtxo/actor.go` — schedules on the frozen absolute `BatchExpiry`. +- `darepod/vhtlc_recovery_target.go` — folds multiple roots into a + most-restrictive absolute `batchExpiry`. +- `unroll/proof_assembler.go` (`BatchExpiry == 0`) — treats zero as "unset", + not terminal; benign, documented for completeness. + +## Deep Docs + +- [ARCHITECTURE.md](../ARCHITECTURE.md) — System-wide package map. + diff --git a/batchcanon/CLAUDE.md b/batchcanon/CLAUDE.md new file mode 100644 index 000000000..47ea46e0f --- /dev/null +++ b/batchcanon/CLAUDE.md @@ -0,0 +1,75 @@ +# batchcanon + +## Purpose + +Client-side **batch canonicality data model** for the reorg-safety epic +(darepo#454, task C2). Holds the durable, reorg-aware record of how each batch +(commitment) transaction is faring against the best chain: its canonicality +state, current confirmation observation, recompute inputs for effective +expiry, the inputs it consumes, the VTXOs it anchors, and the reverse +dependencies needed to restore a provisionally consumed VTXO. + +This package is **data + query/update interface only**. It contains no +interpretation, no chain watching, and no admission behavior — those belong to +the (later) `BatchCanonicalityManager` and the VTXO manager. Keeping the model +in its own package, separate from `chainsource` (raw observation) and `vtxo` +(admission), preserves the epic's observation → interpretation → action split. + +## Key Types + +- `State` — canonicality state enum: `StateUnseen`, `StateProvisional`, + `StateFinalized`, `StateReorgedOut`, `StateConflictProvisional`, + `StateConflictFinalized`. Reorg-reversible; **no state is a terminal + verdict** at this layer. Persisted as an append-only typed INTEGER column — + values must never be renumbered. +- `PolicyState` — reserved policy classification slot (`PolicyStateDefault` + only); persisted and round-tripped, no business meaning yet. +- `Record` — per-batch record keyed by `BatchTxID`. Identity is by **txid**, + never `(txid, block hash)`; `ConfirmationBlock` is an observation attribute + only. `EffectiveExpiry()` derives the absolute expiry as + `ConfirmationHeight + CSVExpiryDelta`, returning `None` when unconfirmed — + the structural guarantee that expiry is recomputed on every + reconfirmation rather than frozen. +- `ProvisionalConsumer` — reverse-dependency edge (consumed VTXO → consumer + batch) enabling VTXO restore if a consumer batch never becomes canonical. +- `Store` — behavior-free durable query/update interface. Implemented by + `db.BatchCanonicalityPersistenceStore` over the `000020` schema; backfilled + from existing VTXOs via `db.BatchCanonicalityPersistenceStore.BackfillFromVTXOs`. + +## Relationships + +- **Depends on**: `btcd/chaincfg/chainhash`, `btcd/wire`, `lnd/fn/v2` only. +- **Depended on by**: `db` (concrete store), and — in later tasks — the + batch canonicality manager and `vtxo` admission. + +## Invariants + +- Identity is by txid / outpoint, never by `(txid, block hash)`. +- Expiry is never persisted as a standalone or terminal value; it is always + derived from `CSVExpiryDelta` + the current confirmation observation. +- State enum integer values are append-only (persisted column). + +## Expiry-as-terminal audit (darepo#454 C2) + +C2 requires auditing every site that treats `BatchExpiry`/`Expired` as a +one-way terminal fact. These are flagged for rework when the +BatchCanonicalityManager (task C3/C4) rewires expiry consumers onto +`Record.EffectiveExpiry()`; **no behavior is changed by C2**: + +- `vtxo/transitions.go` (`ExpiryStatusExpired → FailedState{Recoverable: + false}`, and the Critical/Expired escalations) — the primary offender: a + reorg that lowers the confirmation height could otherwise push a VTXO + permanently into non-recoverable `Failed`. +- `vtxo/expiry.go` (`CheckExpiry`, `BlocksUntilExpiry`) — compute from the + frozen absolute `vtxo.BatchExpiry`; must consume effective (recomputable) + expiry instead. +- `vtxo/actor.go` — schedules on the frozen absolute `BatchExpiry`. +- `darepod/vhtlc_recovery_target.go` — folds multiple roots into a + most-restrictive absolute `batchExpiry`. +- `unroll/proof_assembler.go` (`BatchExpiry == 0`) — treats zero as "unset", + not terminal; benign, documented for completeness. + +## Deep Docs + +- [ARCHITECTURE.md](../ARCHITECTURE.md) — System-wide package map. + diff --git a/batchcanon/doc.go b/batchcanon/doc.go new file mode 100644 index 000000000..59a5064de --- /dev/null +++ b/batchcanon/doc.go @@ -0,0 +1,26 @@ +// Package batchcanon holds the client-side batch canonicality data model: +// the durable record of how each batch (commitment) transaction is faring +// against the best chain, the inputs it consumes, the VTXOs it anchors, and +// the reverse-dependency edges needed to restore a provisionally consumed +// VTXO if its consumer batch never becomes canonical. +// +// This package is the data substrate for the reorg-safety epic +// (darepo#454). It deliberately contains NO interpretation or admission +// behavior: it persists and retrieves observations only. The +// BatchCanonicalityManager (a later task) is the sole interpreter that +// drives state transitions from chainsource observations, and the VTXO +// manager remains the admission boundary. Keeping the model here, separate +// from both chainsource (raw observation) and vtxo (admission), preserves +// the observation -> interpretation -> action split the epic mandates. +// +// Two principles shape the model: +// +// - Identity is by txid / outpoint, never by (txid, block hash). A reorg +// that re-mines the same batch tx in a different block is the SAME +// batch; the block hash is only an observation attribute. +// +// - Expiry is never stored as a terminal fact. The model stores a +// CSV-relative delta plus the current confirmation height and derives +// the effective (absolute) expiry on demand, so a reorg-and-reconfirm +// at a new height recomputes expiry instead of freezing it. +package batchcanon diff --git a/batchcanon/record.go b/batchcanon/record.go new file mode 100644 index 000000000..8f47d8639 --- /dev/null +++ b/batchcanon/record.go @@ -0,0 +1,82 @@ +package batchcanon + +import ( + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +// Record is the durable canonicality view of one batch (commitment) +// transaction, keyed by its txid. It bundles the interpreted State, the +// current confirmation observation, the recompute inputs for effective +// expiry, the consumed inputs and dependent VTXOs, and the reserved policy +// slot. +type Record struct { + // BatchTxID is the commitment transaction id and the record's + // identity. Identity is by txid, never by (txid, block hash): a reorg + // that re-mines the same tx in a different block is the same batch. + BatchTxID chainhash.Hash + + // State is the interpreted canonicality state. + State State + + // ConfirmationHeight is the best-chain height at which the batch tx + // is currently observed confirmed. None when the batch is not + // currently confirmed (unseen or reorged out). A reorg clears it; a + // reconfirmation sets it to the new height. + ConfirmationHeight fn.Option[int32] + + // ConfirmationBlock is the hash of the block currently confirming the + // batch tx. It is an observation attribute only and is NOT part of + // the batch identity. None when the batch is not currently confirmed. + ConfirmationBlock fn.Option[chainhash.Hash] + + // CSVExpiryDelta is the batch's CSV-relative expiry timeout, in + // blocks. The effective (absolute) expiry height is derived from this + // plus the current confirmation height, so it tracks reconfirmations + // after a reorg instead of being frozen at first confirmation. + CSVExpiryDelta int32 + + // PolicyState is the reserved policy classification slot. See + // PolicyState. + PolicyState PolicyState + + // ConsumedInputs are the outpoints this batch tx spends. They are + // tracked so the manager can watch each one for a conflicting spend. + ConsumedInputs []wire.OutPoint + + // DependentVTXOs are the VTXO outpoints anchored by this batch. Their + // derived availability follows this batch's canonicality. + DependentVTXOs []wire.OutPoint +} + +// EffectiveExpiry derives the absolute expiry height from the current +// confirmation observation: ConfirmationHeight + CSVExpiryDelta. It returns +// None when the batch is not currently confirmed. +// +// Deriving expiry on demand (rather than persisting an absolute height) is +// what keeps expiry reorg-safe: a confirmation that is reorged out clears +// ConfirmationHeight and so erases the effective expiry, and a +// reconfirmation at a different height yields a fresh effective expiry. +// Expiry is therefore never a one-way terminal fact at this layer. +func (r *Record) EffectiveExpiry() fn.Option[int32] { + return fn.MapOption( + func(height int32) int32 { + return height + r.CSVExpiryDelta + })(r.ConfirmationHeight) +} + +// ProvisionalConsumer records that a (locally relevant) VTXO has been +// provisionally consumed by a not-yet-canonical consumer batch. It is the +// reverse-dependency edge that lets a provisionally consumed VTXO be restored +// if the consumer batch never becomes canonical — for example a round-2 +// forfeit whose commitment tx is reorged out, which must restore the round-1 +// VTXO it consumed. +type ProvisionalConsumer struct { + // ConsumedVTXO is the outpoint of the VTXO consumed by ConsumerBatch. + ConsumedVTXO wire.OutPoint + + // ConsumerBatch is the batch tx that provisionally consumes + // ConsumedVTXO. + ConsumerBatch chainhash.Hash +} diff --git a/batchcanon/record_test.go b/batchcanon/record_test.go new file mode 100644 index 000000000..bf217f87c --- /dev/null +++ b/batchcanon/record_test.go @@ -0,0 +1,70 @@ +package batchcanon + +import ( + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// TestEffectiveExpiryNoneWhenUnconfirmed verifies that a batch with no +// current confirmation observation has no effective expiry — the structural +// guarantee that expiry is not a standalone terminal fact. +func TestEffectiveExpiryNoneWhenUnconfirmed(t *testing.T) { + t.Parallel() + + rec := &Record{ + BatchTxID: chainhash.Hash{ + 0x01, + }, + State: StateUnseen, + ConfirmationHeight: fn.None[int32](), + CSVExpiryDelta: 144, + } + + require.True(t, rec.EffectiveExpiry().IsNone()) +} + +// TestEffectiveExpiryDerivesFromConfirmation verifies the effective expiry is +// the confirmation height plus the CSV-relative delta. +func TestEffectiveExpiryDerivesFromConfirmation(t *testing.T) { + t.Parallel() + + rec := &Record{ + BatchTxID: chainhash.Hash{ + 0x02, + }, + State: StateProvisional, + ConfirmationHeight: fn.Some[int32](100), + CSVExpiryDelta: 144, + } + + got := rec.EffectiveExpiry() + require.True(t, got.IsSome()) + require.Equal(t, int32(244), got.UnwrapOr(0)) +} + +// TestEffectiveExpiryRecomputesAfterReconfirm verifies that re-confirming the +// same batch at a different height (as happens after a reorg) yields a fresh +// effective expiry rather than a value frozen at first confirmation. +func TestEffectiveExpiryRecomputesAfterReconfirm(t *testing.T) { + t.Parallel() + + rec := &Record{ + BatchTxID: chainhash.Hash{ + 0x03, + }, + ConfirmationHeight: fn.Some[int32](100), + CSVExpiryDelta: 144, + } + require.Equal(t, int32(244), rec.EffectiveExpiry().UnwrapOr(0)) + + // Reorg: the confirmation leaves the best chain. + rec.ConfirmationHeight = fn.None[int32]() + require.True(t, rec.EffectiveExpiry().IsNone()) + + // Reconfirmation at a higher height on the new best chain. + rec.ConfirmationHeight = fn.Some[int32](103) + require.Equal(t, int32(247), rec.EffectiveExpiry().UnwrapOr(0)) +} diff --git a/batchcanon/state.go b/batchcanon/state.go new file mode 100644 index 000000000..803432a65 --- /dev/null +++ b/batchcanon/state.go @@ -0,0 +1,101 @@ +package batchcanon + +import "fmt" + +// State is the canonicality state of a batch (commitment) transaction as +// interpreted from raw chain observation. It is reorg-reversible: a batch may +// move between states any number of times (e.g. provisional -> finalized -> +// reorged_out -> provisional) as the chain evolves. No state at this layer is +// a one-way terminal verdict — even ConflictFinalized can be undone if the +// conflicting transaction itself later reorgs out. +// +// State is persisted as a typed INTEGER column. Values are append-only and +// MUST NOT be renumbered, because persisted rows reference them directly. +type State int + +const ( + // StateUnseen indicates the batch tx has not been observed confirmed + // on the best chain. This is the zero value: a freshly recorded + // batch with no confirmation observation is unseen. + StateUnseen State = iota + + // StateProvisional indicates the batch tx is confirmed but has not + // yet matured past the configured finality depth, so its + // confirmation may still be reorged out. + StateProvisional + + // StateFinalized indicates the batch tx confirmation has matured past + // the configured finality depth. This is policy finality at the + // configured depth, not a claim of absolute Bitcoin finality. + StateFinalized + + // StateReorgedOut indicates a previously observed confirmation left + // the best chain and no consumed input has been seen double-spent. + // The batch may reconfirm, so dependent VTXOs enter limbo rather than + // being invalidated. + StateReorgedOut + + // StateConflictProvisional indicates a consumed batch input was + // double-spent by a conflicting transaction on the best chain, and + // that conflicting spend has not yet matured past the finality depth. + StateConflictProvisional + + // StateConflictFinalized indicates a consumed-input conflict has + // matured past the finality depth. It is the strongest negative + // signal but, like every state here, remains reversible if the + // conflicting transaction is itself reorged out. + StateConflictFinalized +) + +// String returns a stable lower-snake-case name for the state, matching the +// vocabulary used in darepo#454 and the persisted-column documentation. +func (s State) String() string { + switch s { + case StateUnseen: + return "unseen" + + case StateProvisional: + return "provisional" + + case StateFinalized: + return "finalized" + + case StateReorgedOut: + return "reorged_out" + + case StateConflictProvisional: + return "conflict_provisional" + + case StateConflictFinalized: + return "conflict_finalized" + + default: + return fmt.Sprintf("unknown(%d)", int(s)) + } +} + +// PolicyState is a durable, reorg-independent policy classification slot for a +// batch. darepo#454 reserves this field in the data model; this layer +// persists and round-trips it but assigns no business meaning yet. The +// BatchCanonicalityManager and the admission gates in later tasks own its +// interpretation. +// +// Like State, it is persisted as an append-only typed INTEGER column. +type PolicyState int + +const ( + // PolicyStateDefault is the zero value and the only policy state + // defined at the data-model layer. + PolicyStateDefault PolicyState = iota +) + +// String returns a stable lower-snake-case name for the policy state. +func (p PolicyState) String() string { + switch p { + case PolicyStateDefault: + return "default" + + default: + return fmt.Sprintf("unknown(%d)", int(p)) + } +} diff --git a/batchcanon/state_test.go b/batchcanon/state_test.go new file mode 100644 index 000000000..6fcd89ee2 --- /dev/null +++ b/batchcanon/state_test.go @@ -0,0 +1,75 @@ +package batchcanon + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestStateValuesStable pins the integer value and string name of every +// canonicality state. These values are persisted as a typed INTEGER column, +// so a change here would silently re-interpret existing rows — the test +// exists to make any renumbering a deliberate, visible edit. +func TestStateValuesStable(t *testing.T) { + t.Parallel() + + cases := []struct { + state State + value int + name string + }{ + { + StateUnseen, + 0, + "unseen", + }, + { + StateProvisional, + 1, + "provisional", + }, + { + StateFinalized, + 2, + "finalized", + }, + { + StateReorgedOut, + 3, + "reorged_out", + }, + { + StateConflictProvisional, + 4, + "conflict_provisional", + }, + { + StateConflictFinalized, + 5, + "conflict_finalized", + }, + } + + for _, tc := range cases { + require.Equal(t, tc.value, int(tc.state), tc.name) + require.Equal(t, tc.name, tc.state.String()) + } +} + +// TestStateStringUnknown verifies an out-of-range state stringifies to a +// diagnosable unknown form rather than an empty string. +func TestStateStringUnknown(t *testing.T) { + t.Parallel() + + require.Equal(t, "unknown(99)", State(99).String()) +} + +// TestPolicyStateStable pins the policy-state value and name. PolicyState is +// also persisted as an append-only typed INTEGER column. +func TestPolicyStateStable(t *testing.T) { + t.Parallel() + + require.Equal(t, 0, int(PolicyStateDefault)) + require.Equal(t, "default", PolicyStateDefault.String()) + require.Equal(t, "unknown(7)", PolicyState(7).String()) +} diff --git a/batchcanon/store.go b/batchcanon/store.go new file mode 100644 index 000000000..d81bd7f0f --- /dev/null +++ b/batchcanon/store.go @@ -0,0 +1,77 @@ +package batchcanon + +import ( + "context" + "errors" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" +) + +// ErrBatchNotFound is returned by Store.GetBatch when no canonicality record +// exists for the requested batch txid. +var ErrBatchNotFound = errors.New("batch canonicality record not found") + +// Store is the durable query/update surface for batch canonicality records. +// It is intentionally behavior-free: it persists and retrieves observations +// and reverse-dependency edges, leaving all interpretation — state +// transitions, chain watching, and admission — to the BatchCanonicalityManager +// and the later tasks of the reorg-safety epic. +type Store interface { + // UpsertBatch inserts or replaces the canonicality record for a + // batch, including its consumed inputs and dependent VTXOs. It is the + // single entry point for first-seeing a batch and for wholesale + // rewrites; targeted mutations use the methods below. + UpsertBatch(ctx context.Context, record *Record) error + + // GetBatch returns the canonicality record for a batch txid. It + // returns ErrBatchNotFound when no record exists. + GetBatch(ctx context.Context, txid chainhash.Hash) (*Record, error) + + // ListBatchesByState returns every batch currently in the given + // state. Used by the manager to find batches needing a particular + // follow-up (e.g. all provisional batches to re-check for finality). + ListBatchesByState(ctx context.Context, state State) ([]*Record, error) + + // UpdateBatchState transitions a batch to a new canonicality state + // without touching its other fields. + UpdateBatchState(ctx context.Context, txid chainhash.Hash, + state State) error + + // RecordConfirmation records that the batch tx is confirmed at the + // given best-chain height and block hash. A later RecordConfirmation + // at a different height (after a reorg) overwrites the observation so + // the effective expiry tracks the new confirmation. + RecordConfirmation(ctx context.Context, txid chainhash.Hash, + height int32, block chainhash.Hash) error + + // ClearConfirmation clears the confirmation observation for a batch, + // reflecting that its confirming block left the best chain. It does + // not set any terminal flag: the batch may reconfirm. + ClearConfirmation(ctx context.Context, txid chainhash.Hash) error + + // FindBatchesConsumingOutpoint returns the txids of every recorded + // batch that consumes the given outpoint. Used to detect input + // conflicts: two batches consuming the same outpoint are in conflict. + FindBatchesConsumingOutpoint(ctx context.Context, + outpoint wire.OutPoint) ([]chainhash.Hash, error) + + // AddProvisionalConsumer records a reverse-dependency edge: the given + // VTXO outpoint has been provisionally consumed by the given consumer + // batch. Idempotent on (consumedVTXO, consumerBatch). + AddProvisionalConsumer(ctx context.Context, consumedVTXO wire.OutPoint, + consumerBatch chainhash.Hash) error + + // ListProvisionalConsumersForBatch returns the VTXO outpoints that + // the given consumer batch provisionally consumes. Used to find the + // VTXOs to restore when the consumer batch is invalidated. + ListProvisionalConsumersForBatch(ctx context.Context, + consumerBatch chainhash.Hash) ([]wire.OutPoint, error) + + // DeleteProvisionalConsumersForBatch removes every reverse-dependency + // edge for the given consumer batch, used once the batch is canonical + // (the consumption is no longer provisional) or fully invalidated and + // reconciled. + DeleteProvisionalConsumersForBatch(ctx context.Context, + consumerBatch chainhash.Hash) error +} diff --git a/db/batch_canonicality_store.go b/db/batch_canonicality_store.go new file mode 100644 index 000000000..ebeff2d19 --- /dev/null +++ b/db/batch_canonicality_store.go @@ -0,0 +1,648 @@ +package db + +import ( + "context" + "database/sql" + "errors" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/db/sqlc" + "github.com/lightningnetwork/lnd/clock" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +// BatchCanonicalityStore groups the generated SQL methods needed to persist +// the batch canonicality data model. +// +//nolint:interfacebloat // One handle keeps all canonicality ExecTx closures. +type BatchCanonicalityStore interface { + UpsertBatchCanonicality(ctx context.Context, + arg sqlc.UpsertBatchCanonicalityParams) error + + GetBatchCanonicality(ctx context.Context, + batchTxid []byte) (sqlc.BatchCanonicality, error) + + ListBatchCanonicalityByState(ctx context.Context, + state int32) ([]sqlc.BatchCanonicality, error) + + UpdateBatchCanonicalityState(ctx context.Context, + arg sqlc.UpdateBatchCanonicalityStateParams) error + + RecordBatchConfirmation(ctx context.Context, + arg sqlc.RecordBatchConfirmationParams) error + + ClearBatchConfirmation(ctx context.Context, + arg sqlc.ClearBatchConfirmationParams) error + + InsertBatchConsumedInput(ctx context.Context, + arg sqlc.InsertBatchConsumedInputParams) error + + DeleteBatchConsumedInputs(ctx context.Context, batchTxid []byte) error + + ListBatchConsumedInputs(ctx context.Context, + batchTxid []byte) ([]sqlc.ListBatchConsumedInputsRow, error) + + FindBatchesByConsumedOutpoint(ctx context.Context, + arg sqlc.FindBatchesByConsumedOutpointParams) ([][]byte, error) + + InsertBatchDependentVTXO(ctx context.Context, + arg sqlc.InsertBatchDependentVTXOParams) error + + DeleteBatchDependentVTXOs(ctx context.Context, batchTxid []byte) error + + ListBatchDependentVTXOs(ctx context.Context, + batchTxid []byte) ([]sqlc.ListBatchDependentVTXOsRow, error) + + InsertProvisionalConsumer(ctx context.Context, + arg sqlc.InsertProvisionalConsumerParams) error + + ListProvisionalConsumersForBatch(ctx context.Context, + consumerBatchTxid []byte) ( + []sqlc.ListProvisionalConsumersForBatchRow, error) + + DeleteProvisionalConsumersForBatch(ctx context.Context, + consumerBatchTxid []byte) error + + ListVTXOsForCanonicalityBackfill(ctx context.Context) ( + []sqlc.ListVTXOsForCanonicalityBackfillRow, error) +} + +// BatchedBatchCanonicalityStore combines the query surface with batched +// transaction execution. +type BatchedBatchCanonicalityStore interface { + BatchCanonicalityStore + BatchedTx[BatchCanonicalityStore] +} + +// BatchCanonicalityPersistenceStore persists the durable batch canonicality +// data model: per-batch canonicality records, the inputs each batch consumes, +// the VTXOs it anchors, and the reverse-dependency edges used to restore a +// provisionally consumed VTXO. It is behavior-free; interpretation lives in +// the batch canonicality manager. +type BatchCanonicalityPersistenceStore struct { + db BatchedBatchCanonicalityStore + clock clock.Clock +} + +// NewBatchCanonicalityPersistenceStore creates a batch canonicality store +// using the transaction executor pattern. +func NewBatchCanonicalityPersistenceStore(db BatchedBatchCanonicalityStore, + clk clock.Clock) *BatchCanonicalityPersistenceStore { + + return &BatchCanonicalityPersistenceStore{ + db: db, + clock: clk, + } +} + +// UpsertBatch inserts or replaces the canonicality record for a batch, +// including its consumed inputs and dependent VTXOs. The input and dependent +// sets are replaced wholesale (delete-then-insert) so the persisted edges +// always match the supplied record. +func (s *BatchCanonicalityPersistenceStore) UpsertBatch(ctx context.Context, + record *batchcanon.Record) error { + + now := s.clock.Now().Unix() + txid := record.BatchTxID + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + err := q.UpsertBatchCanonicality( + ctx, sqlc.UpsertBatchCanonicalityParams{ + BatchTxid: txid[:], + State: int32(record.State), + ConfirmationHeight: optionToNullInt32( + record.ConfirmationHeight, + ), + ConfirmationBlockHash: optionHashToBytes( + record.ConfirmationBlock, + ), + CsvExpiryDelta: record.CSVExpiryDelta, + PolicyState: int32(record.PolicyState), + CreatedAt: now, + UpdatedAt: now, + }, + ) + if err != nil { + return err + } + + // Replace the consumed-input set. + if err := q.DeleteBatchConsumedInputs( + ctx, txid[:], + ); err != nil { + return err + } + for _, in := range record.ConsumedInputs { + err := q.InsertBatchConsumedInput( + ctx, sqlc.InsertBatchConsumedInputParams{ + BatchTxid: txid[:], + InputHash: in.Hash[:], + InputIndex: int32(in.Index), + }, + ) + if err != nil { + return err + } + } + + // Replace the dependent-VTXO set. + err = q.DeleteBatchDependentVTXOs(ctx, txid[:]) + if err != nil { + return err + } + + return insertDependentVTXOs(ctx, q, txid, record.DependentVTXOs) + }) +} + +// insertDependentVTXOs links each dependent VTXO outpoint to a batch txid. +// Shared by UpsertBatch and BackfillFromVTXOs so neither nests the insert +// loop deeply enough to overflow the line-length budget. +func insertDependentVTXOs(ctx context.Context, q BatchCanonicalityStore, + txid chainhash.Hash, deps []wire.OutPoint) error { + + for _, dep := range deps { + err := q.InsertBatchDependentVTXO( + ctx, sqlc.InsertBatchDependentVTXOParams{ + BatchTxid: txid[:], + VtxoOutpointHash: dep.Hash[:], + VtxoOutpointIndex: int32(dep.Index), + }, + ) + if err != nil { + return err + } + } + + return nil +} + +// GetBatch returns the canonicality record for a batch txid, hydrating its +// consumed inputs and dependent VTXOs. It returns batchcanon.ErrBatchNotFound +// when no record exists. +func (s *BatchCanonicalityPersistenceStore) GetBatch(ctx context.Context, + txid chainhash.Hash) (*batchcanon.Record, error) { + + var record *batchcanon.Record + + err := s.db.ExecTx(ctx, ReadTxOption(), func( + q BatchCanonicalityStore) error { + + row, err := q.GetBatchCanonicality(ctx, txid[:]) + if errors.Is(err, sql.ErrNoRows) { + return batchcanon.ErrBatchNotFound + } + if err != nil { + return err + } + + rec, err := s.hydrateRecord(ctx, q, row) + if err != nil { + return err + } + record = rec + + return nil + }) + + return record, err +} + +// ListBatchesByState returns every batch currently in the given state, +// hydrating each record's consumed inputs and dependent VTXOs. +func (s *BatchCanonicalityPersistenceStore) ListBatchesByState( + ctx context.Context, state batchcanon.State) ([]*batchcanon.Record, + error) { + + var records []*batchcanon.Record + + err := s.db.ExecTx(ctx, ReadTxOption(), func( + q BatchCanonicalityStore) error { + + rows, err := q.ListBatchCanonicalityByState(ctx, int32(state)) + if err != nil { + return err + } + + records = make([]*batchcanon.Record, 0, len(rows)) + for _, row := range rows { + rec, err := s.hydrateRecord(ctx, q, row) + if err != nil { + return err + } + records = append(records, rec) + } + + return nil + }) + + return records, err +} + +// UpdateBatchState transitions a batch to a new canonicality state. +func (s *BatchCanonicalityPersistenceStore) UpdateBatchState( + ctx context.Context, txid chainhash.Hash, + state batchcanon.State) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return q.UpdateBatchCanonicalityState( + ctx, sqlc.UpdateBatchCanonicalityStateParams{ + BatchTxid: txid[:], + State: int32(state), + UpdatedAt: s.clock.Now().Unix(), + }, + ) + }) +} + +// RecordConfirmation records the best-chain height and block hash at which +// the batch tx is confirmed. +func (s *BatchCanonicalityPersistenceStore) RecordConfirmation( + ctx context.Context, txid chainhash.Hash, height int32, + block chainhash.Hash) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return q.RecordBatchConfirmation( + ctx, sqlc.RecordBatchConfirmationParams{ + BatchTxid: txid[:], + ConfirmationHeight: sql.NullInt32{ + Int32: height, + Valid: true, + }, + ConfirmationBlockHash: block[:], + UpdatedAt: s.clock.Now().Unix(), + }, + ) + }) +} + +// ClearConfirmation clears the confirmation observation for a batch. +func (s *BatchCanonicalityPersistenceStore) ClearConfirmation( + ctx context.Context, txid chainhash.Hash) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return q.ClearBatchConfirmation( + ctx, sqlc.ClearBatchConfirmationParams{ + BatchTxid: txid[:], + UpdatedAt: s.clock.Now().Unix(), + }, + ) + }) +} + +// FindBatchesConsumingOutpoint returns the txids of every recorded batch that +// consumes the given outpoint. +func (s *BatchCanonicalityPersistenceStore) FindBatchesConsumingOutpoint( + ctx context.Context, outpoint wire.OutPoint) ([]chainhash.Hash, error) { + + var txids []chainhash.Hash + + err := s.db.ExecTx(ctx, ReadTxOption(), func( + q BatchCanonicalityStore) error { + + rows, err := q.FindBatchesByConsumedOutpoint( + ctx, sqlc.FindBatchesByConsumedOutpointParams{ + InputHash: outpoint.Hash[:], + InputIndex: int32(outpoint.Index), + }, + ) + if err != nil { + return err + } + + txids = make([]chainhash.Hash, 0, len(rows)) + for _, raw := range rows { + hash, err := chainhash.NewHash(raw) + if err != nil { + return err + } + txids = append(txids, *hash) + } + + return nil + }) + + return txids, err +} + +// AddProvisionalConsumer records a reverse-dependency edge: the given VTXO +// outpoint is provisionally consumed by the given consumer batch. +func (s *BatchCanonicalityPersistenceStore) AddProvisionalConsumer( + ctx context.Context, consumedVTXO wire.OutPoint, + consumerBatch chainhash.Hash) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return q.InsertProvisionalConsumer( + ctx, sqlc.InsertProvisionalConsumerParams{ + ConsumedVtxoHash: consumedVTXO.Hash[:], + ConsumedVtxoIndex: int32(consumedVTXO.Index), + ConsumerBatchTxid: consumerBatch[:], + CreatedAt: s.clock.Now().Unix(), + }, + ) + }) +} + +// ListProvisionalConsumersForBatch returns the VTXO outpoints that the given +// consumer batch provisionally consumes. +func (s *BatchCanonicalityPersistenceStore) ListProvisionalConsumersForBatch( + ctx context.Context, consumerBatch chainhash.Hash) ([]wire.OutPoint, + error) { + + var outpoints []wire.OutPoint + + err := s.db.ExecTx(ctx, ReadTxOption(), func( + q BatchCanonicalityStore) error { + + rows, err := q.ListProvisionalConsumersForBatch( + ctx, consumerBatch[:], + ) + if err != nil { + return err + } + + outpoints = make([]wire.OutPoint, 0, len(rows)) + for _, row := range rows { + hash, err := chainhash.NewHash(row.ConsumedVtxoHash) + if err != nil { + return err + } + outpoints = append(outpoints, wire.OutPoint{ + Hash: *hash, + Index: uint32(row.ConsumedVtxoIndex), + }) + } + + return nil + }) + + return outpoints, err +} + +// DeleteProvisionalConsumersForBatch removes every reverse-dependency edge for +// the given consumer batch. +func (s *BatchCanonicalityPersistenceStore) DeleteProvisionalConsumersForBatch( + ctx context.Context, consumerBatch chainhash.Hash) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return q.DeleteProvisionalConsumersForBatch( + ctx, consumerBatch[:], + ) + }) +} + +// backfillGroup accumulates the per-batch data derived from VTXO rows during +// backfill: the batch-level expiry and creation height (shared by every VTXO +// in the batch) plus the set of dependent VTXO outpoints. +type backfillGroup struct { + batchExpiry int32 + createdHeight int32 + dependents []wire.OutPoint +} + +// BackfillFromVTXOs derives an initial canonicality record for every distinct +// batch (commitment) txid present in the VTXO store that does not already have +// a record, so an upgrading node carries forward the batches its existing +// VTXOs depend on. It is idempotent: batches that already have a record are +// left untouched, so a re-run never clobbers state the manager has since +// advanced. It returns the number of batch records created. +// +// Classification uses the supplied best height and finality depth: a batch +// confirmed at least finalityDepth deep (inclusive) is finalized, otherwise +// provisional. The CSV-relative expiry delta is recovered as +// batch_expiry - created_height so the derived effective expiry matches the +// original absolute batch_expiry while remaining reorg-recomputable. Batches +// whose VTXOs carry no positive creation height are skipped: without a +// confirmation observation the manager will create them on first sight. +func (s *BatchCanonicalityPersistenceStore) BackfillFromVTXOs( + ctx context.Context, bestHeight int32, finalityDepth uint32) (int, + error) { + + now := s.clock.Now().Unix() + created := 0 + + err := s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + rows, err := q.ListVTXOsForCanonicalityBackfill(ctx) + if err != nil { + return err + } + + // Group the VTXO rows by commitment (batch) txid. + groups := make(map[chainhash.Hash]*backfillGroup) + for _, row := range rows { + txid, err := chainhash.NewHash(row.CommitmentTxid) + if err != nil { + return err + } + vtxoHash, err := chainhash.NewHash(row.OutpointHash) + if err != nil { + return err + } + + g, ok := groups[*txid] + if !ok { + g = &backfillGroup{ + batchExpiry: row.BatchExpiry, + createdHeight: row.CreatedHeight, + } + groups[*txid] = g + } + g.dependents = append(g.dependents, wire.OutPoint{ + Hash: *vtxoHash, + Index: uint32(row.OutpointIndex), + }) + } + + for txid, g := range groups { + // Skip batches with no positive confirmation height: + // the manager will create them once it observes a + // confirmation. + if g.createdHeight <= 0 { + continue + } + + // Skip batches that already have a record so a re-run + // never overwrites advanced state. + _, err := q.GetBatchCanonicality(ctx, txid[:]) + if err == nil { + continue + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + + csvDelta := g.batchExpiry - g.createdHeight + if csvDelta < 0 { + csvDelta = 0 + } + + state := batchcanon.StateProvisional + depth := bestHeight - g.createdHeight + 1 + if depth >= int32(finalityDepth) { + state = batchcanon.StateFinalized + } + + err = q.UpsertBatchCanonicality( + ctx, sqlc.UpsertBatchCanonicalityParams{ + BatchTxid: txid[:], + State: int32(state), + ConfirmationHeight: sql.NullInt32{ + Int32: g.createdHeight, + Valid: true, + }, + // The confirming block hash is not + // recorded on VTXO rows; it is an + // observation attribute the manager + // fills on its next confirmation + // sighting. + ConfirmationBlockHash: nil, + CsvExpiryDelta: csvDelta, + PolicyState: int32( + batchcanon.PolicyStateDefault, + ), + CreatedAt: now, + UpdatedAt: now, + }, + ) + if err != nil { + return err + } + + err = insertDependentVTXOs(ctx, q, txid, g.dependents) + if err != nil { + return err + } + + created++ + } + + return nil + }) + + return created, err +} + +// hydrateRecord builds a batchcanon.Record from a canonicality row, loading +// its consumed inputs and dependent VTXOs through the same query handle (and +// therefore the same transaction). +func (s *BatchCanonicalityPersistenceStore) hydrateRecord(ctx context.Context, + q BatchCanonicalityStore, row sqlc.BatchCanonicality) ( + *batchcanon.Record, error) { + + txid, err := chainhash.NewHash(row.BatchTxid) + if err != nil { + return nil, err + } + + confBlock, err := bytesToOptionHash(row.ConfirmationBlockHash) + if err != nil { + return nil, err + } + + inputRows, err := q.ListBatchConsumedInputs(ctx, row.BatchTxid) + if err != nil { + return nil, err + } + inputs := make([]wire.OutPoint, 0, len(inputRows)) + for _, in := range inputRows { + hash, err := chainhash.NewHash(in.InputHash) + if err != nil { + return nil, err + } + inputs = append(inputs, wire.OutPoint{ + Hash: *hash, + Index: uint32(in.InputIndex), + }) + } + + depRows, err := q.ListBatchDependentVTXOs(ctx, row.BatchTxid) + if err != nil { + return nil, err + } + deps := make([]wire.OutPoint, 0, len(depRows)) + for _, dep := range depRows { + hash, err := chainhash.NewHash(dep.VtxoOutpointHash) + if err != nil { + return nil, err + } + deps = append(deps, wire.OutPoint{ + Hash: *hash, + Index: uint32(dep.VtxoOutpointIndex), + }) + } + + return &batchcanon.Record{ + BatchTxID: *txid, + State: batchcanon.State(row.State), + ConfirmationHeight: nullInt32ToOption(row.ConfirmationHeight), + ConfirmationBlock: confBlock, + CSVExpiryDelta: row.CsvExpiryDelta, + PolicyState: batchcanon.PolicyState(row.PolicyState), + ConsumedInputs: inputs, + DependentVTXOs: deps, + }, nil +} + +// optionToNullInt32 maps an optional int32 to a sql.NullInt32. +func optionToNullInt32(o fn.Option[int32]) sql.NullInt32 { + if o.IsNone() { + return sql.NullInt32{} + } + + return sql.NullInt32{Int32: o.UnwrapOr(0), Valid: true} +} + +// nullInt32ToOption maps a sql.NullInt32 back to an optional int32. +func nullInt32ToOption(n sql.NullInt32) fn.Option[int32] { + if !n.Valid { + return fn.None[int32]() + } + + return fn.Some(n.Int32) +} + +// optionHashToBytes maps an optional hash to its raw bytes, or nil when None. +func optionHashToBytes(o fn.Option[chainhash.Hash]) []byte { + if o.IsNone() { + return nil + } + + h := o.UnwrapOr(chainhash.Hash{}) + + return h[:] +} + +// bytesToOptionHash maps a (possibly nil) raw hash to an optional hash. A nil +// or empty slice yields None; any other length is validated to 32 bytes. +func bytesToOptionHash(raw []byte) (fn.Option[chainhash.Hash], error) { + if len(raw) == 0 { + return fn.None[chainhash.Hash](), nil + } + + hash, err := chainhash.NewHash(raw) + if err != nil { + return fn.None[chainhash.Hash](), err + } + + return fn.Some(*hash), nil +} + +// Compile-time check that the persistence store satisfies the domain Store +// interface. +var _ batchcanon.Store = (*BatchCanonicalityPersistenceStore)(nil) diff --git a/db/batch_canonicality_store_test.go b/db/batch_canonicality_store_test.go new file mode 100644 index 000000000..8a038df2c --- /dev/null +++ b/db/batch_canonicality_store_test.go @@ -0,0 +1,467 @@ +package db + +import ( + "database/sql" + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/lib/tree" + "github.com/lightninglabs/wavelength/round" + "github.com/lightningnetwork/lnd/clock" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// newBatchCanonicalityStoreForTest creates a batch canonicality store backed +// by a fresh test database. +func newBatchCanonicalityStoreForTest( + t *testing.T) *BatchCanonicalityPersistenceStore { + + t.Helper() + + db := NewTestDB(t) + + canonDB := NewTransactionExecutor( + db.BaseDB, + func(tx *sql.Tx) BatchCanonicalityStore { + return db.WithTx(tx) + }, + btclog.Disabled, + ) + + return NewBatchCanonicalityPersistenceStore( + canonDB, clock.NewDefaultClock(), + ) +} + +// outpoint is a small test helper building a deterministic outpoint. +func outpoint(b byte, index uint32) wire.OutPoint { + return wire.OutPoint{Hash: chainhash.Hash{b}, Index: index} +} + +// TestBatchCanonicalityUpsertRoundTrip verifies a record survives an upsert +// and read with all of its fields, consumed inputs, and dependent VTXOs. +func TestBatchCanonicalityUpsertRoundTrip(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + txid := chainhash.Hash{0xaa} + rec := &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateProvisional, + ConfirmationHeight: fn.Some[int32](100), + ConfirmationBlock: fn.Some(chainhash.Hash{0xbb}), + CSVExpiryDelta: 144, + PolicyState: batchcanon.PolicyStateDefault, + ConsumedInputs: []wire.OutPoint{ + outpoint(0x01, 0), outpoint(0x02, 3), + }, + DependentVTXOs: []wire.OutPoint{ + outpoint(0x03, 1), + }, + } + require.NoError(t, store.UpsertBatch(ctx, rec)) + + got, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal(t, txid, got.BatchTxID) + require.Equal(t, batchcanon.StateProvisional, got.State) + require.Equal(t, int32(100), got.ConfirmationHeight.UnwrapOr(0)) + require.True(t, got.ConfirmationBlock.IsSome()) + require.Equal(t, int32(144), got.CSVExpiryDelta) + require.Equal(t, batchcanon.PolicyStateDefault, got.PolicyState) + require.ElementsMatch(t, rec.ConsumedInputs, got.ConsumedInputs) + require.ElementsMatch(t, rec.DependentVTXOs, got.DependentVTXOs) + + // Effective expiry derives from the stored confirmation. + require.Equal(t, int32(244), got.EffectiveExpiry().UnwrapOr(0)) +} + +// TestBatchCanonicalityGetNotFound verifies the not-found sentinel. +func TestBatchCanonicalityGetNotFound(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + _, err := store.GetBatch(ctx, chainhash.Hash{0xff}) + require.ErrorIs(t, err, batchcanon.ErrBatchNotFound) +} + +// TestBatchCanonicalityUpsertReplacesEdges verifies a re-upsert replaces the +// consumed-input and dependent-VTXO sets rather than appending. +func TestBatchCanonicalityUpsertReplacesEdges(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + txid := chainhash.Hash{0xa1} + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateUnseen, + CSVExpiryDelta: 10, + ConsumedInputs: []wire.OutPoint{ + outpoint(0x01, 0), + }, + DependentVTXOs: []wire.OutPoint{ + outpoint(0x02, 0), + }, + }, + ), + ) + + // Re-upsert with a different edge set. + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 10, + ConsumedInputs: []wire.OutPoint{ + outpoint(0x09, 2), + }, + DependentVTXOs: nil, + }, + ), + ) + + got, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal(t, []wire.OutPoint{outpoint(0x09, 2)}, got.ConsumedInputs) + require.Empty(t, got.DependentVTXOs) +} + +// TestBatchCanonicalityReorgRecomputesExpiry verifies the reorg-aware expiry +// contract end to end through the store: a confirmation yields an effective +// expiry, a reorg (ClearConfirmation) erases it, and a reconfirmation at a new +// height yields a fresh effective expiry. Expiry is never frozen. +func TestBatchCanonicalityReorgRecomputesExpiry(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + txid := chainhash.Hash{0xc0} + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateUnseen, + CSVExpiryDelta: 144, + }, + ), + ) + + // Unconfirmed: no effective expiry. + got, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.True(t, got.EffectiveExpiry().IsNone()) + + // Confirm at height 100. + require.NoError( + t, + store.RecordConfirmation( + ctx, txid, 100, chainhash.Hash{0xc1}, + ), + ) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal(t, int32(244), got.EffectiveExpiry().UnwrapOr(0)) + + // Reorg out: confirmation cleared, effective expiry erased. + require.NoError(t, store.ClearConfirmation(ctx, txid)) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.True(t, got.ConfirmationHeight.IsNone()) + require.True(t, got.EffectiveExpiry().IsNone()) + + // Reconfirm at a higher height: fresh effective expiry. + require.NoError( + t, + store.RecordConfirmation( + ctx, txid, 105, chainhash.Hash{0xc2}, + ), + ) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal(t, int32(249), got.EffectiveExpiry().UnwrapOr(0)) +} + +// TestBatchCanonicalityStateNotTerminal verifies state can move freely in any +// direction (finalized -> reorged_out -> provisional), proving no state is +// persisted as an irreversible terminal verdict. +func TestBatchCanonicalityStateNotTerminal(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + txid := chainhash.Hash{0xd0} + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateFinalized, + CSVExpiryDelta: 10, + }, + ), + ) + + for _, want := range []batchcanon.State{ + batchcanon.StateReorgedOut, + batchcanon.StateConflictFinalized, + batchcanon.StateProvisional, + } { + require.NoError(t, store.UpdateBatchState(ctx, txid, want)) + got, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal(t, want, got.State) + } +} + +// TestBatchCanonicalityListByState verifies state-filtered listing. +func TestBatchCanonicalityListByState(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: chainhash.Hash{0xe0}, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 1, + }, + ), + ) + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: chainhash.Hash{0xe1}, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 1, + }, + ), + ) + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: chainhash.Hash{0xe2}, + State: batchcanon.StateFinalized, + CSVExpiryDelta: 1, + }, + ), + ) + + prov, err := store.ListBatchesByState(ctx, batchcanon.StateProvisional) + require.NoError(t, err) + require.Len(t, prov, 2) + + final, err := store.ListBatchesByState(ctx, batchcanon.StateFinalized) + require.NoError(t, err) + require.Len(t, final, 1) +} + +// TestBatchCanonicalityFindByConsumedOutpoint verifies input-conflict +// detection: two batches consuming the same outpoint are both found. +func TestBatchCanonicalityFindByConsumedOutpoint(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + shared := outpoint(0x55, 1) + batchA := chainhash.Hash{0xa0} + batchB := chainhash.Hash{0xb0} + + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: batchA, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 1, + ConsumedInputs: []wire.OutPoint{shared}, + }, + ), + ) + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: batchB, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 1, + ConsumedInputs: []wire.OutPoint{shared}, + }, + ), + ) + + found, err := store.FindBatchesConsumingOutpoint(ctx, shared) + require.NoError(t, err) + require.ElementsMatch(t, []chainhash.Hash{batchA, batchB}, found) + + none, err := store.FindBatchesConsumingOutpoint(ctx, outpoint(0x99, 0)) + require.NoError(t, err) + require.Empty(t, none) +} + +// TestBatchCanonicalityProvisionalConsumerRestore verifies the reverse- +// dependency lifecycle: a provisionally consumed VTXO is listed for its +// consumer batch (so it can be restored if the batch is invalidated), survives +// a batch state change, and is removed on delete. +func TestBatchCanonicalityProvisionalConsumerRestore(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + consumerBatch := chainhash.Hash{0xf0} + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: consumerBatch, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 1, + }, + ), + ) + + consumed := outpoint(0x44, 2) + require.NoError( + t, store.AddProvisionalConsumer( + ctx, consumed, consumerBatch, + ), + ) + + // Listed for the consumer batch. + got, err := store.ListProvisionalConsumersForBatch(ctx, consumerBatch) + require.NoError(t, err) + require.Equal(t, []wire.OutPoint{consumed}, got) + + // Idempotent re-add. + require.NoError( + t, store.AddProvisionalConsumer( + ctx, consumed, consumerBatch, + ), + ) + got, err = store.ListProvisionalConsumersForBatch(ctx, consumerBatch) + require.NoError(t, err) + require.Len(t, got, 1) + + // The edge survives the batch being marked reorged/invalidated — that + // is exactly when the restore caller needs to read it. + require.NoError( + t, store.UpdateBatchState( + ctx, consumerBatch, batchcanon.StateReorgedOut, + ), + ) + got, err = store.ListProvisionalConsumersForBatch(ctx, consumerBatch) + require.NoError(t, err) + require.Equal(t, []wire.OutPoint{consumed}, got) + + // Deleting clears the edges (e.g. once the consumption is canonical or + // fully reconciled). + require.NoError( + t, store.DeleteProvisionalConsumersForBatch( + ctx, consumerBatch, + ), + ) + got, err = store.ListProvisionalConsumersForBatch(ctx, consumerBatch) + require.NoError(t, err) + require.Empty(t, got) +} + +// TestBatchCanonicalityBackfillFromVTXOs verifies that backfill derives one +// canonicality record per distinct batch present in the VTXO store, with the +// CSV-relative expiry delta recovered from the stored absolute batch_expiry, +// the right provisional/finalized classification, and the dependent VTXO +// linked. It also verifies idempotency: a re-run creates nothing and does not +// clobber state the manager has since advanced. +func TestBatchCanonicalityBackfillFromVTXOs(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + vtxoStore, roundStore, baseDB := newVTXOStoreForTest(t) + + canonDB := NewTransactionExecutor( + baseDB, + func(tx *sql.Tx) BatchCanonicalityStore { + return baseDB.WithTx(tx) + }, + btclog.Disabled, + ) + canon := NewBatchCanonicalityPersistenceStore( + canonDB, clock.NewDefaultClock(), + ) + + // A round must exist to satisfy the VTXO foreign key. + roundID := testRoundIDDB("backfill-round") + testRound := createTestRound(t, roundID) + sigState := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + require.NoError(t, roundStore.CommitState(ctx, testRound, sigState)) + + // Two VTXOs in two distinct batches: + // idx 0: batch_expiry 1000, created_height 500 + // idx 1: batch_expiry 1100, created_height 510 + desc0 := createTestVTXODescriptor(t, roundID, 0) + desc1 := createTestVTXODescriptor(t, roundID, 1) + require.NoError(t, vtxoStore.SaveVTXO(ctx, desc0)) + require.NoError(t, vtxoStore.SaveVTXO(ctx, desc1)) + + // best height 505, finality depth 6: + // batch 0: depth = 505-500+1 = 6 >= 6 -> finalized + // batch 1: depth = 505-510+1 < 6 -> provisional + n, err := canon.BackfillFromVTXOs(ctx, 505, 6) + require.NoError(t, err) + require.Equal(t, 2, n) + + rec0, err := canon.GetBatch(ctx, desc0.CommitmentTxID) + require.NoError(t, err) + require.Equal(t, batchcanon.StateFinalized, rec0.State) + require.Equal(t, int32(500), rec0.ConfirmationHeight.UnwrapOr(0)) + require.Equal(t, int32(500), rec0.CSVExpiryDelta) + require.Equal(t, int32(1000), rec0.EffectiveExpiry().UnwrapOr(0)) + require.Equal(t, []wire.OutPoint{desc0.Outpoint}, rec0.DependentVTXOs) + + rec1, err := canon.GetBatch(ctx, desc1.CommitmentTxID) + require.NoError(t, err) + require.Equal(t, batchcanon.StateProvisional, rec1.State) + require.Equal(t, int32(590), rec1.CSVExpiryDelta) + + // Idempotency: advance one batch's state, re-run backfill, and verify + // it creates nothing new and leaves the advanced state untouched. + require.NoError( + t, canon.UpdateBatchState( + ctx, desc0.CommitmentTxID, batchcanon.StateReorgedOut, + ), + ) + n, err = canon.BackfillFromVTXOs(ctx, 505, 6) + require.NoError(t, err) + require.Equal(t, 0, n) + + rec0, err = canon.GetBatch(ctx, desc0.CommitmentTxID) + require.NoError(t, err) + require.Equal(t, batchcanon.StateReorgedOut, rec0.State) +} diff --git a/db/migrations.go b/db/migrations.go index e0ff1a51e..00c2d10f1 100644 --- a/db/migrations.go +++ b/db/migrations.go @@ -10,7 +10,7 @@ const ( // daemon. // // NOTE: This MUST be updated when a new migration is added. - LatestMigrationVersion uint = 13 + LatestMigrationVersion uint = 14 ) // MigrationTarget is a functional option that can be passed to applyMigrations diff --git a/db/sqlc/batch_canonicality.sql.go b/db/sqlc/batch_canonicality.sql.go new file mode 100644 index 000000000..fe252f4ea --- /dev/null +++ b/db/sqlc/batch_canonicality.sql.go @@ -0,0 +1,503 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: batch_canonicality.sql + +package sqlc + +import ( + "context" + "database/sql" +) + +const ClearBatchConfirmation = `-- name: ClearBatchConfirmation :exec +UPDATE batch_canonicality +SET confirmation_height = NULL, confirmation_block_hash = NULL, updated_at = $2 +WHERE batch_txid = $1 +` + +type ClearBatchConfirmationParams struct { + BatchTxid []byte + UpdatedAt int64 +} + +// ClearBatchConfirmation nulls the confirmation observation, reflecting that +// the confirming block left the best chain. It sets no terminal flag. +func (q *Queries) ClearBatchConfirmation(ctx context.Context, arg ClearBatchConfirmationParams) error { + _, err := q.db.ExecContext(ctx, ClearBatchConfirmation, arg.BatchTxid, arg.UpdatedAt) + return err +} + +const DeleteBatchConsumedInputs = `-- name: DeleteBatchConsumedInputs :exec +DELETE FROM batch_consumed_inputs WHERE batch_txid = $1 +` + +// DeleteBatchConsumedInputs removes every consumed-input row for a batch, +// used by the store's upsert to replace the set atomically. +func (q *Queries) DeleteBatchConsumedInputs(ctx context.Context, batchTxid []byte) error { + _, err := q.db.ExecContext(ctx, DeleteBatchConsumedInputs, batchTxid) + return err +} + +const DeleteBatchDependentVTXOs = `-- name: DeleteBatchDependentVTXOs :exec +DELETE FROM batch_dependent_vtxos WHERE batch_txid = $1 +` + +// DeleteBatchDependentVTXOs removes every dependent-VTXO row for a batch, +// used by the store's upsert to replace the set atomically. +func (q *Queries) DeleteBatchDependentVTXOs(ctx context.Context, batchTxid []byte) error { + _, err := q.db.ExecContext(ctx, DeleteBatchDependentVTXOs, batchTxid) + return err +} + +const DeleteProvisionalConsumersForBatch = `-- name: DeleteProvisionalConsumersForBatch :exec +DELETE FROM batch_provisional_consumers WHERE consumer_batch_txid = $1 +` + +// DeleteProvisionalConsumersForBatch removes every reverse-dependency edge +// for the given consumer batch. +func (q *Queries) DeleteProvisionalConsumersForBatch(ctx context.Context, consumerBatchTxid []byte) error { + _, err := q.db.ExecContext(ctx, DeleteProvisionalConsumersForBatch, consumerBatchTxid) + return err +} + +const FindBatchesByConsumedOutpoint = `-- name: FindBatchesByConsumedOutpoint :many +SELECT batch_txid +FROM batch_consumed_inputs +WHERE input_hash = $1 AND input_index = $2 +` + +type FindBatchesByConsumedOutpointParams struct { + InputHash []byte + InputIndex int32 +} + +// FindBatchesByConsumedOutpoint returns the txids of every batch that +// consumes the given outpoint. +func (q *Queries) FindBatchesByConsumedOutpoint(ctx context.Context, arg FindBatchesByConsumedOutpointParams) ([][]byte, error) { + rows, err := q.db.QueryContext(ctx, FindBatchesByConsumedOutpoint, arg.InputHash, arg.InputIndex) + if err != nil { + return nil, err + } + defer rows.Close() + var items [][]byte + for rows.Next() { + var batch_txid []byte + if err := rows.Scan(&batch_txid); err != nil { + return nil, err + } + items = append(items, batch_txid) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetBatchCanonicality = `-- name: GetBatchCanonicality :one +SELECT batch_txid, state, confirmation_height, confirmation_block_hash, + csv_expiry_delta, policy_state, created_at, updated_at +FROM batch_canonicality +WHERE batch_txid = $1 +` + +type GetBatchCanonicalityRow struct { + BatchTxid []byte + State int32 + ConfirmationHeight sql.NullInt32 + ConfirmationBlockHash []byte + CsvExpiryDelta int32 + PolicyState int32 + CreatedAt int64 + UpdatedAt int64 +} + +// GetBatchCanonicality returns the canonicality row for a batch txid. +func (q *Queries) GetBatchCanonicality(ctx context.Context, batchTxid []byte) (GetBatchCanonicalityRow, error) { + row := q.db.QueryRowContext(ctx, GetBatchCanonicality, batchTxid) + var i GetBatchCanonicalityRow + err := row.Scan( + &i.BatchTxid, + &i.State, + &i.ConfirmationHeight, + &i.ConfirmationBlockHash, + &i.CsvExpiryDelta, + &i.PolicyState, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const InsertBatchConsumedInput = `-- name: InsertBatchConsumedInput :exec +INSERT INTO batch_consumed_inputs (batch_txid, input_hash, input_index) +VALUES ($1, $2, $3) +ON CONFLICT (batch_txid, input_hash, input_index) DO NOTHING +` + +type InsertBatchConsumedInputParams struct { + BatchTxid []byte + InputHash []byte + InputIndex int32 +} + +// InsertBatchConsumedInput records one outpoint consumed by a batch. +func (q *Queries) InsertBatchConsumedInput(ctx context.Context, arg InsertBatchConsumedInputParams) error { + _, err := q.db.ExecContext(ctx, InsertBatchConsumedInput, arg.BatchTxid, arg.InputHash, arg.InputIndex) + return err +} + +const InsertBatchDependentVTXO = `-- name: InsertBatchDependentVTXO :exec +INSERT INTO batch_dependent_vtxos ( + batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index +) VALUES ($1, $2, $3) +ON CONFLICT (batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index) DO NOTHING +` + +type InsertBatchDependentVTXOParams struct { + BatchTxid []byte + VtxoOutpointHash []byte + VtxoOutpointIndex int32 +} + +// InsertBatchDependentVTXO records one VTXO outpoint anchored by a batch. +func (q *Queries) InsertBatchDependentVTXO(ctx context.Context, arg InsertBatchDependentVTXOParams) error { + _, err := q.db.ExecContext(ctx, InsertBatchDependentVTXO, arg.BatchTxid, arg.VtxoOutpointHash, arg.VtxoOutpointIndex) + return err +} + +const InsertProvisionalConsumer = `-- name: InsertProvisionalConsumer :exec +INSERT INTO batch_provisional_consumers ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid, created_at +) VALUES ($1, $2, $3, $4) +ON CONFLICT ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid +) DO NOTHING +` + +type InsertProvisionalConsumerParams struct { + ConsumedVtxoHash []byte + ConsumedVtxoIndex int32 + ConsumerBatchTxid []byte + CreatedAt int64 +} + +// InsertProvisionalConsumer records a reverse-dependency edge: consumed_vtxo +// is provisionally consumed by consumer_batch. Idempotent. +func (q *Queries) InsertProvisionalConsumer(ctx context.Context, arg InsertProvisionalConsumerParams) error { + _, err := q.db.ExecContext(ctx, InsertProvisionalConsumer, + arg.ConsumedVtxoHash, + arg.ConsumedVtxoIndex, + arg.ConsumerBatchTxid, + arg.CreatedAt, + ) + return err +} + +const ListBatchCanonicalityByState = `-- name: ListBatchCanonicalityByState :many +SELECT batch_txid, state, confirmation_height, confirmation_block_hash, + csv_expiry_delta, policy_state, created_at, updated_at +FROM batch_canonicality +WHERE state = $1 +` + +type ListBatchCanonicalityByStateRow struct { + BatchTxid []byte + State int32 + ConfirmationHeight sql.NullInt32 + ConfirmationBlockHash []byte + CsvExpiryDelta int32 + PolicyState int32 + CreatedAt int64 + UpdatedAt int64 +} + +// ListBatchCanonicalityByState returns every batch currently in the given +// state. +func (q *Queries) ListBatchCanonicalityByState(ctx context.Context, state int32) ([]ListBatchCanonicalityByStateRow, error) { + rows, err := q.db.QueryContext(ctx, ListBatchCanonicalityByState, state) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListBatchCanonicalityByStateRow + for rows.Next() { + var i ListBatchCanonicalityByStateRow + if err := rows.Scan( + &i.BatchTxid, + &i.State, + &i.ConfirmationHeight, + &i.ConfirmationBlockHash, + &i.CsvExpiryDelta, + &i.PolicyState, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListBatchConsumedInputs = `-- name: ListBatchConsumedInputs :many +SELECT input_hash, input_index +FROM batch_consumed_inputs +WHERE batch_txid = $1 +` + +type ListBatchConsumedInputsRow struct { + InputHash []byte + InputIndex int32 +} + +// ListBatchConsumedInputs returns the outpoints a batch consumes. +func (q *Queries) ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) ([]ListBatchConsumedInputsRow, error) { + rows, err := q.db.QueryContext(ctx, ListBatchConsumedInputs, batchTxid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListBatchConsumedInputsRow + for rows.Next() { + var i ListBatchConsumedInputsRow + if err := rows.Scan(&i.InputHash, &i.InputIndex); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListBatchDependentVTXOs = `-- name: ListBatchDependentVTXOs :many +SELECT vtxo_outpoint_hash, vtxo_outpoint_index +FROM batch_dependent_vtxos +WHERE batch_txid = $1 +` + +type ListBatchDependentVTXOsRow struct { + VtxoOutpointHash []byte + VtxoOutpointIndex int32 +} + +// ListBatchDependentVTXOs returns the VTXO outpoints a batch anchors. +func (q *Queries) ListBatchDependentVTXOs(ctx context.Context, batchTxid []byte) ([]ListBatchDependentVTXOsRow, error) { + rows, err := q.db.QueryContext(ctx, ListBatchDependentVTXOs, batchTxid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListBatchDependentVTXOsRow + for rows.Next() { + var i ListBatchDependentVTXOsRow + if err := rows.Scan(&i.VtxoOutpointHash, &i.VtxoOutpointIndex); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListProvisionalConsumersForBatch = `-- name: ListProvisionalConsumersForBatch :many +SELECT consumed_vtxo_hash, consumed_vtxo_index +FROM batch_provisional_consumers +WHERE consumer_batch_txid = $1 +` + +type ListProvisionalConsumersForBatchRow struct { + ConsumedVtxoHash []byte + ConsumedVtxoIndex int32 +} + +// ListProvisionalConsumersForBatch returns the VTXO outpoints that the given +// consumer batch provisionally consumes (the VTXOs to restore if the batch +// is invalidated). +func (q *Queries) ListProvisionalConsumersForBatch(ctx context.Context, consumerBatchTxid []byte) ([]ListProvisionalConsumersForBatchRow, error) { + rows, err := q.db.QueryContext(ctx, ListProvisionalConsumersForBatch, consumerBatchTxid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListProvisionalConsumersForBatchRow + for rows.Next() { + var i ListProvisionalConsumersForBatchRow + if err := rows.Scan(&i.ConsumedVtxoHash, &i.ConsumedVtxoIndex); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListVTXOsForCanonicalityBackfill = `-- name: ListVTXOsForCanonicalityBackfill :many +SELECT outpoint_hash, outpoint_index, commitment_txid, batch_expiry, + created_height +FROM vtxos +WHERE length(commitment_txid) = 32 +` + +type ListVTXOsForCanonicalityBackfillRow struct { + OutpointHash []byte + OutpointIndex int32 + CommitmentTxid []byte + BatchExpiry int32 + CreatedHeight int32 +} + +// ListVTXOsForCanonicalityBackfill returns the columns needed to derive +// initial batch canonicality records from already-persisted VTXOs: each +// VTXO's outpoint, its commitment (batch) txid, the absolute batch expiry +// height, and the height at which it was created (confirmed). The backfill +// groups these by commitment txid in Go and recomputes the CSV-relative +// expiry delta as batch_expiry - created_height. +func (q *Queries) ListVTXOsForCanonicalityBackfill(ctx context.Context) ([]ListVTXOsForCanonicalityBackfillRow, error) { + rows, err := q.db.QueryContext(ctx, ListVTXOsForCanonicalityBackfill) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListVTXOsForCanonicalityBackfillRow + for rows.Next() { + var i ListVTXOsForCanonicalityBackfillRow + if err := rows.Scan( + &i.OutpointHash, + &i.OutpointIndex, + &i.CommitmentTxid, + &i.BatchExpiry, + &i.CreatedHeight, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const RecordBatchConfirmation = `-- name: RecordBatchConfirmation :exec +UPDATE batch_canonicality +SET confirmation_height = $2, confirmation_block_hash = $3, updated_at = $4 +WHERE batch_txid = $1 +` + +type RecordBatchConfirmationParams struct { + BatchTxid []byte + ConfirmationHeight sql.NullInt32 + ConfirmationBlockHash []byte + UpdatedAt int64 +} + +// RecordBatchConfirmation records the best-chain height and block hash at +// which the batch tx is confirmed. A later call at a different height (after +// a reorg) overwrites the observation so effective expiry tracks the new +// confirmation. +func (q *Queries) RecordBatchConfirmation(ctx context.Context, arg RecordBatchConfirmationParams) error { + _, err := q.db.ExecContext(ctx, RecordBatchConfirmation, + arg.BatchTxid, + arg.ConfirmationHeight, + arg.ConfirmationBlockHash, + arg.UpdatedAt, + ) + return err +} + +const UpdateBatchCanonicalityState = `-- name: UpdateBatchCanonicalityState :exec +UPDATE batch_canonicality +SET state = $2, updated_at = $3 +WHERE batch_txid = $1 +` + +type UpdateBatchCanonicalityStateParams struct { + BatchTxid []byte + State int32 + UpdatedAt int64 +} + +// UpdateBatchCanonicalityState transitions a batch to a new state without +// touching its other fields. +func (q *Queries) UpdateBatchCanonicalityState(ctx context.Context, arg UpdateBatchCanonicalityStateParams) error { + _, err := q.db.ExecContext(ctx, UpdateBatchCanonicalityState, arg.BatchTxid, arg.State, arg.UpdatedAt) + return err +} + +const UpsertBatchCanonicality = `-- name: UpsertBatchCanonicality :exec + +INSERT INTO batch_canonicality ( + batch_txid, state, confirmation_height, confirmation_block_hash, + csv_expiry_delta, policy_state, created_at, updated_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8 +) +ON CONFLICT (batch_txid) DO UPDATE SET + state = EXCLUDED.state, + confirmation_height = EXCLUDED.confirmation_height, + confirmation_block_hash = EXCLUDED.confirmation_block_hash, + csv_expiry_delta = EXCLUDED.csv_expiry_delta, + policy_state = EXCLUDED.policy_state, + updated_at = EXCLUDED.updated_at +` + +type UpsertBatchCanonicalityParams struct { + BatchTxid []byte + State int32 + ConfirmationHeight sql.NullInt32 + ConfirmationBlockHash []byte + CsvExpiryDelta int32 + PolicyState int32 + CreatedAt int64 + UpdatedAt int64 +} + +// Batch canonicality queries. +// These maintain the durable, reorg-aware record of how each batch +// (commitment) transaction is faring against the best chain, the inputs it +// consumes, the VTXOs it anchors, and the reverse-dependency edges needed to +// restore a provisionally consumed VTXO. The queries are behavior-free; all +// interpretation lives in the batch canonicality manager. +// UpsertBatchCanonicality inserts or replaces the canonicality row for a +// batch. created_at is preserved on conflict; everything else is overwritten. +func (q *Queries) UpsertBatchCanonicality(ctx context.Context, arg UpsertBatchCanonicalityParams) error { + _, err := q.db.ExecContext(ctx, UpsertBatchCanonicality, + arg.BatchTxid, + arg.State, + arg.ConfirmationHeight, + arg.ConfirmationBlockHash, + arg.CsvExpiryDelta, + arg.PolicyState, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} diff --git a/db/sqlc/migrations/000014_batch_canonicality.down.sql b/db/sqlc/migrations/000014_batch_canonicality.down.sql new file mode 100644 index 000000000..c21529715 --- /dev/null +++ b/db/sqlc/migrations/000014_batch_canonicality.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS batch_provisional_consumers; +DROP TABLE IF EXISTS batch_dependent_vtxos; +DROP TABLE IF EXISTS batch_consumed_inputs; +DROP TABLE IF EXISTS batch_canonicality; diff --git a/db/sqlc/migrations/000014_batch_canonicality.up.sql b/db/sqlc/migrations/000014_batch_canonicality.up.sql new file mode 100644 index 000000000..6e7ff9e9b --- /dev/null +++ b/db/sqlc/migrations/000014_batch_canonicality.up.sql @@ -0,0 +1,134 @@ +-- batch_canonicality is the durable, reorg-aware record of how each batch +-- (commitment) transaction is faring against the best chain. It is keyed by +-- the batch txid: identity is by txid, never by (txid, block hash), so a +-- reorg that re-mines the same batch in a different block is the same row. +-- +-- Effective (absolute) expiry is intentionally NOT stored. The row keeps the +-- CSV-relative delta plus the current confirmation height; the effective +-- expiry is derived as confirmation_height + csv_expiry_delta and is therefore +-- recomputed on every (re)confirmation rather than frozen at first +-- confirmation. Expiry is never persisted as a one-way terminal fact. +CREATE TABLE IF NOT EXISTS batch_canonicality ( + -- batch_txid is the 32-byte commitment transaction id and primary key. + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + + -- state is the interpreted canonicality state (batchcanon.State): + -- 0 = unseen + -- 1 = provisional + -- 2 = finalized + -- 3 = reorged_out + -- 4 = conflict_provisional + -- 5 = conflict_finalized + -- Values are append-only and must never be renumbered. + state INTEGER NOT NULL DEFAULT 0, + + -- confirmation_height is the best-chain height at which the batch tx is + -- currently observed confirmed. NULL means the batch is not currently + -- confirmed (unseen or reorged out). A reorg clears it; a reconfirmation + -- sets it to the new height. + confirmation_height INTEGER, + + -- confirmation_block_hash is the hash of the block currently confirming + -- the batch tx. It is an observation attribute only and is NOT part of + -- the batch identity. NULL when not currently confirmed. + confirmation_block_hash BLOB + CHECK (confirmation_block_hash IS NULL + OR length(confirmation_block_hash) = 32), + + -- csv_expiry_delta is the batch's CSV-relative expiry timeout, in blocks. + -- Combined with confirmation_height it yields the effective expiry. + csv_expiry_delta INTEGER NOT NULL, + + -- policy_state is a reserved policy classification slot + -- (batchcanon.PolicyState); 0 = default. The data-model layer persists + -- and round-trips it but assigns no business meaning. + policy_state INTEGER NOT NULL DEFAULT 0, + + -- created_at / updated_at are unix timestamps. + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + + -- confirmation_pk_script is the scriptPubKey of the confirmed batch + -- output, needed to re-register the reorg-aware confirmation watch after + -- a restart: light-client backends (neutrino, Esplora) filter conf + -- watches by pkScript, so a txid alone is insufficient. NULL on rows + -- created by the descriptor backfill (no batch-output pkScript to + -- derive); those fall back to a txid-only re-registration. Kept last so + -- the generated model column order matches the query/store code. + confirmation_pk_script BLOB, + + PRIMARY KEY (batch_txid) +); + +-- Index supporting "find every batch in a given state" (e.g. all provisional +-- batches the manager must re-check for finality). +CREATE INDEX IF NOT EXISTS idx_batch_canonicality_state + ON batch_canonicality(state); + +-- batch_consumed_inputs records the outpoints each batch tx spends, so the +-- canonicality manager can watch every consumed input for a conflicting +-- spend. +CREATE TABLE IF NOT EXISTS batch_consumed_inputs ( + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + input_hash BLOB NOT NULL CHECK (length(input_hash) = 32), + input_index INTEGER NOT NULL CHECK (input_index >= 0), + + -- input_pk_script is the scriptPubKey of the spent output. It is + -- required to register the reorg-aware spend watch: lnd's spend + -- notifier filters by output script, so a bare outpoint is rejected + -- ("an output script must be provided"). Persisting it lets restart + -- reconciliation re-arm every watch. NULL only on legacy rows that + -- predate script tracking. + input_pk_script BLOB, + + PRIMARY KEY (batch_txid, input_hash, input_index), + FOREIGN KEY (batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + +-- Index supporting input-conflict detection: given an outpoint, find every +-- batch that consumes it (two batches consuming the same outpoint conflict). +CREATE INDEX IF NOT EXISTS idx_batch_consumed_inputs_outpoint + ON batch_consumed_inputs(input_hash, input_index); + +-- batch_dependent_vtxos records the VTXO outpoints anchored by each batch. +-- Their derived availability follows the batch's canonicality. There is +-- intentionally no FK to vtxos: a batch may anchor VTXOs the local wallet +-- does not own or persist. +CREATE TABLE IF NOT EXISTS batch_dependent_vtxos ( + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + vtxo_outpoint_hash BLOB NOT NULL CHECK (length(vtxo_outpoint_hash) = 32), + vtxo_outpoint_index INTEGER NOT NULL CHECK (vtxo_outpoint_index >= 0), + + PRIMARY KEY (batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index), + FOREIGN KEY (batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + +-- Index supporting "given a VTXO outpoint, which batch anchors it". +CREATE INDEX IF NOT EXISTS idx_batch_dependent_vtxos_vtxo + ON batch_dependent_vtxos(vtxo_outpoint_hash, vtxo_outpoint_index); + +-- batch_provisional_consumers is the reverse-dependency table that lets a +-- provisionally consumed VTXO be restored if its consumer batch never becomes +-- canonical (e.g. a round-2 forfeit whose commitment tx is reorged out must +-- restore the round-1 VTXO it consumed). Each row says "consumed_vtxo is +-- provisionally consumed by consumer_batch". +CREATE TABLE IF NOT EXISTS batch_provisional_consumers ( + consumed_vtxo_hash BLOB NOT NULL CHECK (length(consumed_vtxo_hash) = 32), + consumed_vtxo_index INTEGER NOT NULL CHECK (consumed_vtxo_index >= 0), + consumer_batch_txid BLOB NOT NULL + CHECK (length(consumer_batch_txid) = 32), + created_at BIGINT NOT NULL, + + PRIMARY KEY ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid + ), + FOREIGN KEY (consumer_batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + +-- Index supporting "given an invalidated consumer batch, list the VTXOs to +-- restore". +CREATE INDEX IF NOT EXISTS idx_batch_prov_consumers_batch + ON batch_provisional_consumers(consumer_batch_txid); diff --git a/db/sqlc/models.go b/db/sqlc/models.go index 4f1d4557c..28b565204 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -61,6 +61,38 @@ type ActivityStatus struct { Name string } +type BatchCanonicality struct { + BatchTxid []byte + State int32 + ConfirmationHeight sql.NullInt32 + ConfirmationBlockHash []byte + CsvExpiryDelta int32 + PolicyState int32 + CreatedAt int64 + UpdatedAt int64 + ConfirmationPkScript []byte +} + +type BatchConsumedInput struct { + BatchTxid []byte + InputHash []byte + InputIndex int32 + InputPkScript []byte +} + +type BatchDependentVtxo struct { + BatchTxid []byte + VtxoOutpointHash []byte + VtxoOutpointIndex int32 +} + +type BatchProvisionalConsumer struct { + ConsumedVtxoHash []byte + ConsumedVtxoIndex int32 + ConsumerBatchTxid []byte + CreatedAt int64 +} + type BoardingAddress struct { PkScript []byte AddressString string diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 90b6d003f..38431f5a3 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -15,6 +15,9 @@ type Querier interface { // contiguous). Callers use it as the resumable-subscribe cursor for the update. AppendActivityEvent(ctx context.Context, arg AppendActivityEventParams) (int64, error) CancelVHTLCRecoveryJob(ctx context.Context, arg CancelVHTLCRecoveryJobParams) (int64, error) + // ClearBatchConfirmation nulls the confirmation observation, reflecting that + // the confirming block left the best chain. It sets no terminal flag. + ClearBatchConfirmation(ctx context.Context, arg ClearBatchConfirmationParams) error ClearPendingIntentAnchorByOutpoint(ctx context.Context, arg ClearPendingIntentAnchorByOutpointParams) error CompleteVHTLCRecoveryJob(ctx context.Context, arg CompleteVHTLCRecoveryJobParams) (int64, error) // CountActivityEntriesByStatus returns the number of current-state rows in the @@ -33,6 +36,12 @@ type Querier interface { // CountVTXOsByStatus returns the count of VTXOs with the specified status. CountVTXOsByStatus(ctx context.Context, status int32) (int64, error) CountWalletUTXOLog(ctx context.Context) (int64, error) + // DeleteBatchConsumedInputs removes every consumed-input row for a batch, + // used by the store's upsert to replace the set atomically. + DeleteBatchConsumedInputs(ctx context.Context, batchTxid []byte) error + // DeleteBatchDependentVTXOs removes every dependent-VTXO row for a batch, + // used by the store's upsert to replace the set atomically. + DeleteBatchDependentVTXOs(ctx context.Context, batchTxid []byte) error DeleteClientTreeTxids(ctx context.Context, arg DeleteClientTreeTxidsParams) error DeleteOORPackageCheckpoints(ctx context.Context, sessionID []byte) error DeleteOrphanedPendingBoardIntents(ctx context.Context) error @@ -53,6 +62,9 @@ type Querier interface { DeletePendingIntentsByKind(ctx context.Context, kind string) error DeletePendingSendIntentByID(ctx context.Context, intentID []byte) error DeletePendingSendIntentsAll(ctx context.Context) error + // DeleteProvisionalConsumersForBatch removes every reverse-dependency edge + // for the given consumer batch. + DeleteProvisionalConsumersForBatch(ctx context.Context, consumerBatchTxid []byte) error // DeleteSpendingReservation removes the reservation for one outpoint. Called // when the VTXO leaves SpendingState (released or completed). DeleteSpendingReservation(ctx context.Context, arg DeleteSpendingReservationParams) error @@ -66,8 +78,13 @@ type Querier interface { EscalateVHTLCRecoveryJob(ctx context.Context, arg EscalateVHTLCRecoveryJobParams) (int64, error) FailVHTLCRecoveryJob(ctx context.Context, arg FailVHTLCRecoveryJobParams) (int64, error) FinalizeRound(ctx context.Context, arg FinalizeRoundParams) error + // FindBatchesByConsumedOutpoint returns the txids of every batch that + // consumes the given outpoint. + FindBatchesByConsumedOutpoint(ctx context.Context, arg FindBatchesByConsumedOutpointParams) ([][]byte, error) // GetActivityEntry returns one entry by its canonical id. GetActivityEntry(ctx context.Context, canonicalID string) (ActivityEntry, error) + // GetBatchCanonicality returns the canonicality row for a batch txid. + GetBatchCanonicality(ctx context.Context, batchTxid []byte) (GetBatchCanonicalityRow, error) GetBoardingAddress(ctx context.Context, pkScript []byte) (BoardingAddress, error) GetBoardingIntent(ctx context.Context, arg GetBoardingIntentParams) (BoardingIntent, error) GetBoardingSweep(ctx context.Context, txid []byte) (BoardingSweep, error) @@ -114,6 +131,10 @@ type Querier interface { // GetVTXOReplacement retrieves the replacement VTXO outpoint for a forfeited // VTXO. Returns NULL if not forfeited or no replacement recorded. GetVTXOReplacement(ctx context.Context, arg GetVTXOReplacementParams) (GetVTXOReplacementRow, error) + // InsertBatchConsumedInput records one outpoint consumed by a batch. + InsertBatchConsumedInput(ctx context.Context, arg InsertBatchConsumedInputParams) error + // InsertBatchDependentVTXO records one VTXO outpoint anchored by a batch. + InsertBatchDependentVTXO(ctx context.Context, arg InsertBatchDependentVTXOParams) error // Boarding address queries. InsertBoardingAddress(ctx context.Context, arg InsertBoardingAddressParams) error // Boarding intent queries. @@ -144,6 +165,9 @@ type Querier interface { InsertExitFundingAddress(ctx context.Context, arg InsertExitFundingAddressParams) error InsertMacaroonRootKey(ctx context.Context, arg InsertMacaroonRootKeyParams) error InsertOORPackageCheckpoint(ctx context.Context, arg InsertOORPackageCheckpointParams) error + // InsertProvisionalConsumer records a reverse-dependency edge: consumed_vtxo + // is provisionally consumed by consumer_batch. Idempotent. + InsertProvisionalConsumer(ctx context.Context, arg InsertProvisionalConsumerParams) error // Round queries. InsertRound(ctx context.Context, arg InsertRoundParams) error // Round boarding intents queries. @@ -179,6 +203,13 @@ type Querier interface { ListAllCreditOperations(ctx context.Context) ([]CreditOperation, error) ListAllOORSessionRegistry(ctx context.Context) ([]OorSessionRegistry, error) ListAllVTXOs(ctx context.Context) ([]Vtxo, error) + // ListBatchCanonicalityByState returns every batch currently in the given + // state. + ListBatchCanonicalityByState(ctx context.Context, state int32) ([]ListBatchCanonicalityByStateRow, error) + // ListBatchConsumedInputs returns the outpoints a batch consumes. + ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) ([]ListBatchConsumedInputsRow, error) + // ListBatchDependentVTXOs returns the VTXO outpoints a batch anchors. + ListBatchDependentVTXOs(ctx context.Context, batchTxid []byte) ([]ListBatchDependentVTXOsRow, error) ListBoardingIntentOutpoints(ctx context.Context) ([]ListBoardingIntentOutpointsRow, error) ListBoardingIntentsByConfHeight(ctx context.Context, confHeight int32) ([]BoardingIntent, error) ListBoardingIntentsByPkScript(ctx context.Context, pkScript []byte) ([]BoardingIntent, error) @@ -238,6 +269,10 @@ type Querier interface { // Only status = 'pending' rows replay; a 'failed' intent is terminally // retired and must not be re-submitted on restart. ListPendingSendIntents(ctx context.Context) ([]ListPendingSendIntentsRow, error) + // ListProvisionalConsumersForBatch returns the VTXO outpoints that the given + // consumer batch provisionally consumes (the VTXOs to restore if the batch + // is invalidated). + ListProvisionalConsumersForBatch(ctx context.Context, consumerBatchTxid []byte) ([]ListProvisionalConsumersForBatchRow, error) ListRoundsByStatus(ctx context.Context, status string) ([]Round, error) // ListRoundsPaginated returns rounds ordered by round_id with cursor- // based pagination. When cursor is empty, returns from the beginning. @@ -282,6 +317,13 @@ type Querier interface { // unknown (all non-forfeited VTXOs, and forfeited ones whose round row is // absent), so consumers must treat them as optional. ListVTXOsByStatus(ctx context.Context, status int32) ([]ListVTXOsByStatusRow, error) + // ListVTXOsForCanonicalityBackfill returns the columns needed to derive + // initial batch canonicality records from already-persisted VTXOs: each + // VTXO's outpoint, its commitment (batch) txid, the absolute batch expiry + // height, and the height at which it was created (confirmed). The backfill + // groups these by commitment txid in Go and recomputes the CSV-relative + // expiry delta as batch_expiry - created_height. + ListVTXOsForCanonicalityBackfill(ctx context.Context) ([]ListVTXOsForCanonicalityBackfillRow, error) ListWalletUTXOLog(ctx context.Context, arg ListWalletUTXOLogParams) ([]WalletUtxoLog, error) ListWalletUTXOLogByBlock(ctx context.Context, blockHeight int32) ([]WalletUtxoLog, error) ListWalletUTXOLogByClassification(ctx context.Context, arg ListWalletUTXOLogByClassificationParams) ([]WalletUtxoLog, error) @@ -326,8 +368,16 @@ type Querier interface { // PullActivityEvents returns transition rows strictly after the cursor in // event_seq order, the resumable-subscribe replay primitive. PullActivityEvents(ctx context.Context, arg PullActivityEventsParams) ([]ActivityEvent, error) + // RecordBatchConfirmation records the best-chain height and block hash at + // which the batch tx is confirmed. A later call at a different height (after + // a reorg) overwrites the observation so effective expiry tracks the new + // confirmation. + RecordBatchConfirmation(ctx context.Context, arg RecordBatchConfirmationParams) error SumBoardingIntentAmountsByStatus(ctx context.Context, status string) (interface{}, error) SumUnspentVTXOAmounts(ctx context.Context) (interface{}, error) + // UpdateBatchCanonicalityState transitions a batch to a new state without + // touching its other fields. + UpdateBatchCanonicalityState(ctx context.Context, arg UpdateBatchCanonicalityStateParams) error UpdateBoardingIntentStatus(ctx context.Context, arg UpdateBoardingIntentStatusParams) error UpdateRoundBoardingIntentSignature(ctx context.Context, arg UpdateRoundBoardingIntentSignatureParams) error UpdateRoundStatus(ctx context.Context, arg UpdateRoundStatusParams) error @@ -344,6 +394,15 @@ type Querier interface { // correlation handles are COALESCEd so an early projection that does not yet // know a txid never clobbers one a later projection already recorded. UpsertActivityEntry(ctx context.Context, arg UpsertActivityEntryParams) error + // Batch canonicality queries. + // These maintain the durable, reorg-aware record of how each batch + // (commitment) transaction is faring against the best chain, the inputs it + // consumes, the VTXOs it anchors, and the reverse-dependency edges needed to + // restore a provisionally consumed VTXO. The queries are behavior-free; all + // interpretation lives in the batch canonicality manager. + // UpsertBatchCanonicality inserts or replaces the canonicality row for a + // batch. created_at is preserved on conflict; everything else is overwritten. + UpsertBatchCanonicality(ctx context.Context, arg UpsertBatchCanonicalityParams) error UpsertChainInfo(ctx context.Context, arg UpsertChainInfoParams) error // Credit operations control-plane queries. UpsertCreditOperation(ctx context.Context, arg UpsertCreditOperationParams) error diff --git a/db/sqlc/queries/batch_canonicality.sql b/db/sqlc/queries/batch_canonicality.sql new file mode 100644 index 000000000..6dc744185 --- /dev/null +++ b/db/sqlc/queries/batch_canonicality.sql @@ -0,0 +1,138 @@ +-- Batch canonicality queries. +-- These maintain the durable, reorg-aware record of how each batch +-- (commitment) transaction is faring against the best chain, the inputs it +-- consumes, the VTXOs it anchors, and the reverse-dependency edges needed to +-- restore a provisionally consumed VTXO. The queries are behavior-free; all +-- interpretation lives in the batch canonicality manager. + +-- name: UpsertBatchCanonicality :exec +-- UpsertBatchCanonicality inserts or replaces the canonicality row for a +-- batch. created_at is preserved on conflict; everything else is overwritten. +INSERT INTO batch_canonicality ( + batch_txid, state, confirmation_height, confirmation_block_hash, + csv_expiry_delta, policy_state, created_at, updated_at +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8 +) +ON CONFLICT (batch_txid) DO UPDATE SET + state = EXCLUDED.state, + confirmation_height = EXCLUDED.confirmation_height, + confirmation_block_hash = EXCLUDED.confirmation_block_hash, + csv_expiry_delta = EXCLUDED.csv_expiry_delta, + policy_state = EXCLUDED.policy_state, + updated_at = EXCLUDED.updated_at; + +-- name: GetBatchCanonicality :one +-- GetBatchCanonicality returns the canonicality row for a batch txid. +SELECT batch_txid, state, confirmation_height, confirmation_block_hash, + csv_expiry_delta, policy_state, created_at, updated_at +FROM batch_canonicality +WHERE batch_txid = $1; + +-- name: ListBatchCanonicalityByState :many +-- ListBatchCanonicalityByState returns every batch currently in the given +-- state. +SELECT batch_txid, state, confirmation_height, confirmation_block_hash, + csv_expiry_delta, policy_state, created_at, updated_at +FROM batch_canonicality +WHERE state = $1; + +-- name: UpdateBatchCanonicalityState :exec +-- UpdateBatchCanonicalityState transitions a batch to a new state without +-- touching its other fields. +UPDATE batch_canonicality +SET state = $2, updated_at = $3 +WHERE batch_txid = $1; + +-- name: RecordBatchConfirmation :exec +-- RecordBatchConfirmation records the best-chain height and block hash at +-- which the batch tx is confirmed. A later call at a different height (after +-- a reorg) overwrites the observation so effective expiry tracks the new +-- confirmation. +UPDATE batch_canonicality +SET confirmation_height = $2, confirmation_block_hash = $3, updated_at = $4 +WHERE batch_txid = $1; + +-- name: ClearBatchConfirmation :exec +-- ClearBatchConfirmation nulls the confirmation observation, reflecting that +-- the confirming block left the best chain. It sets no terminal flag. +UPDATE batch_canonicality +SET confirmation_height = NULL, confirmation_block_hash = NULL, updated_at = $2 +WHERE batch_txid = $1; + +-- name: InsertBatchConsumedInput :exec +-- InsertBatchConsumedInput records one outpoint consumed by a batch. +INSERT INTO batch_consumed_inputs (batch_txid, input_hash, input_index) +VALUES ($1, $2, $3) +ON CONFLICT (batch_txid, input_hash, input_index) DO NOTHING; + +-- name: DeleteBatchConsumedInputs :exec +-- DeleteBatchConsumedInputs removes every consumed-input row for a batch, +-- used by the store's upsert to replace the set atomically. +DELETE FROM batch_consumed_inputs WHERE batch_txid = $1; + +-- name: ListBatchConsumedInputs :many +-- ListBatchConsumedInputs returns the outpoints a batch consumes. +SELECT input_hash, input_index +FROM batch_consumed_inputs +WHERE batch_txid = $1; + +-- name: FindBatchesByConsumedOutpoint :many +-- FindBatchesByConsumedOutpoint returns the txids of every batch that +-- consumes the given outpoint. +SELECT batch_txid +FROM batch_consumed_inputs +WHERE input_hash = $1 AND input_index = $2; + +-- name: InsertBatchDependentVTXO :exec +-- InsertBatchDependentVTXO records one VTXO outpoint anchored by a batch. +INSERT INTO batch_dependent_vtxos ( + batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index +) VALUES ($1, $2, $3) +ON CONFLICT (batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index) DO NOTHING; + +-- name: DeleteBatchDependentVTXOs :exec +-- DeleteBatchDependentVTXOs removes every dependent-VTXO row for a batch, +-- used by the store's upsert to replace the set atomically. +DELETE FROM batch_dependent_vtxos WHERE batch_txid = $1; + +-- name: ListBatchDependentVTXOs :many +-- ListBatchDependentVTXOs returns the VTXO outpoints a batch anchors. +SELECT vtxo_outpoint_hash, vtxo_outpoint_index +FROM batch_dependent_vtxos +WHERE batch_txid = $1; + +-- name: InsertProvisionalConsumer :exec +-- InsertProvisionalConsumer records a reverse-dependency edge: consumed_vtxo +-- is provisionally consumed by consumer_batch. Idempotent. +INSERT INTO batch_provisional_consumers ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid, created_at +) VALUES ($1, $2, $3, $4) +ON CONFLICT ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid +) DO NOTHING; + +-- name: ListProvisionalConsumersForBatch :many +-- ListProvisionalConsumersForBatch returns the VTXO outpoints that the given +-- consumer batch provisionally consumes (the VTXOs to restore if the batch +-- is invalidated). +SELECT consumed_vtxo_hash, consumed_vtxo_index +FROM batch_provisional_consumers +WHERE consumer_batch_txid = $1; + +-- name: DeleteProvisionalConsumersForBatch :exec +-- DeleteProvisionalConsumersForBatch removes every reverse-dependency edge +-- for the given consumer batch. +DELETE FROM batch_provisional_consumers WHERE consumer_batch_txid = $1; + +-- name: ListVTXOsForCanonicalityBackfill :many +-- ListVTXOsForCanonicalityBackfill returns the columns needed to derive +-- initial batch canonicality records from already-persisted VTXOs: each +-- VTXO's outpoint, its commitment (batch) txid, the absolute batch expiry +-- height, and the height at which it was created (confirmed). The backfill +-- groups these by commitment txid in Go and recomputes the CSV-relative +-- expiry delta as batch_expiry - created_height. +SELECT outpoint_hash, outpoint_index, commitment_txid, batch_expiry, + created_height +FROM vtxos +WHERE length(commitment_txid) = 32; diff --git a/db/sqlc/schemas/generated_schema.sql b/db/sqlc/schemas/generated_schema.sql index a9eaedad3..ca0680863 100644 --- a/db/sqlc/schemas/generated_schema.sql +++ b/db/sqlc/schemas/generated_schema.sql @@ -98,6 +98,100 @@ CREATE TABLE ask_results ( expires_at BIGINT NOT NULL ); +CREATE TABLE batch_canonicality ( + -- batch_txid is the 32-byte commitment transaction id and primary key. + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + + -- state is the interpreted canonicality state (batchcanon.State): + -- 0 = unseen + -- 1 = provisional + -- 2 = finalized + -- 3 = reorged_out + -- 4 = conflict_provisional + -- 5 = conflict_finalized + -- Values are append-only and must never be renumbered. + state INTEGER NOT NULL DEFAULT 0, + + -- confirmation_height is the best-chain height at which the batch tx is + -- currently observed confirmed. NULL means the batch is not currently + -- confirmed (unseen or reorged out). A reorg clears it; a reconfirmation + -- sets it to the new height. + confirmation_height INTEGER, + + -- confirmation_block_hash is the hash of the block currently confirming + -- the batch tx. It is an observation attribute only and is NOT part of + -- the batch identity. NULL when not currently confirmed. + confirmation_block_hash BLOB + CHECK (confirmation_block_hash IS NULL + OR length(confirmation_block_hash) = 32), + + -- csv_expiry_delta is the batch's CSV-relative expiry timeout, in blocks. + -- Combined with confirmation_height it yields the effective expiry. + csv_expiry_delta INTEGER NOT NULL, + + -- policy_state is a reserved policy classification slot + -- (batchcanon.PolicyState); 0 = default. The data-model layer persists + -- and round-trips it but assigns no business meaning. + policy_state INTEGER NOT NULL DEFAULT 0, + + -- created_at / updated_at are unix timestamps. + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + + -- confirmation_pk_script is the scriptPubKey of the confirmed batch + -- output, needed to re-register the reorg-aware confirmation watch after + -- a restart: light-client backends (neutrino, Esplora) filter conf + -- watches by pkScript, so a txid alone is insufficient. NULL on rows + -- created by the descriptor backfill (no batch-output pkScript to + -- derive); those fall back to a txid-only re-registration. Kept last so + -- the generated model column order matches the query/store code. + confirmation_pk_script BLOB, + + PRIMARY KEY (batch_txid) +); + +CREATE TABLE batch_consumed_inputs ( + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + input_hash BLOB NOT NULL CHECK (length(input_hash) = 32), + input_index INTEGER NOT NULL CHECK (input_index >= 0), + + -- input_pk_script is the scriptPubKey of the spent output. It is + -- required to register the reorg-aware spend watch: lnd's spend + -- notifier filters by output script, so a bare outpoint is rejected + -- ("an output script must be provided"). Persisting it lets restart + -- reconciliation re-arm every watch. NULL only on legacy rows that + -- predate script tracking. + input_pk_script BLOB, + + PRIMARY KEY (batch_txid, input_hash, input_index), + FOREIGN KEY (batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + +CREATE TABLE batch_dependent_vtxos ( + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + vtxo_outpoint_hash BLOB NOT NULL CHECK (length(vtxo_outpoint_hash) = 32), + vtxo_outpoint_index INTEGER NOT NULL CHECK (vtxo_outpoint_index >= 0), + + PRIMARY KEY (batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index), + FOREIGN KEY (batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + +CREATE TABLE batch_provisional_consumers ( + consumed_vtxo_hash BLOB NOT NULL CHECK (length(consumed_vtxo_hash) = 32), + consumed_vtxo_index INTEGER NOT NULL CHECK (consumed_vtxo_index >= 0), + consumer_batch_txid BLOB NOT NULL + CHECK (length(consumer_batch_txid) = 32), + created_at BIGINT NOT NULL, + + PRIMARY KEY ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid + ), + FOREIGN KEY (consumer_batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + CREATE TABLE boarding_addresses ( -- pk_script is the raw output script (P2TR script) and serves as the -- primary key since it uniquely identifies an address. @@ -429,6 +523,18 @@ CREATE INDEX idx_activity_events_canonical CREATE INDEX idx_ask_results_expires ON ask_results(expires_at); +CREATE INDEX idx_batch_canonicality_state + ON batch_canonicality(state); + +CREATE INDEX idx_batch_consumed_inputs_outpoint + ON batch_consumed_inputs(input_hash, input_index); + +CREATE INDEX idx_batch_dependent_vtxos_vtxo + ON batch_dependent_vtxos(vtxo_outpoint_hash, vtxo_outpoint_index); + +CREATE INDEX idx_batch_prov_consumers_batch + ON batch_provisional_consumers(consumer_batch_txid); + CREATE INDEX idx_boarding_addresses_creation_time ON boarding_addresses(creation_time DESC); diff --git a/db/store.go b/db/store.go index 1524da573..21143400b 100644 --- a/db/store.go +++ b/db/store.go @@ -347,6 +347,29 @@ func (s *Store) NewActivityStore(clk clock.Clock) *ActivityPersistenceStore { return NewActivityPersistenceStore(activityDB, clk) } +// NewBatchCanonicalityStore builds the batch canonicality persistence store +// with transactional query execution. +// +// The store holds the durable, reorg-aware record of how each batch +// (commitment) tx is faring against the best chain, plus the reverse +// dependencies needed to restore a provisionally consumed VTXO. It is +// behavior-free; interpretation lives in the batch canonicality manager. +func (s *Store) NewBatchCanonicalityStore( + clk clock.Clock) *BatchCanonicalityPersistenceStore { + + baseDB := s.BaseDB() + + canonDB := NewTransactionExecutor( + baseDB, + func(tx *sql.Tx) BatchCanonicalityStore { + return s.queries.WithTx(tx) + }, + s.log, + ) + + return NewBatchCanonicalityPersistenceStore(canonDB, clk) +} + // NewUnilateralExitStore builds the unilateral-exit persistence store with // transactional query execution. func (s *Store) NewUnilateralExitStore( From 2d3e6323b9df4d9a47da4acb99100d3e382f8c36 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 8 Jul 2026 14:00:28 -0700 Subject: [PATCH 08/18] batchcanon: BatchCanonicalityManager (C3/C4) Squashed for the btcd v2 port. The batchcanon.Manager actor: one reorg-aware conf watch per batch + one spend watch per consumed input via chainsource, derives State by priority, recomputes effective expiry on reconfirm, and reconciles non-final watches on restart. No admission (that is C5). --- batchcanon/AGENTS.md | 19 +- batchcanon/CLAUDE.md | 19 +- batchcanon/manager.go | 690 ++++++++++++++++++ batchcanon/manager_conflict_shared_test.go | 65 ++ batchcanon/manager_test.go | 803 +++++++++++++++++++++ batchcanon/messages.go | 208 ++++++ batchcanon/record.go | 8 + db/batch_canonicality_store.go | 27 +- db/sqlc/batch_canonicality.sql.go | 51 +- db/sqlc/querier.go | 7 +- db/sqlc/queries/batch_canonicality.sql | 15 +- 11 files changed, 1856 insertions(+), 56 deletions(-) create mode 100644 batchcanon/manager.go create mode 100644 batchcanon/manager_conflict_shared_test.go create mode 100644 batchcanon/manager_test.go create mode 100644 batchcanon/messages.go diff --git a/batchcanon/AGENTS.md b/batchcanon/AGENTS.md index 47ea46e0f..a384e0365 100644 --- a/batchcanon/AGENTS.md +++ b/batchcanon/AGENTS.md @@ -33,8 +33,23 @@ in its own package, separate from `chainsource` (raw observation) and `vtxo` - `ProvisionalConsumer` — reverse-dependency edge (consumed VTXO → consumer batch) enabling VTXO restore if a consumer batch never becomes canonical. - `Store` — behavior-free durable query/update interface. Implemented by - `db.BatchCanonicalityPersistenceStore` over the `000020` schema; backfilled - from existing VTXOs via `db.BatchCanonicalityPersistenceStore.BackfillFromVTXOs`. + `db.BatchCanonicalityPersistenceStore` over the `000020`/`000021` schema; + backfilled from existing VTXOs via + `db.BatchCanonicalityPersistenceStore.BackfillFromVTXOs`. +- `Manager` — the actor that interprets chain observation into canonicality + state (the sole client-side interpreter). Registered under + `ManagerServiceKey`. `RegisterBatchRequest` arms one reorg-aware + confirmation watch on the batch tx and one reorg-aware spend watch per + consumed input (deduped per batch, idempotent — repeats merge dependent + VTXOs). It maps chainsource `ConfirmationEvent`/`ConfReorgedEvent`/ + `ConfDoneEvent` and `SpendEvent`/`SpendReorgedEvent`/`SpendDoneEvent` onto + its own mailbox and derives `State` per the priority + `conflict_finalized > conflict_provisional > reorged_out > + finalized/provisional > unseen`. `Reconcile` re-arms watches for non-final + batches after restart without downgrading persisted state. + `GetBatchStateRequest` reads the persisted record. `NewManager` returns the + behavior; the caller registers it, then calls `SetSelfRef(ref.TellRef())` + and `Reconcile`. ## Relationships diff --git a/batchcanon/CLAUDE.md b/batchcanon/CLAUDE.md index 47ea46e0f..a384e0365 100644 --- a/batchcanon/CLAUDE.md +++ b/batchcanon/CLAUDE.md @@ -33,8 +33,23 @@ in its own package, separate from `chainsource` (raw observation) and `vtxo` - `ProvisionalConsumer` — reverse-dependency edge (consumed VTXO → consumer batch) enabling VTXO restore if a consumer batch never becomes canonical. - `Store` — behavior-free durable query/update interface. Implemented by - `db.BatchCanonicalityPersistenceStore` over the `000020` schema; backfilled - from existing VTXOs via `db.BatchCanonicalityPersistenceStore.BackfillFromVTXOs`. + `db.BatchCanonicalityPersistenceStore` over the `000020`/`000021` schema; + backfilled from existing VTXOs via + `db.BatchCanonicalityPersistenceStore.BackfillFromVTXOs`. +- `Manager` — the actor that interprets chain observation into canonicality + state (the sole client-side interpreter). Registered under + `ManagerServiceKey`. `RegisterBatchRequest` arms one reorg-aware + confirmation watch on the batch tx and one reorg-aware spend watch per + consumed input (deduped per batch, idempotent — repeats merge dependent + VTXOs). It maps chainsource `ConfirmationEvent`/`ConfReorgedEvent`/ + `ConfDoneEvent` and `SpendEvent`/`SpendReorgedEvent`/`SpendDoneEvent` onto + its own mailbox and derives `State` per the priority + `conflict_finalized > conflict_provisional > reorged_out > + finalized/provisional > unseen`. `Reconcile` re-arms watches for non-final + batches after restart without downgrading persisted state. + `GetBatchStateRequest` reads the persisted record. `NewManager` returns the + behavior; the caller registers it, then calls `SetSelfRef(ref.TellRef())` + and `Reconcile`. ## Relationships diff --git a/batchcanon/manager.go b/batchcanon/manager.go new file mode 100644 index 000000000..3b695e389 --- /dev/null +++ b/batchcanon/manager.go @@ -0,0 +1,690 @@ +package batchcanon + +import ( + "context" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/build" + "github.com/lightninglabs/wavelength/chainsource" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +// ManagerServiceKey is the receptionist key the BatchCanonicalityManager +// registers under. +var ManagerServiceKey = actor.NewServiceKey[ManagerMsg, ManagerResp]( + "batch-canonicality", +) + +// usabilityConfs is the confirmation count at which the manager wants the +// first positive confirmation notification. Ark's usability depth is one +// confirmation: a batch is provisionally usable as soon as it confirms, and +// the reorg-aware lifecycle keeps it correct from there. Policy finality is +// signalled separately by chainsource's Done event at its FinalityDepth. +const usabilityConfs uint32 = 1 + +// confState is the manager's in-memory view of a batch tx's confirmation +// observation, distinct from any input-conflict view. +type confState int + +const ( + confUnseen confState = iota + confConfirmed + confFinalized + confReorgedOut +) + +// inputWatch tracks the conflict view of one consumed batch input. +type inputWatch struct { + // spenderIsConflict records whether the last observed spend of this + // input was by a transaction other than the batch itself. The batch + // consuming its own input is the expected, non-conflicting case. + spenderIsConflict bool + + // conflicting is true while a conflicting spend is observed and has not + // been reorged out. + conflicting bool + + // conflictFinal is true once a conflicting spend matured past the + // reorg-safety depth. + conflictFinal bool +} + +// batchWatch is the manager's in-memory state for one watched batch. +type batchWatch struct { + txid chainhash.Hash + pkScript []byte + + conf confState + inputs map[wire.OutPoint]*inputWatch + + // persisted is the State last written to the store, so the manager only + // issues an UpdateBatchState when the derived state actually changes. + persisted State +} + +// ManagerConfig configures the BatchCanonicalityManager. +type ManagerConfig struct { + // Store is the durable canonicality store. + Store Store + + // ChainSource is the chain-observation actor the manager registers + // reorg-aware conf/spend watches with. + ChainSource actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ] + + // Log is an optional logger. + Log fn.Option[btclog.Logger] +} + +// Manager is the sole client-side interpreter of batch canonicality. It +// observes (via chainsource) each batch tx confirmation and each consumed +// input, interprets the reorg-aware lifecycle into batchcanon.State, and +// persists the result. It is an actor behavior: chainsource events arrive as +// internal messages re-wrapped onto the manager's own mailbox. +// +// Light-client backends (neutrino, Esplora) filter spend notifications by the +// prevout pkScript, which the manager does not yet carry per consumed input; +// spend watches are registered by outpoint, which is sufficient for the +// full-node (LND) path. Threading per-input pkScripts is a follow-up for when +// the producers (round, OOR) — which hold those scripts — wire in. +type Manager struct { + cfg ManagerConfig + log btclog.Logger + selfRef actor.TellOnlyRef[ManagerMsg] + + watches map[chainhash.Hash]*batchWatch +} + +// NewManager builds a BatchCanonicalityManager behavior. SetSelfRef must be +// called (with the registered actor's TellRef) before any batch is registered +// so the manager can route chainsource events back to itself. +func NewManager(cfg ManagerConfig) *Manager { + return &Manager{ + cfg: cfg, + log: cfg.Log.UnwrapOr(btclog.Disabled), + watches: make(map[chainhash.Hash]*batchWatch), + } +} + +// SetSelfRef wires the manager's own mailbox ref, used to build the mapped +// chainsource notification refs. +func (m *Manager) SetSelfRef(ref actor.TellOnlyRef[ManagerMsg]) { + m.selfRef = ref +} + +// Receive implements actor.ActorBehavior. It serializes all canonicality +// mutations through the single actor mailbox. +func (m *Manager) Receive(ctx context.Context, + msg ManagerMsg) fn.Result[ManagerResp] { + + switch v := msg.(type) { + case *RegisterBatchRequest: + return m.handleRegisterBatch(ctx, v) + + case *GetBatchStateRequest: + return m.handleGetBatchState(ctx, v) + + case *batchConfirmedMsg: + m.handleBatchConfirmed(ctx, v) + + case *batchReorgedMsg: + m.handleBatchReorged(ctx, v) + + case *batchDoneMsg: + m.handleBatchDone(ctx, v) + + case *inputSpentMsg: + m.handleInputSpent(ctx, v) + + case *inputSpendReorgedMsg: + m.handleInputSpendReorged(ctx, v) + + case *inputSpendDoneMsg: + m.handleInputSpendDone(ctx, v) + + default: + return fn.Err[ManagerResp]( + fmt.Errorf("unknown batchcanon message: %T", msg), + ) + } + + return fn.Ok[ManagerResp](&ackResponse{}) +} + +// logger returns the configured logger, falling back to the context logger. +func (m *Manager) logger(ctx context.Context) btclog.Logger { + return m.cfg.Log.UnwrapOr(build.LoggerFromContext(ctx)) +} + +// handleRegisterBatch persists the batch record and arms its watches. It is +// idempotent: a repeat for the same batch merges the dependent VTXOs into the +// record without re-arming watches. +func (m *Manager) handleRegisterBatch(ctx context.Context, + req *RegisterBatchRequest) fn.Result[ManagerResp] { + + existing, ok := m.watches[req.BatchTxID] + if ok { + // Already watching: merge dependent VTXOs into the record and + // return without duplicating watches. + if err := m.mergeDependents(ctx, existing, req); err != nil { + return fn.Err[ManagerResp](err) + } + + return fn.Ok[ManagerResp](&RegisterBatchResponse{}) + } + + // Persist the initial record (unseen until the first observation). + record := &Record{ + BatchTxID: req.BatchTxID, + State: StateUnseen, + ConfirmationHeight: fn.None[int32](), + ConfirmationBlock: fn.None[chainhash.Hash](), + CSVExpiryDelta: req.CSVExpiryDelta, + PolicyState: PolicyStateDefault, + ConfirmationPkScript: req.ConfirmationPkScript, + ConsumedInputs: req.ConsumedInputs, + DependentVTXOs: req.DependentVTXOs, + } + if err := m.cfg.Store.UpsertBatch(ctx, record); err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("persist batch record: %w", err), + ) + } + + w := &batchWatch{ + txid: req.BatchTxID, + pkScript: req.ConfirmationPkScript, + conf: confUnseen, + inputs: make(map[wire.OutPoint]*inputWatch), + } + for _, in := range req.ConsumedInputs { + w.inputs[in] = &inputWatch{} + } + + // Record the watch only AFTER arming succeeds. If we recorded it first + // and arming failed, a retry would take the idempotent "already + // watching" merge path at the top of handleRegisterBatch and never + // re-arm the missing/partial chain watches until a restart. Leaving + // m.watches untouched on failure means a retry re-arms from scratch; + // re-registering the same conf/spend caller IDs is idempotent (the same + // property Reconcile relies on after restart). + if err := m.armWatches(ctx, w, req.ConsumedInputs); err != nil { + return fn.Err[ManagerResp](err) + } + m.watches[req.BatchTxID] = w + + return fn.Ok[ManagerResp](&RegisterBatchResponse{}) +} + +// mergeDependents adds any new dependent VTXOs from a repeat registration to +// the persisted record, keeping the batch's existing watches and state. +func (m *Manager) mergeDependents(ctx context.Context, w *batchWatch, + req *RegisterBatchRequest) error { + + record, err := m.cfg.Store.GetBatch(ctx, w.txid) + if err != nil { + return fmt.Errorf("load batch for merge: %w", err) + } + + seen := make(map[wire.OutPoint]struct{}, len(record.DependentVTXOs)) + for _, dep := range record.DependentVTXOs { + seen[dep] = struct{}{} + } + changed := false + for _, dep := range req.DependentVTXOs { + if _, ok := seen[dep]; ok { + continue + } + record.DependentVTXOs = append(record.DependentVTXOs, dep) + seen[dep] = struct{}{} + changed = true + } + if !changed { + return nil + } + + return m.cfg.Store.UpsertBatch(ctx, record) +} + +// armWatches registers the reorg-aware confirmation watch on the batch tx and +// a reorg-aware spend watch on each consumed input. +func (m *Manager) armWatches(ctx context.Context, w *batchWatch, + inputs []wire.OutPoint) error { + + heightHint := m.bestHeightHint(ctx) + + confReq := &chainsource.RegisterConfRequest{ + CallerID: confCallerID(w.txid), + Txid: &w.txid, + PkScript: w.pkScript, + TargetConfs: usabilityConfs, + HeightHint: heightHint, + NotifyActor: fn.Some( + chainsource.MapConfirmationEvent( + m.selfRef, + func( + ce chainsource.ConfirmationEvent, + ) ManagerMsg { + + return &batchConfirmedMsg{ + txid: ce.Txid, + blockHeight: ce.BlockHeight, + blockHash: ce.BlockHash, + } + }, + ), + ), + NotifyReorged: fn.Some( + chainsource.MapConfReorgedEvent( + m.selfRef, + func( + ev chainsource.ConfReorgedEvent, + ) ManagerMsg { + + return &batchReorgedMsg{ + txid: ev.Txid, + } + }, + ), + ), + NotifyDone: fn.Some( + chainsource.MapConfDoneEvent( + m.selfRef, + func(ev chainsource.ConfDoneEvent) ManagerMsg { + return &batchDoneMsg{ + txid: ev.Txid, + } + }, + ), + ), + } + if err := m.cfg.ChainSource.Tell(ctx, confReq); err != nil { + return fmt.Errorf("register batch conf watch: %w", err) + } + + for i := range inputs { + op := inputs[i] + if err := m.armSpendWatch( + ctx, w.txid, op, heightHint, + ); err != nil { + return err + } + } + + return nil +} + +// armSpendWatch registers one reorg-aware spend watch on a consumed input. +func (m *Manager) armSpendWatch(ctx context.Context, txid chainhash.Hash, + op wire.OutPoint, heightHint uint32) error { + + spendReq := &chainsource.RegisterSpendRequest{ + CallerID: spendCallerID(txid, op), + Outpoint: &op, + HeightHint: heightHint, + NotifyActor: fn.Some( + chainsource.MapSpendEvent( + m.selfRef, + func(ev chainsource.SpendEvent) ManagerMsg { + return &inputSpentMsg{ + outpoint: ev.Outpoint, + spendingTxid: ev.SpendingTxid, + spendHeight: ev.SpendingHeight, + } + }, + ), + ), + NotifyReorged: fn.Some( + chainsource.MapSpendReorgedEvent( + m.selfRef, + func( + ev chainsource.SpendReorgedEvent, + ) ManagerMsg { + + return &inputSpendReorgedMsg{ + outpoint: ev.Outpoint, + } + }, + ), + ), + NotifyDone: fn.Some( + chainsource.MapSpendDoneEvent( + m.selfRef, + func(ev chainsource.SpendDoneEvent) ManagerMsg { + return &inputSpendDoneMsg{ + outpoint: ev.Outpoint, + } + }, + ), + ), + } + if err := m.cfg.ChainSource.Tell(ctx, spendReq); err != nil { + return fmt.Errorf("register input spend watch %s: %w", op, err) + } + + return nil +} + +// bestHeightHint asks chainsource for the current best height to use as a +// watch height hint. On error it returns 0 (scan from the backend's default), +// logging the failure rather than aborting registration. +func (m *Manager) bestHeightHint(ctx context.Context) uint32 { + resp, err := m.cfg.ChainSource.Ask( + ctx, &chainsource.BestHeightRequest{}, + ).Await(ctx).Unpack() + if err != nil { + m.logger(ctx).WarnS(ctx, "Batch canonicality best-height "+ + "query failed; using zero height hint", err) + + return 0 + } + + height, ok := resp.(*chainsource.BestHeightResponse) + if !ok { + return 0 + } + if height.Height < 0 { + return 0 + } + + return uint32(height.Height) +} + +// handleGetBatchState serves a read of the persisted canonicality record. +func (m *Manager) handleGetBatchState(ctx context.Context, + req *GetBatchStateRequest) fn.Result[ManagerResp] { + + record, err := m.cfg.Store.GetBatch(ctx, req.BatchTxID) + switch { + case errors.Is(err, ErrBatchNotFound): + return fn.Ok[ManagerResp](&GetBatchStateResponse{Found: false}) + + case err != nil: + return fn.Err[ManagerResp](err) + + default: + return fn.Ok[ManagerResp](&GetBatchStateResponse{ + Record: record, + Found: true, + }) + } +} + +// handleBatchConfirmed records the batch tx confirmation observation and +// re-derives the canonicality state. +func (m *Manager) handleBatchConfirmed(ctx context.Context, + msg *batchConfirmedMsg) { + + w, ok := m.watches[msg.txid] + if !ok { + return + } + + w.conf = confConfirmed + err := m.cfg.Store.RecordConfirmation( + ctx, msg.txid, msg.blockHeight, msg.blockHash, + ) + if err != nil { + m.logger(ctx).WarnS(ctx, "Failed to record batch confirmation", + err, "batch", msg.txid) + } + + m.deriveAndPersist(ctx, w) +} + +// handleBatchReorged clears the confirmation observation (the confirming block +// left the best chain) and re-derives state. +func (m *Manager) handleBatchReorged(ctx context.Context, + msg *batchReorgedMsg) { + + w, ok := m.watches[msg.txid] + if !ok { + return + } + + w.conf = confReorgedOut + if err := m.cfg.Store.ClearConfirmation(ctx, msg.txid); err != nil { + m.logger(ctx).WarnS(ctx, "Failed to clear batch confirmation", + err, "batch", msg.txid) + } + + m.deriveAndPersist(ctx, w) +} + +// handleBatchDone marks the batch confirmation as matured past the reorg- +// safety depth (policy finality) and re-derives state. The chainsource conf +// sub-actor releases its own registration on Done; the manager additionally +// releases the per-input spend watches, since a finalized batch's inputs are +// safely consumed and can no longer be double-spent. +func (m *Manager) handleBatchDone(ctx context.Context, msg *batchDoneMsg) { + w, ok := m.watches[msg.txid] + if !ok { + return + } + + w.conf = confFinalized + m.deriveAndPersist(ctx, w) + m.releaseSpendWatches(ctx, w) +} + +// handleInputSpent interprets a spend of a consumed batch input. The SAME +// outpoint can be consumed by more than one registered batch — that is exactly +// the double-spend the manager exists to classify — so every batch watching +// the outpoint is updated, not just one. For each such batch, a spend by that +// batch's own tx is the expected consumption (not a conflict), while a spend by +// any other transaction is a conflicting double-spend of that batch's input. +func (m *Manager) handleInputSpent(ctx context.Context, msg *inputSpentMsg) { + m.forEachInputWatch(msg.outpoint, func(w *batchWatch, iw *inputWatch) { + conflict := msg.spendingTxid != w.txid + iw.spenderIsConflict = conflict + iw.conflicting = conflict + iw.conflictFinal = false + + m.deriveAndPersist(ctx, w) + }) +} + +// handleInputSpendReorged clears a previously observed spend that left the +// best chain, for every batch watching the outpoint. +func (m *Manager) handleInputSpendReorged(ctx context.Context, + msg *inputSpendReorgedMsg) { + + m.forEachInputWatch(msg.outpoint, func(w *batchWatch, iw *inputWatch) { + iw.conflicting = false + iw.conflictFinal = false + + m.deriveAndPersist(ctx, w) + }) +} + +// handleInputSpendDone promotes a conflicting spend to finalized once it has +// matured past the reorg-safety depth, for every batch watching the outpoint. +// A matured spend by a batch's own tx is the normal consumption, so only the +// batches for which the spend was a conflict are promoted. +func (m *Manager) handleInputSpendDone(ctx context.Context, + msg *inputSpendDoneMsg) { + + m.forEachInputWatch(msg.outpoint, func(w *batchWatch, iw *inputWatch) { + if iw.spenderIsConflict { + iw.conflictFinal = true + } + + m.deriveAndPersist(ctx, w) + }) +} + +// forEachInputWatch invokes fn for every batch whose consumed-input set +// contains op. The same outpoint can appear under multiple batches (the +// conflict case: two batches spending the same input), so all matching watches +// must be visited — keying input watches by outpoint alone does NOT uniquely +// identify a batch. +func (m *Manager) forEachInputWatch(op wire.OutPoint, + fn func(w *batchWatch, iw *inputWatch)) { + + for _, w := range m.watches { + if iw, ok := w.inputs[op]; ok { + fn(w, iw) + } + } +} + +// deriveState computes the dominant canonicality state from the batch's +// in-memory confirmation and input-conflict views, applying the priority +// conflict_finalized > conflict_provisional > reorged_out > +// finalized/provisional > unseen. +func deriveState(w *batchWatch) State { + anyConflictFinal := false + anyConflict := false + for _, iw := range w.inputs { + if iw.conflictFinal { + anyConflictFinal = true + } + if iw.conflicting { + anyConflict = true + } + } + + switch { + case anyConflictFinal: + return StateConflictFinalized + + case anyConflict: + return StateConflictProvisional + + case w.conf == confReorgedOut: + return StateReorgedOut + + case w.conf == confFinalized: + return StateFinalized + + case w.conf == confConfirmed: + return StateProvisional + + default: + return StateUnseen + } +} + +// deriveAndPersist recomputes the batch state and writes it only when it +// changed since the last persisted value. +func (m *Manager) deriveAndPersist(ctx context.Context, w *batchWatch) { + next := deriveState(w) + if next == w.persisted { + return + } + + if err := m.cfg.Store.UpdateBatchState(ctx, w.txid, next); err != nil { + m.logger(ctx).WarnS(ctx, "Failed to persist batch state", err, + "batch", w.txid, "state", next.String()) + + return + } + w.persisted = next +} + +// releaseSpendWatches unregisters the per-input spend watches for a batch, +// called once the batch finalizes. +func (m *Manager) releaseSpendWatches(ctx context.Context, w *batchWatch) { + for op := range w.inputs { + err := m.cfg.ChainSource.Tell( + ctx, &chainsource.UnregisterSpendRequest{ + CallerID: spendCallerID(w.txid, op), + Outpoint: &op, + }, + ) + if err != nil { + m. + logger(ctx). + WarnS( + ctx, + "Failed to release input spend "+ + "watch", + err, + "batch", + w.txid, + ) + } + } +} + +// Reconcile re-establishes watches for every non-final persisted batch after a +// restart. It seeds each batch's in-memory state from the persisted record so +// live re-observation does not transiently downgrade a persisted conflict or +// finalized state. It must run after SetSelfRef. +func (m *Manager) Reconcile(ctx context.Context) error { + // Non-final states whose watches must be re-armed. Finalized and + // conflict_finalized batches need no further watching. + live := []State{ + StateUnseen, StateProvisional, StateReorgedOut, + StateConflictProvisional, + } + + for _, state := range live { + records, err := m.cfg.Store.ListBatchesByState(ctx, state) + if err != nil { + return fmt.Errorf("list %s batches: %w", state, err) + } + + for _, record := range records { + m.reconcileOne(ctx, record) + } + } + + return nil +} + +// reconcileOne rebuilds the in-memory watch for one persisted batch and +// re-arms its chain watches. +func (m *Manager) reconcileOne(ctx context.Context, record *Record) { + if _, ok := m.watches[record.BatchTxID]; ok { + return + } + + w := &batchWatch{ + txid: record.BatchTxID, + pkScript: record.ConfirmationPkScript, + inputs: make(map[wire.OutPoint]*inputWatch), + persisted: record.State, + } + + // Seed the confirmation view from the persisted record so a derive + // before re-observation does not regress the stored state. + switch record.State { + case StateProvisional, StateConflictProvisional: + w.conf = confConfirmed + + case StateReorgedOut: + w.conf = confReorgedOut + + default: + w.conf = confUnseen + } + + for _, in := range record.ConsumedInputs { + w.inputs[in] = &inputWatch{} + } + m.watches[record.BatchTxID] = w + + if err := m.armWatches(ctx, w, record.ConsumedInputs); err != nil { + m.logger(ctx).WarnS(ctx, "Failed to re-arm batch watches on "+ + "reconcile", err, "batch", record.BatchTxID) + } +} + +// confCallerID is the stable chainsource caller id for a batch's confirmation +// watch. +func confCallerID(txid chainhash.Hash) string { + return fmt.Sprintf("batchcanon-conf-%s", txid) +} + +// spendCallerID is the stable chainsource caller id for a batch input's spend +// watch. +func spendCallerID(txid chainhash.Hash, op wire.OutPoint) string { + return fmt.Sprintf("batchcanon-spend-%s-%s", txid, op) +} diff --git a/batchcanon/manager_conflict_shared_test.go b/batchcanon/manager_conflict_shared_test.go new file mode 100644 index 000000000..2a2828635 --- /dev/null +++ b/batchcanon/manager_conflict_shared_test.go @@ -0,0 +1,65 @@ +package batchcanon + +import ( + "testing" + + "github.com/btcsuite/btcd/wire/v2" + "github.com/stretchr/testify/require" +) + +// TestManagerSharedInputSpendClassifiesPerBatch verifies that when two batches +// consume the SAME input (the double-spend case), a spend by one batch's own tx +// is classified as the expected consumption for that batch and as a conflict +// for the OTHER batch — every watch on the outpoint is updated, not just one +// arbitrary batch. It also checks the finalize promotion is per-batch. +func TestManagerSharedInputSpendClassifiesPerBatch(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + + txA := testBatchTxid(0xa1) + txB := testBatchTxid(0xb2) + shared := testOutpoint(0xcc, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txA, + ConfirmationPkScript: []byte{0x51, 0x20, 0x01}, + ConsumedInputs: []wire.OutPoint{shared}, + }) + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txB, + ConfirmationPkScript: []byte{0x51, 0x20, 0x02}, + ConsumedInputs: []wire.OutPoint{shared}, + }) + + // Batch A wins the input and confirms; B never confirms. + h.fireConfirmed(t, txA, 101, testBatchTxid(0x01)) + require.Equal(t, StateProvisional, h.state(t, txA).Record.State) + + // The shared input is spent by A's own tx: not a conflict for A, but a + // conflicting double-spend for B (which wanted the same input). + h.fireSpend(t, shared, txA, 101) + + require.Equal( + t, StateProvisional, h.state(t, txA).Record.State, + "batch whose own tx spent the input must not be in conflict", + ) + require.Equal( + t, StateConflictProvisional, h.state(t, txB).Record.State, + "batch losing its input to another tx must be "+ + "conflict-provisional", + ) + + // Once the spend matures, only the conflicted batch (B) is promoted to + // conflict-finalized; A's own consumption stays provisional. + h.fireSpendDone(t, shared) + + require.Equal( + t, StateProvisional, h.state(t, txA).Record.State, + "self-consuming batch must not finalize as a conflict", + ) + require.Equal( + t, StateConflictFinalized, h.state(t, txB).Record.State, + "conflicted batch must promote to conflict-finalized", + ) +} diff --git a/batchcanon/manager_test.go b/batchcanon/manager_test.go new file mode 100644 index 000000000..753c7da51 --- /dev/null +++ b/batchcanon/manager_test.go @@ -0,0 +1,803 @@ +package batchcanon + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/chainsource" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +const testTimeout = 5 * time.Second + +// --------------------------------------------------------------------------- +// In-memory fake Store (the real db store is tested separately; this keeps the +// manager unit test free of a batchcanon -> db import cycle). +// --------------------------------------------------------------------------- + +type fakeStore struct { + mu sync.Mutex + records map[chainhash.Hash]*Record + consumers map[chainhash.Hash][]wire.OutPoint +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + records: make(map[chainhash.Hash]*Record), + consumers: make(map[chainhash.Hash][]wire.OutPoint), + } +} + +func cloneRecord(r *Record) *Record { + cp := *r + cp.ConsumedInputs = append([]wire.OutPoint(nil), r.ConsumedInputs...) + cp.DependentVTXOs = append([]wire.OutPoint(nil), r.DependentVTXOs...) + cp.ConfirmationPkScript = append( + []byte(nil), r.ConfirmationPkScript..., + ) + + return &cp +} + +func (s *fakeStore) UpsertBatch(_ context.Context, r *Record) error { + s.mu.Lock() + defer s.mu.Unlock() + s.records[r.BatchTxID] = cloneRecord(r) + + return nil +} + +func (s *fakeStore) GetBatch(_ context.Context, txid chainhash.Hash) (*Record, + error) { + + s.mu.Lock() + defer s.mu.Unlock() + r, ok := s.records[txid] + if !ok { + return nil, ErrBatchNotFound + } + + return cloneRecord(r), nil +} + +func (s *fakeStore) ListBatchesByState(_ context.Context, state State) ( + []*Record, error) { + + s.mu.Lock() + defer s.mu.Unlock() + var out []*Record + for _, r := range s.records { + if r.State == state { + out = append(out, cloneRecord(r)) + } + } + + return out, nil +} + +func (s *fakeStore) UpdateBatchState(_ context.Context, txid chainhash.Hash, + state State) error { + + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.records[txid]; ok { + r.State = state + } + + return nil +} + +func (s *fakeStore) RecordConfirmation(_ context.Context, txid chainhash.Hash, + height int32, block chainhash.Hash) error { + + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.records[txid]; ok { + r.ConfirmationHeight = fn.Some(height) + r.ConfirmationBlock = fn.Some(block) + } + + return nil +} + +func (s *fakeStore) ClearConfirmation(_ context.Context, + txid chainhash.Hash) error { + + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.records[txid]; ok { + r.ConfirmationHeight = fn.None[int32]() + r.ConfirmationBlock = fn.None[chainhash.Hash]() + } + + return nil +} + +func (s *fakeStore) FindBatchesConsumingOutpoint(_ context.Context, + op wire.OutPoint) ([]chainhash.Hash, error) { + + s.mu.Lock() + defer s.mu.Unlock() + var out []chainhash.Hash + for txid, r := range s.records { + for _, in := range r.ConsumedInputs { + if in == op { + out = append(out, txid) + } + } + } + + return out, nil +} + +func (s *fakeStore) AddProvisionalConsumer(_ context.Context, + consumedVTXO wire.OutPoint, consumerBatch chainhash.Hash) error { + + s.mu.Lock() + defer s.mu.Unlock() + s.consumers[consumerBatch] = append( + s.consumers[consumerBatch], consumedVTXO, + ) + + return nil +} + +func (s *fakeStore) ListProvisionalConsumersForBatch(_ context.Context, + consumerBatch chainhash.Hash) ([]wire.OutPoint, error) { + + s.mu.Lock() + defer s.mu.Unlock() + + return append([]wire.OutPoint(nil), s.consumers[consumerBatch]...), nil +} + +func (s *fakeStore) DeleteProvisionalConsumersForBatch(_ context.Context, + consumerBatch chainhash.Hash) error { + + s.mu.Lock() + defer s.mu.Unlock() + delete(s.consumers, consumerBatch) + + return nil +} + +var _ Store = (*fakeStore)(nil) + +// --------------------------------------------------------------------------- +// Mock chainsource actor: captures the reorg-aware notification refs from +// register requests and lets the test fire lifecycle events back at them. +// --------------------------------------------------------------------------- + +type confRefs struct { + confirmed actor.TellOnlyRef[chainsource.ConfirmationEvent] + reorged actor.TellOnlyRef[chainsource.ConfReorgedEvent] + done actor.TellOnlyRef[chainsource.ConfDoneEvent] +} + +type spendRefs struct { + spend actor.TellOnlyRef[chainsource.SpendEvent] + reorged actor.TellOnlyRef[chainsource.SpendReorgedEvent] + done actor.TellOnlyRef[chainsource.SpendDoneEvent] +} + +type mockChainSource struct { + mu sync.Mutex + bestHeight int32 + confByTxid map[chainhash.Hash]confRefs + spendByOp map[wire.OutPoint]spendRefs + confCancels map[chainhash.Hash]int + spendCancel map[wire.OutPoint]int +} + +func newMockChainSource(bestHeight int32) *mockChainSource { + return &mockChainSource{ + bestHeight: bestHeight, + confByTxid: make(map[chainhash.Hash]confRefs), + spendByOp: make(map[wire.OutPoint]spendRefs), + confCancels: make(map[chainhash.Hash]int), + spendCancel: make(map[wire.OutPoint]int), + } +} + +func (c *mockChainSource) Receive(_ context.Context, + msg chainsource.ChainSourceMsg) fn.Result[chainsource.ChainSourceResp] { + + switch v := msg.(type) { + case *chainsource.BestHeightRequest: + c.mu.Lock() + h := c.bestHeight + c.mu.Unlock() + + return fn.Ok[chainsource.ChainSourceResp]( + &chainsource.BestHeightResponse{ + Height: h, + }, + ) + + case *chainsource.RegisterConfRequest: + c.mu.Lock() + c.confByTxid[*v.Txid] = confRefs{ + confirmed: v.NotifyActor.UnwrapOr(nil), + reorged: v.NotifyReorged.UnwrapOr(nil), + done: v.NotifyDone.UnwrapOr(nil), + } + c.mu.Unlock() + + return fn.Ok[chainsource.ChainSourceResp]( + &chainsource.RegisterConfResponse{}, + ) + + case *chainsource.RegisterSpendRequest: + c.mu.Lock() + c.spendByOp[*v.Outpoint] = spendRefs{ + spend: v.NotifyActor.UnwrapOr(nil), + reorged: v.NotifyReorged.UnwrapOr(nil), + done: v.NotifyDone.UnwrapOr(nil), + } + c.mu.Unlock() + + return fn.Ok[chainsource.ChainSourceResp]( + &chainsource.RegisterSpendResponse{}, + ) + + case *chainsource.UnregisterConfRequest: + c.mu.Lock() + c.confCancels[*v.Txid]++ + c.mu.Unlock() + + return fn.Ok[chainsource.ChainSourceResp]( + &chainsource.UnregisterConfResponse{}, + ) + + case *chainsource.UnregisterSpendRequest: + c.mu.Lock() + c.spendCancel[*v.Outpoint]++ + c.mu.Unlock() + + return fn.Ok[chainsource.ChainSourceResp]( + &chainsource.UnregisterSpendResponse{}, + ) + + default: + return fn.Err[chainsource.ChainSourceResp]( + errUnexpected(msg), + ) + } +} + +func errUnexpected(msg chainsource.ChainSourceMsg) error { + return &unexpectedMsgErr{msg: msg.MessageType()} +} + +type unexpectedMsgErr struct{ msg string } + +func (e *unexpectedMsgErr) Error() string { + return "mock chainsource: unexpected message " + e.msg +} + +// getConfRefs waits until the manager has registered a conf watch for txid and +// returns the captured refs. +func (c *mockChainSource) getConfRefs(t *testing.T, + txid chainhash.Hash) confRefs { + + t.Helper() + var refs confRefs + require.Eventually(t, func() bool { + c.mu.Lock() + defer c.mu.Unlock() + r, ok := c.confByTxid[txid] + if ok { + refs = r + } + + return ok + }, testTimeout, 5*time.Millisecond, "conf watch never registered") + + return refs +} + +func (c *mockChainSource) getSpendRefs(t *testing.T, + op wire.OutPoint) spendRefs { + + t.Helper() + var refs spendRefs + require.Eventually(t, func() bool { + c.mu.Lock() + defer c.mu.Unlock() + r, ok := c.spendByOp[op] + if ok { + refs = r + } + + return ok + }, testTimeout, 5*time.Millisecond, "spend watch never registered") + + return refs +} + +func (c *mockChainSource) spendCancelCount(op wire.OutPoint) int { + c.mu.Lock() + defer c.mu.Unlock() + + return c.spendCancel[op] +} + +// --------------------------------------------------------------------------- +// Harness. +// --------------------------------------------------------------------------- + +type managerHarness struct { + mgrRef actor.ActorRef[ManagerMsg, ManagerResp] + mock *mockChainSource + store *fakeStore +} + +func newManagerHarness(t *testing.T, bestHeight int32) *managerHarness { + t.Helper() + + mock := newMockChainSource(bestHeight) + mockActor := actor.NewActor(actor.ActorConfig[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]{ + ID: "mock-chainsource", + Behavior: mock, + MailboxSize: 64, + }) + mockActor.Start() + t.Cleanup(mockActor.Stop) + + store := newFakeStore() + mgr := NewManager(ManagerConfig{ + Store: store, + ChainSource: mockActor.Ref(), + }) + mgrActor := actor.NewActor(actor.ActorConfig[ManagerMsg, ManagerResp]{ + ID: "batch-canonicality", + Behavior: mgr, + MailboxSize: 64, + }) + mgr.SetSelfRef(mgrActor.TellRef()) + mgrActor.Start() + t.Cleanup(mgrActor.Stop) + + return &managerHarness{ + mgrRef: mgrActor.Ref(), + mock: mock, + store: store, + } +} + +// registerBatch registers a batch and waits for the synchronous response. +func (h *managerHarness) registerBatch(t *testing.T, + req *RegisterBatchRequest) { + + t.Helper() + _, err := h.mgrRef.Ask(t.Context(), req).Await(t.Context()).Unpack() + require.NoError(t, err) +} + +// state reads the persisted record for a batch via the manager. Because the +// manager mailbox is FIFO, issuing this Ask after a fired event guarantees the +// event was processed first. +func (h *managerHarness) state(t *testing.T, + txid chainhash.Hash) *GetBatchStateResponse { + + t.Helper() + resp, err := h.mgrRef.Ask( + t.Context(), &GetBatchStateRequest{BatchTxID: txid}, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + got, ok := resp.(*GetBatchStateResponse) + require.True(t, ok) + + return got +} + +// fire helpers Tell the captured chainsource refs, synchronously enqueuing the +// re-wrapped event onto the manager mailbox. +func (h *managerHarness) fireConfirmed(t *testing.T, txid chainhash.Hash, + height int32, block chainhash.Hash) { + + t.Helper() + refs := h.mock.getConfRefs(t, txid) + require.NoError( + t, + refs.confirmed.Tell( + t.Context(), chainsource.ConfirmationEvent{ + Txid: txid, + BlockHeight: height, + BlockHash: block, + NumConfs: 1, + }, + ), + ) +} + +func (h *managerHarness) fireConfReorged(t *testing.T, txid chainhash.Hash) { + t.Helper() + refs := h.mock.getConfRefs(t, txid) + require.NoError( + t, + refs.reorged.Tell( + t.Context(), chainsource.ConfReorgedEvent{ + Txid: txid, + }, + ), + ) +} + +func (h *managerHarness) fireConfDone(t *testing.T, txid chainhash.Hash) { + t.Helper() + refs := h.mock.getConfRefs(t, txid) + require.NoError( + t, + refs.done.Tell( + t.Context(), chainsource.ConfDoneEvent{ + Txid: txid, + }, + ), + ) +} + +func (h *managerHarness) fireSpend(t *testing.T, op wire.OutPoint, + spender chainhash.Hash, height int32) { + + t.Helper() + refs := h.mock.getSpendRefs(t, op) + require.NoError( + t, + refs.spend.Tell( + t.Context(), chainsource.SpendEvent{ + Outpoint: op, + SpendingTxid: spender, + SpendingHeight: height, + }, + ), + ) +} + +func (h *managerHarness) fireSpendReorged(t *testing.T, op wire.OutPoint) { + t.Helper() + refs := h.mock.getSpendRefs(t, op) + require.NoError( + t, + refs.reorged.Tell( + t.Context(), chainsource.SpendReorgedEvent{ + Outpoint: op, + }, + ), + ) +} + +func (h *managerHarness) fireSpendDone(t *testing.T, op wire.OutPoint) { + t.Helper() + refs := h.mock.getSpendRefs(t, op) + require.NoError( + t, + refs.done.Tell( + t.Context(), chainsource.SpendDoneEvent{ + Outpoint: op, + }, + ), + ) +} + +// --------------------------------------------------------------------------- +// Tests. +// --------------------------------------------------------------------------- + +func testBatchTxid(b byte) chainhash.Hash { + var h chainhash.Hash + h[0] = b + + return h +} + +func testOutpoint(b byte, idx uint32) wire.OutPoint { + return wire.OutPoint{Hash: chainhash.Hash{b}, Index: idx} +} + +// TestManagerConfirmThenFinalize drives the happy path: a registered batch is +// unseen, becomes provisional on first confirmation (with a derived effective +// expiry), then finalized on the chainsource Done. +func TestManagerConfirmThenFinalize(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0xaa) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: []byte{0x51, 0x20, 0x01}, + CSVExpiryDelta: 144, + }) + + // Unseen before any observation. + got := h.state(t, txid) + require.True(t, got.Found) + require.Equal(t, StateUnseen, got.Record.State) + require.True(t, got.Record.EffectiveExpiry().IsNone()) + + // First confirmation -> provisional, effective expiry derived. + h.fireConfirmed(t, txid, 101, testBatchTxid(0xb1)) + got = h.state(t, txid) + require.Equal(t, StateProvisional, got.Record.State) + require.Equal(t, int32(101), got.Record.ConfirmationHeight.UnwrapOr(0)) + require.Equal(t, int32(245), got.Record.EffectiveExpiry().UnwrapOr(0)) + + // Policy finality -> finalized. + h.fireConfDone(t, txid) + got = h.state(t, txid) + require.Equal(t, StateFinalized, got.Record.State) +} + +// TestManagerReorgRecovers proves the core reorg-safety property: a confirmed +// batch that is reorged out moves to reorged_out (with expiry erased), then +// recovers to provisional on reconfirmation at a new height (with a fresh +// effective expiry), then finalizes. +func TestManagerReorgRecovers(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0xcc) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 100, + }) + + h.fireConfirmed(t, txid, 101, testBatchTxid(0xd1)) + got := h.state(t, txid) + require.Equal(t, StateProvisional, got.Record.State) + require.Equal(t, int32(201), got.Record.EffectiveExpiry().UnwrapOr(0)) + + // Reorg out: state reorged_out, confirmation (and effective expiry) + // cleared. + h.fireConfReorged(t, txid) + got = h.state(t, txid) + require.Equal(t, StateReorgedOut, got.Record.State) + require.True(t, got.Record.ConfirmationHeight.IsNone()) + require.True(t, got.Record.EffectiveExpiry().IsNone()) + + // Reconfirm at a higher height: provisional again, fresh expiry. + h.fireConfirmed(t, txid, 105, testBatchTxid(0xd2)) + got = h.state(t, txid) + require.Equal(t, StateProvisional, got.Record.State) + require.Equal(t, int32(205), got.Record.EffectiveExpiry().UnwrapOr(0)) + + h.fireConfDone(t, txid) + require.Equal(t, StateFinalized, h.state(t, txid).Record.State) +} + +// TestManagerInputConflict proves conflict detection: a consumed input spent +// by a transaction OTHER than the batch is a conflict (conflict_provisional), +// promoted to conflict_finalized once the conflicting spend matures. +func TestManagerInputConflict(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x11) + input := testOutpoint(0x22, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + ConsumedInputs: []wire.OutPoint{input}, + }) + + h.fireConfirmed(t, txid, 101, testBatchTxid(0x33)) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) + + // A different tx double-spends the consumed input. + conflictTx := testBatchTxid(0x99) + h.fireSpend(t, input, conflictTx, 102) + require.Equal( + t, StateConflictProvisional, h.state(t, txid).Record.State, + ) + + // The conflict matures -> conflict_finalized. + h.fireSpendDone(t, input) + require.Equal( + t, StateConflictFinalized, h.state(t, txid).Record.State, + ) +} + +// TestManagerConflictClearsOnSpendReorg proves a conflict is reversible: if the +// conflicting spend is itself reorged out, the batch returns to its +// confirmation-derived state. +func TestManagerConflictClearsOnSpendReorg(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x41) + input := testOutpoint(0x42, 1) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + ConsumedInputs: []wire.OutPoint{input}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x43)) + + h.fireSpend(t, input, testBatchTxid(0x99), 102) + require.Equal( + t, StateConflictProvisional, h.state(t, txid).Record.State, + ) + + // The conflicting spend reorgs out -> conflict cleared, back to + // provisional (the batch is still confirmed). + h.fireSpendReorged(t, input) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) +} + +// TestManagerBatchSelfSpendNotConflict proves that the batch consuming its own +// input (the expected case) is not treated as a conflict. +func TestManagerBatchSelfSpendNotConflict(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x51) + input := testOutpoint(0x52, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + ConsumedInputs: []wire.OutPoint{input}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x53)) + + // The spend is by the batch itself: not a conflict. + h.fireSpend(t, input, txid, 101) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) + + // And its maturation is the normal consumption, not a conflict. + h.fireSpendDone(t, input) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) +} + +// TestManagerConflictDominatesReorg proves the state priority: when a batch is +// both reorged out AND has a conflicting input spend, conflict dominates. +func TestManagerConflictDominatesReorg(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x61) + input := testOutpoint(0x62, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + ConsumedInputs: []wire.OutPoint{input}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x63)) + h.fireConfReorged(t, txid) + require.Equal(t, StateReorgedOut, h.state(t, txid).Record.State) + + // A conflicting spend appears while the batch is reorged out: conflict + // dominates reorged_out. + h.fireSpend(t, input, testBatchTxid(0x99), 102) + require.Equal( + t, StateConflictProvisional, h.state(t, txid).Record.State, + ) +} + +// TestManagerFinalizeReleasesSpendWatches proves the manager releases the +// per-input spend watches once a batch finalizes. +func TestManagerFinalizeReleasesSpendWatches(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x71) + input := testOutpoint(0x72, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + ConsumedInputs: []wire.OutPoint{input}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x73)) + h.fireConfDone(t, txid) + + // Drain via a state read, then assert the spend watch was released. + require.Equal(t, StateFinalized, h.state(t, txid).Record.State) + require.Eventually(t, func() bool { + return h.mock.spendCancelCount(input) == 1 + }, testTimeout, 5*time.Millisecond, + "input spend watch not released on finalize") +} + +// TestManagerRegisterIdempotentMergesDependents proves a repeat registration +// merges dependent VTXOs without re-arming or losing state. +func TestManagerRegisterIdempotentMergesDependents(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x81) + depA := testOutpoint(0x8a, 0) + depB := testOutpoint(0x8b, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + DependentVTXOs: []wire.OutPoint{depA}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x83)) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) + + // Repeat with an additional dependent: merged, state preserved. + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + DependentVTXOs: []wire.OutPoint{depB}, + }) + got := h.state(t, txid) + require.Equal(t, StateProvisional, got.Record.State) + require.ElementsMatch( + t, []wire.OutPoint{depA, depB}, got.Record.DependentVTXOs, + ) +} + +// TestManagerReconcileReArmsWatches proves restart reconciliation: a manager +// started against a store with a persisted provisional batch re-arms its +// watches and does not downgrade the persisted state before re-observation. +func TestManagerReconcileReArmsWatches(t *testing.T) { + t.Parallel() + + store := newFakeStore() + txid := testBatchTxid(0x91) + input := testOutpoint(0x92, 0) + + // Seed a persisted provisional batch as if a prior run had observed it. + require.NoError( + t, + store.UpsertBatch( + t.Context(), &Record{ + BatchTxID: txid, + State: StateProvisional, + ConfirmationHeight: fn.Some[int32](90), + CSVExpiryDelta: 50, + ConsumedInputs: []wire.OutPoint{input}, + }, + ), + ) + + mock := newMockChainSource(100) + mockActor := actor.NewActor(actor.ActorConfig[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]{ID: "mock", Behavior: mock, MailboxSize: 64}) + mockActor.Start() + t.Cleanup(mockActor.Stop) + + mgr := NewManager( + ManagerConfig{ + Store: store, + ChainSource: mockActor.Ref(), + }, + ) + mgrActor := actor.NewActor(actor.ActorConfig[ManagerMsg, ManagerResp]{ + ID: "mgr", Behavior: mgr, MailboxSize: 64, + }) + mgr.SetSelfRef(mgrActor.TellRef()) + mgrActor.Start() + t.Cleanup(mgrActor.Stop) + + require.NoError(t, mgr.Reconcile(t.Context())) + + // Watches re-armed for the persisted batch. + mock.getConfRefs(t, txid) + mock.getSpendRefs(t, input) + + // State not downgraded by reconcile. + h := &managerHarness{mgrRef: mgrActor.Ref(), mock: mock, store: store} + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) + + // A reorg after restart is still handled correctly. + h.fireConfReorged(t, txid) + require.Equal(t, StateReorgedOut, h.state(t, txid).Record.State) +} diff --git a/batchcanon/messages.go b/batchcanon/messages.go new file mode 100644 index 000000000..6c7fbab78 --- /dev/null +++ b/batchcanon/messages.go @@ -0,0 +1,208 @@ +package batchcanon + +import ( + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/baselib/actor" +) + +// ManagerMsg is the sealed inbound message interface for the +// BatchCanonicalityManager. It covers both the public register/query API and +// the internal chain-observation messages re-wrapped from chainsource. +type ManagerMsg interface { + actor.Message + + managerMsgSealed() +} + +// ManagerResp is the sealed response interface for the manager. +type ManagerResp interface { + actor.Message + + managerRespSealed() +} + +// RegisterBatchRequest registers (or re-registers, idempotently) a batch with +// the manager: it persists a canonicality record, registers a reorg-aware +// confirmation watch on the batch tx, and a reorg-aware spend watch on every +// consumed input. Calling it again for the same batch txid merges the +// dependent VTXOs into the record without duplicating watches. +type RegisterBatchRequest struct { + actor.BaseMessage + + // BatchTxID is the batch (commitment) transaction id. + BatchTxID chainhash.Hash + + // ConfirmationPkScript is the pkScript of the batch-tx output the + // confirmation watch keys on. Required for light-client backends and + // persisted for restart re-registration. + ConfirmationPkScript []byte + + // CSVExpiryDelta is the batch's CSV-relative expiry timeout in blocks. + CSVExpiryDelta int32 + + // ConsumedInputs are the outpoints the batch tx spends. Each gets a + // reorg-aware spend watch so a conflicting double-spend is detected. + ConsumedInputs []wire.OutPoint + + // DependentVTXOs are the VTXO outpoints anchored by this batch. + DependentVTXOs []wire.OutPoint +} + +// MessageType returns the message type identifier. +func (m *RegisterBatchRequest) MessageType() string { + return "batchcanon.RegisterBatchRequest" +} + +func (m *RegisterBatchRequest) managerMsgSealed() {} + +// RegisterBatchResponse is the reply to RegisterBatchRequest. +type RegisterBatchResponse struct { + actor.BaseMessage +} + +// MessageType returns the message type identifier. +func (m *RegisterBatchResponse) MessageType() string { + return "batchcanon.RegisterBatchResponse" +} + +func (m *RegisterBatchResponse) managerRespSealed() {} + +// GetBatchStateRequest reads the current canonicality record for a batch. +type GetBatchStateRequest struct { + actor.BaseMessage + + // BatchTxID is the batch tx to look up. + BatchTxID chainhash.Hash +} + +// MessageType returns the message type identifier. +func (m *GetBatchStateRequest) MessageType() string { + return "batchcanon.GetBatchStateRequest" +} + +func (m *GetBatchStateRequest) managerMsgSealed() {} + +// GetBatchStateResponse carries the looked-up record, if present. +type GetBatchStateResponse struct { + actor.BaseMessage + + // Record is the canonicality record. Nil when Found is false. + Record *Record + + // Found reports whether a record existed for the batch. + Found bool +} + +// MessageType returns the message type identifier. +func (m *GetBatchStateResponse) MessageType() string { + return "batchcanon.GetBatchStateResponse" +} + +func (m *GetBatchStateResponse) managerRespSealed() {} + +// ackResponse is the no-op reply for internal Tell-delivered messages. +type ackResponse struct { + actor.BaseMessage +} + +// MessageType returns the message type identifier. +func (m *ackResponse) MessageType() string { + return "batchcanon.ackResponse" +} + +func (m *ackResponse) managerRespSealed() {} + +// batchConfirmedMsg is the internal re-wrap of a chainsource ConfirmationEvent +// for a watched batch tx. +type batchConfirmedMsg struct { + actor.BaseMessage + + txid chainhash.Hash + blockHeight int32 + blockHash chainhash.Hash +} + +// MessageType returns the message type identifier. +func (m *batchConfirmedMsg) MessageType() string { + return "batchcanon.batchConfirmedMsg" +} + +func (m *batchConfirmedMsg) managerMsgSealed() {} + +// batchReorgedMsg is the internal re-wrap of a chainsource ConfReorgedEvent. +type batchReorgedMsg struct { + actor.BaseMessage + + txid chainhash.Hash +} + +// MessageType returns the message type identifier. +func (m *batchReorgedMsg) MessageType() string { + return "batchcanon.batchReorgedMsg" +} + +func (m *batchReorgedMsg) managerMsgSealed() {} + +// batchDoneMsg is the internal re-wrap of a chainsource ConfDoneEvent: the +// batch confirmation has matured past the reorg-safety depth (policy +// finality). +type batchDoneMsg struct { + actor.BaseMessage + + txid chainhash.Hash +} + +// MessageType returns the message type identifier. +func (m *batchDoneMsg) MessageType() string { + return "batchcanon.batchDoneMsg" +} + +func (m *batchDoneMsg) managerMsgSealed() {} + +// inputSpentMsg is the internal re-wrap of a chainsource SpendEvent on a +// consumed batch input. +type inputSpentMsg struct { + actor.BaseMessage + + outpoint wire.OutPoint + spendingTxid chainhash.Hash + spendHeight int32 +} + +// MessageType returns the message type identifier. +func (m *inputSpentMsg) MessageType() string { + return "batchcanon.inputSpentMsg" +} + +func (m *inputSpentMsg) managerMsgSealed() {} + +// inputSpendReorgedMsg is the internal re-wrap of a chainsource +// SpendReorgedEvent: a previously observed spend left the best chain. +type inputSpendReorgedMsg struct { + actor.BaseMessage + + outpoint wire.OutPoint +} + +// MessageType returns the message type identifier. +func (m *inputSpendReorgedMsg) MessageType() string { + return "batchcanon.inputSpendReorgedMsg" +} + +func (m *inputSpendReorgedMsg) managerMsgSealed() {} + +// inputSpendDoneMsg is the internal re-wrap of a chainsource SpendDoneEvent: +// the spend observation matured past the reorg-safety depth. +type inputSpendDoneMsg struct { + actor.BaseMessage + + outpoint wire.OutPoint +} + +// MessageType returns the message type identifier. +func (m *inputSpendDoneMsg) MessageType() string { + return "batchcanon.inputSpendDoneMsg" +} + +func (m *inputSpendDoneMsg) managerMsgSealed() {} diff --git a/batchcanon/record.go b/batchcanon/record.go index 8f47d8639..bd891eeb3 100644 --- a/batchcanon/record.go +++ b/batchcanon/record.go @@ -37,6 +37,14 @@ type Record struct { // after a reorg instead of being frozen at first confirmation. CSVExpiryDelta int32 + // ConfirmationPkScript is the pkScript of the batch-tx output the + // confirmation watch keys on. It is persisted so the manager can + // re-register the watch after a restart, since light-client backends + // (neutrino, Esplora) filter confirmation notifications by pkScript. + // May be empty for records seeded by descriptor backfill, which has no + // batch-output pkScript to derive. + ConfirmationPkScript []byte + // PolicyState is the reserved policy classification slot. See // PolicyState. PolicyState PolicyState diff --git a/db/batch_canonicality_store.go b/db/batch_canonicality_store.go index ebeff2d19..9b39e5a59 100644 --- a/db/batch_canonicality_store.go +++ b/db/batch_canonicality_store.go @@ -106,6 +106,7 @@ func (s *BatchCanonicalityPersistenceStore) UpsertBatch(ctx context.Context, now := s.clock.Now().Unix() txid := record.BatchTxID + pkScript := record.ConfirmationPkScript return s.db.ExecTx(ctx, WriteTxOption(), func( q BatchCanonicalityStore) error { @@ -120,10 +121,11 @@ func (s *BatchCanonicalityPersistenceStore) UpsertBatch(ctx context.Context, ConfirmationBlockHash: optionHashToBytes( record.ConfirmationBlock, ), - CsvExpiryDelta: record.CSVExpiryDelta, - PolicyState: int32(record.PolicyState), - CreatedAt: now, - UpdatedAt: now, + CsvExpiryDelta: record.CSVExpiryDelta, + PolicyState: int32(record.PolicyState), + ConfirmationPkScript: pkScript, + CreatedAt: now, + UpdatedAt: now, }, ) if err != nil { @@ -588,14 +590,15 @@ func (s *BatchCanonicalityPersistenceStore) hydrateRecord(ctx context.Context, } return &batchcanon.Record{ - BatchTxID: *txid, - State: batchcanon.State(row.State), - ConfirmationHeight: nullInt32ToOption(row.ConfirmationHeight), - ConfirmationBlock: confBlock, - CSVExpiryDelta: row.CsvExpiryDelta, - PolicyState: batchcanon.PolicyState(row.PolicyState), - ConsumedInputs: inputs, - DependentVTXOs: deps, + BatchTxID: *txid, + State: batchcanon.State(row.State), + ConfirmationHeight: nullInt32ToOption(row.ConfirmationHeight), + ConfirmationBlock: confBlock, + CSVExpiryDelta: row.CsvExpiryDelta, + PolicyState: batchcanon.PolicyState(row.PolicyState), + ConfirmationPkScript: row.ConfirmationPkScript, + ConsumedInputs: inputs, + DependentVTXOs: deps, }, nil } diff --git a/db/sqlc/batch_canonicality.sql.go b/db/sqlc/batch_canonicality.sql.go index fe252f4ea..7729a8049 100644 --- a/db/sqlc/batch_canonicality.sql.go +++ b/db/sqlc/batch_canonicality.sql.go @@ -99,26 +99,17 @@ func (q *Queries) FindBatchesByConsumedOutpoint(ctx context.Context, arg FindBat const GetBatchCanonicality = `-- name: GetBatchCanonicality :one SELECT batch_txid, state, confirmation_height, confirmation_block_hash, - csv_expiry_delta, policy_state, created_at, updated_at + csv_expiry_delta, policy_state, created_at, updated_at, + confirmation_pk_script FROM batch_canonicality WHERE batch_txid = $1 ` -type GetBatchCanonicalityRow struct { - BatchTxid []byte - State int32 - ConfirmationHeight sql.NullInt32 - ConfirmationBlockHash []byte - CsvExpiryDelta int32 - PolicyState int32 - CreatedAt int64 - UpdatedAt int64 -} - -// GetBatchCanonicality returns the canonicality row for a batch txid. -func (q *Queries) GetBatchCanonicality(ctx context.Context, batchTxid []byte) (GetBatchCanonicalityRow, error) { +// GetBatchCanonicality returns the canonicality row for a batch txid. The +// column order matches the table so sqlc reuses the BatchCanonicality model. +func (q *Queries) GetBatchCanonicality(ctx context.Context, batchTxid []byte) (BatchCanonicality, error) { row := q.db.QueryRowContext(ctx, GetBatchCanonicality, batchTxid) - var i GetBatchCanonicalityRow + var i BatchCanonicality err := row.Scan( &i.BatchTxid, &i.State, @@ -128,6 +119,7 @@ func (q *Queries) GetBatchCanonicality(ctx context.Context, batchTxid []byte) (G &i.PolicyState, &i.CreatedAt, &i.UpdatedAt, + &i.ConfirmationPkScript, ) return i, err } @@ -199,33 +191,23 @@ func (q *Queries) InsertProvisionalConsumer(ctx context.Context, arg InsertProvi const ListBatchCanonicalityByState = `-- name: ListBatchCanonicalityByState :many SELECT batch_txid, state, confirmation_height, confirmation_block_hash, - csv_expiry_delta, policy_state, created_at, updated_at + csv_expiry_delta, policy_state, created_at, updated_at, + confirmation_pk_script FROM batch_canonicality WHERE state = $1 ` -type ListBatchCanonicalityByStateRow struct { - BatchTxid []byte - State int32 - ConfirmationHeight sql.NullInt32 - ConfirmationBlockHash []byte - CsvExpiryDelta int32 - PolicyState int32 - CreatedAt int64 - UpdatedAt int64 -} - // ListBatchCanonicalityByState returns every batch currently in the given // state. -func (q *Queries) ListBatchCanonicalityByState(ctx context.Context, state int32) ([]ListBatchCanonicalityByStateRow, error) { +func (q *Queries) ListBatchCanonicalityByState(ctx context.Context, state int32) ([]BatchCanonicality, error) { rows, err := q.db.QueryContext(ctx, ListBatchCanonicalityByState, state) if err != nil { return nil, err } defer rows.Close() - var items []ListBatchCanonicalityByStateRow + var items []BatchCanonicality for rows.Next() { - var i ListBatchCanonicalityByStateRow + var i BatchCanonicality if err := rows.Scan( &i.BatchTxid, &i.State, @@ -235,6 +217,7 @@ func (q *Queries) ListBatchCanonicalityByState(ctx context.Context, state int32) &i.PolicyState, &i.CreatedAt, &i.UpdatedAt, + &i.ConfirmationPkScript, ); err != nil { return nil, err } @@ -456,9 +439,10 @@ const UpsertBatchCanonicality = `-- name: UpsertBatchCanonicality :exec INSERT INTO batch_canonicality ( batch_txid, state, confirmation_height, confirmation_block_hash, - csv_expiry_delta, policy_state, created_at, updated_at + csv_expiry_delta, policy_state, created_at, updated_at, + confirmation_pk_script ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8 + $1, $2, $3, $4, $5, $6, $7, $8, $9 ) ON CONFLICT (batch_txid) DO UPDATE SET state = EXCLUDED.state, @@ -466,6 +450,7 @@ ON CONFLICT (batch_txid) DO UPDATE SET confirmation_block_hash = EXCLUDED.confirmation_block_hash, csv_expiry_delta = EXCLUDED.csv_expiry_delta, policy_state = EXCLUDED.policy_state, + confirmation_pk_script = EXCLUDED.confirmation_pk_script, updated_at = EXCLUDED.updated_at ` @@ -478,6 +463,7 @@ type UpsertBatchCanonicalityParams struct { PolicyState int32 CreatedAt int64 UpdatedAt int64 + ConfirmationPkScript []byte } // Batch canonicality queries. @@ -498,6 +484,7 @@ func (q *Queries) UpsertBatchCanonicality(ctx context.Context, arg UpsertBatchCa arg.PolicyState, arg.CreatedAt, arg.UpdatedAt, + arg.ConfirmationPkScript, ) return err } diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 38431f5a3..20044c1be 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -83,8 +83,9 @@ type Querier interface { FindBatchesByConsumedOutpoint(ctx context.Context, arg FindBatchesByConsumedOutpointParams) ([][]byte, error) // GetActivityEntry returns one entry by its canonical id. GetActivityEntry(ctx context.Context, canonicalID string) (ActivityEntry, error) - // GetBatchCanonicality returns the canonicality row for a batch txid. - GetBatchCanonicality(ctx context.Context, batchTxid []byte) (GetBatchCanonicalityRow, error) + // GetBatchCanonicality returns the canonicality row for a batch txid. The + // column order matches the table so sqlc reuses the BatchCanonicality model. + GetBatchCanonicality(ctx context.Context, batchTxid []byte) (BatchCanonicality, error) GetBoardingAddress(ctx context.Context, pkScript []byte) (BoardingAddress, error) GetBoardingIntent(ctx context.Context, arg GetBoardingIntentParams) (BoardingIntent, error) GetBoardingSweep(ctx context.Context, txid []byte) (BoardingSweep, error) @@ -205,7 +206,7 @@ type Querier interface { ListAllVTXOs(ctx context.Context) ([]Vtxo, error) // ListBatchCanonicalityByState returns every batch currently in the given // state. - ListBatchCanonicalityByState(ctx context.Context, state int32) ([]ListBatchCanonicalityByStateRow, error) + ListBatchCanonicalityByState(ctx context.Context, state int32) ([]BatchCanonicality, error) // ListBatchConsumedInputs returns the outpoints a batch consumes. ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) ([]ListBatchConsumedInputsRow, error) // ListBatchDependentVTXOs returns the VTXO outpoints a batch anchors. diff --git a/db/sqlc/queries/batch_canonicality.sql b/db/sqlc/queries/batch_canonicality.sql index 6dc744185..8be642c5a 100644 --- a/db/sqlc/queries/batch_canonicality.sql +++ b/db/sqlc/queries/batch_canonicality.sql @@ -10,9 +10,10 @@ -- batch. created_at is preserved on conflict; everything else is overwritten. INSERT INTO batch_canonicality ( batch_txid, state, confirmation_height, confirmation_block_hash, - csv_expiry_delta, policy_state, created_at, updated_at + csv_expiry_delta, policy_state, created_at, updated_at, + confirmation_pk_script ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8 + $1, $2, $3, $4, $5, $6, $7, $8, $9 ) ON CONFLICT (batch_txid) DO UPDATE SET state = EXCLUDED.state, @@ -20,12 +21,15 @@ ON CONFLICT (batch_txid) DO UPDATE SET confirmation_block_hash = EXCLUDED.confirmation_block_hash, csv_expiry_delta = EXCLUDED.csv_expiry_delta, policy_state = EXCLUDED.policy_state, + confirmation_pk_script = EXCLUDED.confirmation_pk_script, updated_at = EXCLUDED.updated_at; -- name: GetBatchCanonicality :one --- GetBatchCanonicality returns the canonicality row for a batch txid. +-- GetBatchCanonicality returns the canonicality row for a batch txid. The +-- column order matches the table so sqlc reuses the BatchCanonicality model. SELECT batch_txid, state, confirmation_height, confirmation_block_hash, - csv_expiry_delta, policy_state, created_at, updated_at + csv_expiry_delta, policy_state, created_at, updated_at, + confirmation_pk_script FROM batch_canonicality WHERE batch_txid = $1; @@ -33,7 +37,8 @@ WHERE batch_txid = $1; -- ListBatchCanonicalityByState returns every batch currently in the given -- state. SELECT batch_txid, state, confirmation_height, confirmation_block_hash, - csv_expiry_delta, policy_state, created_at, updated_at + csv_expiry_delta, policy_state, created_at, updated_at, + confirmation_pk_script FROM batch_canonicality WHERE state = $1; From 8c19663ef1855547691c63b5345d9b7d87bddd6b Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 8 Jul 2026 14:04:46 -0700 Subject: [PATCH 09/18] batchcanon+vtxo: VTXO lineage availability + admission gate (C5) Squashed for the btcd v2 port. batchcanon Availability vocab (available_final/provisional/unknown, limbo_reorg/conflict, invalidated) + CombineAvailability + store-driven LineageBlocked, and the vtxo.Manager coin-selection/forfeit admission gate that drops candidates whose batch lineage is limbo/invalidated. Permissive for unseen/unregistered; no-op when the store is nil. --- batchcanon/AGENTS.md | 12 +- batchcanon/CLAUDE.md | 12 +- batchcanon/availability.go | 214 +++++++++++++++++++++++++++ batchcanon/availability_test.go | 196 ++++++++++++++++++++++++ vtxo/manager.go | 112 ++++++++++++++ vtxo/manager_forfeit_gate_test.go | 70 +++++++++ vtxo/manager_lineage_gate_test.go | 237 ++++++++++++++++++++++++++++++ 7 files changed, 851 insertions(+), 2 deletions(-) create mode 100644 batchcanon/availability.go create mode 100644 batchcanon/availability_test.go create mode 100644 vtxo/manager_forfeit_gate_test.go create mode 100644 vtxo/manager_lineage_gate_test.go diff --git a/batchcanon/AGENTS.md b/batchcanon/AGENTS.md index a384e0365..fc599db0c 100644 --- a/batchcanon/AGENTS.md +++ b/batchcanon/AGENTS.md @@ -32,6 +32,16 @@ in its own package, separate from `chainsource` (raw observation) and `vtxo` reconfirmation rather than frozen. - `ProvisionalConsumer` — reverse-dependency edge (consumed VTXO → consumer batch) enabling VTXO restore if a consumer batch never becomes canonical. +- `Availability` — derived (never persisted) VTXO-lineage spendability: + `AvailableFinal`, `AvailableProvisional`, `AvailabilityUnknown`, + `LimboReorg`, `LimboConflict`, `Invalidated`. `AvailabilityForState` + maps one batch's `State`; `CombineAvailability` takes the worst across a + multi-parent lineage; `Usable()` is true only for confirmed lineage. + `LineageAvailability`/`LineageBlocked` load each parent batch from the + `Store` and produce the combined availability / block decision the VTXO + manager's admission gate (C5 wiring) calls per candidate. The gate is + permissive: unseen / not-yet-registered lineage does not block — only + limbo/invalidated lineage does. - `Store` — behavior-free durable query/update interface. Implemented by `db.BatchCanonicalityPersistenceStore` over the `000020`/`000021` schema; backfilled from existing VTXOs via @@ -79,7 +89,7 @@ BatchCanonicalityManager (task C3/C4) rewires expiry consumers onto frozen absolute `vtxo.BatchExpiry`; must consume effective (recomputable) expiry instead. - `vtxo/actor.go` — schedules on the frozen absolute `BatchExpiry`. -- `darepod/vhtlc_recovery_target.go` — folds multiple roots into a +- `waved/vhtlc_recovery_target.go` — folds multiple roots into a most-restrictive absolute `batchExpiry`. - `unroll/proof_assembler.go` (`BatchExpiry == 0`) — treats zero as "unset", not terminal; benign, documented for completeness. diff --git a/batchcanon/CLAUDE.md b/batchcanon/CLAUDE.md index a384e0365..fc599db0c 100644 --- a/batchcanon/CLAUDE.md +++ b/batchcanon/CLAUDE.md @@ -32,6 +32,16 @@ in its own package, separate from `chainsource` (raw observation) and `vtxo` reconfirmation rather than frozen. - `ProvisionalConsumer` — reverse-dependency edge (consumed VTXO → consumer batch) enabling VTXO restore if a consumer batch never becomes canonical. +- `Availability` — derived (never persisted) VTXO-lineage spendability: + `AvailableFinal`, `AvailableProvisional`, `AvailabilityUnknown`, + `LimboReorg`, `LimboConflict`, `Invalidated`. `AvailabilityForState` + maps one batch's `State`; `CombineAvailability` takes the worst across a + multi-parent lineage; `Usable()` is true only for confirmed lineage. + `LineageAvailability`/`LineageBlocked` load each parent batch from the + `Store` and produce the combined availability / block decision the VTXO + manager's admission gate (C5 wiring) calls per candidate. The gate is + permissive: unseen / not-yet-registered lineage does not block — only + limbo/invalidated lineage does. - `Store` — behavior-free durable query/update interface. Implemented by `db.BatchCanonicalityPersistenceStore` over the `000020`/`000021` schema; backfilled from existing VTXOs via @@ -79,7 +89,7 @@ BatchCanonicalityManager (task C3/C4) rewires expiry consumers onto frozen absolute `vtxo.BatchExpiry`; must consume effective (recomputable) expiry instead. - `vtxo/actor.go` — schedules on the frozen absolute `BatchExpiry`. -- `darepod/vhtlc_recovery_target.go` — folds multiple roots into a +- `waved/vhtlc_recovery_target.go` — folds multiple roots into a most-restrictive absolute `batchExpiry`. - `unroll/proof_assembler.go` (`BatchExpiry == 0`) — treats zero as "unset", not terminal; benign, documented for completeness. diff --git a/batchcanon/availability.go b/batchcanon/availability.go new file mode 100644 index 000000000..9764afe2a --- /dev/null +++ b/batchcanon/availability.go @@ -0,0 +1,214 @@ +package batchcanon + +import ( + "context" + "errors" + "fmt" + + "github.com/btcsuite/btcd/chainhash/v2" +) + +// Availability is the derived spendability of a VTXO's lineage, computed from +// the canonicality State of the batch(es) the VTXO descends from. It is the +// vocabulary the VTXO manager's admission gate and the producers consume; it +// is never persisted (it is always recomputed from the current batch State). +type Availability int + +const ( + // AvailableFinal means every parent batch reached policy finality. The + // VTXO is usable and its lineage is as settled as policy allows. + AvailableFinal Availability = iota + + // AvailableProvisional means every parent batch is confirmed but not + // yet final. The VTXO is usable at one-confirmation usability depth, + // but the lineage could still reorg. + AvailableProvisional + + // AvailabilityUnknown means at least one parent batch has no + // confirmation observation yet (unseen), and none is in limbo or + // invalidated. The lineage is not yet usable, but nothing is wrong. + AvailabilityUnknown + + // LimboReorg means at least one parent batch was reorged out with no + // input conflict. The VTXO is temporarily unusable and may recover if + // the batch reconfirms. + LimboReorg + + // LimboConflict means at least one parent batch has a consumed input + // double-spent by a conflicting transaction that has not yet reached + // finality. The VTXO is unusable and may recover only if the conflict + // reorgs out. + LimboConflict + + // Invalidated means at least one parent batch has a consumed-input + // conflict that reached finality. The VTXO is unusable; recovery + // requires the conflicting transaction to itself reorg out (beyond + // policy finality). + Invalidated +) + +// availabilityRank orders availabilities from most to least available, so the +// combined availability of a multi-parent lineage is the worst (highest rank) +// of its parents. +func availabilityRank(a Availability) int { + switch a { + case AvailableFinal: + return 0 + + case AvailableProvisional: + return 1 + + case AvailabilityUnknown: + return 2 + + case LimboReorg: + return 3 + + case LimboConflict: + return 4 + + case Invalidated: + return 5 + + default: + return 2 + } +} + +// String returns a stable lower-snake-case name for the availability. +func (a Availability) String() string { + switch a { + case AvailableFinal: + return "available_final" + + case AvailableProvisional: + return "available_provisional" + + case AvailabilityUnknown: + return "available_unknown" + + case LimboReorg: + return "limbo_reorg" + + case LimboConflict: + return "limbo_conflict" + + case Invalidated: + return "invalidated" + + default: + return fmt.Sprintf("unknown(%d)", int(a)) + } +} + +// Usable reports whether a VTXO with this lineage availability may be admitted +// for spending or forfeiting. Only confirmed lineage (provisional or final) is +// usable; unseen, limbo, and invalidated lineage is not. +func (a Availability) Usable() bool { + return a == AvailableFinal || a == AvailableProvisional +} + +// AvailabilityForState maps a single batch's canonicality State to the +// availability it confers on its dependent VTXOs. +func AvailabilityForState(s State) Availability { + switch s { + case StateFinalized: + return AvailableFinal + + case StateProvisional: + return AvailableProvisional + + case StateReorgedOut: + return LimboReorg + + case StateConflictProvisional: + return LimboConflict + + case StateConflictFinalized: + return Invalidated + + case StateUnseen: + return AvailabilityUnknown + + default: + return AvailabilityUnknown + } +} + +// CombineAvailability returns the availability of a VTXO that depends on +// several parent batches: a VTXO is only as available as its least-available +// parent (the worst rank). With no parents it returns AvailabilityUnknown. +func CombineAvailability(parents ...Availability) Availability { + if len(parents) == 0 { + return AvailabilityUnknown + } + + worst := parents[0] + for _, p := range parents[1:] { + if availabilityRank(p) > availabilityRank(worst) { + worst = p + } + } + + return worst +} + +// LineageAvailability returns the combined availability of a VTXO that +// descends from the given batch txids, loading each batch's canonicality +// state from the store and taking the worst across them. A batch with no +// record yet (e.g. not registered with the manager during rollout) maps to +// AvailabilityUnknown, so a caller that wants a permissive posture can admit +// when no record blocks it. With no txids it returns AvailabilityUnknown. +// +// This is the gate logic the VTXO manager calls per candidate: a VTXO is +// admissible iff LineageAvailability(...).Usable() — or, permissively, iff it +// is not in a limbo/invalidated state. +func LineageAvailability(ctx context.Context, store Store, + batchTxids ...chainhash.Hash) (Availability, error) { + + if len(batchTxids) == 0 { + return AvailabilityUnknown, nil + } + + avails := make([]Availability, 0, len(batchTxids)) + for _, txid := range batchTxids { + record, err := store.GetBatch(ctx, txid) + switch { + case errors.Is(err, ErrBatchNotFound): + avails = append(avails, AvailabilityUnknown) + + case err != nil: + return AvailabilityUnknown, err + + default: + avails = append( + avails, AvailabilityForState(record.State), + ) + } + } + + return CombineAvailability(avails...), nil +} + +// LineageBlocked reports whether a VTXO descending from the given batches must +// be refused admission because at least one parent batch is in a limbo or +// invalidated state. It is the permissive form of the gate: unseen or +// not-yet-registered lineage does NOT block (only positively-bad lineage +// does), which keeps the gate safe to enable before every producer registers +// its batches. +func LineageBlocked(ctx context.Context, store Store, + batchTxids ...chainhash.Hash) (bool, Availability, error) { + + avail, err := LineageAvailability(ctx, store, batchTxids...) + if err != nil { + return false, avail, err + } + + switch avail { + case LimboReorg, LimboConflict, Invalidated: + return true, avail, nil + + default: + return false, avail, nil + } +} diff --git a/batchcanon/availability_test.go b/batchcanon/availability_test.go new file mode 100644 index 000000000..caa3fd2fd --- /dev/null +++ b/batchcanon/availability_test.go @@ -0,0 +1,196 @@ +package batchcanon + +import ( + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/stretchr/testify/require" +) + +// TestAvailabilityForState pins the State -> Availability mapping. +func TestAvailabilityForState(t *testing.T) { + t.Parallel() + + cases := []struct { + state State + want Availability + }{ + { + StateFinalized, + AvailableFinal, + }, + { + StateProvisional, + AvailableProvisional, + }, + { + StateUnseen, + AvailabilityUnknown, + }, + { + StateReorgedOut, + LimboReorg, + }, + { + StateConflictProvisional, + LimboConflict, + }, + { + StateConflictFinalized, + Invalidated, + }, + } + + for _, tc := range cases { + require.Equal( + t, tc.want, AvailabilityForState(tc.state), + tc.state.String(), + ) + } +} + +// TestAvailabilityUsable verifies only confirmed lineage is usable. +func TestAvailabilityUsable(t *testing.T) { + t.Parallel() + + require.True(t, AvailableFinal.Usable()) + require.True(t, AvailableProvisional.Usable()) + require.False(t, AvailabilityUnknown.Usable()) + require.False(t, LimboReorg.Usable()) + require.False(t, LimboConflict.Usable()) + require.False(t, Invalidated.Usable()) +} + +// TestCombineAvailability verifies a multi-parent lineage takes the worst +// (least-available) parent. +func TestCombineAvailability(t *testing.T) { + t.Parallel() + + require.Equal(t, AvailabilityUnknown, CombineAvailability()) + + // All final -> final. + require.Equal( + t, AvailableFinal, CombineAvailability( + AvailableFinal, AvailableFinal, + ), + ) + + // A provisional parent downgrades a final one. + require.Equal( + t, AvailableProvisional, CombineAvailability( + AvailableFinal, AvailableProvisional, + ), + ) + + // Any limbo dominates available parents. + require.Equal( + t, LimboReorg, CombineAvailability( + AvailableFinal, AvailableProvisional, LimboReorg, + ), + ) + + // Conflict limbo dominates reorg limbo. + require.Equal( + t, LimboConflict, CombineAvailability( + LimboReorg, LimboConflict, + ), + ) + + // Invalidated dominates everything. + require.Equal( + t, Invalidated, CombineAvailability( + AvailableFinal, LimboConflict, Invalidated, + AvailableProvisional, + ), + ) + + // Unknown dominates available but not limbo/invalidated. + require.Equal( + t, AvailabilityUnknown, CombineAvailability( + AvailableProvisional, AvailabilityUnknown, + ), + ) + require.Equal( + t, LimboReorg, CombineAvailability( + AvailabilityUnknown, LimboReorg, + ), + ) +} + +// TestAvailabilityStringStable pins the string names. +func TestAvailabilityStringStable(t *testing.T) { + t.Parallel() + + require.Equal(t, "available_final", AvailableFinal.String()) + require.Equal(t, "available_provisional", AvailableProvisional.String()) + require.Equal(t, "available_unknown", AvailabilityUnknown.String()) + require.Equal(t, "limbo_reorg", LimboReorg.String()) + require.Equal(t, "limbo_conflict", LimboConflict.String()) + require.Equal(t, "invalidated", Invalidated.String()) +} + +// TestLineageAvailabilityFromStore exercises the store-driven lineage gate: +// it combines the worst availability across a VTXO's parent batches, treats a +// missing record as unknown (non-blocking), and reports blocking only for +// limbo/invalidated lineage. +func TestLineageAvailabilityFromStore(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newFakeStore() + + finalTx := chainhash.Hash{0x01} + reorgTx := chainhash.Hash{0x02} + conflictTx := chainhash.Hash{0x03} + missingTx := chainhash.Hash{0x04} + + put := func(txid chainhash.Hash, st State) { + require.NoError( + t, + store.UpsertBatch( + ctx, &Record{ + BatchTxID: txid, + State: st, + }, + ), + ) + } + put(finalTx, StateFinalized) + put(reorgTx, StateReorgedOut) + put(conflictTx, StateConflictFinalized) + + // Single finalized parent: available, not blocked. + avail, err := LineageAvailability(ctx, store, finalTx) + require.NoError(t, err) + require.Equal(t, AvailableFinal, avail) + blocked, _, err := LineageBlocked(ctx, store, finalTx) + require.NoError(t, err) + require.False(t, blocked) + + // A reorged parent alongside a final one: limbo, blocked. + avail, err = LineageAvailability(ctx, store, finalTx, reorgTx) + require.NoError(t, err) + require.Equal(t, LimboReorg, avail) + blocked, _, err = LineageBlocked(ctx, store, finalTx, reorgTx) + require.NoError(t, err) + require.True(t, blocked) + + // An invalidated parent dominates: blocked. + blocked, avail, err = LineageBlocked(ctx, store, finalTx, conflictTx) + require.NoError(t, err) + require.True(t, blocked) + require.Equal(t, Invalidated, avail) + + // A missing (unregistered) parent is unknown and does NOT block. + avail, err = LineageAvailability(ctx, store, finalTx, missingTx) + require.NoError(t, err) + require.Equal(t, AvailabilityUnknown, avail) + blocked, _, err = LineageBlocked(ctx, store, finalTx, missingTx) + require.NoError(t, err) + require.False(t, blocked) + + // No parents: unknown, not blocked. + blocked, _, err = LineageBlocked(ctx, store) + require.NoError(t, err) + require.False(t, blocked) +} diff --git a/vtxo/manager.go b/vtxo/manager.go index 1e76b4ad3..7a99b7580 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -16,6 +16,7 @@ import ( "github.com/btcsuite/btclog/v2" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/build" "github.com/lightninglabs/wavelength/chainsource" "github.com/lightninglabs/wavelength/coinselect" @@ -140,6 +141,16 @@ type ManagerConfig struct { // VTXOs leave SpendingState. When nil, the reservation index is not // maintained and the startup sweep is skipped. ReservationStore SpendingReservationStore + + // BatchCanonicality, when set, gates coin selection on batch lineage + // canonicality: a VTXO whose batch reorged out (limbo) or was + // conflict-invalidated is excluded from selection so it is never spent + // or forfeited while its lineage is not on the canonical chain + // (darepo#454). Nil disables the gate, which is the default until the + // batch producers (round, OOR) register their batches with the + // canonicality manager; the gate is permissive for unregistered or + // unseen lineage either way. + BatchCanonicality batchcanon.Store } // Manager coordinates VTXO actor lifecycle - spawning new actors when VTXOs @@ -1260,6 +1271,51 @@ type reserveParams struct { // its actor. On partial failure the rollback function is called for // already-reserved outpoints. Returns the selected VTXO details and // 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. +func (m *Manager) gateUnavailableLineage(ctx context.Context, + candidates []*Descriptor) ([]*Descriptor, error) { + + if m.cfg.BatchCanonicality == nil { + return candidates, nil + } + + kept := make([]*Descriptor, 0, len(candidates)) + for _, c := range candidates { + desc, err := m.cfg.Store.GetVTXO(ctx, c.Outpoint) + if err != nil { + return nil, fmt.Errorf("load vtxo for lineage gate "+ + "%s: %w", c.Outpoint, err) + } + + blocked, avail, err := batchcanon.LineageBlocked( + ctx, m.cfg.BatchCanonicality, desc.CommitmentTxID, + ) + if err != nil { + return nil, fmt.Errorf("lineage gate %s: %w", + c.Outpoint, err) + } + if blocked { + m.logger(ctx).DebugS(ctx, "Excluding VTXO with "+ + "unavailable batch lineage from selection", + slog.String("outpoint", c.Outpoint.String()), + slog.String("availability", avail.String())) + + continue + } + + kept = append(kept, c) + } + + return kept, nil +} + func (m *Manager) selectAndReserveVTXOs(ctx context.Context, p reserveParams) ( []SelectedVTXO, btcutil.Amount, error) { @@ -1305,6 +1361,15 @@ func (m *Manager) selectAndReserveVTXOs(ctx context.Context, p reserveParams) ( }) } + // Drop any candidate whose batch lineage is in limbo or invalidated, so + // a VTXO whose batch reorged out or was conflict-invalidated is never + // selected while its lineage is off the canonical chain. No-op when no + // canonicality store is configured. + candidates, err = m.gateUnavailableLineage(ctx, candidates) + if err != nil { + return nil, 0, err + } + // Run largest-first selection through the shared selector. Map its // typed outcomes back onto the manager's liquidity diagnostics: a // dust-change rejection is reported verbatim, while any shortfall @@ -1924,6 +1989,26 @@ func (m *Manager) handleReserveForfeit(ctx context.Context, ErrVTXOLiquidityLocked, op), ) } + + // Refuse to forfeit a VTXO whose batch lineage is in limbo + // (reorged out) or invalidated: forfeiting commits the VTXO + // into a round, and a VTXO that is not on the canonical chain + // must not be spent. The coin-selection gate + // (gateUnavailableLineage) already excludes such VTXOs, but the + // wallet's explicit-outpoint paths (refresh / leave / sweep-all + // / replay) reserve by name and bypass selection, so the same + // gate is enforced here (darepo#454). + blocked, avail, err := m.forfeitLineageBlocked(ctx, op) + if err != nil { + return fn.Err[ManagerResp](err) + } + if blocked { + return fn.Err[ManagerResp]( + fmt.Errorf("%w: outpoint %s batch lineage "+ + "unavailable (%s)", + ErrVTXOLiquidityLocked, op, avail), + ) + } } // Reserve each VTXO. Track successes for rollback on failure. @@ -1955,6 +2040,33 @@ func (m *Manager) handleReserveForfeit(ctx context.Context, return fn.Ok[ManagerResp](&ReserveForfeitResponse{}) } +// forfeitLineageBlocked reports whether the named VTXO's batch lineage is in a +// 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. +func (m *Manager) forfeitLineageBlocked(ctx context.Context, op wire.OutPoint) ( + bool, batchcanon.Availability, error) { + + if m.cfg.BatchCanonicality == nil { + return false, batchcanon.AvailabilityUnknown, nil + } + + desc, err := m.cfg.Store.GetVTXO(ctx, op) + if err != nil { + return false, batchcanon.AvailabilityUnknown, + fmt.Errorf("load vtxo for forfeit lineage gate %s: %w", + op, err) + } + + return batchcanon.LineageBlocked( + ctx, m.cfg.BatchCanonicality, desc.CommitmentTxID, + ) +} + // rollbackForfeit sends ForfeitReleasedEvent to previously reserved VTXOs. // Best-effort: errors are logged but do not propagate. func (m *Manager) rollbackForfeit(ctx context.Context, diff --git a/vtxo/manager_forfeit_gate_test.go b/vtxo/manager_forfeit_gate_test.go new file mode 100644 index 000000000..c2852fb2a --- /dev/null +++ b/vtxo/manager_forfeit_gate_test.go @@ -0,0 +1,70 @@ +package vtxo + +import ( + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestForfeitLineageBlockedOnLimbo verifies the explicit-outpoint forfeit gate +// refuses a VTXO whose batch reorged out, matching the coin-selection gate so +// the wallet's reserve-by-name paths (refresh/leave/sweep/replay) cannot +// forfeit a VTXO that is off the canonical chain. +func TestForfeitLineageBlockedOnLimbo(t *testing.T) { + t.Parallel() + + v := makeDescriptor(t, 50_000, 0) + v.CommitmentTxID = chainhash.Hash{0xaa} + + mgr, store := newTestManager(t, []*Descriptor{v}) + mgr.cfg.BatchCanonicality = &fakeBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + v.CommitmentTxID: batchcanon.StateReorgedOut, + }, + } + store.On("GetVTXO", mock.Anything, v.Outpoint).Return(v, nil) + + blocked, avail, err := mgr.forfeitLineageBlocked( + t.Context(), v.Outpoint, + ) + require.NoError(t, err) + require.True(t, blocked) + require.Equal(t, batchcanon.LimboReorg, avail) +} + +// TestForfeitLineageNotBlockedWhenCanonical verifies a canonical VTXO is +// admissible for forfeit. +func TestForfeitLineageNotBlockedWhenCanonical(t *testing.T) { + t.Parallel() + + v := makeDescriptor(t, 50_000, 0) + v.CommitmentTxID = chainhash.Hash{0xaa} + + mgr, store := newTestManager(t, []*Descriptor{v}) + mgr.cfg.BatchCanonicality = &fakeBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + v.CommitmentTxID: batchcanon.StateProvisional, + }, + } + store.On("GetVTXO", mock.Anything, v.Outpoint).Return(v, nil) + + blocked, _, err := mgr.forfeitLineageBlocked(t.Context(), v.Outpoint) + require.NoError(t, err) + require.False(t, blocked) +} + +// TestForfeitLineageGateDormantWhenNoStore verifies the forfeit gate is a no-op +// when no canonicality store is wired. +func TestForfeitLineageGateDormantWhenNoStore(t *testing.T) { + t.Parallel() + + v := makeDescriptor(t, 50_000, 0) + mgr, _ := newTestManager(t, []*Descriptor{v}) + + blocked, _, err := mgr.forfeitLineageBlocked(t.Context(), v.Outpoint) + require.NoError(t, err) + require.False(t, blocked) +} diff --git a/vtxo/manager_lineage_gate_test.go b/vtxo/manager_lineage_gate_test.go new file mode 100644 index 000000000..8f63da67f --- /dev/null +++ b/vtxo/manager_lineage_gate_test.go @@ -0,0 +1,237 @@ +package vtxo + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// fakeBatchCanon is a minimal batchcanon.Store for the lineage-gate tests: it +// maps batch txids to canonicality states and answers GetBatch from that map. +// The other Store methods are unused by the gate and return zero values. +type fakeBatchCanon struct { + states map[chainhash.Hash]batchcanon.State +} + +func (f *fakeBatchCanon) GetBatch(_ context.Context, txid chainhash.Hash) ( + *batchcanon.Record, error) { + + st, ok := f.states[txid] + if !ok { + return nil, batchcanon.ErrBatchNotFound + } + + return &batchcanon.Record{BatchTxID: txid, State: st}, nil +} + +func (f *fakeBatchCanon) UpsertBatch(context.Context, + *batchcanon.Record) error { + + return nil +} + +func (f *fakeBatchCanon) ListBatchesByState(context.Context, batchcanon.State) ( + []*batchcanon.Record, error) { + + return nil, nil +} + +func (f *fakeBatchCanon) UpdateBatchState(context.Context, chainhash.Hash, + batchcanon.State) error { + + return nil +} + +func (f *fakeBatchCanon) RecordConfirmation(context.Context, chainhash.Hash, + int32, chainhash.Hash) error { + + return nil +} + +func (f *fakeBatchCanon) ClearConfirmation(context.Context, + chainhash.Hash) error { + + return nil +} + +func (f *fakeBatchCanon) FindBatchesConsumingOutpoint(context.Context, + wire.OutPoint) ([]chainhash.Hash, error) { + + return nil, nil +} + +func (f *fakeBatchCanon) AddProvisionalConsumer(context.Context, wire.OutPoint, + chainhash.Hash) error { + + return nil +} + +func (f *fakeBatchCanon) ListProvisionalConsumersForBatch(context.Context, + chainhash.Hash) ([]wire.OutPoint, error) { + + return nil, nil +} + +func (f *fakeBatchCanon) DeleteProvisionalConsumersForBatch(context.Context, + chainhash.Hash) error { + + return nil +} + +var _ batchcanon.Store = (*fakeBatchCanon)(nil) + +// TestSelectExcludesLimboLineage verifies the admission gate drops a candidate +// whose batch reorged out (limbo), so largest-first selection skips it and +// picks a smaller candidate whose batch is canonical instead. +func TestSelectExcludesLimboLineage(t *testing.T) { + t.Parallel() + + good := makeDescriptor(t, 40000, 0) + bad := makeDescriptor(t, 50000, 1) + good.CommitmentTxID = chainhash.Hash{0xaa} + bad.CommitmentTxID = chainhash.Hash{0xbb} + + mgr, store := newTestManager(t, []*Descriptor{good, bad}) + mgr.cfg.BatchCanonicality = &fakeBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + good.CommitmentTxID: batchcanon.StateProvisional, + bad.CommitmentTxID: batchcanon.StateReorgedOut, + }, + } + + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusLive, + ).Return([]*Descriptor{good, bad}, nil) + store.On("GetVTXO", mock.Anything, good.Outpoint).Return(good, nil) + store.On("GetVTXO", mock.Anything, bad.Outpoint).Return(bad, 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 candidate (largest) is gated out for its reorged-out batch, + // so selection falls to the 40000 candidate with a canonical batch. + require.Len(t, spendResp.SelectedVTXOs, 1) + require.Equal(t, good.Outpoint, spendResp.SelectedVTXOs[0].Outpoint) +} + +// TestSelectFailsWhenAllLineageInvalidated verifies that when every candidate's +// batch is invalidated, selection finds no admissible liquidity and fails +// rather than spending an invalidated VTXO. +func TestSelectFailsWhenAllLineageInvalidated(t *testing.T) { + t.Parallel() + + only := makeDescriptor(t, 50000, 0) + only.CommitmentTxID = chainhash.Hash{0xcc} + + mgr, store := newTestManager(t, []*Descriptor{only}) + mgr.cfg.BatchCanonicality = &fakeBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + only.CommitmentTxID: batchcanon.StateConflictFinalized, + }, + } + + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusLive, + ).Return([]*Descriptor{only}, nil) + store.On("GetVTXO", mock.Anything, only.Outpoint).Return(only, nil) + + // The shortfall path builds a liquidity diagnostic via ListLiveVTXOs. + store.On("ListLiveVTXOs", mock.Anything).Return( + []*Descriptor{only}, nil, + ) + + result := mgr.Receive(t.Context(), &SelectAndReserveSpendRequest{ + TargetAmount: 40000, + }) + _, err := result.Unpack() + require.Error(t, err) +} + +// TestSelectAdmitsCanonicalAndUnregisteredLineage verifies the gate is +// permissive: a candidate whose batch is provisional is admitted, and so is +// one whose batch has no canonicality record yet (unregistered during +// rollout) — only positively limbo/invalidated lineage is refused. +func TestSelectAdmitsCanonicalAndUnregisteredLineage(t *testing.T) { + t.Parallel() + + provisional := makeDescriptor(t, 30000, 0) + unregistered := makeDescriptor(t, 50000, 1) + provisional.CommitmentTxID = chainhash.Hash{0xd1} + unregistered.CommitmentTxID = chainhash.Hash{0xd2} + + mgr, store := newTestManager(t, []*Descriptor{ + provisional, unregistered, + }) + mgr.cfg.BatchCanonicality = &fakeBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + provisional.CommitmentTxID: batchcanon.StateProvisional, + // unregistered: intentionally absent from the map. + }, + } + + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusLive, + ).Return([]*Descriptor{provisional, unregistered}, nil) + store.On( + "GetVTXO", mock.Anything, provisional.Outpoint, + ).Return(provisional, nil) + store.On( + "GetVTXO", mock.Anything, unregistered.Outpoint, + ).Return(unregistered, 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 unregistered-batch candidate (50000, largest) is admitted because + // the gate does not block unseen/unregistered lineage. + require.Len(t, spendResp.SelectedVTXOs, 1) + require.Equal( + t, unregistered.Outpoint, spendResp.SelectedVTXOs[0].Outpoint, + ) +} + +// TestSelectGateDisabledWhenNoStore verifies that with no canonicality store +// configured the gate is a complete no-op (no GetVTXO calls, normal +// largest-first selection). +func TestSelectGateDisabledWhenNoStore(t *testing.T) { + t.Parallel() + + v := makeDescriptor(t, 50000, 0) + mgr, store := newTestManager(t, []*Descriptor{v}) + require.Nil(t, mgr.cfg.BatchCanonicality) + + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusLive, + ).Return([]*Descriptor{v}, 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) + require.Len(t, spendResp.SelectedVTXOs, 1) + require.Equal(t, v.Outpoint, spendResp.SelectedVTXOs[0].Outpoint) + + // The gate must not have queried GetVTXO at all. + store.AssertNotCalled(t, "GetVTXO", mock.Anything, mock.Anything) +} From 8792fe7abe04c47bd76517affe6ace88300c748b Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 8 Jul 2026 14:07:23 -0700 Subject: [PATCH 10/18] round: wire round-born VTXOs to the batch-canonicality gate (C6) Squashed for the btcd v2 port. The round registers its round-born batch + consumed inputs with the canonicality manager, and gates pre-commitment progression on consumed-input canonicality (finality gate kept as interim safety). --- round/actor.go | 310 +++++++++++++++++++++++++++---- round/actor_messages.go | 49 +++++ round/actor_test.go | 247 ++++++++++++++++++++++++ round/batch_canonicality_test.go | 114 ++++++++++++ round/outbox_messages.go | 19 ++ round/transitions.go | 50 ++++- 6 files changed, 749 insertions(+), 40 deletions(-) create mode 100644 round/batch_canonicality_test.go diff --git a/round/actor.go b/round/actor.go index 2536a4db4..049eb9850 100644 --- a/round/actor.go +++ b/round/actor.go @@ -20,6 +20,7 @@ import ( "github.com/google/uuid" "github.com/lightninglabs/wavelength/baselib/actor" "github.com/lightninglabs/wavelength/baselib/protofsm" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/chainsource" "github.com/lightninglabs/wavelength/ledger" "github.com/lightninglabs/wavelength/lib/actormsg" @@ -45,7 +46,7 @@ const defaultForfeitCollectionTimeout = 2 * time.Minute // seal-time quote), so it should arrive within a network round-trip plus brief // server-side queuing; 60s is generous enough to tolerate a slow server or // link while still bounding how long forfeit-reserved inputs sit stranded in -// pending-forfeit when the server never responds (wavelength#653). It is +// pending-forfeit when the server never responds (darepo-client#653). It is // configurable via RoundClientConfig.RegistrationTimeout for operators and // tests that need a different bound. const defaultRegistrationTimeout = 60 * time.Second @@ -229,6 +230,29 @@ type RoundClientActor struct { // keys for routing confirmation events. commitmentTxIndex map[chainhash.Hash]RoundKeyStr + // pendingCommitmentConfs caches the most recent ConfirmationEvent + // for each tracked commitment tx, so handleCommitmentFinalized can + // build the BoardingConfirmed FSM event using the canonical-chain + // confirmation height observed before finality. The entry is + // installed on every ConfirmationEvent (first conf or any + // re-confirmation after a reorg) and consumed on the matching + // CommitmentFinalizedEvent. A reorg without a follow-up + // re-confirmation leaves the stale entry in place, but the + // chainsource finality synthesizer requires a non-zero + // confirmHeight to fire a Done event, so finality cannot land on + // the stale entry; the next re-confirmation overwrites it before + // any Done could be synthesized. + // + // The cache exists because the round FSM intentionally delays its + // terminal transition (InputSigSent -> ConfirmedState) until the + // commitment-tx confirmation is past the chainsource backend's + // reorg-safety depth. The first ConfirmationEvent on its own is + // not sufficient to commit user-visible state (VTXOs marked live, + // ledger emissions, indexer notifications) because a reorg of the + // confirmation block would otherwise leave that state inconsistent + // with the canonical chain. + pendingCommitmentConfs map[chainhash.Hash]*ConfirmationEvent + // pendingQuotes buffers JoinRoundQuoteReceived envelopes that // arrive before the matching RoundJoined re-keys the FSM. The // mailbox contract (docs/RPC_MAILBOX_CONTRACT.md:90-98) allows @@ -306,6 +330,15 @@ type RoundClientConfig struct { // Optional - if nil, notifications are not forwarded. VTXOManager actor.TellOnlyRef[VTXOManagerMsg] + // BatchCanonicality, when set, receives a RegisterBatchRequest for + // each confirmed round-born batch so the reorg-safety availability + // gate (darepo#454) can track the batch's canonicality and exclude its + // VTXOs from coin selection if the batch reorgs out or a consumed input + // is double-spent. None disables registration (the gate stays dormant), + // which preserves pre-C6 behavior for hosts that have not wired the + // canonicality manager. + BatchCanonicality fn.Option[actor.TellOnlyRef[batchcanon.ManagerMsg]] + // DropCustomForfeitSigningContexts clears daemon-local signing // metadata for custom refresh inputs when a round fails before the // connector-bound forfeit signing request is produced. When nil, only @@ -357,7 +390,7 @@ type RoundClientConfig struct { // MetricsSink is an optional reference to the client-side metrics // actor. When set, the round actor emits a RoundCompletedMsg as // each round reaches a terminal outcome so the - // waved_rounds_completed_total counter reflects reality. When + // darepod_rounds_completed_total counter reflects reality. When // None (metrics disabled, or tests), metric emission is silently // skipped. Mirrors LedgerSink: the round actor is the natural seam // because terminal round outcomes are observed here, not at any @@ -431,8 +464,11 @@ func NewRoundClientActor(cfg *RoundClientConfig) fn.Result[*RoundClientActor] { log: actorLog, rounds: make(map[RoundKeyStr]*RoundFSM), commitmentTxIndex: make(map[chainhash.Hash]RoundKeyStr), - pendingQuotes: make(map[RoundID]*JoinRoundQuoteReceived), - env: env, + pendingCommitmentConfs: make( + map[chainhash.Hash]*ConfirmationEvent, + ), + pendingQuotes: make(map[RoundID]*JoinRoundQuoteReceived), + env: env, } // The base env is used as a template for per-round FSM environments. @@ -476,6 +512,53 @@ func NewRoundClientActor(cfg *RoundClientConfig) fn.Result[*RoundClientActor] { // Emission is best-effort: Tell failures are logged but not // propagated, so a momentary ledger outage never breaks the // round actor's downstream dispatch loop. +// registerBatchCanonicality registers the confirmed round-born batch with the +// BatchCanonicalityManager so the reorg-safety availability gate governs the +// round-born VTXOs and the manager arms reorg-aware spend watches on every +// consumed input (darepo#454). It is a no-op when no manager ref is wired +// (the gate stays dormant) or when the round produced no owned VTXOs and +// consumed no client inputs. Delivery is fire-and-forget: a registration +// failure must not break round completion, and the gate stays permissive for +// any unregistered lineage. +func (a *RoundClientActor) registerBatchCanonicality(ctx context.Context, + n *VTXOCreatedNotification) { + + if a.cfg.BatchCanonicality.IsNone() { + return + } + if len(n.VTXOs) == 0 && len(n.ConsumedInputs) == 0 { + return + } + + dependents := make([]wire.OutPoint, 0, len(n.VTXOs)) + for _, v := range n.VTXOs { + dependents = append(dependents, v.Outpoint) + } + + ref := a.cfg.BatchCanonicality.UnsafeFromSome() + req := &batchcanon.RegisterBatchRequest{ + BatchTxID: n.CommitmentTxID, + ConfirmationPkScript: n.ConfirmationPkScript, + CSVExpiryDelta: n.CSVExpiryDelta, + ConsumedInputs: n.ConsumedInputs, + DependentVTXOs: dependents, + } + + // Detach from the triggering request ctx: confirmation handling + // outlives the request (the sibling VTXO store write does the same), + // so a canceled/expired request must not drop the registration and + // leave the gate permanently permissive for these VTXOs. + if err := ref.Tell(context.WithoutCancel(ctx), req); err != nil { + a.log.WarnS(ctx, "Failed to register batch canonicality", err, + slog.String( + "commitment_txid", n.CommitmentTxID.String(), + ), + slog.Int("dependent_vtxos", len(dependents)), + slog.Int("consumed_inputs", len(n.ConsumedInputs)), + ) + } +} + func (a *RoundClientActor) emitVTXOsReceived(ctx context.Context, n *VTXOCreatedNotification) { @@ -511,7 +594,7 @@ func (a *RoundClientActor) emitVTXOsReceived(ctx context.Context, } // emitRoundCompleted reports a terminal round outcome to the metrics -// actor so the waved_rounds_completed_total counter advances. The +// actor so the darepod_rounds_completed_total counter advances. The // round actor is the natural seam: terminal outcomes surface here as // RoundCompletedNotification / RoundFailedNotification, with no RPC // boundary that could observe them. Like ledger emission, this is @@ -583,7 +666,7 @@ func (a *RoundClientActor) handleTerminalJobFailure(ctx context.Context, } // emitRoundJoined reports a round-join attempt to the metrics actor so -// waved_rounds_joined_total advances. It is emitted from createNewRound +// darepod_rounds_joined_total advances. It is emitted from createNewRound // so it counts every round the client assembles — manual and eager alike // — keeping it symmetric with emitRoundCompleted. Best-effort and // fire-and-forget: a Tell failure is logged at debug level and never @@ -901,7 +984,7 @@ func (a *RoundClientActor) createNewRound(ctx context.Context) (*RoundFSM, ) // Count the join attempt here, at the one seam every round passes - // through exactly once. This keeps waved_rounds_joined_total + // through exactly once. This keeps darepod_rounds_joined_total // symmetric with rounds_completed_total (also actor-emitted): both // manual JoinNextRound and eager/automatic joins assemble their // round through createNewRound, so counting at the RPC boundary @@ -1067,6 +1150,25 @@ func (a *RoundClientActor) registerCommitmentConfirmation(ctx context.Context, }, ) + // Reorg-aware lifecycle refs. The actor currently logs these + // rather than reversing state: see the doc on CommitmentReorgedEvent + // for why FSM-level rollback is a follow-up. Wiring the refs now + // means the chainsource conf sub-actor stays alive past first + // confirmation, height-based finality synthesis fires, and a + // future FSM-rollback patch only has to consume the events. + reorgedRef := chainsource.MapConfReorgedEvent( + a.cfg.SelfRef, + func(ev chainsource.ConfReorgedEvent) actormsg.RoundReceivable { + return &CommitmentReorgedEvent{Txid: ev.Txid} + }, + ) + finalizedRef := chainsource.MapConfDoneEvent( + a.cfg.SelfRef, + func(ev chainsource.ConfDoneEvent) actormsg.RoundReceivable { + return &CommitmentFinalizedEvent{Txid: ev.Txid} + }, + ) + // Extract the pkScript LND needs for confirmation tracking. Watch the // validated batch output (the output that receives this client's // funds) rather than assuming output 0; confirmationWatchScript falls @@ -1102,12 +1204,14 @@ func (a *RoundClientActor) registerCommitmentConfirmation(ctx context.Context, } confReq := &chainsource.RegisterConfRequest{ - CallerID: callerID, - Txid: &txid, - PkScript: pkScript, - TargetConfs: a.cfg.OperatorTerms.MinConfirmations, - HeightHint: heightHint, - NotifyActor: fn.Some(mappedRef), + CallerID: callerID, + Txid: &txid, + PkScript: pkScript, + TargetConfs: a.cfg.OperatorTerms.MinConfirmations, + HeightHint: heightHint, + NotifyActor: fn.Some(mappedRef), + NotifyReorged: fn.Some(reorgedRef), + NotifyDone: fn.Some(finalizedRef), } if err := a.cfg.ChainSource.Tell( @@ -1404,6 +1508,12 @@ func (a *RoundClientActor) Receive(ctx context.Context, case *ConfirmationEvent: return a.handleConfirmation(ctx, m) + case *CommitmentReorgedEvent: + return a.handleCommitmentReorged(ctx, m) + + case *CommitmentFinalizedEvent: + return a.handleCommitmentFinalized(ctx, m) + case *TimeoutMsg: return a.handleTimeout(ctx, m) @@ -2210,7 +2320,7 @@ func (a *RoundClientActor) onRoundComplete(ctx context.Context, roundID RoundID, // ListRounds surface it backs) must be able to report a round as FAILED at // least until the client moves on to a fresh round. Reaping on entry made the // terminal state vanish within the same actor turn, so a poller could never -// see it (wavelength#602 systests). Sweeping at the start of the next +// see it (darepo-client#602 systests). Sweeping at the start of the next // assembly keeps the window open while still bounding accumulation to the // failures since the last new round. // @@ -2244,12 +2354,27 @@ func (a *RoundClientActor) reapFailedRounds(ctx context.Context) { // from ChainSource. Boarding address confirmations are now handled via // WalletBoardingConfirmed events from the wallet actor. // +// The commitment-tx confirmation is treated as PROVISIONAL: the FSM +// is NOT transitioned to terminal ConfirmedState on first conf. The +// event is cached on pendingCommitmentConfs so that the matching +// CommitmentFinalizedEvent (synthesized by the chainsource backend at +// the reorg-safety horizon, default six blocks past the latest +// confirmation) can replay it as BoardingConfirmed. A re-confirmation +// after a reorg overwrites the cache entry with the new canonical- +// chain height, and the finality synthesizer's depth counter resets +// on the reorg, so finality is only ever reported for the latest +// re-confirmation. This preserves the property the round FSM relies +// on for safety: user-visible side effects (VTXOs marked live in the +// local store, ledger entries, indexer notifications) only fire once +// the commitment is past the reorg-safety horizon. +// // Concurrency: The actor framework serializes all messages through Receive(), // so no synchronization is needed for rounds map access. func (a *RoundClientActor) handleConfirmation(ctx context.Context, event *ConfirmationEvent) fn.Result[actormsg.RoundActorResp] { - a.log.InfoS(ctx, "Received commitment transaction confirmation", + a.log.InfoS(ctx, "Received provisional commitment-tx confirmation; "+ + "deferring FSM terminal transition to finality", slog.String("txid", event.Txid.String()), slog.Int("block_height", int(event.BlockHeight)), slog.Int("confirmations", int(event.Confirmations)), @@ -2269,7 +2394,90 @@ func (a *RoundClientActor) handleConfirmation(ctx context.Context, return fn.Ok[actormsg.RoundActorResp](nil) } - // Route to the specific round's FSM. + // Sanity-check the routing target exists, but do NOT advance the + // FSM yet. handleCommitmentFinalized is the trigger for the + // terminal transition. + if _, exists := a.rounds[keyStr]; !exists { + return fn.Err[actormsg.RoundActorResp]( + fmt.Errorf("round FSM not found for key %s", keyStr), + ) + } + + // Cache the conf so the matching finality event can replay it. + // Every ConfirmationEvent overwrites — the cache always reflects + // the LATEST positive confirmation, which is what the finality + // synthesizer counts depth from. + cached := *event + a.pendingCommitmentConfs[event.Txid] = &cached + + return fn.Ok[actormsg.RoundActorResp](nil) +} + +// handleCommitmentReorged processes a chainsource ConfReorgedEvent on +// a commitment transaction. The provisional/finalized split means the +// FSM is still in its pre-confirmation state when a reorg lands — +// user-visible side effects have not yet committed — so there is +// nothing to roll back. The cached ConfirmationEvent stays in place; +// either a follow-up re-confirmation overwrites it before the +// chainsource finality synthesizer can fire (the synthesizer's depth +// counter resets on the reorg), or the chain genuinely abandons the +// confirmation and no Done event is ever synthesized. +func (a *RoundClientActor) handleCommitmentReorged(ctx context.Context, + event *CommitmentReorgedEvent) fn.Result[actormsg.RoundActorResp] { + + keyStr, tracked := a.commitmentTxIndex[event.Txid] + if !tracked { + // Round is no longer tracked: either it finalized cleanly + // and the cleanup path already ran, or it was never one of + // ours. Either way there's nothing to undo here. + a.log.DebugS(ctx, "Commitment-tx reorged on untracked txid", + slog.String("txid", event.Txid.String()), + ) + + return fn.Ok[actormsg.RoundActorResp](nil) + } + + a.log.InfoS(ctx, "Commitment-tx provisional confirmation reorged "+ + "out; FSM remains pre-confirmation, awaiting "+ + "re-confirmation or finality on the canonical chain", + slog.String("txid", event.Txid.String()), + slog.String("round_key", string(keyStr)), + ) + + return fn.Ok[actormsg.RoundActorResp](nil) +} + +// handleCommitmentFinalized processes a chainsource ConfDoneEvent on a +// commitment transaction. This is the trigger for the FSM's +// InputSigSent -> ConfirmedState transition: the cached +// ConfirmationEvent's height/hash/numConfs are replayed as the +// BoardingConfirmed FSM event so the terminal-state side effects +// (ledger emission, indexer publish, onRoundComplete cleanup) fire +// only after the chainsource backend has reported the confirmation is +// past the reorg-safety horizon. +// +// If the cache is empty for this txid (no prior ConfirmationEvent +// observed, e.g. a late or duplicate Done event after the round was +// already cleaned up), the handler logs and acks without touching +// the FSM. +func (a *RoundClientActor) handleCommitmentFinalized(ctx context.Context, + event *CommitmentFinalizedEvent) fn.Result[actormsg.RoundActorResp] { + + a.log.InfoS(ctx, "Commitment-tx confirmation finalized; promoting "+ + "round FSM to ConfirmedState", + slog.String("txid", event.Txid.String()), + ) + + keyStr, tracked := a.commitmentTxIndex[event.Txid] + if !tracked { + a.log.DebugS(ctx, "Commitment-tx finalized on untracked txid; "+ + "round already cleaned up", + slog.String("txid", event.Txid.String()), + ) + + return fn.Ok[actormsg.RoundActorResp](nil) + } + roundFSM, exists := a.rounds[keyStr] if !exists { return fn.Err[actormsg.RoundActorResp]( @@ -2277,23 +2485,37 @@ func (a *RoundClientActor) handleConfirmation(ctx context.Context, ) } - a.log.InfoS(ctx, "Routing confirmation to round FSM", - slog.String("key", string(keyStr)), - slog.String("round_id", roundFSM.RoundID.String()), - ) + cached, ok := a.pendingCommitmentConfs[event.Txid] + if !ok { + // Defensive: chainsource should not synthesize a Done event + // without a prior positive ConfirmationEvent (the + // finality-depth synthesizer is gated on a non-zero + // confirmHeight). If we see one anyway, ack without + // transitioning — the FSM cannot reach ConfirmedState + // without the confirmation's height/hash. + a.log.WarnS(ctx, "Commitment-tx finalized without a cached "+ + "prior ConfirmationEvent; skipping FSM transition", + nil, + slog.String("txid", event.Txid.String()), + slog.String("round_key", string(keyStr)), + ) + + return fn.Ok[actormsg.RoundActorResp](nil) + } + delete(a.pendingCommitmentConfs, event.Txid) confirmEvt := &BoardingConfirmed{ - TxID: event.Txid, - BlockHeight: event.BlockHeight, - BlockHash: event.BlockHash, - Confirmations: int32(event.Confirmations), + TxID: cached.Txid, + BlockHeight: cached.BlockHeight, + BlockHash: cached.BlockHash, + Confirmations: int32(cached.Confirmations), } err := a.askEventAndProcessOutbox(ctx, roundFSM, confirmEvt) if err != nil { return fn.Err[actormsg.RoundActorResp]( - fmt.Errorf("FSM error processing commitment "+ - "confirmation: %w", err), + fmt.Errorf("FSM error promoting round on finality: %w", + err), ) } @@ -2527,6 +2749,12 @@ func (a *RoundClientActor) processOutbox(ctx context.Context, } } + // Register the batch's reorg-safety lineage so the + // canonicality gate governs these round-born VTXOs and + // the consumed-input spend watches detect a + // double-spend. + a.registerBatchCanonicality(ctx, m) + // Mirror each newly-confirmed VTXO into the client // ledger so vtxo_balance follows round confirmation. // Source is posted as SourceRoundTransfer with the @@ -2801,6 +3029,22 @@ func (a *RoundClientActor) processConfirmationRequest( }, ) + // Reorg-aware lifecycle refs — see registerCommitmentConfirmation + // for the rationale. Detection-only today; FSM rollback is a + // follow-up. + reorgedRef := chainsource.MapConfReorgedEvent( + a.cfg.SelfRef, + func(ev chainsource.ConfReorgedEvent) actormsg.RoundReceivable { + return &CommitmentReorgedEvent{Txid: ev.Txid} + }, + ) + finalizedRef := chainsource.MapConfDoneEvent( + a.cfg.SelfRef, + func(ev chainsource.ConfDoneEvent) actormsg.RoundReceivable { + return &CommitmentFinalizedEvent{Txid: ev.Txid} + }, + ) + // Query ChainSource for current block height to use as // HeightHint. LND requires HeightHint > 0 for confirmation // scanning. @@ -2826,12 +3070,14 @@ func (a *RoundClientActor) processConfirmationRequest( // Build the complete RegisterConfRequest with the mapper as // the NotifyActor target. confReq := &chainsource.RegisterConfRequest{ - CallerID: callerID, - Txid: m.Txid, - PkScript: m.PkScript, - TargetConfs: m.TargetConfs, - HeightHint: heightHint, - NotifyActor: fn.Some(mappedRef), + CallerID: callerID, + Txid: m.Txid, + PkScript: m.PkScript, + TargetConfs: m.TargetConfs, + HeightHint: heightHint, + NotifyActor: fn.Some(mappedRef), + NotifyReorged: fn.Some(reorgedRef), + NotifyDone: fn.Some(finalizedRef), } a.log.InfoS(ctx, "Sending RegisterConfRequest to ChainSource", diff --git a/round/actor_messages.go b/round/actor_messages.go index f9df95322..4d46fd32f 100644 --- a/round/actor_messages.go +++ b/round/actor_messages.go @@ -268,6 +268,55 @@ func (m *ConfirmationEvent) MessageType() string { // RoundReceivable implements actormsg.RoundReceivable marker interface. func (m *ConfirmationEvent) RoundReceivable() {} +// CommitmentReorgedEvent wraps a chainsource ConfReorgedEvent that +// reports a previously delivered ConfirmationEvent for a commitment +// transaction was rolled back by a reorg of the canonical chain. +// +// Reorg semantics for the round FSM are not yet implemented: the +// commitment-tx confirmation drives the FSM's `InputSigSent -> +// Confirmed` transition (and the actor's `onRoundComplete` cleanup), +// both of which are terminal. Until the FSM gains a provisional/ +// finalized split, the actor-level handler for this event can only +// log the divergence so an operator notices and the future systests +// have something to assert against. Routing to the (now stopped) FSM +// would be a no-op even if the round were still tracked, because +// ConfirmedState has no transition for a "commitment reorged" event. +type CommitmentReorgedEvent struct { + actor.BaseMessage + + // Txid identifies the commitment transaction whose previously + // observed confirmation has been rolled back. + Txid chainhash.Hash +} + +func (m *CommitmentReorgedEvent) MessageType() string { + return "CommitmentReorgedEvent" +} + +// RoundReceivable implements actormsg.RoundReceivable marker interface. +func (m *CommitmentReorgedEvent) RoundReceivable() {} + +// CommitmentFinalizedEvent wraps a chainsource ConfDoneEvent that +// reports a commitment-tx confirmation is past the backend's reorg- +// safety depth and is no longer reversible. The current FSM treats +// the first confirmation as terminal; once the provisional/finalized +// FSM split lands, this is the signal that promotes the round from +// provisional to truly final. +type CommitmentFinalizedEvent struct { + actor.BaseMessage + + // Txid identifies the commitment transaction whose confirmation + // is now past the reorg-safety horizon. + Txid chainhash.Hash +} + +func (m *CommitmentFinalizedEvent) MessageType() string { + return "CommitmentFinalizedEvent" +} + +// RoundReceivable implements actormsg.RoundReceivable marker interface. +func (m *CommitmentFinalizedEvent) RoundReceivable() {} + // TimeoutMsg is sent to the round actor when a timeout expires. type TimeoutMsg struct { actor.BaseMessage diff --git a/round/actor_test.go b/round/actor_test.go index da8d493bb..968f5a025 100644 --- a/round/actor_test.go +++ b/round/actor_test.go @@ -346,6 +346,24 @@ func TestActorRecovery(t *testing.T) { reg := h.chainSource.registrations[0] require.NotNil(t, reg.Txid) require.True(t, reg.Txid.IsEqual(&txid)) + + // Reorg-aware lifecycle refs must be wired so the chainsource + // conf sub-actor keeps the registration alive past first + // confirmation, synthesizes a Done at the reorg-safety + // horizon, and surfaces TxReorged on rollback. Without these + // refs, the actor would never see reorg or finality signals + // for the commitment tx. + require.True( + t, reg.NotifyReorged.IsSome(), + "RegisterConfRequest must wire NotifyReorged so "+ + "the commitment-tx reorg lifecycle reaches "+ + "the actor", + ) + require.True( + t, reg.NotifyDone.IsSome(), + "RegisterConfRequest must wire NotifyDone so the "+ + "finality horizon reaches the actor", + ) }) t.Run("multiple_active_rounds", func(t *testing.T) { @@ -1043,6 +1061,235 @@ func TestActorGetStateWithActiveRounds(t *testing.T) { } } +// TestActorRoundCommitmentLifecycleGatedOnFinality pins the round's +// reorg-safety contract end-to-end at the actor level: +// +// - A ConfirmationEvent is PROVISIONAL. It caches the event on +// pendingCommitmentConfs[txid] and does NOT advance the FSM — +// user-visible side effects (VTXOs marked live in the local +// store, ledger emissions, indexer notifications, onRoundComplete +// cleanup) must not fire until the commitment is past the +// reorg-safety horizon. +// +// - A CommitmentReorgedEvent leaves the FSM in its pre-confirmation +// state (no rollback needed; nothing was committed). The cached +// entry is retained so a follow-up re-confirmation overwrites it +// before the chainsource finality synthesizer can fire. +// +// - A second ConfirmationEvent (re-confirmation after a reorg) +// overwrites the cached entry with the new canonical-chain +// height; the finality synthesizer's depth counter resets on +// reorg, so the eventual Done event always reflects the latest +// re-confirmation. +// +// - A CommitmentFinalizedEvent consumes the cached entry, drives +// BoardingConfirmed into the FSM, and the FSM's terminal-state +// transition fires onRoundComplete which clears the round from +// the actor's tracking maps. +// +// - A CommitmentFinalizedEvent without a cached prior conf +// (defensive: chainsource should not synthesize Done without a +// prior positive event) is a no-op rather than a crash. +// +// - Events for untracked / already-cleaned-up txids ack without +// affecting any other rounds. +func TestActorRoundCommitmentLifecycleGatedOnFinality(t *testing.T) { + t.Parallel() + + t.Run("untracked_txid_is_benign", func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + require.NoError(t, h.start()) + + untrackedTxid := chainhash.Hash{0xfe} + + require.True( + t, h.receive( + &ConfirmationEvent{Txid: untrackedTxid}, + ).IsOk(), + "ConfirmationEvent on untracked txid must ack", + ) + require.True( + t, h.receive( + &CommitmentReorgedEvent{Txid: untrackedTxid}, + ).IsOk(), + "Reorged on untracked txid must ack", + ) + require.True( + t, h.receive( + &CommitmentFinalizedEvent{Txid: untrackedTxid}, + ).IsOk(), + "Finalized on untracked txid must ack", + ) + + // No cache entries should have been installed for the + // untracked txid (the index gate prevents it). + require.NotContains( + t, h.actor.pendingCommitmentConfs, untrackedTxid, + ) + }) + + t.Run("confirmation_caches_without_fsm_transition", + func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + require.NoError(t, h.start()) + + roundID := testRoundID("test-round-conf") + packet := h.setupRoundInInputSigSentState(roundID) + txid := packet.UnsignedTx.TxHash() + h.actor.commitmentTxIndex[txid] = RoundKeyStr( + roundID.KeyString(), + ) + + require.True( + t, h.receive(&ConfirmationEvent{ + Txid: txid, + BlockHeight: 105, + Confirmations: 1, + }).IsOk(), + ) + + // Cache populated, FSM untouched: the round is still + // in the actor's tracking maps because onRoundComplete + // has not run. + cached, ok := h.actor.pendingCommitmentConfs[txid] + require.True( + t, ok, + "ConfirmationEvent must populate the cache", + ) + require.Equal(t, int32(105), cached.BlockHeight) + require.Contains( + t, h.actor.rounds, + RoundKeyStr( + roundID.KeyString(), + ), + "FSM must remain tracked before finality", + ) + require.Contains(t, h.actor.commitmentTxIndex, txid) + }) + + t.Run("reorg_before_finality_keeps_state", + func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + require.NoError(t, h.start()) + + roundID := testRoundID("test-round-reorg") + packet := h.setupRoundInInputSigSentState(roundID) + txid := packet.UnsignedTx.TxHash() + keyStr := RoundKeyStr(roundID.KeyString()) + h.actor.commitmentTxIndex[txid] = keyStr + + require.True( + t, h.receive(&ConfirmationEvent{ + Txid: txid, + BlockHeight: 200, + Confirmations: 1, + }).IsOk(), + ) + require.True( + t, h.receive( + &CommitmentReorgedEvent{Txid: txid}, + ).IsOk(), + ) + + // Reorg before finality: no rollback work to do + // because nothing user-visible was committed. The + // round stays tracked; the cache stays populated + // (a follow-up re-confirmation will overwrite it). + require.Contains(t, h.actor.rounds, keyStr) + require.Contains(t, h.actor.commitmentTxIndex, txid) + require.Contains( + t, h.actor.pendingCommitmentConfs, txid, + ) + }) + + t.Run("reconfirmation_overwrites_cache_with_canonical_height", + func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + require.NoError(t, h.start()) + + roundID := testRoundID("test-round-reconf") + packet := h.setupRoundInInputSigSentState(roundID) + txid := packet.UnsignedTx.TxHash() + h.actor.commitmentTxIndex[txid] = RoundKeyStr( + roundID.KeyString(), + ) + + require.True( + t, h.receive(&ConfirmationEvent{ + Txid: txid, + BlockHeight: 300, + Confirmations: 1, + }).IsOk(), + ) + require.True( + t, h.receive( + &CommitmentReorgedEvent{Txid: txid}, + ).IsOk(), + ) + require.True( + t, h.receive(&ConfirmationEvent{ + Txid: txid, + BlockHeight: 301, + Confirmations: 1, + }).IsOk(), + ) + + cached := h.actor.pendingCommitmentConfs[txid] + require.NotNil(t, cached) + require.Equal( + t, int32(301), cached.BlockHeight, "second "+ + "confirmation must overwrite the "+ + "cache with the canonical-chain "+ + "height; finality will replay the "+ + "latest entry, not a stale "+ + "pre-reorg observation", + ) + }) + + t.Run("finality_without_prior_conf_is_a_no_op", + func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + require.NoError(t, h.start()) + + roundID := testRoundID("test-round-bare-final") + packet := h.setupRoundInInputSigSentState(roundID) + txid := packet.UnsignedTx.TxHash() + keyStr := RoundKeyStr(roundID.KeyString()) + h.actor.commitmentTxIndex[txid] = keyStr + + // Defensive path: chainsource should not synthesize + // Done without a prior positive event (the depth + // synthesizer is gated on confirmHeight != 0), but + // the handler must not crash if it ever does. + require.True( + t, h.receive( + &CommitmentFinalizedEvent{Txid: txid}, + ).IsOk(), + ) + + require.Contains( + t, h.actor.rounds, keyStr, "Finalized "+ + "without cached conf must not "+ + "trigger an FSM transition", + ) + }) +} + // TestActorReceiveUnknownMessageType ensures that the actor rejects // unrecognized message types with an appropriate error rather than silently // ignoring them. diff --git a/round/batch_canonicality_test.go b/round/batch_canonicality_test.go new file mode 100644 index 000000000..2c6a9be01 --- /dev/null +++ b/round/batch_canonicality_test.go @@ -0,0 +1,114 @@ +package round + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// bcRef aliases the canonicality manager tell-ref to keep the test helper +// signatures within the line limit. +type bcRef = actor.TellOnlyRef[batchcanon.ManagerMsg] + +// bcTestOutpoint builds a deterministic outpoint from a single seed byte. +func bcTestOutpoint(seed byte) wire.OutPoint { + var h chainhash.Hash + h[0] = seed + + return wire.OutPoint{Hash: h, Index: uint32(seed)} +} + +// newBatchCanonActor builds a minimal RoundClientActor wired with the given +// canonicality ref option. Only the fields registerBatchCanonicality touches +// are populated. +func newBatchCanonActor(ref fn.Option[bcRef]) *RoundClientActor { + return &RoundClientActor{ + cfg: &RoundClientConfig{ + BatchCanonicality: ref, + }, + log: btclog.Disabled, + } +} + +// TestRegisterBatchCanonicalityEmitsRequest verifies the round actor forwards a +// RegisterBatchRequest carrying the batch txid, consumed inputs (boarding + +// forfeited), dependent VTXO outpoints, confirmation pkScript and CSV delta +// when a canonicality manager ref is wired. +func TestRegisterBatchCanonicalityEmitsRequest(t *testing.T) { + t.Parallel() + + ref := actor.NewChannelTellOnlyRef[batchcanon.ManagerMsg]( + "batchcanon-test", 2, + ) + a := newBatchCanonActor( + fn.Some[bcRef](ref), + ) + + var commitment chainhash.Hash + commitment[0] = 0xaa + board := bcTestOutpoint(1) + forfeit := bcTestOutpoint(2) + vtxoOut := bcTestOutpoint(3) + pkScript := []byte{0x51, 0x20, 0x01} + + a.registerBatchCanonicality(t.Context(), &VTXOCreatedNotification{ + VTXOs: []*ClientVTXO{{Outpoint: vtxoOut}}, + CommitmentTxID: commitment, + ConsumedInputs: []wire.OutPoint{board, forfeit}, + ConfirmationPkScript: pkScript, + CSVExpiryDelta: 144, + }) + + msg, ok := ref.AwaitMessage(time.Second) + require.True(t, ok, "expected a RegisterBatchRequest") + + req, ok := msg.(*batchcanon.RegisterBatchRequest) + require.True(t, ok) + require.Equal(t, commitment, req.BatchTxID) + require.Equal(t, []wire.OutPoint{board, forfeit}, req.ConsumedInputs) + require.Equal(t, []wire.OutPoint{vtxoOut}, req.DependentVTXOs) + require.Equal(t, pkScript, req.ConfirmationPkScript) + require.Equal(t, int32(144), req.CSVExpiryDelta) +} + +// TestRegisterBatchCanonicalityNoopWhenUnwired verifies registration is a +// no-op when no manager ref is configured (the gate stays dormant), preserving +// pre-C6 behavior. +func TestRegisterBatchCanonicalityNoopWhenUnwired(t *testing.T) { + t.Parallel() + + a := newBatchCanonActor( + fn.None[bcRef](), + ) + + // Must not panic and must not attempt any delivery. + a.registerBatchCanonicality(t.Context(), &VTXOCreatedNotification{ + VTXOs: []*ClientVTXO{{Outpoint: bcTestOutpoint(3)}}, + }) +} + +// TestRegisterBatchCanonicalitySkipsEmptyBatch verifies nothing is emitted when +// the round produced no owned VTXOs and consumed no client inputs (nothing for +// the gate to govern). +func TestRegisterBatchCanonicalitySkipsEmptyBatch(t *testing.T) { + t.Parallel() + + ref := actor.NewChannelTellOnlyRef[batchcanon.ManagerMsg]( + "batchcanon-empty", 1, + ) + a := newBatchCanonActor( + fn.Some[bcRef](ref), + ) + + a.registerBatchCanonicality(t.Context(), &VTXOCreatedNotification{}) + + _, ok := ref.AwaitMessage(100 * time.Millisecond) + require.False(t, ok, "no registration expected for an empty batch") +} diff --git a/round/outbox_messages.go b/round/outbox_messages.go index f5c4db62b..1d742a2ae 100644 --- a/round/outbox_messages.go +++ b/round/outbox_messages.go @@ -795,6 +795,25 @@ type VTXOCreatedNotification struct { // CommitmentTxID is the txid of the confirmed commitment transaction. CommitmentTxID chainhash.Hash + // ConsumedInputs are the outpoints the commitment tx spends that this + // client contributed: boarding input outpoints plus forfeited VTXO + // outpoints. The round actor forwards them to the + // BatchCanonicalityManager so a reorg-out or double-spend of a consumed + // input invalidates the round-born VTXOs (darepo#454, F1/F3/F6). + ConsumedInputs []wire.OutPoint + + // ConfirmationPkScript is the commitment-tx batch output script the + // canonicality confirmation watch keys on. Confirmation detection is by + // txid; the script only matters for script-filtering light-client + // backends (e.g. Neutrino, Esplora). + ConfirmationPkScript []byte + + // CSVExpiryDelta is the batch's CSV-relative expiry in blocks (the + // round's SweepDelay). The canonicality manager derives the effective + // absolute expiry as confirmation height plus this delta, so it is + // recomputed cleanly across reorgs rather than stored absolute. + CSVExpiryDelta int32 + // BatchExpiry is the absolute block height when the batch expires. BatchExpiry int32 diff --git a/round/transitions.go b/round/transitions.go index 6b5935def..3a62f79b1 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -4414,18 +4414,52 @@ func (s *InputSigSentState) ProcessEvent(ctx context.Context, event ClientEvent, operatorFeeType := roundOperatorFeeType(s.Intents) outflows := roundLedgerOutflows(s.RoundID, s.Intents) + // Collect the inputs this client contributed to the commitment + // tx (boarding outpoints + forfeited VTXO outpoints) so the + // round actor can register the batch's reorg-safety lineage. A + // double-spend or reorg-out of any of these invalidates the + // round-born VTXOs anchored by this batch (darepo#454). + consumedInputs := make( + []wire.OutPoint, 0, + len(s.Intents.Boarding)+len(s.ForfeitedVTXOs), + ) + for i := range s.Intents.Boarding { + consumedInputs = append( + consumedInputs, s.Intents.Boarding[i].Outpoint, + ) + } + consumedInputs = append(consumedInputs, s.ForfeitedVTXOs...) + + // The batch confirmation watch keys on the commitment tx's + // batch output. Detection is by txid; the script is what + // script-filtering light-client backends (Neutrino, Esplora) + // filter on, so it must be the real batch output, not output 0 + // (which can be a filler/anchor on rounds whose batch output + // sits at a higher index — see TestCommitmentTreeBindingNonZero + // Index). Reuse the helper the round's own commitment conf + // watch uses so both watches key on byte-identical scripts. + var confPkScript []byte + if s.CommitmentTx != nil { + confPkScript = confirmationWatchScript( + s.CommitmentTx.UnsignedTx, s.VTXOTreePaths, + ) + } + // Build outbox messages starting with standard notifications. outbox := make([]ClientOutMsg, 0, 2) if len(vtxos) > 0 || len(outflows) > 0 || operatorFee > 0 { outbox = append(outbox, &VTXOCreatedNotification{ - VTXOs: vtxos, - Outflows: outflows, - RoundID: s.RoundID.String(), - CommitmentTxID: evt.TxID, - BatchExpiry: batchExpiry, - CreatedHeight: evt.BlockHeight, - OperatorFeeSat: operatorFee, - OperatorFeeType: operatorFeeType, + VTXOs: vtxos, + Outflows: outflows, + RoundID: s.RoundID.String(), + CommitmentTxID: evt.TxID, + ConsumedInputs: consumedInputs, + ConfirmationPkScript: confPkScript, + CSVExpiryDelta: sweepDelay, + BatchExpiry: batchExpiry, + CreatedHeight: evt.BlockHeight, + OperatorFeeSat: operatorFee, + OperatorFeeType: operatorFeeType, }) } outbox = append(outbox, &RoundCompletedNotification{ From 248fc4459b83b6a151bd774cc0a674a5e0d43424 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 8 Jul 2026 14:09:13 -0700 Subject: [PATCH 11/18] 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/manager.go | 56 +++++++-- vtxo/manager_multiparent_gate_test.go | 115 ++++++++++++++++++ 5 files changed, 435 insertions(+), 12 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..44f3a2873 --- /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/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/lib/tree" + "github.com/lightninglabs/wavelength/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 0f417572f..6d2a8ae55 100644 --- a/oor/session_actor.go +++ b/oor/session_actor.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/build" clientdb "github.com/lightninglabs/wavelength/db" "github.com/lightninglabs/wavelength/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 888338adf..c7729f40f 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/wavelength/batchcanon" clientdb "github.com/lightninglabs/wavelength/db" "github.com/lightninglabs/wavelength/ledger" libtypes "github.com/lightninglabs/wavelength/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/manager.go b/vtxo/manager.go index 7a99b7580..654487345 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -12,6 +12,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" @@ -1273,12 +1274,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) { @@ -1295,7 +1327,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", @@ -2044,10 +2077,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) { @@ -2063,7 +2095,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..b53ae9477 --- /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/wavelength/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 38426c5febd14716fc5abc5e4cf6197295516922 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 8 Jul 2026 14:12:05 -0700 Subject: [PATCH 12/18] 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). --- unroll/registry.go | 134 +++++++++++++++- unroll/source_lineage_gate_test.go | 240 +++++++++++++++++++++++++++++ 2 files changed, 367 insertions(+), 7 deletions(-) create mode 100644 unroll/source_lineage_gate_test.go diff --git a/unroll/registry.go b/unroll/registry.go index ebbf8ab5c..c56737512 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/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/chainsource" "github.com/lightninglabs/wavelength/ledger" "github.com/lightninglabs/wavelength/lib/actormsg" @@ -74,7 +75,7 @@ type RegistryRecord struct { // on-chain footprint, so the target VTXO is safe to roll back to // live. It is persisted (as a distinct DB status) so boot-time // reconciliation can recover a VTXO whose recovery notification was - // lost before the manager applied it (wavelength#602). + // lost before the manager applied it (darepo-client#602). RecoverableFailure bool } @@ -119,6 +120,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] @@ -165,7 +177,7 @@ type RegistryConfig struct { // to live, and a completed exit asks it to retire the VTXO to spent. // This is the feedback edge that keeps VTXO lifecycle gated on the // unroll job's terminal on-chain outcome rather than the user's intent - // to exit (wavelength#602). When None, terminal outcomes are not + // to exit (darepo-client#602). When None, terminal outcomes are not // forwarded (used by tests that don't exercise the manager). VTXOExitObserver fn.Option[actor.TellOnlyRef[vtxo.ManagerMsg]] } @@ -406,6 +418,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 @@ -460,7 +571,7 @@ func (r *registryBehavior) handleEnsure(ctx context.Context, // // A recoverable failure is the exception: the prior exit failed // cleanly with no on-chain footprint and the VTXO was rolled back to - // live (wavelength#602), so a fresh exit must be admittable rather + // live (darepo-client#602), so a fresh exit must be admittable rather // than deduped against the dead attempt. Fall through to the spawn // path, whose UpsertRecord overwrites the stale recoverable row. if record, ok := r.pending[req.Outpoint]; ok && @@ -486,7 +597,7 @@ func (r *registryBehavior) handleEnsure(ctx context.Context, // stable identity and do not clobber the recorded sweep // txid or failure reason. A *recoverable* terminal // failure is the exception: the VTXO was rolled back to - // live (wavelength#602), so it falls through to a + // live (darepo-client#602), so it falls through to a // fresh spawn (handled below). // // 2. Non-terminal record — the actor was admitted in a @@ -554,6 +665,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)) @@ -762,7 +882,7 @@ func (r *registryBehavior) handleChildAdmissionResult(ctx context.Context, // the child broadcasts anything, so the VTXO has no on-chain footprint and is // recoverable to live. The record is persisted as a recoverable failure and // the VTXO manager is notified so the VTXO is rolled back rather than -// stranded in unilateral exit (wavelength#602). +// stranded in unilateral exit (darepo-client#602). func (r *registryBehavior) failAdmittedChild(ctx context.Context, target wire.OutPoint, child *VTXOUnrollActor, err error) { @@ -1000,7 +1120,7 @@ func (r *registryBehavior) handleTerminated(ctx context.Context, // outlives r.pending: a completed async persist can evict the cached // record before this terminal handoff. Prefer the message's kind so a // recovery-only target is still held in exit rather than relived as a - // live coin (wavelength#602). We only feed it to the manager, not + // live coin (darepo-client#602). We only feed it to the manager, not // the persisted record: the message has no policy ref, so stamping its // kind onto the record would drop the store's (kind, ref) identity. policyKind := req.ExitPolicyKind @@ -1010,7 +1130,7 @@ func (r *registryBehavior) handleTerminated(ctx context.Context, // Forward the terminal outcome to the VTXO manager so the VTXO's // lifecycle tracks the unroll job's terminal on-chain result rather - // than the user's intent to exit (wavelength#602). The handoff must + // than the user's intent to exit (darepo-client#602). The handoff must // survive caller-context cancellation, so detach the context. The exit // policy rides along so the manager can hold a recovery-only target in // exit rather than relive it as a live coin. diff --git a/unroll/source_lineage_gate_test.go b/unroll/source_lineage_gate_test.go new file mode 100644 index 000000000..b26fa4aba --- /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/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/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) +} From 303a1c87a846f42ae348f31bdce63d07fd1d8b19 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 8 Jul 2026 14:13:45 -0700 Subject: [PATCH 13/18] waved: activate the batch-canonicality reorg-safety gate (C9) Squashed for the btcd v2 port. Flag-day activation: darepod builds the batchcanon store, backfills from VTXOs at best height, registers + reconciles the manager, and threads the store into vtxo/unroll configs and fn.Some(ref) into round/oor. Includes the F-series reorg systests (F2/F3/F4/F6, ReorgExcludingMempool harness helper) + reorg-safety depth config and the consumed-input pkScript fix. --- batchcanon/manager.go | 162 ++++++++- batchcanon/manager_conflict_shared_test.go | 5 +- .../manager_provisional_consumer_test.go | 211 ++++++++++++ batchcanon/manager_test.go | 38 +- batchcanon/messages.go | 17 +- batchcanon/record.go | 23 +- cmd/waved/main.go | 35 +- db/batch_canonicality_store.go | 18 +- db/batch_canonicality_store_test.go | 40 ++- db/sqlc/batch_canonicality.sql.go | 35 +- db/sqlc/querier.go | 6 +- db/sqlc/queries/batch_canonicality.sql | 14 +- harness/harness.go | 196 ++++++++++- lib/actormsg/vtxo_admission.go | 32 ++ oor/registry.go | 7 + round/actor.go | 1 + round/batch_canonicality_test.go | 22 +- round/outbox_messages.go | 22 +- round/transitions.go | 60 +++- sample-waved.conf | 8 + systest/batch_canonicality_ancestor_test.go | 326 ++++++++++++++++++ systest/batch_canonicality_conflict_test.go | 226 ++++++++++++ ...batch_canonicality_forfeit_restore_test.go | 316 +++++++++++++++++ systest/batch_canonicality_gate_test.go | 325 +++++++++++++++++ systest/batch_canonicality_multiroot_test.go | 280 +++++++++++++++ systest/batch_canonicality_reorg_test.go | 272 +++++++++++++++ vtxo/manager.go | 79 +++++ vtxo/messages.go | 6 + waved/config.go | 48 +++ waved/config_reorg_safety_depth_test.go | 73 ++++ waved/server.go | 214 ++++++++++-- 31 files changed, 2989 insertions(+), 128 deletions(-) create mode 100644 batchcanon/manager_provisional_consumer_test.go create mode 100644 systest/batch_canonicality_ancestor_test.go create mode 100644 systest/batch_canonicality_conflict_test.go create mode 100644 systest/batch_canonicality_forfeit_restore_test.go create mode 100644 systest/batch_canonicality_gate_test.go create mode 100644 systest/batch_canonicality_multiroot_test.go create mode 100644 systest/batch_canonicality_reorg_test.go create mode 100644 waved/config_reorg_safety_depth_test.go diff --git a/batchcanon/manager.go b/batchcanon/manager.go index 3b695e389..e8ae2a3ef 100644 --- a/batchcanon/manager.go +++ b/batchcanon/manager.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" @@ -78,6 +79,16 @@ type ManagerConfig struct { chainsource.ChainSourceMsg, chainsource.ChainSourceResp, ] + // RestoreConsumedVTXO, when set, is invoked for each VTXO outpoint that + // was provisionally forfeited into a batch that has now been + // invalidated (a finalized conflict reversed its forfeit). The daemon + // wires this to the VTXO manager to roll the VTXO back to a spendable + // state. Nil leaves the reverse-dependency edges in place without + // acting -- the default until the FSM restore path is wired -- so the + // data model stays consistent and a later run can still act on the + // persisted edges. + RestoreConsumedVTXO func(ctx context.Context, vtxo wire.OutPoint) error + // Log is an optional logger. Log fn.Option[btclog.Logger] } @@ -88,11 +99,11 @@ type ManagerConfig struct { // persists the result. It is an actor behavior: chainsource events arrive as // internal messages re-wrapped onto the manager's own mailbox. // -// Light-client backends (neutrino, Esplora) filter spend notifications by the -// prevout pkScript, which the manager does not yet carry per consumed input; -// spend watches are registered by outpoint, which is sufficient for the -// full-node (LND) path. Threading per-input pkScripts is a follow-up for when -// the producers (round, OOR) — which hold those scripts — wire in. +// Spend watches on consumed inputs are registered with the prevout pkScript +// carried on each RegisterBatchRequest.ConsumedInput, which lnd's spend +// notifier requires (it filters by output script). An input with no pkScript +// is skipped rather than failing the whole batch registration, so the +// confirmation watch and the remaining inputs still arm. type Manager struct { cfg ManagerConfig log btclog.Logger @@ -197,6 +208,22 @@ func (m *Manager) handleRegisterBatch(ctx context.Context, ) } + // Record a reverse-dependency edge for every VTXO this batch forfeits, + // so the VTXO can be restored if this batch is later invalidated. The + // edges are persisted and idempotent on (consumedVTXO, consumerBatch). + for _, forfeited := range req.ForfeitedVTXOs { + err := m.cfg.Store.AddProvisionalConsumer( + ctx, forfeited, req.BatchTxID, + ) + if err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("record provisional consumer %s "+ + "-> %s: %w", forfeited, req.BatchTxID, + err), + ) + } + } + w := &batchWatch{ txid: req.BatchTxID, pkScript: req.ConfirmationPkScript, @@ -204,7 +231,7 @@ func (m *Manager) handleRegisterBatch(ctx context.Context, inputs: make(map[wire.OutPoint]*inputWatch), } for _, in := range req.ConsumedInputs { - w.inputs[in] = &inputWatch{} + w.inputs[in.Outpoint] = &inputWatch{} } // Record the watch only AFTER arming succeeds. If we recorded it first @@ -255,7 +282,7 @@ func (m *Manager) mergeDependents(ctx context.Context, w *batchWatch, // armWatches registers the reorg-aware confirmation watch on the batch tx and // a reorg-aware spend watch on each consumed input. func (m *Manager) armWatches(ctx context.Context, w *batchWatch, - inputs []wire.OutPoint) error { + inputs []ConsumedInput) error { heightHint := m.bestHeightHint(ctx) @@ -309,9 +336,27 @@ func (m *Manager) armWatches(ctx context.Context, w *batchWatch, } for i := range inputs { - op := inputs[i] + // A consumed input with no pkScript cannot be watched: lnd's + // spend notifier filters by output script. Rather than fail the + // whole batch registration (which would also drop the working + // confirmation watch), skip just this input's spend watch. + // Conf- based reorg tracking still works; only conflict + // detection on this one input is degraded. Empty scripts should + // only occur on legacy/backfilled inputs that predate script + // tracking. + if len(inputs[i].PkScript) == 0 { + m.logger(ctx).InfoS(ctx, "Skipping spend watch for "+ + "consumed input with no pkScript", + slog.String("batch", w.txid.String()), + slog.String( + "outpoint", inputs[i].Outpoint.String(), + )) + + continue + } + if err := m.armSpendWatch( - ctx, w.txid, op, heightHint, + ctx, w.txid, inputs[i], heightHint, ); err != nil { return err } @@ -320,13 +365,18 @@ func (m *Manager) armWatches(ctx context.Context, w *batchWatch, return nil } -// armSpendWatch registers one reorg-aware spend watch on a consumed input. +// armSpendWatch registers one reorg-aware spend watch on a consumed input. The +// input's pkScript is forwarded to the spend notifier, which filters by output +// script; without it lnd rejects the registration ("an output script must be +// provided") and the conflict-detection watch never arms. func (m *Manager) armSpendWatch(ctx context.Context, txid chainhash.Hash, - op wire.OutPoint, heightHint uint32) error { + in ConsumedInput, heightHint uint32) error { + op := in.Outpoint spendReq := &chainsource.RegisterSpendRequest{ CallerID: spendCallerID(txid, op), Outpoint: &op, + PkScript: in.PkScript, HeightHint: heightHint, NotifyActor: fn.Some( chainsource.MapSpendEvent( @@ -586,6 +636,94 @@ func (m *Manager) deriveAndPersist(ctx context.Context, w *batchWatch) { return } w.persisted = next + + m.handleConsumerLifecycle(ctx, w.txid, next) +} + +// handleConsumerLifecycle reacts to a batch's canonicality transition for the +// VTXOs it provisionally forfeits (its reverse-dependency edges): +// +// - StateConflictFinalized: the batch is permanently off the canonical chain +// (a conflicting spend matured past the reorg-safety depth), so its forfeit +// is reversed -- restore every consumed VTXO, then drop the edges. +// - StateFinalized: the batch is itself canonical and final, so the forfeit +// is now safe and the restore window closes -- drop the edges without +// restoring. +// +// Transient states (provisional / reorged-out / conflict-provisional) are left +// untouched: a reorged-out batch may still reconfirm, so its forfeit must not +// be reversed until the invalidation is final. +func (m *Manager) handleConsumerLifecycle(ctx context.Context, + txid chainhash.Hash, next State) { + + switch next { + case StateConflictFinalized: + m.restoreProvisionalConsumers(ctx, txid) + + case StateFinalized: + err := m.cfg.Store.DeleteProvisionalConsumersForBatch(ctx, txid) + if err != nil { + m.logger(ctx).WarnS(ctx, "Failed to clear provisional "+ + "consumer edges for finalized batch", err, + slog.String("batch", txid.String())) + } + + case StateUnseen, StateProvisional, StateReorgedOut, + StateConflictProvisional: + + // Transient/non-final states: the forfeit's fate is not yet + // decided, so the reverse-dependency edges are left untouched. + // A reorged-out batch may still reconfirm, so its forfeit must + // not be reversed until the invalidation (or finalization) is + // final. + } +} + +// restoreProvisionalConsumers restores every VTXO the given (now invalidated) +// batch provisionally forfeited, then drops the edges so the restore fires at +// most once. A nil RestoreConsumedVTXO callback leaves the edges in place (the +// data model stays consistent for a later wired run). +func (m *Manager) restoreProvisionalConsumers(ctx context.Context, + txid chainhash.Hash) { + + consumed, err := m.cfg.Store.ListProvisionalConsumersForBatch(ctx, txid) + if err != nil { + m.logger(ctx).WarnS(ctx, "Failed to list provisional "+ + "consumers for invalidated batch", err, + slog.String("batch", txid.String())) + + return + } + if len(consumed) == 0 { + return + } + + if m.cfg.RestoreConsumedVTXO == nil { + + // No restore path wired: leave the edges so a future run with a + // callback can still act on them. + return + } + + for _, op := range consumed { + if err := m.cfg.RestoreConsumedVTXO(ctx, op); err != nil { + m.logger(ctx).WarnS(ctx, "Failed to restore forfeited "+ + "VTXO after batch invalidation", err, + slog.String("batch", txid.String()), + slog.String("vtxo", op.String())) + + // Keep the edges so the restore can be retried; do not + // drop them on partial failure. + return + } + } + + err = m.cfg.Store.DeleteProvisionalConsumersForBatch(ctx, txid) + if err != nil { + m.logger(ctx).WarnS(ctx, "Failed to clear provisional "+ + "consumer edges after restore", err, + slog.String("batch", txid.String())) + } } // releaseSpendWatches unregisters the per-input spend watches for a batch, @@ -667,7 +805,7 @@ func (m *Manager) reconcileOne(ctx context.Context, record *Record) { } for _, in := range record.ConsumedInputs { - w.inputs[in] = &inputWatch{} + w.inputs[in.Outpoint] = &inputWatch{} } m.watches[record.BatchTxID] = w diff --git a/batchcanon/manager_conflict_shared_test.go b/batchcanon/manager_conflict_shared_test.go index 2a2828635..90fa19aef 100644 --- a/batchcanon/manager_conflict_shared_test.go +++ b/batchcanon/manager_conflict_shared_test.go @@ -3,7 +3,6 @@ package batchcanon import ( "testing" - "github.com/btcsuite/btcd/wire/v2" "github.com/stretchr/testify/require" ) @@ -24,12 +23,12 @@ func TestManagerSharedInputSpendClassifiesPerBatch(t *testing.T) { h.registerBatch(t, &RegisterBatchRequest{ BatchTxID: txA, ConfirmationPkScript: []byte{0x51, 0x20, 0x01}, - ConsumedInputs: []wire.OutPoint{shared}, + ConsumedInputs: []ConsumedInput{ci(shared)}, }) h.registerBatch(t, &RegisterBatchRequest{ BatchTxID: txB, ConfirmationPkScript: []byte{0x51, 0x20, 0x02}, - ConsumedInputs: []wire.OutPoint{shared}, + ConsumedInputs: []ConsumedInput{ci(shared)}, }) // Batch A wins the input and confirms; B never confirms. diff --git a/batchcanon/manager_provisional_consumer_test.go b/batchcanon/manager_provisional_consumer_test.go new file mode 100644 index 000000000..373baf69f --- /dev/null +++ b/batchcanon/manager_provisional_consumer_test.go @@ -0,0 +1,211 @@ +package batchcanon + +import ( + "context" + "sync" + "testing" + + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/chainsource" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// restoreRecorder captures the VTXO outpoints the manager asks to restore via +// the RestoreConsumedVTXO callback. +type restoreRecorder struct { + mu sync.Mutex + restored []wire.OutPoint +} + +func (r *restoreRecorder) restore(_ context.Context, op wire.OutPoint) error { + r.mu.Lock() + defer r.mu.Unlock() + r.restored = append(r.restored, op) + + return nil +} + +func (r *restoreRecorder) outpoints() []wire.OutPoint { + r.mu.Lock() + defer r.mu.Unlock() + + return append([]wire.OutPoint(nil), r.restored...) +} + +// TestManagerRestoresForfeitedVTXOOnConflictFinalized proves the +// reverse-dependency (provisional-forfeit) restore: when a batch that +// provisionally forfeits a VTXO is invalidated by a finalized conflict, the +// manager restores that VTXO via the RestoreConsumedVTXO callback and drops the +// edge. A transient conflict (not yet finalized) must NOT restore -- the +// forfeit is only reversed once the invalidation is final. +func TestManagerRestoresForfeitedVTXOOnConflictFinalized(t *testing.T) { + t.Parallel() + + rec := &restoreRecorder{} + h := newManagerHarnessWithRestore(t, 100, rec.restore) + + // A round-2 commitment batch that forfeits a round-1 VTXO and spends a + // consumed input we can double-spend. + consumerBatch := testBatchTxid(0xc2) + forfeitedVTXO := testOutpoint(0xa1, 0) + consumedInput := testOutpoint(0x1e, 1) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: consumerBatch, + ConfirmationPkScript: []byte{0x51, 0x20, 0xc2}, + ConsumedInputs: []ConsumedInput{ci(consumedInput)}, + ForfeitedVTXOs: []wire.OutPoint{forfeitedVTXO}, + }) + + // Confirm the batch, then observe a conflicting spend of its input. A + // non-final conflict must not restore yet. + h.fireConfirmed(t, consumerBatch, 101, testBatchTxid(0xb1)) + h.fireSpend(t, consumedInput, testBatchTxid(0x9e), 102) + require.Equal( + t, StateConflictProvisional, + h.state(t, consumerBatch).Record.State, + ) + require.Empty( + t, rec.outpoints(), + "a provisional (non-final) conflict must not restore the "+ + "forfeited VTXO", + ) + + // Mature the conflicting spend past the reorg-safety depth: the batch + // is now permanently invalidated, so its forfeit is reversed. + h.fireSpendDone(t, consumedInput) + require.Equal( + t, StateConflictFinalized, + h.state(t, consumerBatch).Record.State, + ) + require.Equal( + t, []wire.OutPoint{forfeitedVTXO}, rec.outpoints(), + "a finalized conflict must restore the forfeited VTXO", + ) + + // The edge is dropped after restoring, so the restore fires at most + // once even if the state is re-derived. + remaining, err := h.store.ListProvisionalConsumersForBatch( + t.Context(), consumerBatch, + ) + require.NoError(t, err) + require.Empty(t, remaining, "edges must be cleared after restore") +} + +// TestManagerClearsForfeitEdgesOnFinalized proves the other half of the +// lifecycle: when the consumer batch becomes canonical and final, the forfeit +// is permanent, so the reverse-dependency edges are dropped WITHOUT restoring. +func TestManagerClearsForfeitEdgesOnFinalized(t *testing.T) { + t.Parallel() + + rec := &restoreRecorder{} + h := newManagerHarnessWithRestore(t, 100, rec.restore) + + consumerBatch := testBatchTxid(0xf2) + forfeitedVTXO := testOutpoint(0xa2, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: consumerBatch, + ConfirmationPkScript: []byte{0x51, 0x20, 0xf2}, + ForfeitedVTXOs: []wire.OutPoint{forfeitedVTXO}, + }) + + // Confirm then finalize the batch on the canonical chain. + h.fireConfirmed(t, consumerBatch, 101, testBatchTxid(0xb2)) + h.fireConfDone(t, consumerBatch) + require.Equal( + t, StateFinalized, h.state(t, consumerBatch).Record.State, + ) + + require.Empty( + t, rec.outpoints(), + "a canonically finalized batch must not restore its "+ + "forfeited VTXO", + ) + remaining, err := h.store.ListProvisionalConsumersForBatch( + t.Context(), consumerBatch, + ) + require.NoError(t, err) + require.Empty( + t, remaining, + "edges must be cleared once the forfeit is final and safe", + ) +} + +// TestManagerReconcileRestoresInterruptedForfeit proves the crash-recovery +// half of the restore lifecycle: if a batch's restore failed partway through +// (RestoreConsumedVTXO errored, so the edges were deliberately kept for a +// retry) and the daemon then restarted, the retained edges must still be +// re-driven. Because conflict_finalized is terminal, its watches are not +// re-armed on Reconcile and the live spend-done event that first triggered the +// restore will never fire again -- so Reconcile itself must sweep terminal +// conflicts and complete any interrupted restore, otherwise the consumed VTXOs +// would stay forfeited forever (a permanent lock). +func TestManagerReconcileRestoresInterruptedForfeit(t *testing.T) { + t.Parallel() + + store := newFakeStore() + consumerBatch := testBatchTxid(0xd1) + forfeitedVTXO := testOutpoint(0xa3, 0) + + // Seed the state a partial-failure restore leaves behind: the batch is + // already conflict_finalized (persisted), yet its reverse-dependency + // edge is still present because RestoreConsumedVTXO errored before the + // edge could be dropped. + require.NoError( + t, + store.UpsertBatch( + t.Context(), &Record{ + BatchTxID: consumerBatch, + State: StateConflictFinalized, + ConfirmationHeight: fn.Some[int32](101), + CSVExpiryDelta: 50, + }, + ), + ) + require.NoError( + t, + store.AddProvisionalConsumer( + t.Context(), forfeitedVTXO, consumerBatch, + ), + ) + + rec := &restoreRecorder{} + + mock := newMockChainSource(200) + mockActor := actor.NewActor(actor.ActorConfig[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]{ID: "mock", Behavior: mock, MailboxSize: 64}) + mockActor.Start() + t.Cleanup(mockActor.Stop) + + mgr := NewManager( + ManagerConfig{ + Store: store, + ChainSource: mockActor.Ref(), + RestoreConsumedVTXO: rec.restore, + }, + ) + mgrActor := actor.NewActor(actor.ActorConfig[ManagerMsg, ManagerResp]{ + ID: "mgr", Behavior: mgr, MailboxSize: 64, + }) + mgr.SetSelfRef(mgrActor.TellRef()) + mgrActor.Start() + t.Cleanup(mgrActor.Stop) + + require.NoError(t, mgr.Reconcile(t.Context())) + + // The interrupted restore is completed and the edge dropped. + require.Equal( + t, []wire.OutPoint{forfeitedVTXO}, rec.outpoints(), + "Reconcile must re-drive the interrupted restore for a "+ + "terminal conflict", + ) + remaining, err := store.ListProvisionalConsumersForBatch( + t.Context(), consumerBatch, + ) + require.NoError(t, err) + require.Empty(t, remaining, "edges must be cleared after restore") +} diff --git a/batchcanon/manager_test.go b/batchcanon/manager_test.go index 753c7da51..1a9882d6e 100644 --- a/batchcanon/manager_test.go +++ b/batchcanon/manager_test.go @@ -34,9 +34,16 @@ func newFakeStore() *fakeStore { } } +// ci builds a ConsumedInput from an outpoint with a placeholder non-empty +// pkScript. The mock chainsource ignores the script's value, but it must be +// non-empty so the manager arms the spend watch rather than skipping it. +func ci(op wire.OutPoint) ConsumedInput { + return ConsumedInput{Outpoint: op, PkScript: []byte{0x51}} +} + func cloneRecord(r *Record) *Record { cp := *r - cp.ConsumedInputs = append([]wire.OutPoint(nil), r.ConsumedInputs...) + cp.ConsumedInputs = append([]ConsumedInput(nil), r.ConsumedInputs...) cp.DependentVTXOs = append([]wire.OutPoint(nil), r.DependentVTXOs...) cp.ConfirmationPkScript = append( []byte(nil), r.ConfirmationPkScript..., @@ -127,7 +134,7 @@ func (s *fakeStore) FindBatchesConsumingOutpoint(_ context.Context, var out []chainhash.Hash for txid, r := range s.records { for _, in := range r.ConsumedInputs { - if in == op { + if in.Outpoint == op { out = append(out, txid) } } @@ -339,6 +346,16 @@ type managerHarness struct { } func newManagerHarness(t *testing.T, bestHeight int32) *managerHarness { + return newManagerHarnessWithRestore(t, bestHeight, nil) +} + +// newManagerHarnessWithRestore is newManagerHarness with a RestoreConsumedVTXO +// callback wired into the manager config, for the reverse-dependency +// (provisional-forfeit restore) tests. +func newManagerHarnessWithRestore(t *testing.T, bestHeight int32, + restore func(ctx context.Context, vtxo wire.OutPoint) error, +) *managerHarness { + t.Helper() mock := newMockChainSource(bestHeight) @@ -354,8 +371,9 @@ func newManagerHarness(t *testing.T, bestHeight int32) *managerHarness { store := newFakeStore() mgr := NewManager(ManagerConfig{ - Store: store, - ChainSource: mockActor.Ref(), + Store: store, + ChainSource: mockActor.Ref(), + RestoreConsumedVTXO: restore, }) mgrActor := actor.NewActor(actor.ActorConfig[ManagerMsg, ManagerResp]{ ID: "batch-canonicality", @@ -588,7 +606,7 @@ func TestManagerInputConflict(t *testing.T) { h.registerBatch(t, &RegisterBatchRequest{ BatchTxID: txid, CSVExpiryDelta: 50, - ConsumedInputs: []wire.OutPoint{input}, + ConsumedInputs: []ConsumedInput{ci(input)}, }) h.fireConfirmed(t, txid, 101, testBatchTxid(0x33)) @@ -621,7 +639,7 @@ func TestManagerConflictClearsOnSpendReorg(t *testing.T) { h.registerBatch(t, &RegisterBatchRequest{ BatchTxID: txid, CSVExpiryDelta: 50, - ConsumedInputs: []wire.OutPoint{input}, + ConsumedInputs: []ConsumedInput{ci(input)}, }) h.fireConfirmed(t, txid, 101, testBatchTxid(0x43)) @@ -648,7 +666,7 @@ func TestManagerBatchSelfSpendNotConflict(t *testing.T) { h.registerBatch(t, &RegisterBatchRequest{ BatchTxID: txid, CSVExpiryDelta: 50, - ConsumedInputs: []wire.OutPoint{input}, + ConsumedInputs: []ConsumedInput{ci(input)}, }) h.fireConfirmed(t, txid, 101, testBatchTxid(0x53)) @@ -673,7 +691,7 @@ func TestManagerConflictDominatesReorg(t *testing.T) { h.registerBatch(t, &RegisterBatchRequest{ BatchTxID: txid, CSVExpiryDelta: 50, - ConsumedInputs: []wire.OutPoint{input}, + ConsumedInputs: []ConsumedInput{ci(input)}, }) h.fireConfirmed(t, txid, 101, testBatchTxid(0x63)) h.fireConfReorged(t, txid) @@ -699,7 +717,7 @@ func TestManagerFinalizeReleasesSpendWatches(t *testing.T) { h.registerBatch(t, &RegisterBatchRequest{ BatchTxID: txid, CSVExpiryDelta: 50, - ConsumedInputs: []wire.OutPoint{input}, + ConsumedInputs: []ConsumedInput{ci(input)}, }) h.fireConfirmed(t, txid, 101, testBatchTxid(0x73)) h.fireConfDone(t, txid) @@ -762,7 +780,7 @@ func TestManagerReconcileReArmsWatches(t *testing.T) { State: StateProvisional, ConfirmationHeight: fn.Some[int32](90), CSVExpiryDelta: 50, - ConsumedInputs: []wire.OutPoint{input}, + ConsumedInputs: []ConsumedInput{ci(input)}, }, ), ) diff --git a/batchcanon/messages.go b/batchcanon/messages.go index 6c7fbab78..e04c2b18e 100644 --- a/batchcanon/messages.go +++ b/batchcanon/messages.go @@ -41,12 +41,23 @@ type RegisterBatchRequest struct { // CSVExpiryDelta is the batch's CSV-relative expiry timeout in blocks. CSVExpiryDelta int32 - // ConsumedInputs are the outpoints the batch tx spends. Each gets a - // reorg-aware spend watch so a conflicting double-spend is detected. - ConsumedInputs []wire.OutPoint + // ConsumedInputs are the inputs the batch tx spends, each carrying the + // pkScript of the spent output. Each gets a reorg-aware spend watch so + // a conflicting double-spend is detected; the pkScript is required to + // register that watch (lnd's spend notifier filters by output script). + ConsumedInputs []ConsumedInput // DependentVTXOs are the VTXO outpoints anchored by this batch. DependentVTXOs []wire.OutPoint + + // ForfeitedVTXOs are VTXOs from prior batches that this batch consumes + // via forfeit. The manager records a reverse-dependency edge for each + // so the VTXO is restored if this batch is later invalidated (its + // forfeit reversed by a finalized conflict). This is distinct from + // ConsumedInputs, which are the batch tx's own inputs watched for a + // conflicting spend; a forfeited VTXO is a tree leaf, not necessarily a + // direct input of this batch tx. + ForfeitedVTXOs []wire.OutPoint } // MessageType returns the message type identifier. diff --git a/batchcanon/record.go b/batchcanon/record.go index bd891eeb3..c63697c12 100644 --- a/batchcanon/record.go +++ b/batchcanon/record.go @@ -49,15 +49,32 @@ type Record struct { // PolicyState. PolicyState PolicyState - // ConsumedInputs are the outpoints this batch tx spends. They are - // tracked so the manager can watch each one for a conflicting spend. - ConsumedInputs []wire.OutPoint + // ConsumedInputs are the inputs this batch tx spends. They are tracked + // so the manager can watch each one for a conflicting spend. + ConsumedInputs []ConsumedInput // DependentVTXOs are the VTXO outpoints anchored by this batch. Their // derived availability follows this batch's canonicality. DependentVTXOs []wire.OutPoint } +// ConsumedInput is one input a batch (commitment) tx spends, paired with the +// pkScript of the output being spent. The pkScript is required to register the +// reorg-aware spend watch: lnd's spend notifier filters by the output's script, +// so a bare outpoint is rejected ("an output script must be provided"). It is +// persisted alongside the outpoint so the watch can be re-armed after a +// restart. +type ConsumedInput struct { + // Outpoint is the spent output. + Outpoint wire.OutPoint + + // PkScript is the scriptPubKey of the spent output, used to register + // the spend watch. May be empty only for legacy/backfilled rows that + // predate script tracking; such inputs cannot be watched on + // light-client backends. + PkScript []byte +} + // EffectiveExpiry derives the absolute expiry height from the current // confirmation observation: ConfirmationHeight + CSVExpiryDelta. It returns // None when the batch is not currently confirmed. diff --git a/cmd/waved/main.go b/cmd/waved/main.go index d1da04016..251124aa0 100644 --- a/cmd/waved/main.go +++ b/cmd/waved/main.go @@ -175,14 +175,7 @@ func newRootCmd() *cobra.Command { "run on mainnet (required when network=mainnet)", ) - // Cap the per-round operator fee the client is willing to pay - // under the #270 seal-time fee handshake. Zero is rejected at - // config-load time as an explicit misconfiguration. - f.Int64( - "maxoperatorfeesat", cfg.MaxOperatorFeeSat, "maximum "+ - "operator fee (sats) the client will accept per "+ - "seal-time quote; must be positive", - ) + registerProtocolSafetyFlags(f, cfg) // Bound concurrent MuSig2 work. Zero lets the daemon choose a safe // backend-aware default; one restores serial signing. @@ -352,6 +345,32 @@ func registerArkServerFlags(f *pflag.FlagSet, cfg *waved.Config) { ) } +// registerProtocolSafetyFlags registers the client-side protocol safety knobs: +// the per-round operator fee cap and the reorg-safety / finality depth. +func registerProtocolSafetyFlags(f *pflag.FlagSet, cfg *waved.Config) { + // Cap the per-round operator fee the client is willing to pay under the + // #270 seal-time fee handshake. Zero is rejected at config-load time as + // an explicit misconfiguration. + f.Int64( + "maxoperatorfeesat", cfg.MaxOperatorFeeSat, "maximum "+ + "operator fee (sats) the client will accept per "+ + "seal-time quote; must be positive", + ) + + // Reorg-safety / finality depth: the confirmation depth at which a + // batch is treated as final and its reorg-aware chain watches are + // released. Bounds the deepest reorg the daemon detects and recovers + // from. Zero selects a network-aware default (6, or 100 on testnet, + // whose minimum-difficulty rule produces deep reorgs). + f.Uint32( + "reorgsafetydepth", cfg.ReorgSafetyDepth, "confirmation "+ + "depth at which a batch is treated as final and "+ + "its reorg-aware chain watches are released; "+ + "bounds the deepest reorg the daemon recovers "+ + "from. 0 = network-aware default (6; 100 on testnet)", + ) +} + // registerSwapRuntimeFlags registers optional swapruntime flags. func registerSwapRuntimeFlags(f *pflag.FlagSet, cfg *waved.Config) { f.String( diff --git a/db/batch_canonicality_store.go b/db/batch_canonicality_store.go index 9b39e5a59..74e11d192 100644 --- a/db/batch_canonicality_store.go +++ b/db/batch_canonicality_store.go @@ -141,9 +141,10 @@ func (s *BatchCanonicalityPersistenceStore) UpsertBatch(ctx context.Context, for _, in := range record.ConsumedInputs { err := q.InsertBatchConsumedInput( ctx, sqlc.InsertBatchConsumedInputParams{ - BatchTxid: txid[:], - InputHash: in.Hash[:], - InputIndex: int32(in.Index), + BatchTxid: txid[:], + InputHash: in.Outpoint.Hash[:], + InputIndex: int32(in.Outpoint.Index), + InputPkScript: in.PkScript, }, ) if err != nil { @@ -561,15 +562,18 @@ func (s *BatchCanonicalityPersistenceStore) hydrateRecord(ctx context.Context, if err != nil { return nil, err } - inputs := make([]wire.OutPoint, 0, len(inputRows)) + inputs := make([]batchcanon.ConsumedInput, 0, len(inputRows)) for _, in := range inputRows { hash, err := chainhash.NewHash(in.InputHash) if err != nil { return nil, err } - inputs = append(inputs, wire.OutPoint{ - Hash: *hash, - Index: uint32(in.InputIndex), + inputs = append(inputs, batchcanon.ConsumedInput{ + Outpoint: wire.OutPoint{ + Hash: *hash, + Index: uint32(in.InputIndex), + }, + PkScript: in.InputPkScript, }) } diff --git a/db/batch_canonicality_store_test.go b/db/batch_canonicality_store_test.go index 8a038df2c..9dee5f8a2 100644 --- a/db/batch_canonicality_store_test.go +++ b/db/batch_canonicality_store_test.go @@ -42,6 +42,20 @@ func outpoint(b byte, index uint32) wire.OutPoint { return wire.OutPoint{Hash: chainhash.Hash{b}, Index: index} } +// consumedInput builds a batchcanon.ConsumedInput from an outpoint with a +// deterministic non-empty pkScript so persistence round-trips can assert the +// script is stored and reloaded alongside the outpoint. +func consumedInput(op wire.OutPoint) batchcanon.ConsumedInput { + return batchcanon.ConsumedInput{ + Outpoint: op, + PkScript: []byte{ + 0x51, + 0x20, + op.Hash[0], + }, + } +} + // TestBatchCanonicalityUpsertRoundTrip verifies a record survives an upsert // and read with all of its fields, consumed inputs, and dependent VTXOs. func TestBatchCanonicalityUpsertRoundTrip(t *testing.T) { @@ -58,8 +72,9 @@ func TestBatchCanonicalityUpsertRoundTrip(t *testing.T) { ConfirmationBlock: fn.Some(chainhash.Hash{0xbb}), CSVExpiryDelta: 144, PolicyState: batchcanon.PolicyStateDefault, - ConsumedInputs: []wire.OutPoint{ - outpoint(0x01, 0), outpoint(0x02, 3), + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(outpoint(0x01, 0)), + consumedInput(outpoint(0x02, 3)), }, DependentVTXOs: []wire.OutPoint{ outpoint(0x03, 1), @@ -109,8 +124,8 @@ func TestBatchCanonicalityUpsertReplacesEdges(t *testing.T) { BatchTxID: txid, State: batchcanon.StateUnseen, CSVExpiryDelta: 10, - ConsumedInputs: []wire.OutPoint{ - outpoint(0x01, 0), + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(outpoint(0x01, 0)), }, DependentVTXOs: []wire.OutPoint{ outpoint(0x02, 0), @@ -127,8 +142,8 @@ func TestBatchCanonicalityUpsertReplacesEdges(t *testing.T) { BatchTxID: txid, State: batchcanon.StateProvisional, CSVExpiryDelta: 10, - ConsumedInputs: []wire.OutPoint{ - outpoint(0x09, 2), + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(outpoint(0x09, 2)), }, DependentVTXOs: nil, }, @@ -137,7 +152,10 @@ func TestBatchCanonicalityUpsertReplacesEdges(t *testing.T) { got, err := store.GetBatch(ctx, txid) require.NoError(t, err) - require.Equal(t, []wire.OutPoint{outpoint(0x09, 2)}, got.ConsumedInputs) + require.Equal( + t, []batchcanon.ConsumedInput{consumedInput(outpoint(0x09, 2))}, + got.ConsumedInputs, + ) require.Empty(t, got.DependentVTXOs) } @@ -297,7 +315,9 @@ func TestBatchCanonicalityFindByConsumedOutpoint(t *testing.T) { BatchTxID: batchA, State: batchcanon.StateProvisional, CSVExpiryDelta: 1, - ConsumedInputs: []wire.OutPoint{shared}, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(shared), + }, }, ), ) @@ -308,7 +328,9 @@ func TestBatchCanonicalityFindByConsumedOutpoint(t *testing.T) { BatchTxID: batchB, State: batchcanon.StateProvisional, CSVExpiryDelta: 1, - ConsumedInputs: []wire.OutPoint{shared}, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(shared), + }, }, ), ) diff --git a/db/sqlc/batch_canonicality.sql.go b/db/sqlc/batch_canonicality.sql.go index 7729a8049..9ceb9b4bc 100644 --- a/db/sqlc/batch_canonicality.sql.go +++ b/db/sqlc/batch_canonicality.sql.go @@ -125,20 +125,29 @@ func (q *Queries) GetBatchCanonicality(ctx context.Context, batchTxid []byte) (B } const InsertBatchConsumedInput = `-- name: InsertBatchConsumedInput :exec -INSERT INTO batch_consumed_inputs (batch_txid, input_hash, input_index) -VALUES ($1, $2, $3) +INSERT INTO batch_consumed_inputs ( + batch_txid, input_hash, input_index, input_pk_script +) +VALUES ($1, $2, $3, $4) ON CONFLICT (batch_txid, input_hash, input_index) DO NOTHING ` type InsertBatchConsumedInputParams struct { - BatchTxid []byte - InputHash []byte - InputIndex int32 + BatchTxid []byte + InputHash []byte + InputIndex int32 + InputPkScript []byte } -// InsertBatchConsumedInput records one outpoint consumed by a batch. +// InsertBatchConsumedInput records one input consumed by a batch, together +// with the pkScript of the spent output (needed to register the spend watch). func (q *Queries) InsertBatchConsumedInput(ctx context.Context, arg InsertBatchConsumedInputParams) error { - _, err := q.db.ExecContext(ctx, InsertBatchConsumedInput, arg.BatchTxid, arg.InputHash, arg.InputIndex) + _, err := q.db.ExecContext(ctx, InsertBatchConsumedInput, + arg.BatchTxid, + arg.InputHash, + arg.InputIndex, + arg.InputPkScript, + ) return err } @@ -233,17 +242,19 @@ func (q *Queries) ListBatchCanonicalityByState(ctx context.Context, state int32) } const ListBatchConsumedInputs = `-- name: ListBatchConsumedInputs :many -SELECT input_hash, input_index +SELECT input_hash, input_index, input_pk_script FROM batch_consumed_inputs WHERE batch_txid = $1 ` type ListBatchConsumedInputsRow struct { - InputHash []byte - InputIndex int32 + InputHash []byte + InputIndex int32 + InputPkScript []byte } -// ListBatchConsumedInputs returns the outpoints a batch consumes. +// ListBatchConsumedInputs returns the inputs a batch consumes, with the +// pkScript of each spent output. func (q *Queries) ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) ([]ListBatchConsumedInputsRow, error) { rows, err := q.db.QueryContext(ctx, ListBatchConsumedInputs, batchTxid) if err != nil { @@ -253,7 +264,7 @@ func (q *Queries) ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) var items []ListBatchConsumedInputsRow for rows.Next() { var i ListBatchConsumedInputsRow - if err := rows.Scan(&i.InputHash, &i.InputIndex); err != nil { + if err := rows.Scan(&i.InputHash, &i.InputIndex, &i.InputPkScript); err != nil { return nil, err } items = append(items, i) diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 20044c1be..8bc67673f 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -132,7 +132,8 @@ type Querier interface { // GetVTXOReplacement retrieves the replacement VTXO outpoint for a forfeited // VTXO. Returns NULL if not forfeited or no replacement recorded. GetVTXOReplacement(ctx context.Context, arg GetVTXOReplacementParams) (GetVTXOReplacementRow, error) - // InsertBatchConsumedInput records one outpoint consumed by a batch. + // InsertBatchConsumedInput records one input consumed by a batch, together + // with the pkScript of the spent output (needed to register the spend watch). InsertBatchConsumedInput(ctx context.Context, arg InsertBatchConsumedInputParams) error // InsertBatchDependentVTXO records one VTXO outpoint anchored by a batch. InsertBatchDependentVTXO(ctx context.Context, arg InsertBatchDependentVTXOParams) error @@ -207,7 +208,8 @@ type Querier interface { // ListBatchCanonicalityByState returns every batch currently in the given // state. ListBatchCanonicalityByState(ctx context.Context, state int32) ([]BatchCanonicality, error) - // ListBatchConsumedInputs returns the outpoints a batch consumes. + // ListBatchConsumedInputs returns the inputs a batch consumes, with the + // pkScript of each spent output. ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) ([]ListBatchConsumedInputsRow, error) // ListBatchDependentVTXOs returns the VTXO outpoints a batch anchors. ListBatchDependentVTXOs(ctx context.Context, batchTxid []byte) ([]ListBatchDependentVTXOsRow, error) diff --git a/db/sqlc/queries/batch_canonicality.sql b/db/sqlc/queries/batch_canonicality.sql index 8be642c5a..2449163c4 100644 --- a/db/sqlc/queries/batch_canonicality.sql +++ b/db/sqlc/queries/batch_canonicality.sql @@ -66,9 +66,12 @@ SET confirmation_height = NULL, confirmation_block_hash = NULL, updated_at = $2 WHERE batch_txid = $1; -- name: InsertBatchConsumedInput :exec --- InsertBatchConsumedInput records one outpoint consumed by a batch. -INSERT INTO batch_consumed_inputs (batch_txid, input_hash, input_index) -VALUES ($1, $2, $3) +-- InsertBatchConsumedInput records one input consumed by a batch, together +-- with the pkScript of the spent output (needed to register the spend watch). +INSERT INTO batch_consumed_inputs ( + batch_txid, input_hash, input_index, input_pk_script +) +VALUES ($1, $2, $3, $4) ON CONFLICT (batch_txid, input_hash, input_index) DO NOTHING; -- name: DeleteBatchConsumedInputs :exec @@ -77,8 +80,9 @@ ON CONFLICT (batch_txid, input_hash, input_index) DO NOTHING; DELETE FROM batch_consumed_inputs WHERE batch_txid = $1; -- name: ListBatchConsumedInputs :many --- ListBatchConsumedInputs returns the outpoints a batch consumes. -SELECT input_hash, input_index +-- ListBatchConsumedInputs returns the inputs a batch consumes, with the +-- pkScript of each spent output. +SELECT input_hash, input_index, input_pk_script FROM batch_consumed_inputs WHERE batch_txid = $1; diff --git a/harness/harness.go b/harness/harness.go index 3c421f10d..fa799e0ca 100644 --- a/harness/harness.go +++ b/harness/harness.go @@ -1629,10 +1629,47 @@ func (h *Harness) ReorgDepth(depth int) ReorgResult { // Reorg invalidates the current tip's last depth blocks, mines newBlocks on // top of the fork point, and waits for the primary LND node to resync. The -// harness must be fully started before calling Reorg. +// harness must be fully started before calling Reorg. The replacement branch +// is mined with generatetoaddress, which sweeps the mempool, so a transaction +// from the disconnected branch re-confirms on the first replacement block. Use +// ReorgExcludingMempool when the disconnected transaction must stay off-chain. func (h *Harness) Reorg(depth, newBlocks int) ReorgResult { h.T.Helper() + return h.reorgWith(depth, newBlocks, "", h.Generate) +} + +// ReorgExcludingMempool performs a reorg like Reorg, but mines the replacement +// branch with EMPTY blocks (via the generateblock RPC) so transactions from the +// disconnected branch are NOT automatically re-confirmed on the new branch. +// This lets a caller observe the post-reorg "transaction off-chain" window +// deterministically -- e.g. a confirmation watch staying in its reorged-out +// state, or a canonicality record holding ReorgedOut -- instead of the tx +// silently re-confirming on the first replacement block as it would under +// Reorg's generatetoaddress (which pulls the mempool). The stranded tx stays in +// the mempool; mine a normal block afterwards with Generate to re-confirm it. +// +// newBlocks must be > depth so the replacement branch strictly outweighs the +// disconnected one and becomes active. +func (h *Harness) ReorgExcludingMempool(depth, newBlocks int) ReorgResult { + h.T.Helper() + + return h.reorgWith( + depth, newBlocks, " (empty replacement)", h.generateEmptyBlocks, + ) +} + +// reorgWith is the shared reorg driver behind Reorg and ReorgExcludingMempool. +// It invalidates the last depth blocks, waits for bitcoind to roll the active +// chain back to the fork point, mines the replacement branch via the supplied +// generate function, asserts the new branch became active, and waits for the +// primary LND node to resync. logSuffix is appended to the progress log line so +// the variant is identifiable. +func (h *Harness) reorgWith(depth, newBlocks int, logSuffix string, + generate func(int) []BlockHeader) ReorgResult { + + h.T.Helper() + require.Positive(h.T, depth, "reorg depth must be positive") require.Greater( h.T, newBlocks, depth, @@ -1657,13 +1694,14 @@ func (h *Harness) Reorg(depth, newBlocks int) ReorgResult { invalidateHash := disconnected[0].Hash h.Logf( - "Reorging depth=%d from old_tip=%s fork_point=%s "+ - "invalidate=%s new_blocks=%d", depth, oldTip.Hash, - forkPoint.Hash, invalidateHash, newBlocks, + "Reorging%s depth=%d from old_tip=%s fork_point=%s "+ + "invalidate=%s new_blocks=%d", logSuffix, depth, + oldTip.Hash, forkPoint.Hash, invalidateHash, newBlocks, ) _, err := h.bitcoinRPCCall("invalidateblock", invalidateHash) require.NoError(h.T, err, "invalidateblock %s", invalidateHash) + // forkHeight is validated non-negative above. expectedForkHeight := uint32(forkHeight) require.Eventually( @@ -1673,7 +1711,7 @@ func (h *Harness) Reorg(depth, newBlocks int) ReorgResult { "bitcoind did not roll back to fork height %d", forkHeight, ) - connected := h.Generate(newBlocks) + connected := generate(newBlocks) newTip := h.BestBlockHeader() require.Equal( h.T, connected[len(connected)-1].Hash, newTip.Hash, @@ -1694,6 +1732,39 @@ func (h *Harness) Reorg(depth, newBlocks int) ReorgResult { } } +// generateEmptyBlocks mines the given number of blocks that contain only their +// coinbase, using the generateblock RPC with an empty transaction list so the +// mempool is NOT swept into them. Returns the new block headers in height +// order. Used by ReorgExcludingMempool to build a replacement branch that does +// not re-confirm the disconnected branch's transactions. +func (h *Harness) generateEmptyBlocks(blocks int) []BlockHeader { + h.T.Helper() + + addr := h.bitcoindGetNewAddress() + + headers := make([]BlockHeader, 0, blocks) + for range blocks { + // generateblock mines a single block containing only the + // listed transactions (plus coinbase); an empty list yields an + // empty block that ignores the mempool entirely. + res, err := h.bitcoinRPCCall( + "generateblock", addr, []string{}, + ) + require.NoError(h.T, err, "generateblock rpc failed") + + var out struct { + Hash string `json:"hash"` + } + require.NoError( + h.T, json.Unmarshal(res, &out), + "generateblock unmarshal failed", + ) + headers = append(headers, h.BlockHeader(out.Hash)) + } + + return headers +} + // ReconsiderBlock asks bitcoind to reconsider a previously invalidated block. func (h *Harness) ReconsiderBlock(hash string) { h.T.Helper() @@ -1827,7 +1898,7 @@ func (h *Harness) SignedV3Tx(destPkScript []byte, // Find a confirmed wallet UTXO with enough value to cover the // destination + change + a generous fee. - utxoTxid, utxoVout, utxoValueBTC := h.bitcoindFirstSpendableUTXO() + utxoTxid, utxoVout, utxoValueBTC, _ := h.bitcoindFirstSpendableUTXO() utxoValue := btcutil.Amount(utxoValueBTC * btcutil.SatoshiPerBitcoin) feeSat := btcutil.Amount(2_000) // ~5 sat/vB on a ~400 vB tx. @@ -1918,7 +1989,9 @@ func (h *Harness) SignedV3Tx(destPkScript []byte, // bitcoindFirstSpendableUTXO picks the first confirmed wallet UTXO via // `listunspent` and returns its txid, vout, and amount in BTC. -func (h *Harness) bitcoindFirstSpendableUTXO() (string, uint32, float64) { +func (h *Harness) bitcoindFirstSpendableUTXO() (string, uint32, float64, + string) { + h.T.Helper() // Restrict to confirmed and spendable; bitcoind defaults are @@ -1927,9 +2000,10 @@ func (h *Harness) bitcoindFirstSpendableUTXO() (string, uint32, float64) { require.NoError(h.T, err, "listunspent rpc failed") var utxos []struct { - Txid string `json:"txid"` - Vout uint32 `json:"vout"` - Amount float64 `json:"amount"` + Txid string `json:"txid"` + Vout uint32 `json:"vout"` + Amount float64 `json:"amount"` + ScriptPubKey string `json:"scriptPubKey"` } require.NoError( h.T, json.Unmarshal(res, &utxos), @@ -1939,7 +2013,107 @@ func (h *Harness) bitcoindFirstSpendableUTXO() (string, uint32, float64) { first := utxos[0] - return first.Txid, first.Vout, first.Amount + return first.Txid, first.Vout, first.Amount, first.ScriptPubKey +} + +// FirstSpendableOutpoint returns a confirmed, spendable wallet outpoint along +// with its value in BTC and the pkScript of the output, WITHOUT spending it. It +// is used to obtain an outpoint a test can register as a batch's consumed input +// (the pkScript is needed to arm the spend watch) and later double-spend (via +// SpendOutpoint) to exercise conflict detection. +func (h *Harness) FirstSpendableOutpoint() (wire.OutPoint, float64, []byte) { + h.T.Helper() + + h.bitcoindEnsureWallet() + + txid, vout, valueBTC, scriptHex := h.bitcoindFirstSpendableUTXO() + hash, err := chainhash.NewHashFromStr(txid) + require.NoError(h.T, err, "parse spendable utxo txid") + + pkScript, err := hex.DecodeString(scriptHex) + require.NoError(h.T, err, "decode spendable utxo pkScript") + + return wire.OutPoint{Hash: *hash, Index: vout}, valueBTC, pkScript +} + +// SpendOutpoint builds, signs, and broadcasts a transaction that spends the +// given wallet-owned outpoint (worth valueBTC) to a fresh wallet address, +// returning the spending txid. The output value is valueBTC minus a flat fee. +// The transaction is broadcast to the mempool but NOT mined -- the caller mines +// it -- so a test can register watches before the spend confirms. Used to +// create a controlled double-spend of a batch's registered consumed input. +func (h *Harness) SpendOutpoint(op wire.OutPoint, valueBTC float64) string { + h.T.Helper() + + h.bitcoindEnsureWallet() + + value := btcutil.Amount(valueBTC * btcutil.SatoshiPerBitcoin) + feeSat := btcutil.Amount(2_000) // ~5 sat/vB on a ~400 vB tx. + require.Greater( + h.T, value, feeSat, "outpoint value too small to cover fee", + ) + + destAddrRes, err := h.bitcoinRPCCall("getnewaddress") + require.NoError(h.T, err, "getnewaddress for spend dest failed") + var destAddrStr string + require.NoError( + h.T, json.Unmarshal(destAddrRes, &destAddrStr), + "getnewaddress unmarshal failed", + ) + destAddr, err := btcaddr.DecodeAddress( + destAddrStr, &chaincfg.RegressionNetParams, + ) + require.NoError(h.T, err, "decode spend dest address failed") + destPkScript, err := txscript.PayToAddrScript(destAddr) + require.NoError(h.T, err, "derive spend dest pkScript failed") + + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: op, + Sequence: wire.MaxTxInSequenceNum, + }) + tx.AddTxOut(&wire.TxOut{ + Value: int64(value - feeSat), + PkScript: destPkScript, + }) + + var buf bytes.Buffer + require.NoError(h.T, tx.Serialize(&buf), "serialize spend tx failed") + + signRes, err := h.bitcoinRPCCall( + "signrawtransactionwithwallet", + hex.EncodeToString( + buf.Bytes(), + ), + ) + require.NoError(h.T, err, "signrawtransactionwithwallet failed") + + var signResult struct { + Hex string `json:"hex"` + Complete bool `json:"complete"` + } + require.NoError( + h.T, json.Unmarshal(signRes, &signResult), + "signrawtransactionwithwallet unmarshal failed", + ) + require.True( + h.T, signResult.Complete, "spend tx signing incomplete: %s", + signResult.Hex, + ) + + sendRes, err := h.bitcoinRPCCall( + "sendrawtransaction", signResult.Hex, + ) + require.NoError(h.T, err, "sendrawtransaction failed") + var txid string + require.NoError( + h.T, json.Unmarshal(sendRes, &txid), + "sendrawtransaction unmarshal failed", + ) + + h.Logf("Spent outpoint %s in tx %s", op, txid) + + return txid } // Faucet funds a test address by sending the specified amount from bitcoind's diff --git a/lib/actormsg/vtxo_admission.go b/lib/actormsg/vtxo_admission.go index 63cb89a31..c1736fc5e 100644 --- a/lib/actormsg/vtxo_admission.go +++ b/lib/actormsg/vtxo_admission.go @@ -177,6 +177,38 @@ type ReleaseForfeitResponse struct { // VTXOManagerResp implements the VTXOManagerResp marker interface. func (r *ReleaseForfeitResponse) VTXOManagerResp() {} +// RestoreForfeitedVTXORequest asks the VTXO manager to roll a forfeited VTXO +// back to a spendable (Live) state because the batch that consumed it via +// forfeit has been invalidated (its forfeit reversed by a finalized reorg / +// conflict). The manager re-materializes the VTXO from its persisted +// descriptor, mirroring unilateral-exit recovery. It is idempotent: a VTXO +// that is not currently forfeited is left untouched. +type RestoreForfeitedVTXORequest struct { + actor.BaseMessage + + // Outpoint identifies the forfeited VTXO to restore. + Outpoint wire.OutPoint +} + +// VTXOManagerMsg implements the VTXOManagerMsg marker interface. +func (m *RestoreForfeitedVTXORequest) VTXOManagerMsg() {} + +// MessageType returns the message type for logging. +func (m *RestoreForfeitedVTXORequest) MessageType() string { + return "RestoreForfeitedVTXORequest" +} + +// RestoreForfeitedVTXOResponse confirms the restore request was processed. +// Restored reports whether the VTXO was actually rolled back to Live (false +// when it was not in a forfeited state, i.e. the request was a no-op). +type RestoreForfeitedVTXOResponse struct { + // Restored is true when the VTXO was rolled back to Live. + Restored bool +} + +// VTXOManagerResp implements the VTXOManagerResp marker interface. +func (r *RestoreForfeitedVTXOResponse) VTXOManagerResp() {} + // CustomForfeitInput describes a caller-supplied VTXO that is not part of the // wallet's live coin set but still needs a local VTXO actor to sign the exact // round forfeit transaction once connector details are known. diff --git a/oor/registry.go b/oor/registry.go index 4eb61d611..83cc2bdea 100644 --- a/oor/registry.go +++ b/oor/registry.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/build" clientdb "github.com/lightninglabs/wavelength/db" "github.com/lightninglabs/wavelength/ledger" @@ -85,6 +86,11 @@ type OORRegistryConfig struct { TimeoutActor actor.TellOnlyRef[timeout.Msg] CallbackRef actor.TellOnlyRef[*timeout.ExpiredMsg] ActorSystem actor.SystemContext + + // BatchCanonicality, when set, is forwarded to every spawned session + // actor so a received VTXO's lineage batches get registered with the + // reorg-safety gate as they materialize. None disables registration. + BatchCanonicality fn.Option[actor.TellOnlyRef[batchcanon.ManagerMsg]] } // OORRegistryActor is the thin coordinator over per-session OOR actors. It @@ -1479,6 +1485,7 @@ func (r *oorRegistryBehavior) childConfig(sessionID SessionID, Limits: normalizeReceiveLimits(r.cfg.Limits), TimeoutActor: r.cfg.TimeoutActor, CallbackRef: r.cfg.CallbackRef, + BatchCanonicality: r.cfg.BatchCanonicality, Registry: r.selfRef, } } diff --git a/round/actor.go b/round/actor.go index 049eb9850..0e66ae46e 100644 --- a/round/actor.go +++ b/round/actor.go @@ -541,6 +541,7 @@ func (a *RoundClientActor) registerBatchCanonicality(ctx context.Context, ConfirmationPkScript: n.ConfirmationPkScript, CSVExpiryDelta: n.CSVExpiryDelta, ConsumedInputs: n.ConsumedInputs, + ForfeitedVTXOs: n.ForfeitedVTXOs, DependentVTXOs: dependents, } diff --git a/round/batch_canonicality_test.go b/round/batch_canonicality_test.go index 2c6a9be01..bfa651946 100644 --- a/round/batch_canonicality_test.go +++ b/round/batch_canonicality_test.go @@ -57,11 +57,29 @@ func TestRegisterBatchCanonicalityEmitsRequest(t *testing.T) { forfeit := bcTestOutpoint(2) vtxoOut := bcTestOutpoint(3) pkScript := []byte{0x51, 0x20, 0x01} + consumed := []batchcanon.ConsumedInput{ + { + Outpoint: board, + PkScript: []byte{ + 0x51, + 0x20, + 0x0b, + }, + }, + { + Outpoint: forfeit, + PkScript: []byte{ + 0x51, + 0x20, + 0x0f, + }, + }, + } a.registerBatchCanonicality(t.Context(), &VTXOCreatedNotification{ VTXOs: []*ClientVTXO{{Outpoint: vtxoOut}}, CommitmentTxID: commitment, - ConsumedInputs: []wire.OutPoint{board, forfeit}, + ConsumedInputs: consumed, ConfirmationPkScript: pkScript, CSVExpiryDelta: 144, }) @@ -72,7 +90,7 @@ func TestRegisterBatchCanonicalityEmitsRequest(t *testing.T) { req, ok := msg.(*batchcanon.RegisterBatchRequest) require.True(t, ok) require.Equal(t, commitment, req.BatchTxID) - require.Equal(t, []wire.OutPoint{board, forfeit}, req.ConsumedInputs) + require.Equal(t, consumed, req.ConsumedInputs) require.Equal(t, []wire.OutPoint{vtxoOut}, req.DependentVTXOs) require.Equal(t, pkScript, req.ConfirmationPkScript) require.Equal(t, int32(144), req.CSVExpiryDelta) diff --git a/round/outbox_messages.go b/round/outbox_messages.go index 1d742a2ae..1f83c732b 100644 --- a/round/outbox_messages.go +++ b/round/outbox_messages.go @@ -12,6 +12,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/lib/arkscript" "github.com/lightninglabs/wavelength/lib/tree" "github.com/lightninglabs/wavelength/lib/types" @@ -795,12 +796,21 @@ type VTXOCreatedNotification struct { // CommitmentTxID is the txid of the confirmed commitment transaction. CommitmentTxID chainhash.Hash - // ConsumedInputs are the outpoints the commitment tx spends that this - // client contributed: boarding input outpoints plus forfeited VTXO - // outpoints. The round actor forwards them to the - // BatchCanonicalityManager so a reorg-out or double-spend of a consumed - // input invalidates the round-born VTXOs (darepo#454, F1/F3/F6). - ConsumedInputs []wire.OutPoint + // ConsumedInputs are the inputs the commitment tx spends that this + // client contributed: boarding inputs plus forfeited VTXOs, each + // carrying the pkScript of the spent output. The round actor forwards + // them to the BatchCanonicalityManager so a reorg-out or double-spend + // of a consumed input invalidates the round-born VTXOs (darepo#454, + // F1/F3/F6). The pkScript is required to register the spend watch (lnd + // filters spend notifications by output script). + ConsumedInputs []batchcanon.ConsumedInput + + // ForfeitedVTXOs are the VTXOs (from prior rounds) this round forfeits. + // The round actor forwards them to the BatchCanonicalityManager as + // reverse-dependency edges so that if THIS round's commitment is later + // invalidated (its forfeit reversed by a reorg/conflict), the forfeited + // VTXOs are restored to a spendable state (darepo#454, F6). + ForfeitedVTXOs []wire.OutPoint // ConfirmationPkScript is the commitment-tx batch output script the // canonicality confirmation watch keys on. Confirmation detection is by diff --git a/round/transitions.go b/round/transitions.go index 3a62f79b1..8bc96e60c 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -19,6 +19,7 @@ import ( "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/ledger" "github.com/lightninglabs/wavelength/lib/arkscript" "github.com/lightninglabs/wavelength/lib/tree" @@ -751,7 +752,7 @@ func (s *PendingRoundAssembly) processEvent(ctx context.Context, // Arm the registration (admission) timeout so a server that // never returns a RoundJoined watermark cannot park us in // IntentSentState forever. On expiry the FSM fails the round - // and releases any forfeit-reserved inputs (wavelength#653). + // and releases any forfeit-reserved inputs (darepo-client#653). // A non-positive timeout disables the safety net. if env.RegistrationTimeout > 0 { outbox = append(outbox, &StartTimeoutReq{ @@ -877,7 +878,7 @@ func (s *IntentSentState) processEvent(ctx context.Context, event ClientEvent, // admission window. Fail the round as recoverable (the client // may retry) and release any forfeit-reserved inputs back to // LiveState so they are not stranded in pending-forfeit - // (wavelength#653). Releasing is safe here: at this phase no + // (darepo-client#653). Releasing is safe here: at this phase no // forfeit signatures have been produced or sent to the server, // so there is nothing to double-spend. const reason = "round admission timed out" @@ -2147,7 +2148,7 @@ func (s *CommitmentTxReceivedState) processEvent(ctx context.Context, // proves internal self-consistency; without this binding a // self-consistent tree rooted at the wrong commitment output // would be co-signed, after which the client's new VTXOs are - // unrecoverable (wavelength#680). + // unrecoverable (darepo-client#680). if err := validateVTXOTreeBinding( s.CommitmentTx.UnsignedTx, s.VTXOTreePaths, ); err != nil { @@ -2281,7 +2282,7 @@ func (s *CommitmentTxReceivedState) processEvent(ctx context.Context, // exists independently of the commitment tx; signing the // forfeit over it would make the old VTXO claimable even if // the replacement round never commits, breaking round - // atomicity (wavelength#681). + // atomicity (darepo-client#681). // //nolint:contextcheck // Connector-tree reconstruction is pure, // deterministic CPU work (the lib/tree materializer ignores its @@ -3652,7 +3653,7 @@ const ( // Binding the connector to the commitment tx is what preserves round // atomicity: a connector leaf is only ever spendable once the commitment tx // confirms, so the old VTXO cannot be forfeited unless the replacement round -// also commits (wavelength#681). +// also commits (darepo-client#681). func validateConnectorAncestry(commitmentTx *wire.MsgTx, operatorKey *btcec.PublicKey, mappings map[wire.OutPoint]*ConnectorLeafInfo) error { @@ -3858,7 +3859,7 @@ func connectorLeafOutput(leaf *tree.Node) (*wire.TxOut, error) { // real batch output, so the client's new VTXOs (whose outpoints are derived // from the tree root) are unrecoverable. This is the VTXO-tree counterpart to // validateConnectorAncestry; together they restore round atomicity -// (wavelength#680, companion to #681). +// (darepo-client#680, companion to #681). // // For each (outputIdx, tree) pair it asserts that the tree's BatchOutpoint // names this commitment tx, that outputIdx agrees with BatchOutpoint.Index, @@ -4307,6 +4308,30 @@ func buildClientVTXOs(ctx context.Context, checker OwnedScriptChecker, return vtxos, nil } +// commitmentInputScripts maps each commitment-tx input outpoint to the +// pkScript of the output it spends, read from the PSBT's per-input witness +// UTXO. It is used to stamp the consumed-input pkScripts the batch-canonicality +// manager needs to register reorg-aware spend watches. Returns nil for a nil +// packet; an input whose witness UTXO is absent is simply omitted (its watch is +// skipped downstream rather than failing the whole batch registration). +func commitmentInputScripts(pkt *psbt.Packet) map[wire.OutPoint][]byte { + if pkt == nil { + return nil + } + + scripts := make(map[wire.OutPoint][]byte, len(pkt.UnsignedTx.TxIn)) + for i, txIn := range pkt.UnsignedTx.TxIn { + if i >= len(pkt.Inputs) { + break + } + if wu := pkt.Inputs[i].WitnessUtxo; wu != nil { + scripts[txIn.PreviousOutPoint] = wu.PkScript + } + } + + return scripts +} + // ProcessEvent for InputSigSentState. func (s *InputSigSentState) ProcessEvent(ctx context.Context, event ClientEvent, env *ClientEnvironment) (*ClientStateTransition, error) { @@ -4419,16 +4444,32 @@ func (s *InputSigSentState) ProcessEvent(ctx context.Context, event ClientEvent, // round actor can register the batch's reorg-safety lineage. A // double-spend or reorg-out of any of these invalidates the // round-born VTXOs anchored by this batch (darepo#454). + // Each consumed input carries the pkScript of the spent output, + // sourced from the commitment PSBT's witness UTXOs. The spend + // watch the manager arms needs it (lnd filters spend + // notifications by output script). + inputScripts := commitmentInputScripts(s.CommitmentTx) consumedInputs := make( - []wire.OutPoint, 0, + []batchcanon.ConsumedInput, 0, len(s.Intents.Boarding)+len(s.ForfeitedVTXOs), ) for i := range s.Intents.Boarding { + op := s.Intents.Boarding[i].Outpoint consumedInputs = append( - consumedInputs, s.Intents.Boarding[i].Outpoint, + consumedInputs, batchcanon.ConsumedInput{ + Outpoint: op, + PkScript: inputScripts[op], + }, + ) + } + for _, op := range s.ForfeitedVTXOs { + consumedInputs = append( + consumedInputs, batchcanon.ConsumedInput{ + Outpoint: op, + PkScript: inputScripts[op], + }, ) } - consumedInputs = append(consumedInputs, s.ForfeitedVTXOs...) // The batch confirmation watch keys on the commitment tx's // batch output. Detection is by txid; the script is what @@ -4454,6 +4495,7 @@ func (s *InputSigSentState) ProcessEvent(ctx context.Context, event ClientEvent, RoundID: s.RoundID.String(), CommitmentTxID: evt.TxID, ConsumedInputs: consumedInputs, + ForfeitedVTXOs: s.ForfeitedVTXOs, ConfirmationPkScript: confPkScript, CSVExpiryDelta: sweepDelay, BatchExpiry: batchExpiry, diff --git a/sample-waved.conf b/sample-waved.conf index a4f154227..430bcd660 100644 --- a/sample-waved.conf +++ b/sample-waved.conf @@ -17,6 +17,14 @@ # Bitcoin network: mainnet, testnet, testnet4, regtest, simnet, or signet. # network=mainnet +# Blocks past a commitment/lineage transaction's first confirmation at which +# it is treated as final and its reorg-aware chain watches are released. +# Deferring irreversible effects until this depth lets a reorg before it be +# recovered from. 0 (the default) selects a network-aware value at load: the +# deeper testnet default on testnet, else the conventional six blocks. Set a +# positive value to override. +# reorgsafetydepth=0 + # Logging verbosity. A single level sets the global level; a comma-separated # list can set per-subsystem levels, for example ROND=debug,OORC=trace,info. # debuglevel=info diff --git a/systest/batch_canonicality_ancestor_test.go b/systest/batch_canonicality_ancestor_test.go new file mode 100644 index 000000000..7e1517c2e --- /dev/null +++ b/systest/batch_canonicality_ancestor_test.go @@ -0,0 +1,326 @@ +//go:build systest + +package systest + +import ( + "context" + "crypto/sha256" + "testing" + + btcaddr "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/db" + "github.com/lightninglabs/wavelength/lib/arkscript" + "github.com/lightninglabs/wavelength/lib/tree" + "github.com/lightninglabs/wavelength/lib/types" + "github.com/lightninglabs/wavelength/lndbackend" + "github.com/lightninglabs/wavelength/vtxo" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// TestBatchCanonicalityGateBlocksReorgedAncestor proves the LIVE coin-selection +// gate governs the FULL multi-parent lineage, not just a VTXO's direct +// commitment: a VTXO whose ANCESTOR batch (a cross-commitment parent, distinct +// from its direct commitment) is reorged off the canonical chain is excluded +// from coin selection, then admitted again once the ancestor reconfirms. This +// is the F4 acceptance scenario (OOR ancestor reorged then reconfirmed) at the +// vtxo.Manager seam. +// +// It proves the lineage-depth dimension that F2/F3 do not: those use a +// single-commitment VTXO, so they exercise only the direct commitment. Here the +// VTXO carries a separate ancestor in its Ancestry. The gate +// (gateUnavailableLineage) reloads the full descriptor via GetVTXO -- which +// hydrates the ancestry side table -- so lineageCommitmentTxids yields BOTH the +// direct commitment and the ancestor, and CombineAvailability takes the worst +// state across them. A reorged-out ancestor must therefore block the VTXO even +// while its direct commitment stays confirmed. +// +// The ancestor is isolated from the direct commitment by confirming them in +// different blocks (direct first, ancestor second) and reorging ONLY the +// ancestor's (later) block with an empty replacement branch. The direct +// commitment's earlier block is untouched, so the contrast is unambiguous: the +// only batch that changes state is the ancestor. +func TestBatchCanonicalityGateBlocksReorgedAncestor(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + sqlDB := db.NewTestDB(t) + clk := clock.NewDefaultClock() + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), btclog.Disabled, + ) + vtxoStore := dbStore.NewVTXOStore(clk) + canonStore := dbStore.NewBatchCanonicalityStore(clk) + + // Real batchcanon.Manager + vtxo.Manager (gate on the same store), + // mirroring waved. + bcMgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSource, + Log: fn.Some(h.SubLogger("BCAN")), + }) + bcRef := actor.RegisterWithSystem( + h.ActorSystem(), + "batch-canonicality", batchcanon.ManagerServiceKey, bcMgr, + ) + bcMgr.SetSelfRef(bcRef) + + vtxoWallet := lndbackend.NewClientWallet( + h.Harness.LND.Signer, h.Harness.LND.WalletKit, + ) + vtxoMgr := vtxo.NewManager(&vtxo.ManagerConfig{ + Store: vtxoStore, + Wallet: vtxoWallet, + ChainSource: chainSource, + ActorSystem: h.ActorSystem(), + ChainParams: h.ChainParams(), + BatchCanonicality: canonStore, + Log: fn.Some(h.SubLogger(vtxo.Subsystem)), + }) + const vtxoMgrName = "systest-vtxo-manager-f4-ancestor" + vtxoKey := actor.NewServiceKey[vtxo.ManagerMsg, vtxo.ManagerResp]( + vtxoMgrName, + ) + vtxoRef := actor.RegisterWithSystem( + h.ActorSystem(), vtxoMgrName, vtxoKey, vtxoMgr, + ) + require.NoError(t, vtxoMgr.Start(ctx, vtxoRef)) + + // Confirm the DIRECT commitment first, in its own earlier block, so + // reorging the ancestor's later block leaves it untouched. Each batch + // is faucet -> register (live watch) -> mine, one block apart, so they + // land in distinct blocks (fauceting both up front would confirm them + // in the same block and defeat the isolation). + directTxid, directScript := faucetSyntheticBatch(t, h, "direct") + registerBatch(ctx, t, bcRef, *directTxid, directScript) + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *directTxid) + + // Now the ANCESTOR, in the next block. + ancestorTxid, ancestorScript := faucetSyntheticBatch(t, h, "ancestor") + registerBatch(ctx, t, bcRef, *ancestorTxid, ancestorScript) + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *ancestorTxid) + + // Seed a live VTXO whose direct commitment is directTxid and whose + // ancestry carries ancestorTxid as a cross-commitment parent. + const vtxoAmount = btcutil.Amount(50_000) + outpoint := seedLiveVTXOWithAncestor( + t, vtxoStore, t.Name(), *directTxid, *ancestorTxid, vtxoAmount, + ) + + // ---------------------------------------------------------------- + // Beat 1: reorg ONLY the ancestor off-chain -> the VTXO must be + // EXCLUDED even though its direct commitment stays confirmed. + // ---------------------------------------------------------------- + reorg := h.Harness.ReorgExcludingMempool(1, 2) + require.Len(t, reorg.Connected, 2) + + awaitBatchState( + ctx, t, bcRef, *ancestorTxid, batchcanon.StateReorgedOut, + ) + // The direct commitment must remain Provisional throughout, so the + // block can only be attributed to the ancestor. + require.Equal( + t, batchcanon.StateProvisional, + batchState(ctx, t, bcRef, *directTxid), + "direct commitment must stay confirmed while the ancestor "+ + "is reorged out", + ) + + blockedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.False( + t, blockedResp.IsOk(), + "coin selection must fail while the VTXO's ANCESTOR batch "+ + "is reorged out, even though its direct commitment "+ + "is still confirmed", + ) + t.Logf("ancestor ReorgedOut: coin selection excluded %s", outpoint) + + // ---------------------------------------------------------------- + // Beat 2: reconfirm the ancestor -> the VTXO must be ADMITTED again. + // ---------------------------------------------------------------- + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *ancestorTxid) + + admittedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.True( + t, admittedResp.IsOk(), + "coin selection must succeed once the ancestor reconfirms", + ) + resp, err := admittedResp.Unpack() + require.NoError(t, err) + selected, ok := resp.(*vtxo.SelectAndReserveSpendResponse) + require.True(t, ok, "unexpected select response type %T", resp) + + selectedOutpoints := make( + []wire.OutPoint, 0, len(selected.SelectedVTXOs), + ) + for _, s := range selected.SelectedVTXOs { + selectedOutpoints = append(selectedOutpoints, s.Outpoint) + } + require.Contains( + t, selectedOutpoints, outpoint, + "the VTXO must be selected once its ancestor reconfirms", + ) + t.Logf( + "ancestor reconfirmed Provisional: coin selection admitted %s", + outpoint, + ) +} + +// faucetSyntheticBatch faucets a real tx to a deterministic synthetic P2WPKH +// script derived from the test name + a label, returning the tx's txid and +// pkScript. The tx stands in for a batch (commitment) tx the canonicality +// manager can track and reorg. +func faucetSyntheticBatch(t *testing.T, h *SysTestHarness, + label string) (*chainhash.Hash, []byte) { + + t.Helper() + + pubKeyHash := sha256.Sum256([]byte(t.Name() + "-" + label)) + addr, err := btcaddr.NewAddressWitnessPubKeyHash( + pubKeyHash[:20], &chaincfg.RegressionNetParams, + ) + require.NoError(t, err, "build synthetic P2WPKH address (%s)", label) + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err, "derive pkScript (%s)", label) + + amount := btcutil.Amount(btcutil.SatoshiPerBitcoin / 100) + txidStr := h.Harness.Faucet(addr.String(), amount) + txid, err := chainhash.NewHashFromStr(txidStr) + require.NoError(t, err, "parse faucet txid (%s)", label) + + return txid, pkScript +} + +// registerBatch registers a batch (no consumed inputs) with the manager and +// waits for the synchronous response. +func registerBatch(ctx context.Context, t *testing.T, + bcRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid chainhash.Hash, pkScript []byte) { + + t.Helper() + + resp := bcRef.Ask(ctx, &batchcanon.RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: pkScript, + CSVExpiryDelta: f2VTXOCSVDelay, + }).Await(ctx) + require.True(t, resp.IsOk(), "register batch %s", txid) +} + +// batchState reads a batch's current canonicality state via the manager. +func batchState(ctx context.Context, t *testing.T, + bcRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid chainhash.Hash) batchcanon.State { + + t.Helper() + + resp, err := bcRef.Ask( + ctx, &batchcanon.GetBatchStateRequest{BatchTxID: txid}, + ).Await(ctx).Unpack() + require.NoError(t, err) + got, ok := resp.(*batchcanon.GetBatchStateResponse) + require.True(t, ok, "unexpected get-state response type %T", resp) + require.True(t, got.Found, "batch %s not found", txid) + + return got.Record.State +} + +// seedLiveVTXOWithAncestor persists a single live VTXO whose direct commitment +// is directTxid and whose ancestry carries ancestorTxid as a distinct +// cross-commitment parent, returning its outpoint. The owner/operator keys and +// tapscript are real so the descriptor is well-formed; the ancestry tree +// fragment is a minimal placeholder (the gate reads only the commitment txids). +func seedLiveVTXOWithAncestor(t *testing.T, vtxoStore *db.VTXOPersistenceStore, + name string, directTxid, ancestorTxid chainhash.Hash, + amount btcutil.Amount) wire.OutPoint { + + t.Helper() + + clientPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "client key") + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "operator key") + operatorKey := operatorPriv.PubKey() + + descriptor, err := tree.NewVTXODescriptor( + amount, clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo descriptor") + + tapScript, err := arkscript.VTXOTapScript( + clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo tapscript") + + outpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte(name + "-seeded-vtxo")), + Index: 0, + } + + // Minimal ancestry tree fragment; only the CommitmentTxID is consulted + // by the canonicality gate. + ancestorTree := &tree.Tree{ + BatchOutpoint: outpoint, + Root: &tree.Node{ + Input: outpoint, + Outputs: []*wire.TxOut{}, + CoSigners: []*btcec.PublicKey{}, + Children: make(map[uint32]*tree.Node), + }, + } + + err = vtxoStore.SaveVTXO(t.Context(), &vtxo.Descriptor{ + Outpoint: outpoint, + Amount: amount, + PolicyTemplate: descriptor.PolicyTemplate, + PkScript: descriptor.PkScript, + ClientKey: keychain.KeyDescriptor{ + PubKey: clientPriv.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: types.VTXOOwnerKeyFamily, + Index: 7, + }, + }, + OperatorKey: operatorKey, + TapScript: tapScript, + Ancestry: []types.Ancestry{{ + TreePath: ancestorTree, + CommitmentTxID: ancestorTxid, + TreeDepth: 0, + }}, + RoundID: chainhash. + HashH([]byte(name + "-round")). + String(), + CommitmentTxID: directTxid, + BatchExpiry: 500000, + RelativeExpiry: f2VTXOCSVDelay, + CreatedHeight: 1, + Status: vtxo.VTXOStatusLive, + }) + require.NoError(t, err, "save live vtxo with ancestor") + + return outpoint +} diff --git a/systest/batch_canonicality_conflict_test.go b/systest/batch_canonicality_conflict_test.go new file mode 100644 index 000000000..8495604ce --- /dev/null +++ b/systest/batch_canonicality_conflict_test.go @@ -0,0 +1,226 @@ +//go:build systest + +package systest + +import ( + "crypto/sha256" + "testing" + + btcaddr "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/db" + "github.com/lightninglabs/wavelength/lndbackend" + "github.com/lightninglabs/wavelength/vtxo" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// TestBatchCanonicalityGateBlocksConflictedVTXO proves the LIVE coin-selection +// reorg-safety gate handles the INPUT-CONFLICT path: a VTXO whose batch has one +// of its consumed inputs double-spent by a competing transaction is excluded +// from coin selection, then admitted again once the conflicting spend is +// reorged away. This is the F3 acceptance scenario (batch input conflicted) at +// the vtxo.Manager seam, driven by a real bitcoind double-spend + reorg. +// +// It exercises a DIFFERENT code path from the F2 reorg test +// (TestBatchCanonicalityGateBlocksReorgedVTXO): F2 trips the conf-watch +// (LimboReorg) by reorging the batch tx itself off-chain, whereas this test +// trips the per-input SPEND watch (LimboConflict). The batchcanon.Manager flags +// a batch ConflictProvisional the moment one of its registered consumed inputs +// is spent by any tx other than the batch tx (handleInputSpent: +// spendingTxid != w.txid), and clears the conflict when that spend is reorged +// out (handleInputSpendReorged). Both layers are governed by the same gate +// (batchcanon.LineageBlocked treats LimboConflict as blocked), so coin +// selection must refuse the VTXO while the conflict stands and resume once it +// clears. +// +// The wiring mirrors production exactly as the F2 test does: real chainsource, +// real batchcanon.Manager arming reorg-aware conf + spend watches, real +// vtxo.Manager whose gate reads the same durable store. +// +// The flow: +// +// 1. Faucet the batch (commitment) tx and pick an independent spendable +// outpoint O. Register the batch with O as its consumed input and seed a +// live VTXO anchored on the batch. Confirm -> Provisional. +// 2. Double-spend O with a competing tx and confirm it. The spend watch fires +// with spendingTxid != batchTxid -> ConflictProvisional. Coin selection +// must FAIL (the VTXO is gated out). +// 3. Reorg the conflicting spend off-chain (empty replacement branch, so it is +// not re-mined). The spend watch reports the spend reorged out -> the +// conflict clears -> Provisional. Coin selection must SUCCEED again. +func TestBatchCanonicalityGateBlocksConflictedVTXO(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + sqlDB := db.NewTestDB(t) + clk := clock.NewDefaultClock() + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), btclog.Disabled, + ) + vtxoStore := dbStore.NewVTXOStore(clk) + canonStore := dbStore.NewBatchCanonicalityStore(clk) + + // Faucet the batch (commitment) tx to a synthetic P2WPKH script. + pubKeyHash := sha256.Sum256([]byte(t.Name())) + addr, err := btcaddr.NewAddressWitnessPubKeyHash( + pubKeyHash[:20], &chaincfg.RegressionNetParams, + ) + require.NoError(t, err, "build synthetic P2WPKH address") + batchPkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err, "derive pkScript for synthetic address") + + batchAmount := btcutil.Amount(btcutil.SatoshiPerBitcoin / 100) + batchTxidStr := h.Harness.Faucet(addr.String(), batchAmount) + batchTxid, err := chainhash.NewHashFromStr(batchTxidStr) + require.NoError(t, err, "parse faucet txid") + + // Pick an independent confirmed wallet outpoint to register as the + // batch's consumed input and later double-spend. listunspent excludes + // the faucet tx's own (mempool-spent) input and its unconfirmed + // outputs, so this outpoint is unrelated to the batch tx -- which is + // fine: the manager flags a conflict on whatever consumed inputs it is + // told to watch, by comparing the spending txid to the batch txid. + conflictInput, conflictValueBTC, conflictScript := + h.Harness.FirstSpendableOutpoint() + + // Seed a live VTXO anchored on the batch tx, before the manager starts. + const vtxoAmount = btcutil.Amount(50_000) + outpoint := seedLiveVTXOForBatch( + t, vtxoStore, t.Name(), *batchTxid, vtxoAmount, + ) + + // Real batchcanon.Manager over the durable store + real chainsource. + bcMgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSource, + Log: fn.Some(h.SubLogger("BCAN")), + }) + bcRef := actor.RegisterWithSystem( + h.ActorSystem(), + "batch-canonicality", batchcanon.ManagerServiceKey, bcMgr, + ) + bcMgr.SetSelfRef(bcRef) + + // Real vtxo.Manager with the coin-selection gate on the same store. + vtxoWallet := lndbackend.NewClientWallet( + h.Harness.LND.Signer, h.Harness.LND.WalletKit, + ) + vtxoMgr := vtxo.NewManager(&vtxo.ManagerConfig{ + Store: vtxoStore, + Wallet: vtxoWallet, + ChainSource: chainSource, + ActorSystem: h.ActorSystem(), + ChainParams: h.ChainParams(), + BatchCanonicality: canonStore, + Log: fn.Some(h.SubLogger(vtxo.Subsystem)), + }) + const vtxoMgrName = "systest-vtxo-manager-f3-conflict" + vtxoKey := actor.NewServiceKey[vtxo.ManagerMsg, vtxo.ManagerResp]( + vtxoMgrName, + ) + vtxoRef := actor.RegisterWithSystem( + h.ActorSystem(), vtxoMgrName, vtxoKey, vtxoMgr, + ) + require.NoError(t, vtxoMgr.Start(ctx, vtxoRef)) + + // Register the batch with the conflict input as its consumed input, so + // the manager arms a reorg-aware spend watch on it. + regResp := bcRef.Ask(ctx, &batchcanon.RegisterBatchRequest{ + BatchTxID: *batchTxid, + ConfirmationPkScript: batchPkScript, + CSVExpiryDelta: f2VTXOCSVDelay, + ConsumedInputs: []batchcanon.ConsumedInput{{ + Outpoint: conflictInput, + PkScript: conflictScript, + }}, + }).Await(ctx) + require.True(t, regResp.IsOk(), "register batch with manager") + + // Confirm the batch tx -> Provisional. + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *batchTxid) + + // ---------------------------------------------------------------- + // Beat 1: double-spend the consumed input -> ConflictProvisional -> + // the VTXO must be EXCLUDED from coin selection. + // ---------------------------------------------------------------- + conflictTxid := h.Harness.SpendOutpoint(conflictInput, conflictValueBTC) + require.NotEqual( + t, batchTxidStr, conflictTxid, + "the conflicting spend must be a different tx from the batch", + ) + h.Harness.Generate(1) + + awaitBatchState( + ctx, t, bcRef, *batchTxid, batchcanon.StateConflictProvisional, + ) + + blockedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.False( + t, blockedResp.IsOk(), + "coin selection must fail while the VTXO's batch input is "+ + "conflicted: the only candidate is gated out", + ) + t.Logf( + "batch ConflictProvisional: coin selection excluded %s", + outpoint, + ) + + // ---------------------------------------------------------------- + // Beat 2: reorg the conflicting spend off-chain (empty replacement + // branch, so it is not re-mined) -> the spend watch reports it reorged + // out -> the conflict clears -> Provisional -> the VTXO is ADMITTED. + // ---------------------------------------------------------------- + reorg := h.Harness.ReorgExcludingMempool(1, 2) + require.Len(t, reorg.Connected, 2) + t.Logf( + "reorg (empty replacement): disconnected=%d connected=%d "+ + "fork_height=%d", len(reorg.Disconnected), + len(reorg.Connected), reorg.ForkPoint.Height, + ) + + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *batchTxid) + + admittedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.True( + t, admittedResp.IsOk(), + "coin selection must succeed once the conflicting spend is "+ + "reorged away", + ) + resp, err := admittedResp.Unpack() + require.NoError(t, err) + selected, ok := resp.(*vtxo.SelectAndReserveSpendResponse) + require.True(t, ok, "unexpected select response type %T", resp) + + selectedOutpoints := make( + []wire.OutPoint, 0, len(selected.SelectedVTXOs), + ) + for _, s := range selected.SelectedVTXOs { + selectedOutpoints = append(selectedOutpoints, s.Outpoint) + } + require.Contains( + t, selectedOutpoints, outpoint, + "the de-conflicted VTXO must be selected", + ) + t.Logf( + "conflict cleared, batch Provisional: coin selection "+ + "admitted %s", outpoint, + ) +} diff --git a/systest/batch_canonicality_forfeit_restore_test.go b/systest/batch_canonicality_forfeit_restore_test.go new file mode 100644 index 000000000..1400c97a3 --- /dev/null +++ b/systest/batch_canonicality_forfeit_restore_test.go @@ -0,0 +1,316 @@ +//go:build systest + +package systest + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/chainsource" + "github.com/lightninglabs/wavelength/db" + "github.com/lightninglabs/wavelength/lib/arkscript" + "github.com/lightninglabs/wavelength/lib/tree" + "github.com/lightninglabs/wavelength/lib/types" + "github.com/lightninglabs/wavelength/lndbackend" + "github.com/lightninglabs/wavelength/vtxo" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// TestBatchCanonicalityRestoresForfeitedVTXO is the F6 acceptance scenario -- +// the strongest proof that business state tracks chain canonicality rather than +// the reverse. A VTXO forfeited into a round-2 commitment is restored to a +// spendable state when that commitment is invalidated (its forfeit reversed by +// a finalized conflict), driven end to end through real bitcoind + LND. +// +// It exercises the reverse-dependency restore wired across two managers: +// +// batchcanon.Manager (records the consumer-batch edge, detects the +// finalized conflict that invalidates the consumer batch) +// -> RestoreConsumedVTXO callback +// -> vtxo.Manager.RestoreForfeitedVTXORequest +// -> re-materialize the forfeited VTXO as Live from its descriptor +// +// The round-1 VTXO is seeded directly in the FORFEITED state (as the FSM leaves +// it once a round consuming it confirms; its actor was reaped). The round-2 +// commitment is a faucet tx registered with that VTXO as a forfeited VTXO and +// with an independent consumed input we can double-spend. Double-spending the +// input and maturing the conflict past the reorg-safety depth drives the +// consumer batch to ConflictFinalized, which fires the restore. The VTXO must +// return to Live and become selectable again. +func TestBatchCanonicalityRestoresForfeitedVTXO(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + sqlDB := db.NewTestDB(t) + clk := clock.NewDefaultClock() + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), btclog.Disabled, + ) + vtxoStore := dbStore.NewVTXOStore(clk) + canonStore := dbStore.NewBatchCanonicalityStore(clk) + + // Seed the round-1 VTXO in the FORFEITED state (its actor reaped), as + // the FSM leaves it once the round-2 commitment that consumes it + // confirms. + const vtxoAmount = btcutil.Amount(50_000) + forfeitedOutpoint := seedForfeitedVTXO( + t, vtxoStore, t.Name(), vtxoAmount, + ) + + // The round-2 commitment batch + an independent consumed input we can + // double-spend to invalidate it. + batchTxid, batchScript := faucetSyntheticBatch(t, h, "round2") + conflictInput, conflictValueBTC, conflictScript := + h.Harness.FirstSpendableOutpoint() + + // Real vtxo.Manager (with the restore handler) first, so the + // batchcanon manager's restore callback can target it. + vtxoWallet := lndbackend.NewClientWallet( + h.Harness.LND.Signer, h.Harness.LND.WalletKit, + ) + vtxoMgr := vtxo.NewManager(&vtxo.ManagerConfig{ + Store: vtxoStore, + Wallet: vtxoWallet, + ChainSource: chainSource, + ActorSystem: h.ActorSystem(), + ChainParams: h.ChainParams(), + BatchCanonicality: canonStore, + Log: fn.Some(h.SubLogger(vtxo.Subsystem)), + }) + const vtxoMgrName = "systest-vtxo-manager-f6-restore" + vtxoKey := actor.NewServiceKey[vtxo.ManagerMsg, vtxo.ManagerResp]( + vtxoMgrName, + ) + vtxoRef := actor.RegisterWithSystem( + h.ActorSystem(), vtxoMgrName, vtxoKey, vtxoMgr, + ) + require.NoError(t, vtxoMgr.Start(ctx, vtxoRef)) + + // Real batchcanon.Manager with the restore callback wired to the VTXO + // manager -- mirroring waved.restoreForfeitedVTXO. + bcMgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSource, + Log: fn.Some(h.SubLogger("BCAN")), + RestoreConsumedVTXO: func(ctx context.Context, + op wire.OutPoint) error { + + _, err := vtxoRef.Ask( + ctx, &vtxo.RestoreForfeitedVTXORequest{ + Outpoint: op, + }, + ).Await(ctx).Unpack() + + return err + }, + }) + bcRef := actor.RegisterWithSystem( + h.ActorSystem(), + "batch-canonicality", batchcanon.ManagerServiceKey, bcMgr, + ) + bcMgr.SetSelfRef(bcRef) + + // Register the round-2 commitment batch: it forfeits the round-1 VTXO + // and consumes the input we will double-spend. + regResp := bcRef.Ask(ctx, &batchcanon.RegisterBatchRequest{ + BatchTxID: *batchTxid, + ConfirmationPkScript: batchScript, + CSVExpiryDelta: f2VTXOCSVDelay, + ConsumedInputs: []batchcanon.ConsumedInput{{ + Outpoint: conflictInput, + PkScript: conflictScript, + }}, + ForfeitedVTXOs: []wire.OutPoint{forfeitedOutpoint}, + }).Await(ctx) + require.True(t, regResp.IsOk(), "register round-2 batch") + + // Confirm the round-2 commitment so the forfeit looks complete. + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *batchTxid) + + // Precondition: the round-1 VTXO is forfeited and therefore not + // spendable. + require.Equal( + t, vtxo.VTXOStatusForfeited, + vtxoStatus(ctx, t, vtxoStore, forfeitedOutpoint), + "precondition: the round-1 VTXO must be forfeited", + ) + + // ---------------------------------------------------------------- + // Invalidate the round-2 commitment: double-spend its consumed input + // and mature the conflict past the reorg-safety depth, driving the + // batch to ConflictFinalized. + // ---------------------------------------------------------------- + conflictTxid := h.Harness.SpendOutpoint(conflictInput, conflictValueBTC) + require.NotEqual(t, batchTxid.String(), conflictTxid) + h.Harness.Generate(1) + awaitBatchState( + ctx, t, bcRef, *batchTxid, batchcanon.StateConflictProvisional, + ) + + // Mature the conflicting spend past the finality depth so it + // finalizes. DefaultFinalityDepth + a margin guarantees the spend Done + // event synthesizes and the batch reaches ConflictFinalized. + h.Harness.Generate(int(chainsource.DefaultFinalityDepth) + 2) + awaitBatchState( + ctx, t, bcRef, *batchTxid, batchcanon.StateConflictFinalized, + ) + + // ---------------------------------------------------------------- + // The forfeit is reversed: the round-1 VTXO must be restored to Live + // and become selectable again. + // ---------------------------------------------------------------- + awaitVTXOStatus( + ctx, t, vtxoStore, forfeitedOutpoint, vtxo.VTXOStatusLive, + ) + t.Logf( + "round-2 ConflictFinalized: forfeited VTXO %s restored to live", + forfeitedOutpoint, + ) + + resp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.True( + t, resp.IsOk(), + "the restored VTXO must be selectable for spending again", + ) + unpacked, err := resp.Unpack() + require.NoError(t, err) + selected, ok := unpacked.(*vtxo.SelectAndReserveSpendResponse) + require.True(t, ok, "unexpected select response type %T", unpacked) + + selectedOutpoints := make( + []wire.OutPoint, 0, len(selected.SelectedVTXOs), + ) + for _, s := range selected.SelectedVTXOs { + selectedOutpoints = append(selectedOutpoints, s.Outpoint) + } + require.Contains( + t, selectedOutpoints, forfeitedOutpoint, + "the restored VTXO must be among the selected coins", + ) +} + +// vtxoStatus reads a VTXO's persisted status. +func vtxoStatus(ctx context.Context, t *testing.T, + store *db.VTXOPersistenceStore, op wire.OutPoint) vtxo.VTXOStatus { + + t.Helper() + + desc, err := store.GetVTXO(ctx, op) + require.NoError(t, err) + require.NotNil(t, desc) + + return desc.Status +} + +// awaitVTXOStatus polls until a VTXO reaches the wanted persisted status, +// failing on timeout. The restore is asynchronous (the canonicality manager +// fires a callback that asks the VTXO manager), so a retry is required. +func awaitVTXOStatus(ctx context.Context, t *testing.T, + store *db.VTXOPersistenceStore, op wire.OutPoint, + want vtxo.VTXOStatus) { + + t.Helper() + + require.Eventuallyf( + t, func() bool { + desc, err := store.GetVTXO(ctx, op) + if err != nil || desc == nil { + return false + } + + return desc.Status == want + }, reorgSystestEventTimeout, batchCanonPollInterval, + "vtxo %s never reached status %v", op, want, + ) +} + +// seedForfeitedVTXO persists a single VTXO in the FORFEITED state, returning +// its outpoint. It mirrors the descriptor a round leaves behind once the +// commitment that forfeits the VTXO confirms (status Forfeited, no live actor). +func seedForfeitedVTXO(t *testing.T, vtxoStore *db.VTXOPersistenceStore, + name string, amount btcutil.Amount) wire.OutPoint { + + t.Helper() + + clientPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "client key") + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "operator key") + operatorKey := operatorPriv.PubKey() + + descriptor, err := tree.NewVTXODescriptor( + amount, clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo descriptor") + + tapScript, err := arkscript.VTXOTapScript( + clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo tapscript") + + // A distinct synthetic commitment txid for the round-1 batch. It is not + // registered with the canonicality manager, so its lineage is unseen + // (permissive) and the restored VTXO is selectable. + commitmentTxid := chainhash.HashH([]byte(name + "-round1-commitment")) + outpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte(name + "-forfeited-vtxo")), + Index: 0, + } + + err = vtxoStore.SaveVTXO(t.Context(), &vtxo.Descriptor{ + Outpoint: outpoint, + Amount: amount, + PolicyTemplate: descriptor.PolicyTemplate, + PkScript: descriptor.PkScript, + ClientKey: keychain.KeyDescriptor{ + PubKey: clientPriv.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: types.VTXOOwnerKeyFamily, + Index: 7, + }, + }, + OperatorKey: operatorKey, + TapScript: tapScript, + RoundID: chainhash. + HashH([]byte(name + "-round1")). + String(), + CommitmentTxID: commitmentTxid, + BatchExpiry: 500000, + RelativeExpiry: f2VTXOCSVDelay, + CreatedHeight: 1, + Status: vtxo.VTXOStatusForfeited, + }) + require.NoError(t, err, "save forfeited vtxo") + + // SaveVTXO always persists a freshly-created VTXO as Live, so flip it + // to Forfeited explicitly. This must happen before the VTXO manager + // starts so the VTXO is excluded from live recovery (no actor spawned), + // exactly as a reaped forfeit leaves it. + require.NoError( + t, + vtxoStore.UpdateVTXOStatus( + t.Context(), outpoint, vtxo.VTXOStatusForfeited, + ), + "mark seeded vtxo forfeited", + ) + + return outpoint +} diff --git a/systest/batch_canonicality_gate_test.go b/systest/batch_canonicality_gate_test.go new file mode 100644 index 000000000..4c7dcefe6 --- /dev/null +++ b/systest/batch_canonicality_gate_test.go @@ -0,0 +1,325 @@ +//go:build systest + +package systest + +import ( + "context" + "crypto/sha256" + "testing" + + btcaddr "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/db" + "github.com/lightninglabs/wavelength/lib/arkscript" + "github.com/lightninglabs/wavelength/lib/tree" + "github.com/lightninglabs/wavelength/lib/types" + "github.com/lightninglabs/wavelength/lndbackend" + "github.com/lightninglabs/wavelength/round" + "github.com/lightninglabs/wavelength/vtxo" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// f2VTXOCSVDelay is the relative-expiry CSV delay stamped on the synthetic +// test VTXO. The value is arbitrary for this test (expiry is never exercised); +// it just has to be a valid non-zero delay for descriptor/tapscript +// construction. +const f2VTXOCSVDelay = 144 + +// TestBatchCanonicalityGateBlocksReorgedVTXO proves the LIVE coin-selection +// reorg-safety gate does its job end to end: a VTXO whose batch (commitment +// tx) is reorged off the canonical chain is excluded from coin selection, +// then admitted again once the batch reconfirms. This is the F2 acceptance +// scenario (batch reorged out then reconfirmed) at the vtxo.Manager seam, +// driven by a real bitcoind reorg. +// +// The wiring mirrors production: a real chainsource actor over the harness +// LND, a real batchcanon.Manager arming reorg-aware watches, and a real +// vtxo.Manager whose ManagerConfig.BatchCanonicality points at the SAME +// durable store the manager writes -- exactly how waved.initBatchCanonicality +// connects them. Only the VTXO is seeded directly (as seedLiveVTXO does for the +// directed-send systest) rather than produced by a live round; the round +// production path is covered by TestSendVTXOEndToEnd. +// +// The batch (commitment) tx is a real faucet transaction so its txid is a live +// on-chain tx the canonicality conf-watch can track and reorg. The seeded +// VTXO's CommitmentTxID is set to that txid, so the gate (which reloads the +// full descriptor via GetVTXO and reads its direct commitment txid) governs +// the VTXO by that batch. +// +// To make the "batch off-chain" window deterministic, the reorg mines its +// replacement branch with EMPTY blocks (ReorgExcludingMempool), so the batch +// tx is NOT auto-re-confirmed and the canonicality record holds ReorgedOut +// stably. A plain Reorg would re-mine the tx from the mempool on the first +// replacement block, collapsing the window before coin selection could observe +// it. A subsequent normal block re-confirms the stranded tx. +// +// The proof is a contrast on a single VTXO with a single selection target, +// where the ONLY thing that changes between the two beats is the batch's chain +// canonicality: +// +// 1. Batch ReorgedOut -> SelectAndReserveSpendRequest fails (gated out). +// 2. Batch reconfirmed -> SelectAndReserveSpendRequest succeeds (admitted). +func TestBatchCanonicalityGateBlocksReorgedVTXO(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + sqlDB := db.NewTestDB(t) + clk := clock.NewDefaultClock() + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), btclog.Disabled, + ) + vtxoStore := dbStore.NewVTXOStore(clk) + canonStore := dbStore.NewBatchCanonicalityStore(clk) + + // Faucet a real tx whose txid is the batch (commitment) tx. We faucet + // to a synthetic P2WPKH script we never spend; we only need a known + // txid + pkScript to anchor the VTXO lineage and arm the conf watch. + pubKeyHash := sha256.Sum256([]byte(t.Name())) + addr, err := btcaddr.NewAddressWitnessPubKeyHash( + pubKeyHash[:20], &chaincfg.RegressionNetParams, + ) + require.NoError(t, err, "build synthetic P2WPKH address") + batchPkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err, "derive pkScript for synthetic address") + + batchAmount := btcutil.Amount(btcutil.SatoshiPerBitcoin / 100) + batchTxidStr := h.Harness.Faucet(addr.String(), batchAmount) + batchTxid, err := chainhash.NewHashFromStr(batchTxidStr) + require.NoError(t, err, "parse faucet txid") + + // Seed a live VTXO anchored on the batch tx, BEFORE the manager starts + // so it is recovered into a resident actor. Its outpoint is synthetic + // (a VTXO leaf is not the batch tx itself); only its CommitmentTxID + // matters to the gate. + const vtxoAmount = btcutil.Amount(50_000) + outpoint := seedLiveVTXOForBatch( + t, vtxoStore, t.Name(), *batchTxid, vtxoAmount, + ) + + // Real batchcanon.Manager over the durable store, wired to the real + // chainsource so it arms reorg-aware watches. + bcMgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSource, + Log: fn.Some(h.SubLogger("BCAN")), + }) + bcRef := actor.RegisterWithSystem( + h.ActorSystem(), + "batch-canonicality", batchcanon.ManagerServiceKey, bcMgr, + ) + bcMgr.SetSelfRef(bcRef) + + // Real vtxo.Manager with the coin-selection gate pointed at the SAME + // canonicality store, mirroring waved's wiring. + vtxoWallet := lndbackend.NewClientWallet( + h.Harness.LND.Signer, h.Harness.LND.WalletKit, + ) + vtxoMgr := vtxo.NewManager(&vtxo.ManagerConfig{ + Store: vtxoStore, + Wallet: vtxoWallet, + ChainSource: chainSource, + ActorSystem: h.ActorSystem(), + ChainParams: h.ChainParams(), + BatchCanonicality: canonStore, + Log: fn.Some(h.SubLogger(vtxo.Subsystem)), + }) + const vtxoMgrName = "systest-vtxo-manager-f2-gate" + vtxoKey := actor.NewServiceKey[vtxo.ManagerMsg, vtxo.ManagerResp]( + vtxoMgrName, + ) + vtxoRef := actor.RegisterWithSystem( + h.ActorSystem(), vtxoMgrName, vtxoKey, vtxoMgr, + ) + require.NoError(t, vtxoMgr.Start(ctx, vtxoRef)) + + // Register the batch so the manager arms a reorg-aware conf watch on + // the faucet tx. + regResp := bcRef.Ask(ctx, &batchcanon.RegisterBatchRequest{ + BatchTxID: *batchTxid, + ConfirmationPkScript: batchPkScript, + CSVExpiryDelta: f2VTXOCSVDelay, + }).Await(ctx) + require.True(t, regResp.IsOk(), "register batch with manager") + + // Confirm the batch tx -> Provisional. (The gate is not asserted here; + // the reconfirm beat below proves Provisional is selectable.) + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *batchTxid) + + // ---------------------------------------------------------------- + // Beat 1: reorg the batch off-chain (stable ReorgedOut) -> the VTXO + // must be EXCLUDED from coin selection. The replacement branch is + // mined empty so the batch tx is not auto-re-confirmed. + // ---------------------------------------------------------------- + reorg := h.Harness.ReorgExcludingMempool(1, 2) + require.Len(t, reorg.Connected, 2) + t.Logf( + "reorg (empty replacement): disconnected=%d connected=%d "+ + "fork_height=%d", len(reorg.Disconnected), + len(reorg.Connected), reorg.ForkPoint.Height, + ) + + awaitBatchState(ctx, t, bcRef, *batchTxid, batchcanon.StateReorgedOut) + + blockedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.False( + t, blockedResp.IsOk(), + "coin selection must fail while the VTXO's batch is "+ + "reorged out: the only candidate is gated out, "+ + "leaving no liquidity", + ) + t.Logf( + "batch ReorgedOut: coin selection correctly excluded %s", + outpoint, + ) + + // ---------------------------------------------------------------- + // Beat 2: reconfirm the batch -> Provisional -> the VTXO must be + // ADMITTED again. Mine a normal block so the stranded mempool tx is + // re-included. + // ---------------------------------------------------------------- + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *batchTxid) + + admittedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.True( + t, admittedResp.IsOk(), + "coin selection must succeed once the VTXO's batch reconfirms", + ) + resp, err := admittedResp.Unpack() + require.NoError(t, err) + selected, ok := resp.(*vtxo.SelectAndReserveSpendResponse) + require.True(t, ok, "unexpected select response type %T", resp) + + selectedOutpoints := make( + []wire.OutPoint, 0, len(selected.SelectedVTXOs), + ) + for _, s := range selected.SelectedVTXOs { + selectedOutpoints = append(selectedOutpoints, s.Outpoint) + } + require.Contains( + t, selectedOutpoints, outpoint, + "the reconfirmed VTXO must be selected", + ) + t.Logf( + "batch reconfirmed Provisional: coin selection admitted %s", + outpoint, + ) +} + +// awaitBatchProvisionalAtNoBlock polls the manager until the batch is +// Provisional, without asserting a specific confirmation block (the block hash +// changes across the reorg). It reuses the shared awaitBatchRecord poller. +func awaitBatchProvisionalAtNoBlock(ctx context.Context, t *testing.T, + mgrRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid chainhash.Hash) *batchcanon.Record { + + t.Helper() + + return awaitBatchRecord( + ctx, t, mgrRef, txid, + func(rec *batchcanon.Record) bool { + return rec.State == batchcanon.StateProvisional + }, + "Provisional", + ) +} + +// awaitBatchState polls the manager until the batch reaches the wanted state. +func awaitBatchState(ctx context.Context, t *testing.T, + mgrRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid chainhash.Hash, want batchcanon.State) *batchcanon.Record { + + t.Helper() + + return awaitBatchRecord( + ctx, t, mgrRef, txid, + func(rec *batchcanon.Record) bool { + return rec.State == want + }, + "state %v", want, + ) +} + +// seedLiveVTXOForBatch persists a single live VTXO whose lineage is anchored on +// batchTxid, returning its outpoint. It is a focused analogue of the directed- +// send systest's seedLiveVTXO: it writes straight to the provided VTXO store +// (SaveVTXO auto-inserts the backing round row) instead of a daemon DB dir, and +// stamps CommitmentTxID = batchTxid so the canonicality gate governs it by that +// batch. The owner/operator keys and tapscript are real so the descriptor is +// well-formed, but they are never used to sign in this test. +func seedLiveVTXOForBatch(t *testing.T, vtxoStore *db.VTXOPersistenceStore, + name string, batchTxid chainhash.Hash, + amount btcutil.Amount) wire.OutPoint { + + t.Helper() + + clientPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "client key") + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "operator key") + operatorKey := operatorPriv.PubKey() + + roundID, err := round.NewRoundID() + require.NoError(t, err, "round id") + + descriptor, err := tree.NewVTXODescriptor( + amount, clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo descriptor") + + tapScript, err := arkscript.VTXOTapScript( + clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo tapscript") + + outpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte(name + "-seeded-vtxo")), + Index: 0, + } + + err = vtxoStore.SaveVTXO(t.Context(), &vtxo.Descriptor{ + Outpoint: outpoint, + Amount: amount, + PolicyTemplate: descriptor.PolicyTemplate, + PkScript: descriptor.PkScript, + ClientKey: keychain.KeyDescriptor{ + PubKey: clientPriv.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: types.VTXOOwnerKeyFamily, + Index: 7, + }, + }, + OperatorKey: operatorKey, + TapScript: tapScript, + RoundID: roundID.String(), + CommitmentTxID: batchTxid, + BatchExpiry: 500000, + RelativeExpiry: f2VTXOCSVDelay, + CreatedHeight: 1, + Status: vtxo.VTXOStatusLive, + }) + require.NoError(t, err, "save live vtxo") + + return outpoint +} diff --git a/systest/batch_canonicality_multiroot_test.go b/systest/batch_canonicality_multiroot_test.go new file mode 100644 index 000000000..db8576a5e --- /dev/null +++ b/systest/batch_canonicality_multiroot_test.go @@ -0,0 +1,280 @@ +//go:build systest + +package systest + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/db" + "github.com/lightninglabs/wavelength/lib/arkscript" + "github.com/lightninglabs/wavelength/lib/tree" + "github.com/lightninglabs/wavelength/lib/types" + "github.com/lightninglabs/wavelength/lndbackend" + "github.com/lightninglabs/wavelength/vtxo" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// TestBatchCanonicalityGateBlocksPartialRootReorg proves the F7 acceptance +// scenario: the coin-selection gate combines availability across a VTXO's +// ENTIRE multi-root ancestry, so reorging out any STRICT SUBSET of its roots +// (a "partial-root" reorg) excludes the VTXO even while every other root stays +// confirmed. Once the reorged root reconfirms, the VTXO is admitted again. +// +// This is the N>2 generalization of F4 +// (TestBatchCanonicalityGateBlocksReorgedAncestor). F4 has a single ancestor, +// so its gate combines availability over exactly {direct commitment, one +// ancestor} — a 2-element set where "worst-of-N" is indistinguishable from a +// simple pairwise min. A VTXO minted from a merge/OOR that draws inputs from +// several distinct commitment batches carries MULTIPLE cross-commitment roots; +// the gate must reduce over all of them and take the worst. Here the VTXO +// carries two independent ancestors (rootA, rootB) plus its direct commitment, +// and we reorg ONLY rootB. If the gate stopped at the first canonical root, or +// short-circuited on the direct commitment, the reorged-out rootB would slip +// through and the VTXO would be wrongly spendable against a lineage that is no +// longer fully on-chain. +// +// The roots are isolated into distinct blocks (direct, then rootA, then rootB, +// one block apart) so reorging only the tip block cleanly targets rootB and +// leaves the direct commitment and rootA untouched — making the contrast +// unambiguous: the sole batch that changes state is rootB. +func TestBatchCanonicalityGateBlocksPartialRootReorg(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + sqlDB := db.NewTestDB(t) + clk := clock.NewDefaultClock() + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), btclog.Disabled, + ) + vtxoStore := dbStore.NewVTXOStore(clk) + canonStore := dbStore.NewBatchCanonicalityStore(clk) + + // Real batchcanon.Manager + vtxo.Manager (gate on the same store), + // mirroring waved. + bcMgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSource, + Log: fn.Some(h.SubLogger("BCAN")), + }) + bcRef := actor.RegisterWithSystem( + h.ActorSystem(), + "batch-canonicality", batchcanon.ManagerServiceKey, bcMgr, + ) + bcMgr.SetSelfRef(bcRef) + + vtxoWallet := lndbackend.NewClientWallet( + h.Harness.LND.Signer, h.Harness.LND.WalletKit, + ) + vtxoMgr := vtxo.NewManager(&vtxo.ManagerConfig{ + Store: vtxoStore, + Wallet: vtxoWallet, + ChainSource: chainSource, + ActorSystem: h.ActorSystem(), + ChainParams: h.ChainParams(), + BatchCanonicality: canonStore, + Log: fn.Some(h.SubLogger(vtxo.Subsystem)), + }) + const vtxoMgrName = "systest-vtxo-manager-f7-multiroot" + vtxoKey := actor.NewServiceKey[vtxo.ManagerMsg, vtxo.ManagerResp]( + vtxoMgrName, + ) + vtxoRef := actor.RegisterWithSystem( + h.ActorSystem(), vtxoMgrName, vtxoKey, vtxoMgr, + ) + require.NoError(t, vtxoMgr.Start(ctx, vtxoRef)) + + // Confirm the DIRECT commitment first, then each root, one block apart, + // so the roots land in distinct blocks and reorging only the tip block + // targets rootB alone (fauceting them together would confirm them in + // the same block and defeat the isolation). + directTxid, directScript := faucetSyntheticBatch(t, h, "direct") + registerBatch(ctx, t, bcRef, *directTxid, directScript) + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *directTxid) + + rootATxid, rootAScript := faucetSyntheticBatch(t, h, "rootA") + registerBatch(ctx, t, bcRef, *rootATxid, rootAScript) + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *rootATxid) + + rootBTxid, rootBScript := faucetSyntheticBatch(t, h, "rootB") + registerBatch(ctx, t, bcRef, *rootBTxid, rootBScript) + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *rootBTxid) + + // Seed a live VTXO whose direct commitment is directTxid and whose + // ancestry carries BOTH rootA and rootB as distinct cross-commitment + // parents. + const vtxoAmount = btcutil.Amount(50_000) + outpoint := seedLiveVTXOWithAncestors( + t, vtxoStore, t.Name(), *directTxid, + []chainhash.Hash{*rootATxid, *rootBTxid}, vtxoAmount, + ) + + // ---------------------------------------------------------------- + // Beat 1: reorg ONLY rootB off-chain (a partial-root reorg) -> the + // VTXO must be EXCLUDED even though its direct commitment and rootA + // both stay confirmed. + // ---------------------------------------------------------------- + reorg := h.Harness.ReorgExcludingMempool(1, 2) + require.Len(t, reorg.Connected, 2) + + awaitBatchState(ctx, t, bcRef, *rootBTxid, batchcanon.StateReorgedOut) + + // The direct commitment and rootA must remain Provisional throughout, + // so the reorged block can only be attributed to rootB. + require.Equal( + t, batchcanon.StateProvisional, + batchState(ctx, t, bcRef, *directTxid), + "direct commitment must stay confirmed while rootB is "+ + "reorged out", + ) + require.Equal( + t, batchcanon.StateProvisional, + batchState(ctx, t, bcRef, *rootATxid), + "rootA must stay confirmed while rootB is reorged out", + ) + + blockedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.False( + t, blockedResp.IsOk(), + "coin selection must fail while ANY root (rootB) is "+ + "reorged out, even though the direct commitment "+ + "and rootA are still confirmed", + ) + t.Logf("partial-root ReorgedOut: coin selection excluded %s", outpoint) + + // ---------------------------------------------------------------- + // Beat 2: reconfirm rootB -> the whole lineage is canonical again, so + // the VTXO must be ADMITTED. + // ---------------------------------------------------------------- + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *rootBTxid) + + admittedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.True( + t, admittedResp.IsOk(), + "coin selection must succeed once every root is canonical "+ + "again", + ) + resp, err := admittedResp.Unpack() + require.NoError(t, err) + selected, ok := resp.(*vtxo.SelectAndReserveSpendResponse) + require.True(t, ok, "unexpected select response type %T", resp) + + selectedOutpoints := make( + []wire.OutPoint, 0, len(selected.SelectedVTXOs), + ) + for _, s := range selected.SelectedVTXOs { + selectedOutpoints = append(selectedOutpoints, s.Outpoint) + } + require.Contains( + t, selectedOutpoints, outpoint, "the VTXO must be selected "+ + "once its full multi-root lineage reconfirms", + ) + t.Logf( + "all roots reconfirmed Provisional: coin selection admitted %s", + outpoint, + ) +} + +// seedLiveVTXOWithAncestors persists a single live VTXO whose direct commitment +// is directTxid and whose ancestry carries EACH of ancestorTxids as a distinct +// cross-commitment parent, returning its outpoint. It is the multi-root +// generalization of seedLiveVTXOWithAncestor. The owner/operator keys and +// tapscript are real so the descriptor is well-formed; each ancestry tree +// fragment is a minimal placeholder (the gate reads only the commitment txids). +func seedLiveVTXOWithAncestors(t *testing.T, vtxoStore *db.VTXOPersistenceStore, + name string, directTxid chainhash.Hash, ancestorTxids []chainhash.Hash, + amount btcutil.Amount) wire.OutPoint { + + t.Helper() + + clientPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "client key") + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "operator key") + operatorKey := operatorPriv.PubKey() + + descriptor, err := tree.NewVTXODescriptor( + amount, clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo descriptor") + + tapScript, err := arkscript.VTXOTapScript( + clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo tapscript") + + outpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte(name + "-seeded-vtxo")), + Index: 0, + } + + // One minimal ancestry entry per root; only the CommitmentTxID of each + // is consulted by the canonicality gate. + ancestry := make([]types.Ancestry, 0, len(ancestorTxids)) + for _, ancestorTxid := range ancestorTxids { + ancestorTree := &tree.Tree{ + BatchOutpoint: outpoint, + Root: &tree.Node{ + Input: outpoint, + Outputs: []*wire.TxOut{}, + CoSigners: []*btcec.PublicKey{}, + Children: make(map[uint32]*tree.Node), + }, + } + ancestry = append(ancestry, types.Ancestry{ + TreePath: ancestorTree, + CommitmentTxID: ancestorTxid, + TreeDepth: 0, + }) + } + + err = vtxoStore.SaveVTXO(t.Context(), &vtxo.Descriptor{ + Outpoint: outpoint, + Amount: amount, + PolicyTemplate: descriptor.PolicyTemplate, + PkScript: descriptor.PkScript, + ClientKey: keychain.KeyDescriptor{ + PubKey: clientPriv.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: types.VTXOOwnerKeyFamily, + Index: 7, + }, + }, + OperatorKey: operatorKey, + TapScript: tapScript, + Ancestry: ancestry, + RoundID: chainhash. + HashH([]byte(name + "-round")). + String(), + CommitmentTxID: directTxid, + BatchExpiry: 500000, + RelativeExpiry: f2VTXOCSVDelay, + CreatedHeight: 1, + Status: vtxo.VTXOStatusLive, + }) + require.NoError(t, err, "save live vtxo with ancestors") + + return outpoint +} diff --git a/systest/batch_canonicality_reorg_test.go b/systest/batch_canonicality_reorg_test.go new file mode 100644 index 000000000..1aa3876d8 --- /dev/null +++ b/systest/batch_canonicality_reorg_test.go @@ -0,0 +1,272 @@ +//go:build systest + +package systest + +import ( + "context" + "crypto/sha256" + "testing" + "time" + + btcaddr "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/db" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// batchCanonPollInterval is how often the systest re-reads the manager's +// persisted batch record while waiting for an expected canonicality state. +// It is shorter than reorgSystestEventTimeout so several reads land inside a +// single event-propagation window; 250ms keeps the Ask traffic on the +// manager mailbox light without lengthening the test materially. +const batchCanonPollInterval = 250 * time.Millisecond + +// TestBatchCanonicalityReorgRoundTrip drives a real bitcoind reorg through the +// full batch-canonicality pipeline and proves the manager re-anchors a batch's +// interpreted canonicality to the replacement chain: +// +// bitcoind invalidate/mine +// -> lnd chainntnfs (in-process) +// -> lndclient gRPC (WithReOrgChan) +// -> chainbackends.LNDBackend (multi-shot forwarder) +// -> chainsource.ConfActor (reorg-aware mode) +// -> batchcanon.Manager (conf/reorg interpretation) +// -> db.BatchCanonicalityPersistenceStore (durable record) +// +// The batchcanon unit tests (batchcanon/manager_test.go) already prove the +// StateProvisional -> StateReorgedOut -> StateProvisional transition against a +// mock conf actor, including the transient reorged-out beat. They cannot prove +// that lndclient.WithReOrgChan actually fires the reorg signal over the real +// gRPC transport, nor that the durable store survives the round-trip. This +// test is the systest-level oracle for that. +// +// The oracle is re-anchoring: a batch confirmed at block X must, after the +// reorg, end up Provisional again but confirmed at a DIFFERENT block Y that +// belongs to the replacement branch. The only way the manager's persisted +// confirmation block can move from X to Y is the full reorg -> re-confirmation +// round-trip (a non-reorg-aware watch would stay pinned to X forever). The +// intermediate reorged-out state is transient — bitcoind preserves the tx in +// its mempool across the invalidate so it re-confirms on the first new block — +// so it is not asserted here (the unit tests own that beat); the block-hash +// move is the end-to-end proof. +// +// The flow is: +// +// 1. Faucet to a synthetic P2WPKH pkScript (no spendable key needed) so we +// know the batch txid + confirmation pkScript up front. +// 2. Register the batch with the manager BEFORE mining, exercising the +// live-detection conf watch rather than the historical-backfill path. +// 3. Mine one block. Assert StateProvisional anchored to the mined block +// (one conf is < DefaultFinalityDepth, so not yet Finalized). +// 4. Drive a 1-block reorg replaced by a longer 2-block branch. Assert the +// batch becomes Provisional again, re-anchored to a NEW block on the +// replacement branch. +func TestBatchCanonicalityReorgRoundTrip(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + // Spawn a real chainsource actor over the harness's LND, then build + // the batch-canonicality manager on top of a real durable store. + chainSource := h.NewChainSourceActor() + + sqlDB := db.NewTestDB(t) + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), btclog.Disabled, + ) + canonStore := dbStore.NewBatchCanonicalityStore( + clock.NewDefaultClock(), + ) + + mgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSource, + Log: fn.Some(h.SubLogger("BCAN")), + }) + mgrRef := actor.RegisterWithSystem( + h.ActorSystem(), + "batch-canonicality", batchcanon.ManagerServiceKey, mgr, + ) + mgr.SetSelfRef(mgrRef) + + // Build a synthetic P2WPKH address from a deterministic per-test + // pubkey hash. We never spend it; we only need a known pkScript to + // faucet to and register a confirmation watch on. + pubKeyHash := sha256.Sum256([]byte(t.Name())) + addr, err := btcaddr.NewAddressWitnessPubKeyHash( + pubKeyHash[:20], &chaincfg.RegressionNetParams, + ) + require.NoError(t, err, "build synthetic P2WPKH address") + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err, "derive pkScript for synthetic address") + + // Faucet first so we have the txid before registering the watch. The + // watch keys on (txid, pkScript), so the ordering between mempool + // entry and registration is fine; what must NOT happen is mining the + // block before registering, which would dispatch historical conf + // state and race two delivery paths. + amount := btcutil.Amount(btcutil.SatoshiPerBitcoin / 100) + txidStr := h.Harness.Faucet(addr.String(), amount) + txid, err := chainhash.NewHashFromStr(txidStr) + require.NoError(t, err, "parse faucet txid") + + // Register the batch. CSVExpiryDelta is a representative non-zero + // value; this test does not exercise expiry. No consumed inputs or + // dependent VTXOs are needed to observe the conf/reorg lifecycle. + const csvExpiryDelta = 144 + regResp := mgrRef.Ask(ctx, &batchcanon.RegisterBatchRequest{ + BatchTxID: *txid, + ConfirmationPkScript: pkScript, + CSVExpiryDelta: csvExpiryDelta, + }).Await(ctx) + require.True(t, regResp.IsOk(), "register batch with manager") + + // 1. Mine the block that confirms the faucet tx and assert the batch + // becomes Provisional, anchored to the mined block. + originalBlocks := h.Harness.Generate(1) + require.Len(t, originalBlocks, 1) + originalHash, err := chainhash.NewHashFromStr(originalBlocks[0].Hash) + require.NoError(t, err, "parse original block hash") + + rec := awaitBatchProvisionalAt(ctx, t, mgrRef, *txid, *originalHash) + t.Logf( + "batch %s Provisional at height %d block %s", txid, + rec.ConfirmationHeight.UnwrapOr(0), originalHash, + ) + + // 2. Drive a reorg: invalidate the confirmation block and mine a + // strictly longer (2-block) replacement branch. bitcoind preserves + // the tx in its mempool across the invalidate, so it re-confirms in + // the new chain. The manager's conf watch fires its reorg and then a + // fresh confirmation, re-anchoring the record to the new branch. + reorg := h.Harness.Reorg(1, 2) + require.Equal( + t, originalBlocks[0].Hash, reorg.Disconnected[0].Hash, + "the reorg should have disconnected the confirmation block", + ) + require.Len(t, reorg.Connected, 2) + t.Logf( + "reorg: disconnected=%d connected=%d fork_height=%d", + len(reorg.Disconnected), len(reorg.Connected), + reorg.ForkPoint.Height, + ) + + // 3. Assert the manager re-anchored the batch to a NEW block on the + // replacement branch, proving the reorg round-trip propagated all the + // way to the durable canonicality record. + reanchored := awaitBatchReanchored( + ctx, t, mgrRef, *txid, *originalHash, + ) + newHash := reanchored.ConfirmationBlock.UnwrapOr(chainhash.Hash{}) + + replacementHashes := make(map[string]struct{}, len(reorg.Connected)) + for _, blk := range reorg.Connected { + replacementHashes[blk.Hash] = struct{}{} + } + require.Contains( + t, replacementHashes, newHash.String(), + "re-confirmation block must belong to the replacement branch", + ) + t.Logf( + "batch %s re-anchored Provisional at height %d block %s", txid, + reanchored.ConfirmationHeight.UnwrapOr(0), newHash, + ) +} + +// awaitBatchProvisionalAt polls the manager's persisted record until the batch +// is Provisional and anchored to the wanted confirmation block, failing on +// timeout. The returned record is the one observed in that state. +func awaitBatchProvisionalAt(ctx context.Context, t *testing.T, + mgrRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid, wantBlock chainhash.Hash) *batchcanon.Record { + + t.Helper() + + return awaitBatchRecord( + ctx, t, mgrRef, txid, + func(rec *batchcanon.Record) bool { + return rec.State == batchcanon.StateProvisional && + rec.ConfirmationBlock.UnwrapOr( + chainhash.Hash{}, + ) == wantBlock + }, + "Provisional anchored at %s", wantBlock, + ) +} + +// awaitBatchReanchored polls the manager's persisted record until the batch is +// Provisional and anchored to a confirmation block DIFFERENT from oldBlock, +// failing on timeout. This is the re-anchoring oracle: it can only succeed if +// the reorg disconnected the original confirmation and a fresh confirmation +// re-anchored the record to the replacement chain. +func awaitBatchReanchored(ctx context.Context, t *testing.T, + mgrRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid, oldBlock chainhash.Hash) *batchcanon.Record { + + t.Helper() + + return awaitBatchRecord( + ctx, t, mgrRef, txid, + func(rec *batchcanon.Record) bool { + if rec.State != batchcanon.StateProvisional || + rec.ConfirmationBlock.IsNone() { + return false + } + block := rec.ConfirmationBlock.UnwrapOr( + chainhash.Hash{}, + ) + + return block != oldBlock + }, + "Provisional re-anchored off %s", oldBlock, + ) +} + +// awaitBatchRecord polls the manager's persisted record for a batch until pred +// holds, failing the test on timeout. Because the conf/reorg events propagate +// asynchronously over the real gRPC transport, we retry rather than read once. +// The returned record is the one that satisfied pred. +func awaitBatchRecord(ctx context.Context, t *testing.T, + mgrRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid chainhash.Hash, pred func(*batchcanon.Record) bool, + wantDesc string, wantArgs ...any) *batchcanon.Record { + + t.Helper() + + var matched *batchcanon.Record + require.Eventuallyf( + t, func() bool { + resp, err := mgrRef.Ask( + ctx, &batchcanon.GetBatchStateRequest{ + BatchTxID: txid, + }, + ).Await(ctx).Unpack() + if err != nil { + return false + } + got, ok := resp.(*batchcanon.GetBatchStateResponse) + if !ok || !got.Found { + return false + } + if !pred(got.Record) { + return false + } + matched = got.Record + + return true + }, reorgSystestEventTimeout, batchCanonPollInterval, + "batch %s never reached state: "+wantDesc, + append([]any{txid}, wantArgs...)..., + ) + + return matched +} diff --git a/vtxo/manager.go b/vtxo/manager.go index 654487345..d48228e0d 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -610,6 +610,9 @@ func (m *Manager) Receive(ctx context.Context, case *ReleaseForfeitRequest: return m.handleReleaseForfeit(ctx, req) + case *RestoreForfeitedVTXORequest: + return m.handleRestoreForfeitedVTXO(ctx, req) + case *ActivateCustomForfeitInputsRequest: return m.handleActivateCustomForfeitInputs(ctx, req) @@ -1049,6 +1052,82 @@ func (m *Manager) recoverExitedVTXO(ctx context.Context, return fn.Ok[ManagerResp](&ExitOutcomeResp{}) } +// handleRestoreForfeitedVTXO rolls a forfeited VTXO back to LiveState because +// the batch that consumed it via forfeit has been invalidated (its forfeit +// reversed by a finalized reorg / conflict). The forfeit transition reaped the +// VTXO actor and persisted VTXOStatusForfeited, so this re-materializes a live +// actor from the persisted descriptor, mirroring recoverExitedVTXO. It is +// idempotent: a VTXO that is not currently forfeited is left untouched. +func (m *Manager) handleRestoreForfeitedVTXO(ctx context.Context, + req *RestoreForfeitedVTXORequest) fn.Result[ManagerResp] { + + descriptor, err := m.cfg.Store.GetVTXO(ctx, req.Outpoint) + if err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("load vtxo for forfeit restore: %w", err), + ) + } + if descriptor == nil { + m.logger(ctx).WarnS(ctx, "No descriptor to restore forfeited "+ + "VTXO", nil, + slog.String("outpoint", req.Outpoint.String())) + + return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{}) + } + + // Idempotency guard: only relive a VTXO that is actually forfeited. A + // re-delivered restore must not clobber a VTXO that already moved on or + // was never forfeited. + if descriptor.Status != VTXOStatusForfeited { + m.logger(ctx).DebugS(ctx, "Skipping forfeit restore for "+ + "non-forfeited VTXO", + slog.String("outpoint", req.Outpoint.String()), + slog.String("status", descriptor.Status.String())) + + return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{}) + } + + // The forfeit transition reaps the actor, so none should be resident. + // If one somehow is, do not spawn a duplicate; treat it as already + // restored. + if _, ok := m.actors[req.Outpoint]; ok { + m.logger(ctx).DebugS(ctx, "Forfeited VTXO already has a live "+ + "actor; skipping restore", + slog.String("outpoint", req.Outpoint.String())) + + return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{}) + } + + // Spawn the live actor BEFORE persisting the status flip (same ordering + // rationale as recoverExitedVTXO: the coin must be monitored the moment + // its status becomes recoverable, and a failed status write is + // re-driven by the persisted Forfeited status on the next restore + // attempt). + descriptor.Status = VTXOStatusLive + + ref, err := m.spawnVTXOActor(ctx, descriptor) + if err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("respawn restored vtxo actor: %w", err), + ) + } + m.actors[req.Outpoint] = ref + + if err := m.cfg.Store.UpdateVTXOStatus( + ctx, req.Outpoint, VTXOStatusLive, + ); err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("restore forfeited vtxo status: %w", err), + ) + } + + m.logger(ctx).InfoS(ctx, "Restored forfeited VTXO to live after "+ + "batch invalidation", + slog.String("outpoint", req.Outpoint.String())) + + return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{Restored: true}) +} + // confirmExitedVTXO retires a VTXO to the terminal SpentState after its // unilateral exit confirmed on-chain. When the actor is alive it drives the // ExitConfirmedEvent through the FSM (which emits the terminated diff --git a/vtxo/messages.go b/vtxo/messages.go index 984353c1f..4c6a4e1b2 100644 --- a/vtxo/messages.go +++ b/vtxo/messages.go @@ -289,6 +289,12 @@ type ReleaseForfeitRequest = actormsg.ReleaseForfeitRequest // ReleaseForfeitResponse is an alias for the canonical type in actormsg. type ReleaseForfeitResponse = actormsg.ReleaseForfeitResponse +// RestoreForfeitedVTXORequest is an alias for the canonical type in actormsg. +type RestoreForfeitedVTXORequest = actormsg.RestoreForfeitedVTXORequest + +// RestoreForfeitedVTXOResponse is an alias for the canonical type in actormsg. +type RestoreForfeitedVTXOResponse = actormsg.RestoreForfeitedVTXOResponse + // CustomForfeitInput is an alias for the canonical type in actormsg. type CustomForfeitInput = actormsg.CustomForfeitInput diff --git a/waved/config.go b/waved/config.go index cc83eefa6..8486e0408 100644 --- a/waved/config.go +++ b/waved/config.go @@ -184,6 +184,24 @@ const ( // config knob. DefaultMaxOperatorFeeSat int64 = 1_000_000 + // DefaultReorgSafetyDepth is the reorg-safety / finality depth on + // networks with conventional reorg behavior (mainnet, regtest, simnet, + // signet): the confirmation depth at which a batch (commitment) tx is + // treated as final and its reorg-aware chain watches (confirmation + + // consumed-input spend) are released. It bounds the deepest reorg the + // daemon actively detects and recovers from. Six matches the wider + // Lightning stack's finality threshold. + DefaultReorgSafetyDepth uint32 = 6 + + // DefaultTestnetReorgSafetyDepth is the deeper default used on testnet, + // whose 20-minute minimum-difficulty rule routinely produces reorgs far + // past the conventional six blocks (occasionally many tens to hundreds + // of blocks). 100 covers the deep reorgs observed on testnet3 with + // margin while keeping watch lifetimes bounded; operators on an + // unusually volatile network can raise it via the `reorgsafetydepth` + // knob. + DefaultTestnetReorgSafetyDepth uint32 = 100 + // RPCTransportGRPC selects native gRPC for daemon-owned outbound RPCs. RPCTransportGRPC = "grpc" @@ -355,6 +373,17 @@ type Config struct { // below any reasonable mainnet abuse threshold. MaxOperatorFeeSat int64 `mapstructure:"maxoperatorfeesat"` + // ReorgSafetyDepth is the confirmation depth at which a batch + // (commitment) tx is treated as final and its reorg-aware chain watches + // (confirmation + consumed-input spend) are released. It bounds the + // deepest reorg the daemon actively detects and recovers from across + // the batch-canonicality gate and the unilateral-exit txconfirm + // watches. Zero selects a network-aware default via + // ResolveReorgSafetyDepth (DefaultReorgSafetyDepth, or the deeper + // DefaultTestnetReorgSafetyDepth on testnet). Raise it on networks + // prone to deep reorgs. + ReorgSafetyDepth uint32 `mapstructure:"reorgsafetydepth"` + // OOR configures off-band receive/send actor behavior. OOR *OORConfig `mapstructure:"oor"` @@ -1491,6 +1520,25 @@ func (c *Config) NetworkDir() string { return filepath.Join(c.DataDir, "data", c.Network) } +// ResolveReorgSafetyDepth returns the configured reorg-safety / finality +// depth, or a network-aware default when unset (zero). Testnet gets the +// deeper DefaultTestnetReorgSafetyDepth because its minimum-difficulty rule +// produces reorgs far past the conventional six blocks; every other network +// uses DefaultReorgSafetyDepth. The result is always positive, so chain +// watches always finalize (a zero depth would disable Done synthesis and leak +// watch state). +func (c *Config) ResolveReorgSafetyDepth() uint32 { + if c.ReorgSafetyDepth > 0 { + return c.ReorgSafetyDepth + } + + if c.Network == "testnet" { + return DefaultTestnetReorgSafetyDepth + } + + return DefaultReorgSafetyDepth +} + // LogDir returns the network-scoped log directory. Path fields are // normalized by Validate via expandPaths, so callers must validate // before reaching this helper. diff --git a/waved/config_reorg_safety_depth_test.go b/waved/config_reorg_safety_depth_test.go new file mode 100644 index 000000000..c0eb9b65d --- /dev/null +++ b/waved/config_reorg_safety_depth_test.go @@ -0,0 +1,73 @@ +package waved + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestResolveReorgSafetyDepth asserts the network-aware default and the +// explicit override for the reorg-safety / finality depth. An explicit +// positive value always wins; otherwise testnet gets the deeper default +// (its minimum-difficulty rule produces deep reorgs) and every other network +// gets the conventional six-block default. The resolved value is always +// positive so chain watches finalize rather than leak. +func TestResolveReorgSafetyDepth(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + network string + configured uint32 + want uint32 + }{ + { + name: "mainnet default", + network: "mainnet", + want: DefaultReorgSafetyDepth, + }, + { + name: "regtest default", + network: "regtest", + want: DefaultReorgSafetyDepth, + }, + { + name: "signet default", + network: "signet", + want: DefaultReorgSafetyDepth, + }, + { + name: "testnet deeper default", + network: "testnet", + want: DefaultTestnetReorgSafetyDepth, + }, + { + name: "explicit override wins on testnet", + network: "testnet", + configured: 30, + want: 30, + }, + { + name: "explicit override wins on mainnet", + network: "mainnet", + configured: 50, + want: 50, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + c := &Config{ + Network: tc.network, + ReorgSafetyDepth: tc.configured, + } + got := c.ResolveReorgSafetyDepth() + require.Equal(t, tc.want, got) + require.Positive( + t, got, "resolved depth must be positive", + ) + }) + } +} diff --git a/waved/server.go b/waved/server.go index b70440f9a..f482bdb79 100644 --- a/waved/server.go +++ b/waved/server.go @@ -29,6 +29,7 @@ import ( "github.com/lightninglabs/lndclient" "github.com/lightninglabs/wavelength/arkrpc" "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/btcwbackend" "github.com/lightninglabs/wavelength/build" "github.com/lightninglabs/wavelength/chainbackends" @@ -386,6 +387,19 @@ type Server struct { // subsystem is initialized. lazyChainResolver *vtxo.LazyChainResolver + // batchCanonStore is the durable batch-canonicality store backing the + // VTXO coin-selection and unroll-admission reorg-safety gates. They + // read batch lineage canonicality lazily at admission time, so a nil + // store (e.g. before the wallet-dependent actors start) is a no-op + // gate rather than a hard dependency. + batchCanonStore batchcanon.Store + + // batchCanonRef is the BatchCanonicalityManager actor ref. The round + // and OOR producers Tell it a RegisterBatchRequest as their VTXOs are + // born so each lineage batch gets reorg-aware conf/spend watches. + // None until startWalletDependentActors registers the manager. + batchCanonRef fn.Option[actor.TellOnlyRef[batchcanon.ManagerMsg]] + serverConn *grpc.ClientConn arkClient arkrpc.ArkServiceClient mailboxClient mailboxpb.MailboxServiceClient @@ -2082,13 +2096,16 @@ func (s *Server) registerChainSourceActor( chainsource.ChainSourceConfig{ Backend: s.chainBackend, System: s.actorSystem, - // Enable height-based Done synthesis at the default - // safety depth. Without this, conf/spend sub-actors - // driven through lndclient (whose Done channel is - // allocated-but-never-written) would never receive a - // finality signal, and reorg-aware consumers like - // txconfirm would leak watch state forever. - FinalityDepth: chainsource.DefaultFinalityDepth, + // Enable height-based Done synthesis at the + // operator-configured reorg-safety depth (network-aware + // default; deeper on testnet). Without this, conf/spend + // sub-actors driven through lndclient (whose Done + // channel is allocated-but-never-written) would never + // receive a finality signal, and reorg-aware consumers + // like txconfirm and the batch-canonicality manager + // would leak watch state forever. The depth also bounds + // the deepest reorg those consumers detect. + FinalityDepth: s.cfg.ResolveReorgSafetyDepth(), }, ) @@ -2293,6 +2310,18 @@ func (s *Server) startWalletDependentActors(ctx context.Context, } s.walletRef = fn.Some(walletRef) + // ------------------------------------------------------- + // 9b. Build the batch-canonicality store and manager. The + // store backs the VTXO and unroll reorg-safety gates; + // the manager ref lets the round and OOR producers + // register batches as their VTXOs are born. Built before + // the VTXO manager so its config can carry the store + // directly without a post-Start mutation. + // ------------------------------------------------------- + if err := s.initBatchCanonicality(ctx, chainSourceRef); err != nil { + return err + } + // ------------------------------------------------------- // 10. Start the VTXO manager before the round actor so // the manager ref can be passed directly in the round @@ -4069,6 +4098,7 @@ func (s *Server) initRoundActor(ctx context.Context, OwnedScriptChecker: scriptChecker, OwnedScriptRegistrar: scriptRegistrar, LedgerSink: fn.Some(ledger.NewSink(s.actorSystem)), + BatchCanonicality: s.batchCanonRef, MetricsSink: s.metricsSink, ForfeitCollectionTimeout: s.cfg. ForfeitCollectionTimeout, @@ -4118,6 +4148,135 @@ func (s *Server) dropCustomForfeitSigningContexts(_ context.Context, // initVTXOManager creates, registers, and starts the VTXO manager actor. // The manager recovers persisted VTXOs on startup and spawns one VTXO actor // per live descriptor. +// initBatchCanonicality builds the durable batch-canonicality store, backfills +// canonicality records from the VTXOs already in the DB, and starts the +// BatchCanonicalityManager actor that arms reorg-aware confirmation and spend +// watches on every tracked batch. The store and manager ref are stashed on the +// Server so the VTXO, round, unroll, and OOR subsystems can consult and feed +// the reorg-safety gate. It must run after the chain source is registered: it +// needs the current best height to anchor backfilled records and the chain +// source ref to arm watches. +func (s *Server) initBatchCanonicality(ctx context.Context, + chainSourceRef actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]) error { + + dbStore := db.NewStore( + s.db.DB, s.db.Queries, s.db.Backend(), + s.subLogger(db.Subsystem), + ) + canonStore := dbStore.NewBatchCanonicalityStore(s.clk) + + // Seed canonicality records for batches anchoring VTXOs already in the + // DB so historical lineage is gated too, not just batches born after + // this start. Backfill anchors each record relative to the live tip, + // so a record's confirmation depth is correct on the first reconcile. + bestHeight, err := s.batchCanonBestHeight(ctx, chainSourceRef) + if err != nil { + return fmt.Errorf("unable to read best height for batch "+ + "canonicality backfill: %w", err) + } + created, err := canonStore.BackfillFromVTXOs( + ctx, bestHeight, s.cfg.ResolveReorgSafetyDepth(), + ) + if err != nil { + return fmt.Errorf("unable to backfill batch canonicality: %w", + err) + } + + mgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSourceRef, + Log: fn.Some(s.subLogger("BCAN")), + RestoreConsumedVTXO: s.restoreForfeitedVTXO, + }) + mgrRef := actor.RegisterWithSystem( + s.actorSystem, "batch-canonicality", + batchcanon.ManagerServiceKey, mgr, + ) + mgr.SetSelfRef(mgrRef) + + // Reconcile re-arms watches for every persisted record (the freshly + // backfilled ones plus any that survived a restart) so a reorg that + // lands while the daemon is down is still detected on the next start. + if err := mgr.Reconcile(ctx); err != nil { + s.actorSystem.StopAndRemoveActor("batch-canonicality") + + return fmt.Errorf("unable to reconcile batch canonicality: %w", + err) + } + + s.batchCanonStore = canonStore + s.batchCanonRef = fn.Some[actor.TellOnlyRef[batchcanon.ManagerMsg]]( + mgrRef, + ) + + s.log.InfoS(ctx, "Batch canonicality manager registered and started", + slog.Int("backfilled_records", created), + ) + + return nil +} + +// batchCanonBestHeight asks the chain source for the current best block height, +// used to anchor batch-canonicality records during backfill. +func (s *Server) batchCanonBestHeight(ctx context.Context, + chainSourceRef actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]) (int32, error) { + + resp, err := chainSourceRef.Ask( + ctx, &chainsource.BestHeightRequest{}, + ).Await(ctx).Unpack() + if err != nil { + return 0, err + } + + heightResp, ok := resp.(*chainsource.BestHeightResponse) + if !ok { + return 0, fmt.Errorf("unexpected best-height response type %T", + resp) + } + + return heightResp.Height, nil +} + +// restoreForfeitedVTXOTimeout bounds the cross-actor ask the batch-canonicality +// manager issues to the VTXO manager to roll a forfeited VTXO back to live. It +// runs on the canonicality manager's actor turn, so the bound stops a slow or +// wedged VTXO manager from stalling canonicality processing. 30s is generous +// for an in-process spawn + single status write while still being a hard cap. +const restoreForfeitedVTXOTimeout = 30 * time.Second + +// restoreForfeitedVTXO rolls a forfeited VTXO back to a spendable state when +// the batch that consumed it via forfeit has been invalidated. It is wired as +// the batch-canonicality manager's RestoreConsumedVTXO callback. The VTXO +// manager ref is resolved lazily from s.vtxoMgrRef: this callback only fires on +// a chain event, long after the VTXO manager registers (step 10), so the ref is +// always populated by the time it runs. +func (s *Server) restoreForfeitedVTXO(ctx context.Context, + outpoint wire.OutPoint) error { + + if !s.vtxoMgrRef.IsSome() { + return fmt.Errorf("vtxo manager not ready to restore "+ + "forfeited vtxo %s", outpoint) + } + + // Detach from the canonicality manager's turn context (this runs on its + // actor goroutine) and bound the ask so a slow VTXO manager cannot + // stall canonicality processing indefinitely. + askCtx, cancel := context.WithTimeout( + context.WithoutCancel(ctx), restoreForfeitedVTXOTimeout, + ) + defer cancel() + + _, err := s.vtxoMgrRef.UnsafeFromSome().Ask( + askCtx, &vtxo.RestoreForfeitedVTXORequest{Outpoint: outpoint}, + ).Await(askCtx).Unpack() + + return err +} + func (s *Server) initVTXOManager(ctx context.Context, chainSourceRef actor.ActorRef[ chainsource.ChainSourceMsg, chainsource.ChainSourceResp, @@ -4163,6 +4322,7 @@ func (s *Server) initVTXOManager(ctx context.Context, RoundActor: roundActor, LedgerSink: fn.Some(ledgerSink), ChainResolver: chainResolver, + BatchCanonicality: s.batchCanonStore, RefreshFeeQuoter: s.autoRefreshFeeQuoter(), FetchOperatorKey: s.fetchCurrentOperatorPubKey, ForfeitParticipantSigner: s.forfeitSignatures.sign, @@ -4373,19 +4533,20 @@ func (s *Server) initOORActor(ctx context.Context, s.oorSessionStore = registryStore s.oorRegistry, err = oor.NewOORRegistryActor(oor.OORRegistryConfig{ - Log: fn.Some(s.subLogger(oor.Subsystem)), - Signer: oorSigner, - IncomingHandler: outboxHandler, - RegistryStore: registryStore, - DeliveryStore: s.deliveryStore, - ServerConn: s.runtime.TellRef(), - VTXOManager: vtxoManagerRef, - SpendCompleter: s.oorCompleteSpend, - SpendReleaser: s.oorReleaseSpend, - ReservationStore: reservationStore, - PackageStore: packageStore, - VTXOStore: vtxoStore, - LedgerSink: fn.Some(ledger.NewSink(s.actorSystem)), + Log: fn.Some(s.subLogger(oor.Subsystem)), + Signer: oorSigner, + IncomingHandler: outboxHandler, + RegistryStore: registryStore, + DeliveryStore: s.deliveryStore, + ServerConn: s.runtime.TellRef(), + VTXOManager: vtxoManagerRef, + SpendCompleter: s.oorCompleteSpend, + SpendReleaser: s.oorReleaseSpend, + ReservationStore: reservationStore, + PackageStore: packageStore, + VTXOStore: vtxoStore, + BatchCanonicality: s.batchCanonRef, + LedgerSink: fn.Some(ledger.NewSink(s.actorSystem)), IncomingVTXOObserver: func(ctx context.Context, descs []*vtxo.Descriptor) error { @@ -5332,12 +5493,13 @@ func (s *Server) initUnrollSubsystem(ctx context.Context, Store: &unroll.DBRegistryStore{ UEStore: ueStore, }, - DeliveryStore: s.deliveryStore, - ProofAssembler: proofAssembler, - VTXOStore: vtxoStore, - TxConfirmRef: unrollTxConfirmRef, - ChainSource: chainSourceRef, - Wallet: unrollWallet, + DeliveryStore: s.deliveryStore, + ProofAssembler: proofAssembler, + VTXOStore: vtxoStore, + TxConfirmRef: unrollTxConfirmRef, + ChainSource: chainSourceRef, + Wallet: unrollWallet, + BatchCanonicality: s.batchCanonStore, LedgerSink: fn.Some( ledger.NewSink(s.actorSystem), ), From 684db3601becdb8a72a0a3188170044481fffabc Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 9 Jul 2026 14:59:55 -0700 Subject: [PATCH 14/18] vtxo: reconcile DB status when restoring a resident forfeited VTXO RestoreForfeitedVTXO spawns the live actor before persisting the VTXOStatusLive flip, so a failed status write is re-driven on the next restore attempt. But the retry short-circuited: a resident actor made the function return early without ever completing the missed status write, leaving the coin marked Forfeited in the DB. On the next restart that VTXO is dropped from live recovery -- a permanent loss of funds. Complete the missed status flip in the resident-actor branch so the DB is reconciled with the already-live actor. --- vtxo/manager.go | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/vtxo/manager.go b/vtxo/manager.go index d48228e0d..c0e27f0a4 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -1088,14 +1088,30 @@ func (m *Manager) handleRestoreForfeitedVTXO(ctx context.Context, } // The forfeit transition reaps the actor, so none should be resident. - // If one somehow is, do not spawn a duplicate; treat it as already - // restored. + // If one somehow is, do not spawn a duplicate. A resident actor whose + // descriptor is still marked Forfeited means a prior restore spawned + // the actor but its status write did not land (the spawn happens + // before the status flip below, precisely so a failed write is + // re-driven on the next attempt). Complete that missed flip here so + // the coin is not left Forfeited in the DB, which would drop it on the + // next restart -- a permanent loss of funds. if _, ok := m.actors[req.Outpoint]; ok { m.logger(ctx).DebugS(ctx, "Forfeited VTXO already has a live "+ - "actor; skipping restore", + "actor; reconciling DB status to live", slog.String("outpoint", req.Outpoint.String())) - return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{}) + if err := m.cfg.Store.UpdateVTXOStatus( + ctx, req.Outpoint, VTXOStatusLive, + ); err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("unable to reconcile restored "+ + "vtxo status: %w", err), + ) + } + + return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{ + Restored: true, + }) } // Spawn the live actor BEFORE persisting the status flip (same ordering From 79e0c97a1406dece55b710b3496619f33cd766b7 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 9 Jul 2026 14:59:55 -0700 Subject: [PATCH 15/18] batchcanon: re-drive interrupted forfeit restores on reconcile When a conflict-finalized batch's forfeit restore fails partway through, restoreProvisionalConsumers deliberately keeps the reverse-dependency edges for a retry. But conflict_finalized is terminal: its watches are not re-armed on Reconcile and the spend-done event that first triggered the restore never fires again, so the retained edges were never re-driven and the consumed VTXOs stayed forfeited forever. Sweep conflict-finalized batches on Reconcile and re-drive their restores. The restore is idempotent (no-ops when no edges remain), so this safely completes any interrupted restore across a restart. --- batchcanon/manager.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/batchcanon/manager.go b/batchcanon/manager.go index e8ae2a3ef..985b978e3 100644 --- a/batchcanon/manager.go +++ b/batchcanon/manager.go @@ -774,6 +774,26 @@ func (m *Manager) Reconcile(ctx context.Context) error { } } + // A conflict-finalized batch's restore may have failed partway through + // (RestoreConsumedVTXO errored, so the edges were deliberately kept for + // a retry). Because conflict_finalized is terminal, its watches are not + // re-armed above and handleConsumerLifecycle will never fire again -- + // so without this sweep the retained edges would never be re-driven + // and the consumed VTXOs would stay forfeited forever. The restore is + // idempotent (it no-ops when no edges remain), so re-driving every + // conflict-finalized batch here is safe and completes any interrupted + // restore. + finalizedConflicts, err := m.cfg.Store.ListBatchesByState( + ctx, StateConflictFinalized, + ) + if err != nil { + return fmt.Errorf("list %s batches: %w", StateConflictFinalized, + err) + } + for _, record := range finalizedConflicts { + m.restoreProvisionalConsumers(ctx, record.BatchTxID) + } + return nil } From 2c1d0660e5536d7f0c88b86682774a1c907faa0a Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Thu, 9 Jul 2026 14:59:55 -0700 Subject: [PATCH 16/18] round: drop cached commitment confs on round removal pendingCommitmentConfs caches a provisional commitment confirmation so the matching finality event can replay it. Entries were only cleared on successful finality; a round that was cancelled, reaped after failure, or completed via onRoundComplete left its cached conf behind, so the map grew without bound over the daemon's lifetime. Drop the cached conf at every round-removal site (cancel, reap, and complete), keyed on the commitment txid. --- round/actor.go | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/round/actor.go b/round/actor.go index 0e66ae46e..a1172df7d 100644 --- a/round/actor.go +++ b/round/actor.go @@ -390,7 +390,7 @@ type RoundClientConfig struct { // MetricsSink is an optional reference to the client-side metrics // actor. When set, the round actor emits a RoundCompletedMsg as // each round reaches a terminal outcome so the - // darepod_rounds_completed_total counter reflects reality. When + // waved_rounds_completed_total counter reflects reality. When // None (metrics disabled, or tests), metric emission is silently // skipped. Mirrors LedgerSink: the round actor is the natural seam // because terminal round outcomes are observed here, not at any @@ -595,7 +595,7 @@ func (a *RoundClientActor) emitVTXOsReceived(ctx context.Context, } // emitRoundCompleted reports a terminal round outcome to the metrics -// actor so the darepod_rounds_completed_total counter advances. The +// actor so the waved_rounds_completed_total counter advances. The // round actor is the natural seam: terminal outcomes surface here as // RoundCompletedNotification / RoundFailedNotification, with no RPC // boundary that could observe them. Like ledger emission, this is @@ -667,7 +667,7 @@ func (a *RoundClientActor) handleTerminalJobFailure(ctx context.Context, } // emitRoundJoined reports a round-join attempt to the metrics actor so -// darepod_rounds_joined_total advances. It is emitted from createNewRound +// waved_rounds_joined_total advances. It is emitted from createNewRound // so it counts every round the client assembles — manual and eager alike // — keeping it symmetric with emitRoundCompleted. Best-effort and // fire-and-forget: a Tell failure is logged at debug level and never @@ -985,7 +985,7 @@ func (a *RoundClientActor) createNewRound(ctx context.Context) (*RoundFSM, ) // Count the join attempt here, at the one seam every round passes - // through exactly once. This keeps darepod_rounds_joined_total + // through exactly once. This keeps waved_rounds_joined_total // symmetric with rounds_completed_total (also actor-emitted): both // manual JoinNextRound and eager/automatic joins assemble their // round through createNewRound, so counting at the RPC boundary @@ -2274,6 +2274,13 @@ func (a *RoundClientActor) handleCancelRound(ctx context.Context, if _, exists := a.rounds[keyStr]; exists { targetFSM.FSM.Stop() delete(a.rounds, keyStr) + delete(a.commitmentTxIndex, targetFSM.TxID) + + // Drop any cached provisional confirmation so a cancelled round + // does not leave an entry behind (the zero txid of a round + // cancelled before its commitment was seen is a harmless + // no-op). + delete(a.pendingCommitmentConfs, targetFSM.TxID) } a.log.InfoS(ctx, "Round participation cancelled successfully") @@ -2301,6 +2308,10 @@ func (a *RoundClientActor) onRoundComplete(ctx context.Context, roundID RoundID, } delete(a.commitmentTxIndex, txid) + // Drop any cached provisional confirmation for this commitment so the + // map does not retain an entry for a round that is no longer tracked. + delete(a.pendingCommitmentConfs, txid) + return a.cfg.RoundStore.FinalizeRound(ctx, roundID, txid, confInfo) } @@ -2348,6 +2359,13 @@ func (a *RoundClientActor) reapFailedRounds(ctx context.Context) { roundFSM.FSM.Stop() delete(a.rounds, keyStr) delete(a.commitmentTxIndex, roundFSM.TxID) + + // A round that confirmed provisionally but then failed (e.g. a + // reorg dropped the commitment past the point of no return, or + // forfeit collection timed out after confirmation) leaves a + // cached conf behind. Drop it so the map does not grow without + // bound across the daemon's lifetime. + delete(a.pendingCommitmentConfs, roundFSM.TxID) } } From 45fcd14222806fc889e78c9a69c253883a80206d Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 15 Jul 2026 10:08:05 -0700 Subject: [PATCH 17/18] db: persist per-input conflict flags on batch_consumed_inputs Add conflicting / conflict_final columns (0/1 INTEGER, default 0) to batch_consumed_inputs, extend ListBatchConsumedInputs to return them, and add a targeted RecordBatchInputConflict update. Regenerated via make sqlc. These persist the observed conflict status of each consumed batch input so restart reconciliation can rebuild the per-input conflict view rather than re-deriving it only from the batch tx confirmation. The columns default to 0 (no conflict), so the first-see insert path is unchanged; only the manager's conflict transitions write them. Folded into the existing 000014 migration (pre-production: DBs are nuked and recreated, so no forward ALTER migration is needed). --- db/sqlc/batch_canonicality.sql.go | 42 +++++++++++++++++-- .../000014_batch_canonicality.up.sql | 11 +++++ db/sqlc/models.go | 2 + db/sqlc/querier.go | 6 ++- db/sqlc/queries/batch_canonicality.sql | 12 +++++- db/sqlc/schemas/generated_schema.sql | 11 +++++ 6 files changed, 78 insertions(+), 6 deletions(-) diff --git a/db/sqlc/batch_canonicality.sql.go b/db/sqlc/batch_canonicality.sql.go index 9ceb9b4bc..dc2115b34 100644 --- a/db/sqlc/batch_canonicality.sql.go +++ b/db/sqlc/batch_canonicality.sql.go @@ -242,7 +242,7 @@ func (q *Queries) ListBatchCanonicalityByState(ctx context.Context, state int32) } const ListBatchConsumedInputs = `-- name: ListBatchConsumedInputs :many -SELECT input_hash, input_index, input_pk_script +SELECT input_hash, input_index, input_pk_script, conflicting, conflict_final FROM batch_consumed_inputs WHERE batch_txid = $1 ` @@ -251,10 +251,12 @@ type ListBatchConsumedInputsRow struct { InputHash []byte InputIndex int32 InputPkScript []byte + Conflicting int32 + ConflictFinal int32 } // ListBatchConsumedInputs returns the inputs a batch consumes, with the -// pkScript of each spent output. +// pkScript of each spent output and its persisted conflict observation. func (q *Queries) ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) ([]ListBatchConsumedInputsRow, error) { rows, err := q.db.QueryContext(ctx, ListBatchConsumedInputs, batchTxid) if err != nil { @@ -264,7 +266,13 @@ func (q *Queries) ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) var items []ListBatchConsumedInputsRow for rows.Next() { var i ListBatchConsumedInputsRow - if err := rows.Scan(&i.InputHash, &i.InputIndex, &i.InputPkScript); err != nil { + if err := rows.Scan( + &i.InputHash, + &i.InputIndex, + &i.InputPkScript, + &i.Conflicting, + &i.ConflictFinal, + ); err != nil { return nil, err } items = append(items, i) @@ -427,6 +435,34 @@ func (q *Queries) RecordBatchConfirmation(ctx context.Context, arg RecordBatchCo return err } +const RecordBatchInputConflict = `-- name: RecordBatchInputConflict :exec +UPDATE batch_consumed_inputs +SET conflicting = $4, conflict_final = $5 +WHERE batch_txid = $1 AND input_hash = $2 AND input_index = $3 +` + +type RecordBatchInputConflictParams struct { + BatchTxid []byte + InputHash []byte + InputIndex int32 + Conflicting int32 + ConflictFinal int32 +} + +// RecordBatchInputConflict persists the observed conflict status of one +// consumed input, so restart reconciliation can rebuild the per-input +// conflict view and not transiently downgrade a persisted conflict. +func (q *Queries) RecordBatchInputConflict(ctx context.Context, arg RecordBatchInputConflictParams) error { + _, err := q.db.ExecContext(ctx, RecordBatchInputConflict, + arg.BatchTxid, + arg.InputHash, + arg.InputIndex, + arg.Conflicting, + arg.ConflictFinal, + ) + return err +} + const UpdateBatchCanonicalityState = `-- name: UpdateBatchCanonicalityState :exec UPDATE batch_canonicality SET state = $2, updated_at = $3 diff --git a/db/sqlc/migrations/000014_batch_canonicality.up.sql b/db/sqlc/migrations/000014_batch_canonicality.up.sql index 6e7ff9e9b..60776df69 100644 --- a/db/sqlc/migrations/000014_batch_canonicality.up.sql +++ b/db/sqlc/migrations/000014_batch_canonicality.up.sql @@ -81,6 +81,17 @@ CREATE TABLE IF NOT EXISTS batch_consumed_inputs ( -- predate script tracking. input_pk_script BLOB, + -- conflicting / conflict_final persist the last observed conflict + -- status of this input (a spend by a tx other than the batch itself), + -- 0 = false, 1 = true. They let restart reconciliation rebuild the + -- per-input conflict view: without them, a reconciled conflict batch + -- whose confirmation is re-observed before its conflicting spend is + -- re-observed would transiently derive back to (non-conflict) + -- provisional and briefly admit the coin. Default 0: a freshly recorded + -- input has seen no conflict yet. + conflicting INTEGER NOT NULL DEFAULT 0, + conflict_final INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (batch_txid, input_hash, input_index), FOREIGN KEY (batch_txid) REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE diff --git a/db/sqlc/models.go b/db/sqlc/models.go index 28b565204..37c875778 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -78,6 +78,8 @@ type BatchConsumedInput struct { InputHash []byte InputIndex int32 InputPkScript []byte + Conflicting int32 + ConflictFinal int32 } type BatchDependentVtxo struct { diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 8bc67673f..f0278f2c2 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -209,7 +209,7 @@ type Querier interface { // state. ListBatchCanonicalityByState(ctx context.Context, state int32) ([]BatchCanonicality, error) // ListBatchConsumedInputs returns the inputs a batch consumes, with the - // pkScript of each spent output. + // pkScript of each spent output and its persisted conflict observation. ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) ([]ListBatchConsumedInputsRow, error) // ListBatchDependentVTXOs returns the VTXO outpoints a batch anchors. ListBatchDependentVTXOs(ctx context.Context, batchTxid []byte) ([]ListBatchDependentVTXOsRow, error) @@ -376,6 +376,10 @@ type Querier interface { // a reorg) overwrites the observation so effective expiry tracks the new // confirmation. RecordBatchConfirmation(ctx context.Context, arg RecordBatchConfirmationParams) error + // RecordBatchInputConflict persists the observed conflict status of one + // consumed input, so restart reconciliation can rebuild the per-input + // conflict view and not transiently downgrade a persisted conflict. + RecordBatchInputConflict(ctx context.Context, arg RecordBatchInputConflictParams) error SumBoardingIntentAmountsByStatus(ctx context.Context, status string) (interface{}, error) SumUnspentVTXOAmounts(ctx context.Context) (interface{}, error) // UpdateBatchCanonicalityState transitions a batch to a new state without diff --git a/db/sqlc/queries/batch_canonicality.sql b/db/sqlc/queries/batch_canonicality.sql index 2449163c4..5004b3ec7 100644 --- a/db/sqlc/queries/batch_canonicality.sql +++ b/db/sqlc/queries/batch_canonicality.sql @@ -81,11 +81,19 @@ DELETE FROM batch_consumed_inputs WHERE batch_txid = $1; -- name: ListBatchConsumedInputs :many -- ListBatchConsumedInputs returns the inputs a batch consumes, with the --- pkScript of each spent output. -SELECT input_hash, input_index, input_pk_script +-- pkScript of each spent output and its persisted conflict observation. +SELECT input_hash, input_index, input_pk_script, conflicting, conflict_final FROM batch_consumed_inputs WHERE batch_txid = $1; +-- name: RecordBatchInputConflict :exec +-- RecordBatchInputConflict persists the observed conflict status of one +-- consumed input, so restart reconciliation can rebuild the per-input +-- conflict view and not transiently downgrade a persisted conflict. +UPDATE batch_consumed_inputs +SET conflicting = $4, conflict_final = $5 +WHERE batch_txid = $1 AND input_hash = $2 AND input_index = $3; + -- name: FindBatchesByConsumedOutpoint :many -- FindBatchesByConsumedOutpoint returns the txids of every batch that -- consumes the given outpoint. diff --git a/db/sqlc/schemas/generated_schema.sql b/db/sqlc/schemas/generated_schema.sql index ca0680863..19220a06a 100644 --- a/db/sqlc/schemas/generated_schema.sql +++ b/db/sqlc/schemas/generated_schema.sql @@ -163,6 +163,17 @@ CREATE TABLE batch_consumed_inputs ( -- predate script tracking. input_pk_script BLOB, + -- conflicting / conflict_final persist the last observed conflict + -- status of this input (a spend by a tx other than the batch itself), + -- 0 = false, 1 = true. They let restart reconciliation rebuild the + -- per-input conflict view: without them, a reconciled conflict batch + -- whose confirmation is re-observed before its conflicting spend is + -- re-observed would transiently derive back to (non-conflict) + -- provisional and briefly admit the coin. Default 0: a freshly recorded + -- input has seen no conflict yet. + conflicting INTEGER NOT NULL DEFAULT 0, + conflict_final INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (batch_txid, input_hash, input_index), FOREIGN KEY (batch_txid) REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE From d6dec4e9657e92e08af2ac20f2e8dbbe4f563b36 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 15 Jul 2026 10:08:30 -0700 Subject: [PATCH 18/18] batchcanon: fix restart reconcile transiently clearing a conflict On restart, reconcileOne seeded every consumed input's watch as non-conflicting and seeded the batch's confirmation view from the persisted state. For a persisted conflict_provisional batch this meant that if the confirmation was re-observed before the conflicting spend was re-observed, deriveState recomputed (confirmed, no conflict) -> provisional and persisted it, transiently admitting a coin whose input is double-spent. persisted was only a write-dedup guard, not a floor. Persist each input's conflict flags (RecordInputConflict, written before the batch state so a crash leaves the flags ahead of, never behind, the state) and seed the per-input conflict view from them on reconcile, so a bare re-confirmation can no longer clear a conflict it did not resolve. Also harden reconcile watch-arm failure: arm before recording the watch (mirroring the initial registration path) and return on failure, leaving m.watches untouched so a later Reconcile retries the full arm rather than treating the batch as permanently armed. A closing deriveAndPersist reconciles any state/flag drift toward the most-restrictive state. --- batchcanon/manager.go | 55 +++++++++++++++- batchcanon/manager_test.go | 97 +++++++++++++++++++++++++++++ batchcanon/record.go | 13 ++++ batchcanon/store.go | 10 +++ db/batch_canonicality_store.go | 38 ++++++++++- db/batch_canonicality_store_test.go | 76 ++++++++++++++++++++++ unroll/source_lineage_gate_test.go | 6 ++ vtxo/manager_lineage_gate_test.go | 6 ++ 8 files changed, 297 insertions(+), 4 deletions(-) diff --git a/batchcanon/manager.go b/batchcanon/manager.go index 985b978e3..a48ed2308 100644 --- a/batchcanon/manager.go +++ b/batchcanon/manager.go @@ -536,10 +536,35 @@ func (m *Manager) handleInputSpent(ctx context.Context, msg *inputSpentMsg) { iw.conflicting = conflict iw.conflictFinal = false + // Persist the input flags BEFORE the batch state so a crash + // between the two writes leaves the flags ahead of the state, + // not behind it: reconcile derives conflict from the flags and + // self-corrects the state, whereas stale non-conflict flags + // would let a re-observed confirmation downgrade a real + // conflict. + m.persistInputConflict(ctx, w, msg.outpoint, iw) m.deriveAndPersist(ctx, w) }) } +// persistInputConflict writes the observed conflict flags of one consumed +// input so a restart can rebuild the per-input conflict view (see +// reconcileOne). A failure is logged, not fatal: the in-memory view stays +// correct for this run, and the worst case on the next restart is the +// reconciliation window this persistence exists to close. +func (m *Manager) persistInputConflict(ctx context.Context, w *batchWatch, + op wire.OutPoint, iw *inputWatch) { + + err := m.cfg.Store.RecordInputConflict( + ctx, w.txid, op, iw.conflicting, iw.conflictFinal, + ) + if err != nil { + m.logger(ctx).WarnS(ctx, "Failed to persist input conflict "+ + "flags", err, slog.String("batch", w.txid.String()), + slog.String("outpoint", op.String())) + } +} + // handleInputSpendReorged clears a previously observed spend that left the // best chain, for every batch watching the outpoint. func (m *Manager) handleInputSpendReorged(ctx context.Context, @@ -549,6 +574,7 @@ func (m *Manager) handleInputSpendReorged(ctx context.Context, iw.conflicting = false iw.conflictFinal = false + m.persistInputConflict(ctx, w, msg.outpoint, iw) m.deriveAndPersist(ctx, w) }) } @@ -565,6 +591,7 @@ func (m *Manager) handleInputSpendDone(ctx context.Context, iw.conflictFinal = true } + m.persistInputConflict(ctx, w, msg.outpoint, iw) m.deriveAndPersist(ctx, w) }) } @@ -824,15 +851,37 @@ func (m *Manager) reconcileOne(ctx context.Context, record *Record) { w.conf = confUnseen } + // Seed the per-input conflict view from the persisted flags. Without + // this, a reconciled conflict batch whose confirmation is re-observed + // before its conflicting spend is re-observed would derive back to + // (non-conflict) provisional and briefly admit the coin. for _, in := range record.ConsumedInputs { - w.inputs[in.Outpoint] = &inputWatch{} + w.inputs[in.Outpoint] = &inputWatch{ + spenderIsConflict: in.Conflicting || in.ConflictFinal, + conflicting: in.Conflicting, + conflictFinal: in.ConflictFinal, + } } - m.watches[record.BatchTxID] = w + // Arm the chain watches BEFORE recording the watch, mirroring the + // initial registration path. If arming fails, leaving m.watches + // untouched lets a later Reconcile retry the full arm from scratch + // rather than treating this batch as permanently armed; re-registering + // the same conf/spend caller IDs is idempotent. if err := m.armWatches(ctx, w, record.ConsumedInputs); err != nil { m.logger(ctx).WarnS(ctx, "Failed to re-arm batch watches on "+ - "reconcile", err, "batch", record.BatchTxID) + "reconcile; will retry on next reconcile", err, + slog.String("batch", record.BatchTxID.String())) + + return } + m.watches[record.BatchTxID] = w + + // Reconcile any drift between the persisted state and the persisted + // per-input flags toward the most-restrictive derived state (e.g. if a + // prior state write failed after the input flags were written). The + // write is a no-op when they already agree. + m.deriveAndPersist(ctx, w) } // confCallerID is the stable chainsource caller id for a batch's confirmation diff --git a/batchcanon/manager_test.go b/batchcanon/manager_test.go index 1a9882d6e..e91aff2b6 100644 --- a/batchcanon/manager_test.go +++ b/batchcanon/manager_test.go @@ -100,6 +100,25 @@ func (s *fakeStore) UpdateBatchState(_ context.Context, txid chainhash.Hash, return nil } +func (s *fakeStore) RecordInputConflict(_ context.Context, + batchTxid chainhash.Hash, op wire.OutPoint, conflicting, + conflictFinal bool) error { + + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.records[batchTxid]; ok { + for i := range r.ConsumedInputs { + in := &r.ConsumedInputs[i] + if in.Outpoint == op { + in.Conflicting = conflicting + in.ConflictFinal = conflictFinal + } + } + } + + return nil +} + func (s *fakeStore) RecordConfirmation(_ context.Context, txid chainhash.Hash, height int32, block chainhash.Hash) error { @@ -819,3 +838,81 @@ func TestManagerReconcileReArmsWatches(t *testing.T) { h.fireConfReorged(t, txid) require.Equal(t, StateReorgedOut, h.state(t, txid).Record.State) } + +// TestManagerReconcileConflictNotDowngradedByConfReplay proves that a persisted +// conflict_provisional batch is NOT transiently downgraded to provisional when, +// after a restart, the confirmation is re-observed before the conflicting spend +// is re-observed. Reconciliation seeds the per-input conflict view from the +// persisted flags, so a bare re-confirmation cannot clear a conflict it did not +// resolve. Without the fix this asserted state 4 (conflict_provisional) but got +// state 1 (provisional) — a window in which the coin would be wrongly admitted. +func TestManagerReconcileConflictNotDowngradedByConfReplay(t *testing.T) { + t.Parallel() + + store := newFakeStore() + txid := testBatchTxid(0x93) + input := testOutpoint(0x94, 0) + + // Seed a persisted conflict_provisional batch whose consumed input was + // observed conflicting by a prior run: confirmed, but a foreign tx + // double-spent its input. + require.NoError( + t, + store.UpsertBatch( + t.Context(), &Record{ + BatchTxID: txid, + State: StateConflictProvisional, + ConfirmationHeight: fn.Some[int32](90), + CSVExpiryDelta: 50, + ConsumedInputs: []ConsumedInput{{ + Outpoint: input, + PkScript: []byte{0x51}, + Conflicting: true, + }}, + }, + ), + ) + + mock := newMockChainSource(100) + mockActor := actor.NewActor(actor.ActorConfig[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]{ID: "mock", Behavior: mock, MailboxSize: 64}) + mockActor.Start() + t.Cleanup(mockActor.Stop) + + mgr := NewManager( + ManagerConfig{ + Store: store, + ChainSource: mockActor.Ref(), + }, + ) + mgrActor := actor.NewActor(actor.ActorConfig[ManagerMsg, ManagerResp]{ + ID: "mgr", Behavior: mgr, MailboxSize: 64, + }) + mgr.SetSelfRef(mgrActor.TellRef()) + mgrActor.Start() + t.Cleanup(mgrActor.Stop) + + require.NoError(t, mgr.Reconcile(t.Context())) + + h := &managerHarness{mgrRef: mgrActor.Ref(), mock: mock, store: store} + + // Reconcile alone must preserve the persisted conflict. + require.Equal( + t, StateConflictProvisional, h.state(t, txid).Record.State, + ) + + // The confirmation is re-observed first (the batch is still mined). + // This must NOT clear the conflict: the conflicting spend has not been + // observed to reorg away. + h.fireConfirmed(t, txid, 90, testBatchTxid(0x95)) + require.Equal( + t, StateConflictProvisional, h.state(t, txid).Record.State, + "re-confirmation wrongly downgraded a persisted conflict", + ) + + // The conflict clears only when the conflicting spend is observed to + // reorg out — then, and only then, the batch returns to provisional. + h.fireSpendReorged(t, input) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) +} diff --git a/batchcanon/record.go b/batchcanon/record.go index c63697c12..0e75398d3 100644 --- a/batchcanon/record.go +++ b/batchcanon/record.go @@ -73,6 +73,19 @@ type ConsumedInput struct { // predate script tracking; such inputs cannot be watched on // light-client backends. PkScript []byte + + // Conflicting is true while a conflicting spend of this input (a spend + // by a transaction other than the batch itself) is observed and has not + // been reorged out. Persisting it lets restart reconciliation rebuild + // the per-input conflict view so live re-observation cannot transiently + // downgrade a persisted conflict before the conflicting spend + // re-arrives. + Conflicting bool + + // ConflictFinal is true once a conflicting spend of this input has + // matured past the reorg-safety depth. Persisted for the same + // restart-reconciliation reason as Conflicting. + ConflictFinal bool } // EffectiveExpiry derives the absolute expiry height from the current diff --git a/batchcanon/store.go b/batchcanon/store.go index d81bd7f0f..c57bb034c 100644 --- a/batchcanon/store.go +++ b/batchcanon/store.go @@ -17,6 +17,8 @@ var ErrBatchNotFound = errors.New("batch canonicality record not found") // and reverse-dependency edges, leaving all interpretation — state // transitions, chain watching, and admission — to the BatchCanonicalityManager // and the later tasks of the reorg-safety epic. +// +//nolint:interfacebloat type Store interface { // UpsertBatch inserts or replaces the canonicality record for a // batch, including its consumed inputs and dependent VTXOs. It is the @@ -38,6 +40,14 @@ type Store interface { UpdateBatchState(ctx context.Context, txid chainhash.Hash, state State) error + // RecordInputConflict persists the observed conflict status of one of a + // batch's consumed inputs (a spend by a transaction other than the + // batch itself). It exists so restart reconciliation can rebuild the + // per-input conflict view and not transiently downgrade a persisted + // conflict before the conflicting spend is re-observed. + RecordInputConflict(ctx context.Context, batchTxid chainhash.Hash, + outpoint wire.OutPoint, conflicting, conflictFinal bool) error + // RecordConfirmation records that the batch tx is confirmed at the // given best-chain height and block hash. A later RecordConfirmation // at a different height (after a reorg) overwrites the observation so diff --git a/db/batch_canonicality_store.go b/db/batch_canonicality_store.go index 74e11d192..6a65785ea 100644 --- a/db/batch_canonicality_store.go +++ b/db/batch_canonicality_store.go @@ -44,6 +44,9 @@ type BatchCanonicalityStore interface { ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) ([]sqlc.ListBatchConsumedInputsRow, error) + RecordBatchInputConflict(ctx context.Context, + arg sqlc.RecordBatchInputConflictParams) error + FindBatchesByConsumedOutpoint(ctx context.Context, arg sqlc.FindBatchesByConsumedOutpointParams) ([][]byte, error) @@ -303,6 +306,27 @@ func (s *BatchCanonicalityPersistenceStore) ClearConfirmation( }) } +// RecordInputConflict persists the observed conflict status of one consumed +// input, so restart reconciliation can rebuild the per-input conflict view. +func (s *BatchCanonicalityPersistenceStore) RecordInputConflict( + ctx context.Context, batchTxid chainhash.Hash, outpoint wire.OutPoint, + conflicting, conflictFinal bool) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return q.RecordBatchInputConflict( + ctx, sqlc.RecordBatchInputConflictParams{ + BatchTxid: batchTxid[:], + InputHash: outpoint.Hash[:], + InputIndex: int32(outpoint.Index), + Conflicting: boolToInt32(conflicting), + ConflictFinal: boolToInt32(conflictFinal), + }, + ) + }) +} + // FindBatchesConsumingOutpoint returns the txids of every recorded batch that // consumes the given outpoint. func (s *BatchCanonicalityPersistenceStore) FindBatchesConsumingOutpoint( @@ -573,7 +597,9 @@ func (s *BatchCanonicalityPersistenceStore) hydrateRecord(ctx context.Context, Hash: *hash, Index: uint32(in.InputIndex), }, - PkScript: in.InputPkScript, + PkScript: in.InputPkScript, + Conflicting: in.Conflicting != 0, + ConflictFinal: in.ConflictFinal != 0, }) } @@ -606,6 +632,16 @@ func (s *BatchCanonicalityPersistenceStore) hydrateRecord(ctx context.Context, }, nil } +// boolToInt32 maps a Go bool to the 0/1 INTEGER encoding used for the +// consumed-input conflict flags. +func boolToInt32(b bool) int32 { + if b { + return 1 + } + + return 0 +} + // optionToNullInt32 maps an optional int32 to a sql.NullInt32. func optionToNullInt32(o fn.Option[int32]) sql.NullInt32 { if o.IsNone() { diff --git a/db/batch_canonicality_store_test.go b/db/batch_canonicality_store_test.go index 9dee5f8a2..c2024ed5e 100644 --- a/db/batch_canonicality_store_test.go +++ b/db/batch_canonicality_store_test.go @@ -97,6 +97,82 @@ func TestBatchCanonicalityUpsertRoundTrip(t *testing.T) { require.Equal(t, int32(244), got.EffectiveExpiry().UnwrapOr(0)) } +// TestBatchCanonicalityRecordInputConflict verifies that per-input conflict +// flags round-trip through the store, so restart reconciliation can rebuild +// the per-input conflict view (darepo#454 reconciliation-ordering fix). +func TestBatchCanonicalityRecordInputConflict(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + txid := chainhash.Hash{0xac} + inA := outpoint(0x01, 0) + inB := outpoint(0x02, 1) + rec := &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateConflictProvisional, + CSVExpiryDelta: 144, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(inA), + consumedInput(inB), + }, + } + require.NoError(t, store.UpsertBatch(ctx, rec)) + + // Freshly inserted inputs carry no conflict. + got, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + for _, in := range got.ConsumedInputs { + require.False(t, in.Conflicting) + require.False(t, in.ConflictFinal) + } + + // Mark input A conflicting (provisional), leave B untouched. + require.NoError( + t, store.RecordInputConflict(ctx, txid, inA, true, false), + ) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.True(t, inputFlags(t, got, inA).Conflicting) + require.False(t, inputFlags(t, got, inA).ConflictFinal) + require.False(t, inputFlags(t, got, inB).Conflicting) + + // Promote input A to a finalized conflict; the flag persists. + require.NoError( + t, store.RecordInputConflict(ctx, txid, inA, true, true), + ) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.True(t, inputFlags(t, got, inA).Conflicting) + require.True(t, inputFlags(t, got, inA).ConflictFinal) + + // Clearing the conflict (spend reorged away) resets both flags. + require.NoError( + t, store.RecordInputConflict(ctx, txid, inA, false, false), + ) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.False(t, inputFlags(t, got, inA).Conflicting) + require.False(t, inputFlags(t, got, inA).ConflictFinal) +} + +// inputFlags returns the consumed input matching op from a record, failing the +// test if it is absent. +func inputFlags(t *testing.T, rec *batchcanon.Record, + op wire.OutPoint) batchcanon.ConsumedInput { + + t.Helper() + for _, in := range rec.ConsumedInputs { + if in.Outpoint == op { + return in + } + } + t.Fatalf("consumed input %v not found", op) + + return batchcanon.ConsumedInput{} +} + // TestBatchCanonicalityGetNotFound verifies the not-found sentinel. func TestBatchCanonicalityGetNotFound(t *testing.T) { t.Parallel() diff --git a/unroll/source_lineage_gate_test.go b/unroll/source_lineage_gate_test.go index b26fa4aba..18db03350 100644 --- a/unroll/source_lineage_gate_test.go +++ b/unroll/source_lineage_gate_test.go @@ -56,6 +56,12 @@ func (s *stubBatchCanon) RecordConfirmation(context.Context, chainhash.Hash, return nil } +func (s *stubBatchCanon) RecordInputConflict(context.Context, chainhash.Hash, + wire.OutPoint, bool, bool) error { + + return nil +} + func (s *stubBatchCanon) ClearConfirmation(context.Context, chainhash.Hash) error { diff --git a/vtxo/manager_lineage_gate_test.go b/vtxo/manager_lineage_gate_test.go index 8f63da67f..4c7e6f992 100644 --- a/vtxo/manager_lineage_gate_test.go +++ b/vtxo/manager_lineage_gate_test.go @@ -53,6 +53,12 @@ func (f *fakeBatchCanon) RecordConfirmation(context.Context, chainhash.Hash, return nil } +func (f *fakeBatchCanon) RecordInputConflict(context.Context, chainhash.Hash, + wire.OutPoint, bool, bool) error { + + return nil +} + func (f *fakeBatchCanon) ClearConfirmation(context.Context, chainhash.Hash) error {