diff --git a/chainsource/finality.go b/chainsource/finality.go index 5bdc97ac6..2b8561b1f 100644 --- a/chainsource/finality.go +++ b/chainsource/finality.go @@ -21,16 +21,6 @@ var finalityBlockSubscriptionBackoffs = []time.Duration{ 2 * time.Second, } -// finalityBlockSubscriptionAttemptTimeout bounds each individual -// RegisterBlocks attempt. Without it a single hung RegisterBlocks call -// (e.g. a wedged lndclient gRPC stream) would block the conf/spend -// monitoring goroutine indefinitely — stalling Confirmed/Reorged/Done -// delivery on that watch — since the retry schedule only bounds the gaps -// between attempts, not the attempts themselves. 10s mirrors the per-call -// registration timeout used in conf_actor.go's handleRegisterConf so the -// whole file behaves consistently under a slow backend. -const finalityBlockSubscriptionAttemptTimeout = 10 * time.Second - // registerBlocksForFinality registers a block-epoch subscription used // to synthesize a Done signal at FinalityDepth past an observed // confirmation or spend. The call is retried with a short bounded @@ -39,11 +29,22 @@ const finalityBlockSubscriptionAttemptTimeout = 10 * time.Second // lndclient over gRPC); a one-shot RegisterBlocks attempt that // briefly hiccups would leak the per-watch sub-actor indefinitely. // -// The retries run in the calling sub-actor's monitoring goroutine, so -// brief blocking here is safe: more confirmation/spend events on this -// specific watch are not expected during the retry window (we already -// consumed the one that triggered the arm), and ctx cancellation -// breaks out promptly. +// The retries run in a dedicated arming goroutine (not the sub-actor's +// select loop), so brief blocking here is safe: more confirmation/spend +// events on this specific watch are not expected during the retry window +// (we already consumed the one that triggered the arm), and ctx +// cancellation breaks out promptly. +// +// The passed ctx MUST be the sub-actor's long-lived context, and it is +// handed to RegisterBlocks unwrapped: for in-process backends the +// block-epoch forwarder goroutine is tied to the ctx it receives, so +// bounding each attempt with a cancellable child ctx (and cancelling it +// once the call returns) would tear the subscription down the instant it +// was armed — starving finality synthesis of the very epochs it needs. +// A hung RegisterBlocks can therefore stall this arming goroutine, but +// that is contained: it is off the select loop (fix moved arming there +// precisely so a slow backend cannot wedge Confirmed/Reorged/Done +// delivery), and a genuinely wedged backend is a lost watch regardless. // // Returns the registration on success, or a non-nil error after // retries are exhausted. Callers should log the error at warn level @@ -54,14 +55,7 @@ func registerBlocksForFinality(ctx context.Context, backend ChainBackend, var lastErr error for attempt, backoff := range finalityBlockSubscriptionBackoffs { - // Bound each attempt so a hung RegisterBlocks cannot wedge the - // monitoring goroutine; the retry schedule only bounds the gaps - // between attempts, not a single stuck call. - attemptCtx, cancel := context.WithTimeout( - ctx, finalityBlockSubscriptionAttemptTimeout, - ) - reg, err := backend.RegisterBlocks(attemptCtx) - cancel() + reg, err := backend.RegisterBlocks(ctx) if err == nil { return reg, nil } diff --git a/oor/lineage_batch_canon_test.go b/oor/lineage_batch_canon_test.go new file mode 100644 index 000000000..2a2fc0c26 --- /dev/null +++ b/oor/lineage_batch_canon_test.go @@ -0,0 +1,165 @@ +package oor + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/batchcanon" + "github.com/lightninglabs/darepo-client/lib/tree" + "github.com/lightninglabs/darepo-client/vtxo" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// oorBCRef aliases the canonicality manager tell-ref to keep test literals +// within the line limit. +type oorBCRef = actor.TellOnlyRef[batchcanon.ManagerMsg] + +// lineageFragment builds an ancestry fragment anchored at the given commitment +// txid whose tree root carries the supplied batch-output pkScript. +func lineageFragment(txid chainhash.Hash, pkScript []byte) vtxo.Ancestry { + return vtxo.Ancestry{ + CommitmentTxID: txid, + TreePath: &tree.Tree{ + BatchOutput: &wire.TxOut{ + PkScript: pkScript, + }, + }, + } +} + +func lineageOutpoint(seed byte) wire.OutPoint { + var h chainhash.Hash + h[0] = seed + + return wire.OutPoint{Hash: h, Index: uint32(seed)} +} + +// TestRegisterLineageBatchesRegistersDistinctAncestors verifies that receiving +// OOR VTXOs registers one RegisterBatchRequest per distinct ancestor commitment +// tx, carrying the tree-root batch pkScript and the dependent VTXO outpoints, +// and that a batch shared across two received VTXOs accumulates both. +func TestRegisterLineageBatchesRegistersDistinctAncestors(t *testing.T) { + t.Parallel() + + ref := actor.NewChannelTellOnlyRef[batchcanon.ManagerMsg]( + "oor-batchcanon-test", 8, + ) + b := &sessionBehavior{ + cfg: SessionActorConfig{ + BatchCanonicality: fn.Some[oorBCRef](ref), + }, + log: btclog.Disabled, + } + + txA := chainhash.Hash{0xaa} + txB := chainhash.Hash{0xbb} + scriptA := []byte{0x51, 0x20, 0xaa} + scriptB := []byte{0x51, 0x20, 0xbb} + + vtxo1 := lineageOutpoint(1) + vtxo2 := lineageOutpoint(2) + + descs := []*vtxo.Descriptor{ + { + Outpoint: vtxo1, + Ancestry: []vtxo.Ancestry{ + lineageFragment(txA, scriptA), + }, + }, + { + // Multi-parent: shares txA and adds txB. + Outpoint: vtxo2, + Ancestry: []vtxo.Ancestry{ + lineageFragment(txA, scriptA), + lineageFragment(txB, scriptB), + }, + }, + } + + b.registerLineageBatches(t.Context(), descs) + + got := make(map[chainhash.Hash]*batchcanon.RegisterBatchRequest) + for range 2 { + msg, ok := ref.AwaitMessage(time.Second) + require.True(t, ok, "expected a RegisterBatchRequest") + req, ok := msg.(*batchcanon.RegisterBatchRequest) + require.True(t, ok) + got[req.BatchTxID] = req + } + + // No third registration. + _, extra := ref.AwaitMessage(100 * time.Millisecond) + require.False(t, extra, "expected exactly two distinct batches") + + require.Contains(t, got, txA) + require.Equal(t, scriptA, got[txA].ConfirmationPkScript) + require.ElementsMatch( + t, []wire.OutPoint{vtxo1, vtxo2}, got[txA].DependentVTXOs, + ) + + require.Contains(t, got, txB) + require.Equal(t, scriptB, got[txB].ConfirmationPkScript) + require.Equal(t, []wire.OutPoint{vtxo2}, got[txB].DependentVTXOs) +} + +// TestRegisterLineageBatchesDormantWhenUnwired verifies registration is a no-op +// when no canonicality manager ref is configured. +func TestRegisterLineageBatchesDormantWhenUnwired(t *testing.T) { + t.Parallel() + + b := &sessionBehavior{ + cfg: SessionActorConfig{ + BatchCanonicality: fn.None[oorBCRef](), + }, + log: btclog.Disabled, + } + + // Must not panic with a populated lineage and no ref. + b.registerLineageBatches(t.Context(), []*vtxo.Descriptor{ + { + Outpoint: lineageOutpoint(1), + Ancestry: []vtxo.Ancestry{ + lineageFragment( + chainhash.Hash{0xaa}, []byte{0x51}, + ), + }, + }, + }) +} + +// TestRegisterLineageBatchesSkipsIncompleteFragments verifies fragments with no +// tree path / batch output or a zero txid are skipped (they cannot be watched), +// leaving the gate permissive rather than registering an unwatchable batch. +func TestRegisterLineageBatchesSkipsIncompleteFragments(t *testing.T) { + t.Parallel() + + ref := actor.NewChannelTellOnlyRef[batchcanon.ManagerMsg]( + "oor-batchcanon-skip", 4, + ) + b := &sessionBehavior{ + cfg: SessionActorConfig{ + BatchCanonicality: fn.Some[oorBCRef](ref), + }, + log: btclog.Disabled, + } + + b.registerLineageBatches(t.Context(), []*vtxo.Descriptor{ + { + Outpoint: lineageOutpoint(1), + Ancestry: []vtxo.Ancestry{ + // Nil tree path: skipped. + {CommitmentTxID: chainhash.Hash{0xaa}}, + // Zero txid: skipped. + lineageFragment(chainhash.Hash{}, []byte{0x51}), + }, + }, + }) + + _, ok := ref.AwaitMessage(200 * time.Millisecond) + require.False(t, ok, "no registration expected for incomplete lineage") +} diff --git a/oor/session_actor.go b/oor/session_actor.go index 4d39fd854..beea7c00d 100644 --- a/oor/session_actor.go +++ b/oor/session_actor.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/batchcanon" "github.com/lightninglabs/darepo-client/build" clientdb "github.com/lightninglabs/darepo-client/db" "github.com/lightninglabs/darepo-client/ledger" @@ -64,6 +65,14 @@ type SessionActorConfig struct { // materialized so it can spawn monitoring actors. VTXOManager actor.TellOnlyRef[vtxo.ManagerMsg] + // BatchCanonicality, when set, receives a RegisterBatchRequest for + // each commitment batch in a received OOR VTXO's lineage so the + // reorg-safety availability gate (darepo#454) can govern the received + // VTXO: a reorg-out or invalidation of any ancestor batch marks the + // VTXO limbo. None disables registration (the gate stays dormant), + // matching the C5/C6 dormancy contract. + BatchCanonicality fn.Option[actor.TellOnlyRef[batchcanon.ManagerMsg]] + // SpendCompleter routes outgoing input-spend completion through the // VTXO manager. The manager's status write commits in the VTXO actor's // own transaction, so it does NOT join this actor's turn: the spend is diff --git a/oor/session_actor_handlers.go b/oor/session_actor_handlers.go index 5ce8e1ac2..51c9f2582 100644 --- a/oor/session_actor_handlers.go +++ b/oor/session_actor_handlers.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/darepo-client/batchcanon" clientdb "github.com/lightninglabs/darepo-client/db" "github.com/lightninglabs/darepo-client/ledger" libtypes "github.com/lightninglabs/darepo-client/lib/types" @@ -757,10 +758,111 @@ func (b *sessionBehavior) notifyMaterialized(ctx context.Context, event Event) { "observer failed", err) } } + + b.registerLineageBatches(ctx, descs) }() }) } +// registerLineageBatches registers every commitment batch in the received +// VTXOs' lineage with the canonicality manager so the multi-parent admission +// gate can govern these OOR-received VTXOs: a reorg-out or invalidation of any +// ancestor batch marks the dependent VTXO limbo, excluding it from selection +// until its lineage is canonical again (darepo#454, F4/F5). +// +// It is a no-op when no manager ref is wired (the gate stays dormant). It runs +// on the post-commit best-effort goroutine alongside the VTXO-manager +// notification: a dropped registration only leaves the gate permissive for +// these VTXOs (the safe default), and the manager's RegisterBatch is +// idempotent so a re-materialization re-registers harmlessly. +// +// The batch-output pkScript comes from each ancestry fragment's tree root +// (BatchOutput), which is what script-filtering light-client backends filter +// on, so confirmation detection of the ancestor batch works on Esplora/Neutrino +// receivers too. ConsumedInputs are left empty: the receiver does not hold the +// ancestor commitment txs' inputs at this seam, so per-input double-spend +// watches are a follow-up — a reorg-out of an ancestor batch is still detected +// via its confirmation watch and marks the VTXO limbo. +func (b *sessionBehavior) registerLineageBatches(ctx context.Context, + descs []*vtxo.Descriptor) { + + if b.cfg.BatchCanonicality.IsNone() { + return + } + ref := b.cfg.BatchCanonicality.UnsafeFromSome() + + // Collect, per distinct ancestor commitment tx, its batch-output + // pkScript and the deduped set of received VTXO outpoints that depend + // on it (a desc may carry the same commitment txid across more than one + // ancestry fragment). + type batchReg struct { + pkScript []byte + depSeen map[wire.OutPoint]struct{} + dependents []wire.OutPoint + } + batches := make(map[chainhash.Hash]*batchReg) + order := make([]chainhash.Hash, 0) + + for _, desc := range descs { + for i := range desc.Ancestry { + frag := desc.Ancestry[i] + if frag.TreePath == nil || + frag.TreePath.BatchOutput == nil { + + continue + } + txid := frag.CommitmentTxID + if txid == (chainhash.Hash{}) { + continue + } + + reg, ok := batches[txid] + if !ok { + reg = &batchReg{ + pkScript: frag.TreePath. + BatchOutput.PkScript, + depSeen: make( + map[wire.OutPoint]struct{}, + ), + } + batches[txid] = reg + order = append(order, txid) + } + if _, dup := reg.depSeen[desc.Outpoint]; !dup { + reg.depSeen[desc.Outpoint] = struct{}{} + reg.dependents = append( + reg.dependents, desc.Outpoint, + ) + } + } + } + + for _, txid := range order { + reg := batches[txid] + + // CSVExpiryDelta is intentionally left zero here: the + // admission gate consults only the batch State (reorged / + // conflict), never EffectiveExpiry, and the per-batch + // effective expiry is not consumed for OOR-registered ancestor + // batches (each received VTXO carries its own BatchExpiry). + // Threading the real per-fragment CSV delta is a follow-up + // alongside ConsumedInputs. + err := ref.Tell(ctx, &batchcanon.RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: reg.pkScript, + DependentVTXOs: reg.dependents, + }) + if err != nil { + b.log.WarnS(ctx, "Failed to register OOR lineage "+ + "batch canonicality", err, + slog.String("commitment_txid", txid.String()), + slog.Int("dependent_vtxos", + len(reg.dependents)), + ) + } + } +} + // queueVTXOsReceived stages one VTXOReceivedMsg per materialized incoming // VTXO for the durable outbox enqueue in commitAck. func (b *sessionBehavior) queueVTXOsReceived(ctx context.Context, diff --git a/round/actor.go b/round/actor.go index b7a390ec5..7dacd8e38 100644 --- a/round/actor.go +++ b/round/actor.go @@ -20,6 +20,7 @@ import ( "github.com/google/uuid" "github.com/lightninglabs/darepo-client/baselib/actor" "github.com/lightninglabs/darepo-client/baselib/protofsm" + "github.com/lightninglabs/darepo-client/batchcanon" "github.com/lightninglabs/darepo-client/chainsource" "github.com/lightninglabs/darepo-client/ledger" "github.com/lightninglabs/darepo-client/lib/actormsg" @@ -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 @@ -302,6 +326,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 @@ -423,8 +456,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. @@ -468,6 +504,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) { @@ -1007,6 +1090,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 @@ -1042,12 +1144,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( @@ -1300,6 +1404,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) @@ -2140,12 +2250,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)), @@ -2165,7 +2290,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]( @@ -2173,23 +2381,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), ) } @@ -2423,6 +2645,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 @@ -2687,6 +2915,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. @@ -2712,12 +2956,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 c9d434f3f..e1884725a 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 8bdc8d2da..f872c80b3 100644 --- a/round/actor_test.go +++ b/round/actor_test.go @@ -290,6 +290,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) { @@ -987,6 +1005,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..521cc6cd9 --- /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/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/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 ec6d69b44..7da27f219 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 97072e185..e64897b56 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -4201,18 +4201,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{ diff --git a/vtxo/CLAUDE.md b/vtxo/CLAUDE.md index 64b910e50..31ccdea3b 100644 --- a/vtxo/CLAUDE.md +++ b/vtxo/CLAUDE.md @@ -31,11 +31,13 @@ when the local wallet owns the receive script. `BatchCanonicality` (optional `batchcanon.Store`), when set, gates coin selection on batch lineage canonicality: `selectAndReserveVTXOs` drops any candidate whose batch is in limbo (reorged-out) or invalidated - (conflict-finalized) state via `batchcanon.LineageBlocked`, reading the - candidate's direct commitment txid through `GetVTXO`. Nil disables the gate - (a complete no-op, the default until the batch producers register batches); - it is permissive otherwise (unseen / unregistered lineage does not block). - Full multi-parent ancestry gating is a follow-up. + (conflict-finalized) state via `batchcanon.LineageBlocked`. It gates on the + FULL lineage (`lineageCommitmentTxids`): the candidate's direct commitment + txid plus every cross-commitment ancestor batch in `Descriptor.Ancestry`, so + a multi-input OOR VTXO is blocked if ANY contributing batch is off the + canonical chain. Nil disables the gate (a complete no-op, the default until + the batch producers register batches); it is permissive otherwise (unseen / + unregistered lineage does not block). - `ExitOutcomeResolution` — Terminal result for an exiting VTXO: `Outcome` (`ExitOutcomeRecoverable` or `ExitOutcomeConfirmed`) and `Reason`. - `ExitOutcomeResolver` — Function type diff --git a/vtxo/manager.go b/vtxo/manager.go index e08efc347..1d5057e66 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -13,6 +13,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btclog/v2" "github.com/btcsuite/btcwallet/waddrmgr" @@ -1161,12 +1162,43 @@ type reserveParams struct { // total amount on success. // gateUnavailableLineage drops candidates whose batch lineage is in a limbo // (reorged-out) or invalidated (conflict-finalized) canonicality state, so a -// VTXO is never selected while its batch is off the canonical chain. It is a -// no-op when no canonicality store is configured (the gate stays dormant until -// the batch producers register their batches). It reads each candidate's -// direct commitment txid via the store; full multi-parent ancestry gating for -// cross-commitment OOR VTXOs is a follow-up. The gate is permissive: an -// unregistered or unseen batch does not block selection. +// VTXO is never selected while any batch in its lineage is off the canonical +// chain. It is a no-op when no canonicality store is configured (the gate +// stays dormant until the batch producers register their batches). It gates on +// the FULL lineage: a VTXO's direct commitment txid plus every cross-commitment +// ancestor batch (a multi-input OOR VTXO descends from more than one batch, and +// any single reorged-out/invalidated parent makes the leaf unspendable). The +// gate is permissive: an unregistered or unseen batch does not block selection. +// lineageCommitmentTxids returns the deduped set of commitment txids in a +// VTXO's lineage: its direct commitment tx plus every distinct ancestor +// commitment tx recorded in its ancestry. A round-direct or same-commitment +// OOR VTXO yields one txid; a cross-commitment multi-input OOR VTXO yields one +// per contributing batch. The direct commitment txid is included even when the +// ancestry slice is empty (e.g. incoming VTXOs materialized without their +// commitment tree) so the gate still governs the leaf by its batch. +func lineageCommitmentTxids(desc *Descriptor) []chainhash.Hash { + seen := make(map[chainhash.Hash]struct{}, len(desc.Ancestry)+1) + txids := make([]chainhash.Hash, 0, len(desc.Ancestry)+1) + + add := func(txid chainhash.Hash) { + if txid == (chainhash.Hash{}) { + return + } + if _, ok := seen[txid]; ok { + return + } + seen[txid] = struct{}{} + txids = append(txids, txid) + } + + add(desc.CommitmentTxID) + for i := range desc.Ancestry { + add(desc.Ancestry[i].CommitmentTxID) + } + + return txids +} + func (m *Manager) gateUnavailableLineage(ctx context.Context, candidates []*Descriptor) ([]*Descriptor, error) { @@ -1183,7 +1215,8 @@ func (m *Manager) gateUnavailableLineage(ctx context.Context, } blocked, avail, err := batchcanon.LineageBlocked( - ctx, m.cfg.BatchCanonicality, desc.CommitmentTxID, + ctx, m.cfg.BatchCanonicality, + lineageCommitmentTxids(desc)..., ) if err != nil { return nil, fmt.Errorf("lineage gate %s: %w", @@ -1932,10 +1965,9 @@ func (m *Manager) handleReserveForfeit(ctx context.Context, // limbo (reorged-out) or invalidated (conflict-finalized) canonicality state, // so an explicit forfeit reservation must be refused. It mirrors the // coin-selection gate (gateUnavailableLineage) for the explicit-outpoint -// reserve path: a no-op when no canonicality store is configured, and -// permissive for unseen / unregistered lineage. It reads the candidate's -// direct commitment txid; full multi-parent ancestry gating arrives with the -// selection gate's multi-parent extension. +// reserve path, gating on the full lineage (direct + ancestor commitment +// txids): a no-op when no canonicality store is configured, and permissive for +// unseen / unregistered lineage. func (m *Manager) forfeitLineageBlocked(ctx context.Context, op wire.OutPoint) ( bool, batchcanon.Availability, error) { @@ -1951,7 +1983,7 @@ func (m *Manager) forfeitLineageBlocked(ctx context.Context, op wire.OutPoint) ( } return batchcanon.LineageBlocked( - ctx, m.cfg.BatchCanonicality, desc.CommitmentTxID, + ctx, m.cfg.BatchCanonicality, lineageCommitmentTxids(desc)..., ) } diff --git a/vtxo/manager_multiparent_gate_test.go b/vtxo/manager_multiparent_gate_test.go new file mode 100644 index 000000000..db6a9d44b --- /dev/null +++ b/vtxo/manager_multiparent_gate_test.go @@ -0,0 +1,115 @@ +package vtxo + +import ( + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/lightninglabs/darepo-client/batchcanon" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestLineageCommitmentTxids verifies the helper collects the direct +// commitment txid plus every distinct ancestor commitment txid, dedupes, and +// skips the zero hash. +func TestLineageCommitmentTxids(t *testing.T) { + t.Parallel() + + direct := chainhash.Hash{0x01} + parentA := chainhash.Hash{0x02} + parentB := chainhash.Hash{0x03} + + desc := &Descriptor{ + CommitmentTxID: direct, + Ancestry: []Ancestry{ + { + CommitmentTxID: parentA, + }, + { + CommitmentTxID: parentB, + }, + // Duplicate of the direct txid: must be deduped. + { + CommitmentTxID: direct, + }, + // Zero hash: must be skipped. + { + CommitmentTxID: chainhash.Hash{}, + }, + }, + } + + got := lineageCommitmentTxids(desc) + require.Equal( + t, []chainhash.Hash{direct, parentA, parentB}, got, + ) +} + +// TestLineageCommitmentTxidsDirectOnly verifies a VTXO with no ancestry (e.g. +// an incoming VTXO materialized without its commitment tree) still yields its +// direct commitment txid so the gate governs it. +func TestLineageCommitmentTxidsDirectOnly(t *testing.T) { + t.Parallel() + + desc := &Descriptor{CommitmentTxID: chainhash.Hash{0x09}} + require.Equal( + t, []chainhash.Hash{{0x09}}, lineageCommitmentTxids(desc), + ) +} + +// TestSelectExcludesMultiParentLimboLineage verifies that a cross-commitment +// OOR VTXO is gated out when ANY of its ancestor batches is in limbo, even +// though its direct commitment batch is canonical. This is the multi-parent +// extension: the worst parent dominates. +func TestSelectExcludesMultiParentLimboLineage(t *testing.T) { + t.Parallel() + + good := makeDescriptor(t, 40000, 0) + multi := makeDescriptor(t, 50000, 1) + + good.CommitmentTxID = chainhash.Hash{0xaa} + + // multi descends from two batches: its direct commitment (canonical) + // and a cross-commitment ancestor that reorged out. + directBatch := chainhash.Hash{0xbb} + ancestorBatch := chainhash.Hash{0xcc} + multi.CommitmentTxID = directBatch + multi.Ancestry = []Ancestry{ + { + CommitmentTxID: directBatch, + }, + { + CommitmentTxID: ancestorBatch, + }, + } + + mgr, store := newTestManager(t, []*Descriptor{good, multi}) + mgr.cfg.BatchCanonicality = &fakeBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + good.CommitmentTxID: batchcanon.StateProvisional, + directBatch: batchcanon.StateProvisional, + ancestorBatch: batchcanon.StateReorgedOut, + }, + } + + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusLive, + ).Return([]*Descriptor{good, multi}, nil) + store.On("GetVTXO", mock.Anything, good.Outpoint).Return(good, nil) + store.On("GetVTXO", mock.Anything, multi.Outpoint).Return(multi, nil) + + result := mgr.Receive(t.Context(), &SelectAndReserveSpendRequest{ + TargetAmount: 40000, + }) + resp, err := result.Unpack() + require.NoError(t, err) + + spendResp, ok := resp.(*SelectAndReserveSpendResponse) + require.True(t, ok) + + // The 50000 multi-parent candidate is gated out for its reorged-out + // ANCESTOR batch despite its direct batch being canonical, so selection + // falls to the 40000 candidate. + require.Len(t, spendResp.SelectedVTXOs, 1) + require.Equal(t, good.Outpoint, spendResp.SelectedVTXOs[0].Outpoint) +}