From a18c0a46cd5f6497aff420407829c85a37a95976 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 8 Jul 2026 14:04:46 -0700 Subject: [PATCH 1/2] 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 | 10 ++ batchcanon/CLAUDE.md | 10 ++ batchcanon/availability.go | 214 +++++++++++++++++++++++++++ batchcanon/availability_test.go | 196 ++++++++++++++++++++++++ vtxo/AGENTS.md | 8 + vtxo/CLAUDE.md | 8 + vtxo/manager.go | 112 ++++++++++++++ vtxo/manager_forfeit_gate_test.go | 70 +++++++++ vtxo/manager_lineage_gate_test.go | 237 ++++++++++++++++++++++++++++++ 9 files changed, 865 insertions(+) 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..8a6f32f80 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 diff --git a/batchcanon/CLAUDE.md b/batchcanon/CLAUDE.md index a384e0365..8a6f32f80 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 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/AGENTS.md b/vtxo/AGENTS.md index 354b1ac8e..64b910e50 100644 --- a/vtxo/AGENTS.md +++ b/vtxo/AGENTS.md @@ -28,6 +28,14 @@ when the local wallet owns the receive script. context. `ExitOutcomeResolver` is called at startup to reconcile VTXOs still persisted in `VTXOStatusUnilateralExit` with their terminal job outcome. `ReservationStore` is used at startup to sweep orphaned Spending VTXOs. + `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. - `ExitOutcomeResolution` — Terminal result for an exiting VTXO: `Outcome` (`ExitOutcomeRecoverable` or `ExitOutcomeConfirmed`) and `Reason`. - `ExitOutcomeResolver` — Function type diff --git a/vtxo/CLAUDE.md b/vtxo/CLAUDE.md index 354b1ac8e..64b910e50 100644 --- a/vtxo/CLAUDE.md +++ b/vtxo/CLAUDE.md @@ -28,6 +28,14 @@ when the local wallet owns the receive script. context. `ExitOutcomeResolver` is called at startup to reconcile VTXOs still persisted in `VTXOStatusUnilateralExit` with their terminal job outcome. `ReservationStore` is used at startup to sweep orphaned Spending VTXOs. + `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. - `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 2d5378c3c..e08efc347 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -17,6 +17,7 @@ import ( "github.com/btcsuite/btclog/v2" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/batchcanon" "github.com/lightninglabs/darepo-client/build" "github.com/lightninglabs/darepo-client/chainsource" "github.com/lightninglabs/darepo-client/coinselect" @@ -136,6 +137,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 @@ -1148,6 +1159,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) { @@ -1193,6 +1249,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 @@ -1812,6 +1877,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. @@ -1843,6 +1928,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..26692916b --- /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/darepo-client/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..843aa5300 --- /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/darepo-client/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 28576e72df99786811d8431dd3956ece30796a16 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 8 Jul 2026 14:07:23 -0700 Subject: [PATCH 2/2] 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). --- chainsource/finality.go | 40 ++--- round/actor.go | 298 ++++++++++++++++++++++++++++--- 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 +++++- 7 files changed, 760 insertions(+), 57 deletions(-) create mode 100644 round/batch_canonicality_test.go diff --git a/chainsource/finality.go b/chainsource/finality.go index 5bdc97ac6..2b8561b1f 100644 --- a/chainsource/finality.go +++ b/chainsource/finality.go @@ -21,16 +21,6 @@ var finalityBlockSubscriptionBackoffs = []time.Duration{ 2 * time.Second, } -// finalityBlockSubscriptionAttemptTimeout bounds each individual -// RegisterBlocks attempt. Without it a single hung RegisterBlocks call -// (e.g. a wedged lndclient gRPC stream) would block the conf/spend -// monitoring goroutine indefinitely — stalling Confirmed/Reorged/Done -// delivery on that watch — since the retry schedule only bounds the gaps -// between attempts, not the attempts themselves. 10s mirrors the per-call -// registration timeout used in conf_actor.go's handleRegisterConf so the -// whole file behaves consistently under a slow backend. -const finalityBlockSubscriptionAttemptTimeout = 10 * time.Second - // registerBlocksForFinality registers a block-epoch subscription used // to synthesize a Done signal at FinalityDepth past an observed // confirmation or spend. The call is retried with a short bounded @@ -39,11 +29,22 @@ const finalityBlockSubscriptionAttemptTimeout = 10 * time.Second // lndclient over gRPC); a one-shot RegisterBlocks attempt that // briefly hiccups would leak the per-watch sub-actor indefinitely. // -// The retries run in the calling sub-actor's monitoring goroutine, so -// brief blocking here is safe: more confirmation/spend events on this -// specific watch are not expected during the retry window (we already -// consumed the one that triggered the arm), and ctx cancellation -// breaks out promptly. +// The retries run in a dedicated arming goroutine (not the sub-actor's +// select loop), so brief blocking here is safe: more confirmation/spend +// events on this specific watch are not expected during the retry window +// (we already consumed the one that triggered the arm), and ctx +// cancellation breaks out promptly. +// +// The passed ctx MUST be the sub-actor's long-lived context, and it is +// handed to RegisterBlocks unwrapped: for in-process backends the +// block-epoch forwarder goroutine is tied to the ctx it receives, so +// bounding each attempt with a cancellable child ctx (and cancelling it +// once the call returns) would tear the subscription down the instant it +// was armed — starving finality synthesis of the very epochs it needs. +// A hung RegisterBlocks can therefore stall this arming goroutine, but +// that is contained: it is off the select loop (fix moved arming there +// precisely so a slow backend cannot wedge Confirmed/Reorged/Done +// delivery), and a genuinely wedged backend is a lost watch regardless. // // Returns the registration on success, or a non-nil error after // retries are exhausted. Callers should log the error at warn level @@ -54,14 +55,7 @@ func registerBlocksForFinality(ctx context.Context, backend ChainBackend, var lastErr error for attempt, backoff := range finalityBlockSubscriptionBackoffs { - // Bound each attempt so a hung RegisterBlocks cannot wedge the - // monitoring goroutine; the retry schedule only bounds the gaps - // between attempts, not a single stuck call. - attemptCtx, cancel := context.WithTimeout( - ctx, finalityBlockSubscriptionAttemptTimeout, - ) - reg, err := backend.RegisterBlocks(attemptCtx) - cancel() + reg, err := backend.RegisterBlocks(ctx) if err == nil { return reg, nil } diff --git a/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{