diff --git a/batchcanon/AGENTS.md b/batchcanon/AGENTS.md new file mode 100644 index 000000000..fc599db0c --- /dev/null +++ b/batchcanon/AGENTS.md @@ -0,0 +1,100 @@ +# batchcanon + +## Purpose + +Client-side **batch canonicality data model** for the reorg-safety epic +(darepo#454, task C2). Holds the durable, reorg-aware record of how each batch +(commitment) transaction is faring against the best chain: its canonicality +state, current confirmation observation, recompute inputs for effective +expiry, the inputs it consumes, the VTXOs it anchors, and the reverse +dependencies needed to restore a provisionally consumed VTXO. + +This package is **data + query/update interface only**. It contains no +interpretation, no chain watching, and no admission behavior — those belong to +the (later) `BatchCanonicalityManager` and the VTXO manager. Keeping the model +in its own package, separate from `chainsource` (raw observation) and `vtxo` +(admission), preserves the epic's observation → interpretation → action split. + +## Key Types + +- `State` — canonicality state enum: `StateUnseen`, `StateProvisional`, + `StateFinalized`, `StateReorgedOut`, `StateConflictProvisional`, + `StateConflictFinalized`. Reorg-reversible; **no state is a terminal + verdict** at this layer. Persisted as an append-only typed INTEGER column — + values must never be renumbered. +- `PolicyState` — reserved policy classification slot (`PolicyStateDefault` + only); persisted and round-tripped, no business meaning yet. +- `Record` — per-batch record keyed by `BatchTxID`. Identity is by **txid**, + never `(txid, block hash)`; `ConfirmationBlock` is an observation attribute + only. `EffectiveExpiry()` derives the absolute expiry as + `ConfirmationHeight + CSVExpiryDelta`, returning `None` when unconfirmed — + the structural guarantee that expiry is recomputed on every + reconfirmation rather than frozen. +- `ProvisionalConsumer` — reverse-dependency edge (consumed VTXO → consumer + batch) enabling VTXO restore if a consumer batch never becomes canonical. +- `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 + `db.BatchCanonicalityPersistenceStore.BackfillFromVTXOs`. +- `Manager` — the actor that interprets chain observation into canonicality + state (the sole client-side interpreter). Registered under + `ManagerServiceKey`. `RegisterBatchRequest` arms one reorg-aware + confirmation watch on the batch tx and one reorg-aware spend watch per + consumed input (deduped per batch, idempotent — repeats merge dependent + VTXOs). It maps chainsource `ConfirmationEvent`/`ConfReorgedEvent`/ + `ConfDoneEvent` and `SpendEvent`/`SpendReorgedEvent`/`SpendDoneEvent` onto + its own mailbox and derives `State` per the priority + `conflict_finalized > conflict_provisional > reorged_out > + finalized/provisional > unseen`. `Reconcile` re-arms watches for non-final + batches after restart without downgrading persisted state. + `GetBatchStateRequest` reads the persisted record. `NewManager` returns the + behavior; the caller registers it, then calls `SetSelfRef(ref.TellRef())` + and `Reconcile`. + +## Relationships + +- **Depends on**: `btcd/chaincfg/chainhash`, `btcd/wire`, `lnd/fn/v2` only. +- **Depended on by**: `db` (concrete store), and — in later tasks — the + batch canonicality manager and `vtxo` admission. + +## Invariants + +- Identity is by txid / outpoint, never by `(txid, block hash)`. +- Expiry is never persisted as a standalone or terminal value; it is always + derived from `CSVExpiryDelta` + the current confirmation observation. +- State enum integer values are append-only (persisted column). + +## Expiry-as-terminal audit (darepo#454 C2) + +C2 requires auditing every site that treats `BatchExpiry`/`Expired` as a +one-way terminal fact. These are flagged for rework when the +BatchCanonicalityManager (task C3/C4) rewires expiry consumers onto +`Record.EffectiveExpiry()`; **no behavior is changed by C2**: + +- `vtxo/transitions.go` (`ExpiryStatusExpired → FailedState{Recoverable: + false}`, and the Critical/Expired escalations) — the primary offender: a + reorg that lowers the confirmation height could otherwise push a VTXO + permanently into non-recoverable `Failed`. +- `vtxo/expiry.go` (`CheckExpiry`, `BlocksUntilExpiry`) — compute from the + frozen absolute `vtxo.BatchExpiry`; must consume effective (recomputable) + expiry instead. +- `vtxo/actor.go` — schedules on the frozen absolute `BatchExpiry`. +- `waved/vhtlc_recovery_target.go` — folds multiple roots into a + most-restrictive absolute `batchExpiry`. +- `unroll/proof_assembler.go` (`BatchExpiry == 0`) — treats zero as "unset", + not terminal; benign, documented for completeness. + +## Deep Docs + +- [ARCHITECTURE.md](../ARCHITECTURE.md) — System-wide package map. + diff --git a/batchcanon/CLAUDE.md b/batchcanon/CLAUDE.md new file mode 100644 index 000000000..fc599db0c --- /dev/null +++ b/batchcanon/CLAUDE.md @@ -0,0 +1,100 @@ +# batchcanon + +## Purpose + +Client-side **batch canonicality data model** for the reorg-safety epic +(darepo#454, task C2). Holds the durable, reorg-aware record of how each batch +(commitment) transaction is faring against the best chain: its canonicality +state, current confirmation observation, recompute inputs for effective +expiry, the inputs it consumes, the VTXOs it anchors, and the reverse +dependencies needed to restore a provisionally consumed VTXO. + +This package is **data + query/update interface only**. It contains no +interpretation, no chain watching, and no admission behavior — those belong to +the (later) `BatchCanonicalityManager` and the VTXO manager. Keeping the model +in its own package, separate from `chainsource` (raw observation) and `vtxo` +(admission), preserves the epic's observation → interpretation → action split. + +## Key Types + +- `State` — canonicality state enum: `StateUnseen`, `StateProvisional`, + `StateFinalized`, `StateReorgedOut`, `StateConflictProvisional`, + `StateConflictFinalized`. Reorg-reversible; **no state is a terminal + verdict** at this layer. Persisted as an append-only typed INTEGER column — + values must never be renumbered. +- `PolicyState` — reserved policy classification slot (`PolicyStateDefault` + only); persisted and round-tripped, no business meaning yet. +- `Record` — per-batch record keyed by `BatchTxID`. Identity is by **txid**, + never `(txid, block hash)`; `ConfirmationBlock` is an observation attribute + only. `EffectiveExpiry()` derives the absolute expiry as + `ConfirmationHeight + CSVExpiryDelta`, returning `None` when unconfirmed — + the structural guarantee that expiry is recomputed on every + reconfirmation rather than frozen. +- `ProvisionalConsumer` — reverse-dependency edge (consumed VTXO → consumer + batch) enabling VTXO restore if a consumer batch never becomes canonical. +- `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 + `db.BatchCanonicalityPersistenceStore.BackfillFromVTXOs`. +- `Manager` — the actor that interprets chain observation into canonicality + state (the sole client-side interpreter). Registered under + `ManagerServiceKey`. `RegisterBatchRequest` arms one reorg-aware + confirmation watch on the batch tx and one reorg-aware spend watch per + consumed input (deduped per batch, idempotent — repeats merge dependent + VTXOs). It maps chainsource `ConfirmationEvent`/`ConfReorgedEvent`/ + `ConfDoneEvent` and `SpendEvent`/`SpendReorgedEvent`/`SpendDoneEvent` onto + its own mailbox and derives `State` per the priority + `conflict_finalized > conflict_provisional > reorged_out > + finalized/provisional > unseen`. `Reconcile` re-arms watches for non-final + batches after restart without downgrading persisted state. + `GetBatchStateRequest` reads the persisted record. `NewManager` returns the + behavior; the caller registers it, then calls `SetSelfRef(ref.TellRef())` + and `Reconcile`. + +## Relationships + +- **Depends on**: `btcd/chaincfg/chainhash`, `btcd/wire`, `lnd/fn/v2` only. +- **Depended on by**: `db` (concrete store), and — in later tasks — the + batch canonicality manager and `vtxo` admission. + +## Invariants + +- Identity is by txid / outpoint, never by `(txid, block hash)`. +- Expiry is never persisted as a standalone or terminal value; it is always + derived from `CSVExpiryDelta` + the current confirmation observation. +- State enum integer values are append-only (persisted column). + +## Expiry-as-terminal audit (darepo#454 C2) + +C2 requires auditing every site that treats `BatchExpiry`/`Expired` as a +one-way terminal fact. These are flagged for rework when the +BatchCanonicalityManager (task C3/C4) rewires expiry consumers onto +`Record.EffectiveExpiry()`; **no behavior is changed by C2**: + +- `vtxo/transitions.go` (`ExpiryStatusExpired → FailedState{Recoverable: + false}`, and the Critical/Expired escalations) — the primary offender: a + reorg that lowers the confirmation height could otherwise push a VTXO + permanently into non-recoverable `Failed`. +- `vtxo/expiry.go` (`CheckExpiry`, `BlocksUntilExpiry`) — compute from the + frozen absolute `vtxo.BatchExpiry`; must consume effective (recomputable) + expiry instead. +- `vtxo/actor.go` — schedules on the frozen absolute `BatchExpiry`. +- `waved/vhtlc_recovery_target.go` — folds multiple roots into a + most-restrictive absolute `batchExpiry`. +- `unroll/proof_assembler.go` (`BatchExpiry == 0`) — treats zero as "unset", + not terminal; benign, documented for completeness. + +## Deep Docs + +- [ARCHITECTURE.md](../ARCHITECTURE.md) — System-wide package map. + 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/batchcanon/doc.go b/batchcanon/doc.go new file mode 100644 index 000000000..59a5064de --- /dev/null +++ b/batchcanon/doc.go @@ -0,0 +1,26 @@ +// Package batchcanon holds the client-side batch canonicality data model: +// the durable record of how each batch (commitment) transaction is faring +// against the best chain, the inputs it consumes, the VTXOs it anchors, and +// the reverse-dependency edges needed to restore a provisionally consumed +// VTXO if its consumer batch never becomes canonical. +// +// This package is the data substrate for the reorg-safety epic +// (darepo#454). It deliberately contains NO interpretation or admission +// behavior: it persists and retrieves observations only. The +// BatchCanonicalityManager (a later task) is the sole interpreter that +// drives state transitions from chainsource observations, and the VTXO +// manager remains the admission boundary. Keeping the model here, separate +// from both chainsource (raw observation) and vtxo (admission), preserves +// the observation -> interpretation -> action split the epic mandates. +// +// Two principles shape the model: +// +// - Identity is by txid / outpoint, never by (txid, block hash). A reorg +// that re-mines the same batch tx in a different block is the SAME +// batch; the block hash is only an observation attribute. +// +// - Expiry is never stored as a terminal fact. The model stores a +// CSV-relative delta plus the current confirmation height and derives +// the effective (absolute) expiry on demand, so a reorg-and-reconfirm +// at a new height recomputes expiry instead of freezing it. +package batchcanon diff --git a/batchcanon/manager.go b/batchcanon/manager.go new file mode 100644 index 000000000..a48ed2308 --- /dev/null +++ b/batchcanon/manager.go @@ -0,0 +1,897 @@ +package batchcanon + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/build" + "github.com/lightninglabs/wavelength/chainsource" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +// ManagerServiceKey is the receptionist key the BatchCanonicalityManager +// registers under. +var ManagerServiceKey = actor.NewServiceKey[ManagerMsg, ManagerResp]( + "batch-canonicality", +) + +// usabilityConfs is the confirmation count at which the manager wants the +// first positive confirmation notification. Ark's usability depth is one +// confirmation: a batch is provisionally usable as soon as it confirms, and +// the reorg-aware lifecycle keeps it correct from there. Policy finality is +// signalled separately by chainsource's Done event at its FinalityDepth. +const usabilityConfs uint32 = 1 + +// confState is the manager's in-memory view of a batch tx's confirmation +// observation, distinct from any input-conflict view. +type confState int + +const ( + confUnseen confState = iota + confConfirmed + confFinalized + confReorgedOut +) + +// inputWatch tracks the conflict view of one consumed batch input. +type inputWatch struct { + // spenderIsConflict records whether the last observed spend of this + // input was by a transaction other than the batch itself. The batch + // consuming its own input is the expected, non-conflicting case. + spenderIsConflict bool + + // conflicting is true while a conflicting spend is observed and has not + // been reorged out. + conflicting bool + + // conflictFinal is true once a conflicting spend matured past the + // reorg-safety depth. + conflictFinal bool +} + +// batchWatch is the manager's in-memory state for one watched batch. +type batchWatch struct { + txid chainhash.Hash + pkScript []byte + + conf confState + inputs map[wire.OutPoint]*inputWatch + + // persisted is the State last written to the store, so the manager only + // issues an UpdateBatchState when the derived state actually changes. + persisted State +} + +// ManagerConfig configures the BatchCanonicalityManager. +type ManagerConfig struct { + // Store is the durable canonicality store. + Store Store + + // ChainSource is the chain-observation actor the manager registers + // reorg-aware conf/spend watches with. + ChainSource actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ] + + // RestoreConsumedVTXO, when set, is invoked for each VTXO outpoint that + // was provisionally forfeited into a batch that has now been + // invalidated (a finalized conflict reversed its forfeit). The daemon + // wires this to the VTXO manager to roll the VTXO back to a spendable + // state. Nil leaves the reverse-dependency edges in place without + // acting -- the default until the FSM restore path is wired -- so the + // data model stays consistent and a later run can still act on the + // persisted edges. + RestoreConsumedVTXO func(ctx context.Context, vtxo wire.OutPoint) error + + // Log is an optional logger. + Log fn.Option[btclog.Logger] +} + +// Manager is the sole client-side interpreter of batch canonicality. It +// observes (via chainsource) each batch tx confirmation and each consumed +// input, interprets the reorg-aware lifecycle into batchcanon.State, and +// persists the result. It is an actor behavior: chainsource events arrive as +// internal messages re-wrapped onto the manager's own mailbox. +// +// Spend watches on consumed inputs are registered with the prevout pkScript +// carried on each RegisterBatchRequest.ConsumedInput, which lnd's spend +// notifier requires (it filters by output script). An input with no pkScript +// is skipped rather than failing the whole batch registration, so the +// confirmation watch and the remaining inputs still arm. +type Manager struct { + cfg ManagerConfig + log btclog.Logger + selfRef actor.TellOnlyRef[ManagerMsg] + + watches map[chainhash.Hash]*batchWatch +} + +// NewManager builds a BatchCanonicalityManager behavior. SetSelfRef must be +// called (with the registered actor's TellRef) before any batch is registered +// so the manager can route chainsource events back to itself. +func NewManager(cfg ManagerConfig) *Manager { + return &Manager{ + cfg: cfg, + log: cfg.Log.UnwrapOr(btclog.Disabled), + watches: make(map[chainhash.Hash]*batchWatch), + } +} + +// SetSelfRef wires the manager's own mailbox ref, used to build the mapped +// chainsource notification refs. +func (m *Manager) SetSelfRef(ref actor.TellOnlyRef[ManagerMsg]) { + m.selfRef = ref +} + +// Receive implements actor.ActorBehavior. It serializes all canonicality +// mutations through the single actor mailbox. +func (m *Manager) Receive(ctx context.Context, + msg ManagerMsg) fn.Result[ManagerResp] { + + switch v := msg.(type) { + case *RegisterBatchRequest: + return m.handleRegisterBatch(ctx, v) + + case *GetBatchStateRequest: + return m.handleGetBatchState(ctx, v) + + case *batchConfirmedMsg: + m.handleBatchConfirmed(ctx, v) + + case *batchReorgedMsg: + m.handleBatchReorged(ctx, v) + + case *batchDoneMsg: + m.handleBatchDone(ctx, v) + + case *inputSpentMsg: + m.handleInputSpent(ctx, v) + + case *inputSpendReorgedMsg: + m.handleInputSpendReorged(ctx, v) + + case *inputSpendDoneMsg: + m.handleInputSpendDone(ctx, v) + + default: + return fn.Err[ManagerResp]( + fmt.Errorf("unknown batchcanon message: %T", msg), + ) + } + + return fn.Ok[ManagerResp](&ackResponse{}) +} + +// logger returns the configured logger, falling back to the context logger. +func (m *Manager) logger(ctx context.Context) btclog.Logger { + return m.cfg.Log.UnwrapOr(build.LoggerFromContext(ctx)) +} + +// handleRegisterBatch persists the batch record and arms its watches. It is +// idempotent: a repeat for the same batch merges the dependent VTXOs into the +// record without re-arming watches. +func (m *Manager) handleRegisterBatch(ctx context.Context, + req *RegisterBatchRequest) fn.Result[ManagerResp] { + + existing, ok := m.watches[req.BatchTxID] + if ok { + // Already watching: merge dependent VTXOs into the record and + // return without duplicating watches. + if err := m.mergeDependents(ctx, existing, req); err != nil { + return fn.Err[ManagerResp](err) + } + + return fn.Ok[ManagerResp](&RegisterBatchResponse{}) + } + + // Persist the initial record (unseen until the first observation). + record := &Record{ + BatchTxID: req.BatchTxID, + State: StateUnseen, + ConfirmationHeight: fn.None[int32](), + ConfirmationBlock: fn.None[chainhash.Hash](), + CSVExpiryDelta: req.CSVExpiryDelta, + PolicyState: PolicyStateDefault, + ConfirmationPkScript: req.ConfirmationPkScript, + ConsumedInputs: req.ConsumedInputs, + DependentVTXOs: req.DependentVTXOs, + } + if err := m.cfg.Store.UpsertBatch(ctx, record); err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("persist batch record: %w", err), + ) + } + + // Record a reverse-dependency edge for every VTXO this batch forfeits, + // so the VTXO can be restored if this batch is later invalidated. The + // edges are persisted and idempotent on (consumedVTXO, consumerBatch). + for _, forfeited := range req.ForfeitedVTXOs { + err := m.cfg.Store.AddProvisionalConsumer( + ctx, forfeited, req.BatchTxID, + ) + if err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("record provisional consumer %s "+ + "-> %s: %w", forfeited, req.BatchTxID, + err), + ) + } + } + + w := &batchWatch{ + txid: req.BatchTxID, + pkScript: req.ConfirmationPkScript, + conf: confUnseen, + inputs: make(map[wire.OutPoint]*inputWatch), + } + for _, in := range req.ConsumedInputs { + w.inputs[in.Outpoint] = &inputWatch{} + } + + // Record the watch only AFTER arming succeeds. If we recorded it first + // and arming failed, a retry would take the idempotent "already + // watching" merge path at the top of handleRegisterBatch and never + // re-arm the missing/partial chain watches until a restart. Leaving + // m.watches untouched on failure means a retry re-arms from scratch; + // re-registering the same conf/spend caller IDs is idempotent (the same + // property Reconcile relies on after restart). + if err := m.armWatches(ctx, w, req.ConsumedInputs); err != nil { + return fn.Err[ManagerResp](err) + } + m.watches[req.BatchTxID] = w + + return fn.Ok[ManagerResp](&RegisterBatchResponse{}) +} + +// mergeDependents adds any new dependent VTXOs from a repeat registration to +// the persisted record, keeping the batch's existing watches and state. +func (m *Manager) mergeDependents(ctx context.Context, w *batchWatch, + req *RegisterBatchRequest) error { + + record, err := m.cfg.Store.GetBatch(ctx, w.txid) + if err != nil { + return fmt.Errorf("load batch for merge: %w", err) + } + + seen := make(map[wire.OutPoint]struct{}, len(record.DependentVTXOs)) + for _, dep := range record.DependentVTXOs { + seen[dep] = struct{}{} + } + changed := false + for _, dep := range req.DependentVTXOs { + if _, ok := seen[dep]; ok { + continue + } + record.DependentVTXOs = append(record.DependentVTXOs, dep) + seen[dep] = struct{}{} + changed = true + } + if !changed { + return nil + } + + return m.cfg.Store.UpsertBatch(ctx, record) +} + +// armWatches registers the reorg-aware confirmation watch on the batch tx and +// a reorg-aware spend watch on each consumed input. +func (m *Manager) armWatches(ctx context.Context, w *batchWatch, + inputs []ConsumedInput) error { + + heightHint := m.bestHeightHint(ctx) + + confReq := &chainsource.RegisterConfRequest{ + CallerID: confCallerID(w.txid), + Txid: &w.txid, + PkScript: w.pkScript, + TargetConfs: usabilityConfs, + HeightHint: heightHint, + NotifyActor: fn.Some( + chainsource.MapConfirmationEvent( + m.selfRef, + func( + ce chainsource.ConfirmationEvent, + ) ManagerMsg { + + return &batchConfirmedMsg{ + txid: ce.Txid, + blockHeight: ce.BlockHeight, + blockHash: ce.BlockHash, + } + }, + ), + ), + NotifyReorged: fn.Some( + chainsource.MapConfReorgedEvent( + m.selfRef, + func( + ev chainsource.ConfReorgedEvent, + ) ManagerMsg { + + return &batchReorgedMsg{ + txid: ev.Txid, + } + }, + ), + ), + NotifyDone: fn.Some( + chainsource.MapConfDoneEvent( + m.selfRef, + func(ev chainsource.ConfDoneEvent) ManagerMsg { + return &batchDoneMsg{ + txid: ev.Txid, + } + }, + ), + ), + } + if err := m.cfg.ChainSource.Tell(ctx, confReq); err != nil { + return fmt.Errorf("register batch conf watch: %w", err) + } + + for i := range inputs { + // A consumed input with no pkScript cannot be watched: lnd's + // spend notifier filters by output script. Rather than fail the + // whole batch registration (which would also drop the working + // confirmation watch), skip just this input's spend watch. + // Conf- based reorg tracking still works; only conflict + // detection on this one input is degraded. Empty scripts should + // only occur on legacy/backfilled inputs that predate script + // tracking. + if len(inputs[i].PkScript) == 0 { + m.logger(ctx).InfoS(ctx, "Skipping spend watch for "+ + "consumed input with no pkScript", + slog.String("batch", w.txid.String()), + slog.String( + "outpoint", inputs[i].Outpoint.String(), + )) + + continue + } + + if err := m.armSpendWatch( + ctx, w.txid, inputs[i], heightHint, + ); err != nil { + return err + } + } + + return nil +} + +// armSpendWatch registers one reorg-aware spend watch on a consumed input. The +// input's pkScript is forwarded to the spend notifier, which filters by output +// script; without it lnd rejects the registration ("an output script must be +// provided") and the conflict-detection watch never arms. +func (m *Manager) armSpendWatch(ctx context.Context, txid chainhash.Hash, + in ConsumedInput, heightHint uint32) error { + + op := in.Outpoint + spendReq := &chainsource.RegisterSpendRequest{ + CallerID: spendCallerID(txid, op), + Outpoint: &op, + PkScript: in.PkScript, + HeightHint: heightHint, + NotifyActor: fn.Some( + chainsource.MapSpendEvent( + m.selfRef, + func(ev chainsource.SpendEvent) ManagerMsg { + return &inputSpentMsg{ + outpoint: ev.Outpoint, + spendingTxid: ev.SpendingTxid, + spendHeight: ev.SpendingHeight, + } + }, + ), + ), + NotifyReorged: fn.Some( + chainsource.MapSpendReorgedEvent( + m.selfRef, + func( + ev chainsource.SpendReorgedEvent, + ) ManagerMsg { + + return &inputSpendReorgedMsg{ + outpoint: ev.Outpoint, + } + }, + ), + ), + NotifyDone: fn.Some( + chainsource.MapSpendDoneEvent( + m.selfRef, + func(ev chainsource.SpendDoneEvent) ManagerMsg { + return &inputSpendDoneMsg{ + outpoint: ev.Outpoint, + } + }, + ), + ), + } + if err := m.cfg.ChainSource.Tell(ctx, spendReq); err != nil { + return fmt.Errorf("register input spend watch %s: %w", op, err) + } + + return nil +} + +// bestHeightHint asks chainsource for the current best height to use as a +// watch height hint. On error it returns 0 (scan from the backend's default), +// logging the failure rather than aborting registration. +func (m *Manager) bestHeightHint(ctx context.Context) uint32 { + resp, err := m.cfg.ChainSource.Ask( + ctx, &chainsource.BestHeightRequest{}, + ).Await(ctx).Unpack() + if err != nil { + m.logger(ctx).WarnS(ctx, "Batch canonicality best-height "+ + "query failed; using zero height hint", err) + + return 0 + } + + height, ok := resp.(*chainsource.BestHeightResponse) + if !ok { + return 0 + } + if height.Height < 0 { + return 0 + } + + return uint32(height.Height) +} + +// handleGetBatchState serves a read of the persisted canonicality record. +func (m *Manager) handleGetBatchState(ctx context.Context, + req *GetBatchStateRequest) fn.Result[ManagerResp] { + + record, err := m.cfg.Store.GetBatch(ctx, req.BatchTxID) + switch { + case errors.Is(err, ErrBatchNotFound): + return fn.Ok[ManagerResp](&GetBatchStateResponse{Found: false}) + + case err != nil: + return fn.Err[ManagerResp](err) + + default: + return fn.Ok[ManagerResp](&GetBatchStateResponse{ + Record: record, + Found: true, + }) + } +} + +// handleBatchConfirmed records the batch tx confirmation observation and +// re-derives the canonicality state. +func (m *Manager) handleBatchConfirmed(ctx context.Context, + msg *batchConfirmedMsg) { + + w, ok := m.watches[msg.txid] + if !ok { + return + } + + w.conf = confConfirmed + err := m.cfg.Store.RecordConfirmation( + ctx, msg.txid, msg.blockHeight, msg.blockHash, + ) + if err != nil { + m.logger(ctx).WarnS(ctx, "Failed to record batch confirmation", + err, "batch", msg.txid) + } + + m.deriveAndPersist(ctx, w) +} + +// handleBatchReorged clears the confirmation observation (the confirming block +// left the best chain) and re-derives state. +func (m *Manager) handleBatchReorged(ctx context.Context, + msg *batchReorgedMsg) { + + w, ok := m.watches[msg.txid] + if !ok { + return + } + + w.conf = confReorgedOut + if err := m.cfg.Store.ClearConfirmation(ctx, msg.txid); err != nil { + m.logger(ctx).WarnS(ctx, "Failed to clear batch confirmation", + err, "batch", msg.txid) + } + + m.deriveAndPersist(ctx, w) +} + +// handleBatchDone marks the batch confirmation as matured past the reorg- +// safety depth (policy finality) and re-derives state. The chainsource conf +// sub-actor releases its own registration on Done; the manager additionally +// releases the per-input spend watches, since a finalized batch's inputs are +// safely consumed and can no longer be double-spent. +func (m *Manager) handleBatchDone(ctx context.Context, msg *batchDoneMsg) { + w, ok := m.watches[msg.txid] + if !ok { + return + } + + w.conf = confFinalized + m.deriveAndPersist(ctx, w) + m.releaseSpendWatches(ctx, w) +} + +// handleInputSpent interprets a spend of a consumed batch input. The SAME +// outpoint can be consumed by more than one registered batch — that is exactly +// the double-spend the manager exists to classify — so every batch watching +// the outpoint is updated, not just one. For each such batch, a spend by that +// batch's own tx is the expected consumption (not a conflict), while a spend by +// any other transaction is a conflicting double-spend of that batch's input. +func (m *Manager) handleInputSpent(ctx context.Context, msg *inputSpentMsg) { + m.forEachInputWatch(msg.outpoint, func(w *batchWatch, iw *inputWatch) { + conflict := msg.spendingTxid != w.txid + iw.spenderIsConflict = conflict + iw.conflicting = conflict + iw.conflictFinal = false + + // Persist the input flags BEFORE the batch state so a crash + // between the two writes leaves the flags ahead of the state, + // not behind it: reconcile derives conflict from the flags and + // self-corrects the state, whereas stale non-conflict flags + // would let a re-observed confirmation downgrade a real + // conflict. + m.persistInputConflict(ctx, w, msg.outpoint, iw) + m.deriveAndPersist(ctx, w) + }) +} + +// persistInputConflict writes the observed conflict flags of one consumed +// input so a restart can rebuild the per-input conflict view (see +// reconcileOne). A failure is logged, not fatal: the in-memory view stays +// correct for this run, and the worst case on the next restart is the +// reconciliation window this persistence exists to close. +func (m *Manager) persistInputConflict(ctx context.Context, w *batchWatch, + op wire.OutPoint, iw *inputWatch) { + + err := m.cfg.Store.RecordInputConflict( + ctx, w.txid, op, iw.conflicting, iw.conflictFinal, + ) + if err != nil { + m.logger(ctx).WarnS(ctx, "Failed to persist input conflict "+ + "flags", err, slog.String("batch", w.txid.String()), + slog.String("outpoint", op.String())) + } +} + +// handleInputSpendReorged clears a previously observed spend that left the +// best chain, for every batch watching the outpoint. +func (m *Manager) handleInputSpendReorged(ctx context.Context, + msg *inputSpendReorgedMsg) { + + m.forEachInputWatch(msg.outpoint, func(w *batchWatch, iw *inputWatch) { + iw.conflicting = false + iw.conflictFinal = false + + m.persistInputConflict(ctx, w, msg.outpoint, iw) + m.deriveAndPersist(ctx, w) + }) +} + +// handleInputSpendDone promotes a conflicting spend to finalized once it has +// matured past the reorg-safety depth, for every batch watching the outpoint. +// A matured spend by a batch's own tx is the normal consumption, so only the +// batches for which the spend was a conflict are promoted. +func (m *Manager) handleInputSpendDone(ctx context.Context, + msg *inputSpendDoneMsg) { + + m.forEachInputWatch(msg.outpoint, func(w *batchWatch, iw *inputWatch) { + if iw.spenderIsConflict { + iw.conflictFinal = true + } + + m.persistInputConflict(ctx, w, msg.outpoint, iw) + m.deriveAndPersist(ctx, w) + }) +} + +// forEachInputWatch invokes fn for every batch whose consumed-input set +// contains op. The same outpoint can appear under multiple batches (the +// conflict case: two batches spending the same input), so all matching watches +// must be visited — keying input watches by outpoint alone does NOT uniquely +// identify a batch. +func (m *Manager) forEachInputWatch(op wire.OutPoint, + fn func(w *batchWatch, iw *inputWatch)) { + + for _, w := range m.watches { + if iw, ok := w.inputs[op]; ok { + fn(w, iw) + } + } +} + +// deriveState computes the dominant canonicality state from the batch's +// in-memory confirmation and input-conflict views, applying the priority +// conflict_finalized > conflict_provisional > reorged_out > +// finalized/provisional > unseen. +func deriveState(w *batchWatch) State { + anyConflictFinal := false + anyConflict := false + for _, iw := range w.inputs { + if iw.conflictFinal { + anyConflictFinal = true + } + if iw.conflicting { + anyConflict = true + } + } + + switch { + case anyConflictFinal: + return StateConflictFinalized + + case anyConflict: + return StateConflictProvisional + + case w.conf == confReorgedOut: + return StateReorgedOut + + case w.conf == confFinalized: + return StateFinalized + + case w.conf == confConfirmed: + return StateProvisional + + default: + return StateUnseen + } +} + +// deriveAndPersist recomputes the batch state and writes it only when it +// changed since the last persisted value. +func (m *Manager) deriveAndPersist(ctx context.Context, w *batchWatch) { + next := deriveState(w) + if next == w.persisted { + return + } + + if err := m.cfg.Store.UpdateBatchState(ctx, w.txid, next); err != nil { + m.logger(ctx).WarnS(ctx, "Failed to persist batch state", err, + "batch", w.txid, "state", next.String()) + + return + } + w.persisted = next + + m.handleConsumerLifecycle(ctx, w.txid, next) +} + +// handleConsumerLifecycle reacts to a batch's canonicality transition for the +// VTXOs it provisionally forfeits (its reverse-dependency edges): +// +// - StateConflictFinalized: the batch is permanently off the canonical chain +// (a conflicting spend matured past the reorg-safety depth), so its forfeit +// is reversed -- restore every consumed VTXO, then drop the edges. +// - StateFinalized: the batch is itself canonical and final, so the forfeit +// is now safe and the restore window closes -- drop the edges without +// restoring. +// +// Transient states (provisional / reorged-out / conflict-provisional) are left +// untouched: a reorged-out batch may still reconfirm, so its forfeit must not +// be reversed until the invalidation is final. +func (m *Manager) handleConsumerLifecycle(ctx context.Context, + txid chainhash.Hash, next State) { + + switch next { + case StateConflictFinalized: + m.restoreProvisionalConsumers(ctx, txid) + + case StateFinalized: + err := m.cfg.Store.DeleteProvisionalConsumersForBatch(ctx, txid) + if err != nil { + m.logger(ctx).WarnS(ctx, "Failed to clear provisional "+ + "consumer edges for finalized batch", err, + slog.String("batch", txid.String())) + } + + case StateUnseen, StateProvisional, StateReorgedOut, + StateConflictProvisional: + + // Transient/non-final states: the forfeit's fate is not yet + // decided, so the reverse-dependency edges are left untouched. + // A reorged-out batch may still reconfirm, so its forfeit must + // not be reversed until the invalidation (or finalization) is + // final. + } +} + +// restoreProvisionalConsumers restores every VTXO the given (now invalidated) +// batch provisionally forfeited, then drops the edges so the restore fires at +// most once. A nil RestoreConsumedVTXO callback leaves the edges in place (the +// data model stays consistent for a later wired run). +func (m *Manager) restoreProvisionalConsumers(ctx context.Context, + txid chainhash.Hash) { + + consumed, err := m.cfg.Store.ListProvisionalConsumersForBatch(ctx, txid) + if err != nil { + m.logger(ctx).WarnS(ctx, "Failed to list provisional "+ + "consumers for invalidated batch", err, + slog.String("batch", txid.String())) + + return + } + if len(consumed) == 0 { + return + } + + if m.cfg.RestoreConsumedVTXO == nil { + + // No restore path wired: leave the edges so a future run with a + // callback can still act on them. + return + } + + for _, op := range consumed { + if err := m.cfg.RestoreConsumedVTXO(ctx, op); err != nil { + m.logger(ctx).WarnS(ctx, "Failed to restore forfeited "+ + "VTXO after batch invalidation", err, + slog.String("batch", txid.String()), + slog.String("vtxo", op.String())) + + // Keep the edges so the restore can be retried; do not + // drop them on partial failure. + return + } + } + + err = m.cfg.Store.DeleteProvisionalConsumersForBatch(ctx, txid) + if err != nil { + m.logger(ctx).WarnS(ctx, "Failed to clear provisional "+ + "consumer edges after restore", err, + slog.String("batch", txid.String())) + } +} + +// releaseSpendWatches unregisters the per-input spend watches for a batch, +// called once the batch finalizes. +func (m *Manager) releaseSpendWatches(ctx context.Context, w *batchWatch) { + for op := range w.inputs { + err := m.cfg.ChainSource.Tell( + ctx, &chainsource.UnregisterSpendRequest{ + CallerID: spendCallerID(w.txid, op), + Outpoint: &op, + }, + ) + if err != nil { + m. + logger(ctx). + WarnS( + ctx, + "Failed to release input spend "+ + "watch", + err, + "batch", + w.txid, + ) + } + } +} + +// Reconcile re-establishes watches for every non-final persisted batch after a +// restart. It seeds each batch's in-memory state from the persisted record so +// live re-observation does not transiently downgrade a persisted conflict or +// finalized state. It must run after SetSelfRef. +func (m *Manager) Reconcile(ctx context.Context) error { + // Non-final states whose watches must be re-armed. Finalized and + // conflict_finalized batches need no further watching. + live := []State{ + StateUnseen, StateProvisional, StateReorgedOut, + StateConflictProvisional, + } + + for _, state := range live { + records, err := m.cfg.Store.ListBatchesByState(ctx, state) + if err != nil { + return fmt.Errorf("list %s batches: %w", state, err) + } + + for _, record := range records { + m.reconcileOne(ctx, record) + } + } + + // A conflict-finalized batch's restore may have failed partway through + // (RestoreConsumedVTXO errored, so the edges were deliberately kept for + // a retry). Because conflict_finalized is terminal, its watches are not + // re-armed above and handleConsumerLifecycle will never fire again -- + // so without this sweep the retained edges would never be re-driven + // and the consumed VTXOs would stay forfeited forever. The restore is + // idempotent (it no-ops when no edges remain), so re-driving every + // conflict-finalized batch here is safe and completes any interrupted + // restore. + finalizedConflicts, err := m.cfg.Store.ListBatchesByState( + ctx, StateConflictFinalized, + ) + if err != nil { + return fmt.Errorf("list %s batches: %w", StateConflictFinalized, + err) + } + for _, record := range finalizedConflicts { + m.restoreProvisionalConsumers(ctx, record.BatchTxID) + } + + return nil +} + +// reconcileOne rebuilds the in-memory watch for one persisted batch and +// re-arms its chain watches. +func (m *Manager) reconcileOne(ctx context.Context, record *Record) { + if _, ok := m.watches[record.BatchTxID]; ok { + return + } + + w := &batchWatch{ + txid: record.BatchTxID, + pkScript: record.ConfirmationPkScript, + inputs: make(map[wire.OutPoint]*inputWatch), + persisted: record.State, + } + + // Seed the confirmation view from the persisted record so a derive + // before re-observation does not regress the stored state. + switch record.State { + case StateProvisional, StateConflictProvisional: + w.conf = confConfirmed + + case StateReorgedOut: + w.conf = confReorgedOut + + default: + w.conf = confUnseen + } + + // Seed the per-input conflict view from the persisted flags. Without + // this, a reconciled conflict batch whose confirmation is re-observed + // before its conflicting spend is re-observed would derive back to + // (non-conflict) provisional and briefly admit the coin. + for _, in := range record.ConsumedInputs { + w.inputs[in.Outpoint] = &inputWatch{ + spenderIsConflict: in.Conflicting || in.ConflictFinal, + conflicting: in.Conflicting, + conflictFinal: in.ConflictFinal, + } + } + + // Arm the chain watches BEFORE recording the watch, mirroring the + // initial registration path. If arming fails, leaving m.watches + // untouched lets a later Reconcile retry the full arm from scratch + // rather than treating this batch as permanently armed; re-registering + // the same conf/spend caller IDs is idempotent. + if err := m.armWatches(ctx, w, record.ConsumedInputs); err != nil { + m.logger(ctx).WarnS(ctx, "Failed to re-arm batch watches on "+ + "reconcile; will retry on next reconcile", err, + slog.String("batch", record.BatchTxID.String())) + + return + } + m.watches[record.BatchTxID] = w + + // Reconcile any drift between the persisted state and the persisted + // per-input flags toward the most-restrictive derived state (e.g. if a + // prior state write failed after the input flags were written). The + // write is a no-op when they already agree. + m.deriveAndPersist(ctx, w) +} + +// confCallerID is the stable chainsource caller id for a batch's confirmation +// watch. +func confCallerID(txid chainhash.Hash) string { + return fmt.Sprintf("batchcanon-conf-%s", txid) +} + +// spendCallerID is the stable chainsource caller id for a batch input's spend +// watch. +func spendCallerID(txid chainhash.Hash, op wire.OutPoint) string { + return fmt.Sprintf("batchcanon-spend-%s-%s", txid, op) +} diff --git a/batchcanon/manager_conflict_shared_test.go b/batchcanon/manager_conflict_shared_test.go new file mode 100644 index 000000000..90fa19aef --- /dev/null +++ b/batchcanon/manager_conflict_shared_test.go @@ -0,0 +1,64 @@ +package batchcanon + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestManagerSharedInputSpendClassifiesPerBatch verifies that when two batches +// consume the SAME input (the double-spend case), a spend by one batch's own tx +// is classified as the expected consumption for that batch and as a conflict +// for the OTHER batch — every watch on the outpoint is updated, not just one +// arbitrary batch. It also checks the finalize promotion is per-batch. +func TestManagerSharedInputSpendClassifiesPerBatch(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + + txA := testBatchTxid(0xa1) + txB := testBatchTxid(0xb2) + shared := testOutpoint(0xcc, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txA, + ConfirmationPkScript: []byte{0x51, 0x20, 0x01}, + ConsumedInputs: []ConsumedInput{ci(shared)}, + }) + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txB, + ConfirmationPkScript: []byte{0x51, 0x20, 0x02}, + ConsumedInputs: []ConsumedInput{ci(shared)}, + }) + + // Batch A wins the input and confirms; B never confirms. + h.fireConfirmed(t, txA, 101, testBatchTxid(0x01)) + require.Equal(t, StateProvisional, h.state(t, txA).Record.State) + + // The shared input is spent by A's own tx: not a conflict for A, but a + // conflicting double-spend for B (which wanted the same input). + h.fireSpend(t, shared, txA, 101) + + require.Equal( + t, StateProvisional, h.state(t, txA).Record.State, + "batch whose own tx spent the input must not be in conflict", + ) + require.Equal( + t, StateConflictProvisional, h.state(t, txB).Record.State, + "batch losing its input to another tx must be "+ + "conflict-provisional", + ) + + // Once the spend matures, only the conflicted batch (B) is promoted to + // conflict-finalized; A's own consumption stays provisional. + h.fireSpendDone(t, shared) + + require.Equal( + t, StateProvisional, h.state(t, txA).Record.State, + "self-consuming batch must not finalize as a conflict", + ) + require.Equal( + t, StateConflictFinalized, h.state(t, txB).Record.State, + "conflicted batch must promote to conflict-finalized", + ) +} diff --git a/batchcanon/manager_provisional_consumer_test.go b/batchcanon/manager_provisional_consumer_test.go new file mode 100644 index 000000000..373baf69f --- /dev/null +++ b/batchcanon/manager_provisional_consumer_test.go @@ -0,0 +1,211 @@ +package batchcanon + +import ( + "context" + "sync" + "testing" + + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/chainsource" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// restoreRecorder captures the VTXO outpoints the manager asks to restore via +// the RestoreConsumedVTXO callback. +type restoreRecorder struct { + mu sync.Mutex + restored []wire.OutPoint +} + +func (r *restoreRecorder) restore(_ context.Context, op wire.OutPoint) error { + r.mu.Lock() + defer r.mu.Unlock() + r.restored = append(r.restored, op) + + return nil +} + +func (r *restoreRecorder) outpoints() []wire.OutPoint { + r.mu.Lock() + defer r.mu.Unlock() + + return append([]wire.OutPoint(nil), r.restored...) +} + +// TestManagerRestoresForfeitedVTXOOnConflictFinalized proves the +// reverse-dependency (provisional-forfeit) restore: when a batch that +// provisionally forfeits a VTXO is invalidated by a finalized conflict, the +// manager restores that VTXO via the RestoreConsumedVTXO callback and drops the +// edge. A transient conflict (not yet finalized) must NOT restore -- the +// forfeit is only reversed once the invalidation is final. +func TestManagerRestoresForfeitedVTXOOnConflictFinalized(t *testing.T) { + t.Parallel() + + rec := &restoreRecorder{} + h := newManagerHarnessWithRestore(t, 100, rec.restore) + + // A round-2 commitment batch that forfeits a round-1 VTXO and spends a + // consumed input we can double-spend. + consumerBatch := testBatchTxid(0xc2) + forfeitedVTXO := testOutpoint(0xa1, 0) + consumedInput := testOutpoint(0x1e, 1) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: consumerBatch, + ConfirmationPkScript: []byte{0x51, 0x20, 0xc2}, + ConsumedInputs: []ConsumedInput{ci(consumedInput)}, + ForfeitedVTXOs: []wire.OutPoint{forfeitedVTXO}, + }) + + // Confirm the batch, then observe a conflicting spend of its input. A + // non-final conflict must not restore yet. + h.fireConfirmed(t, consumerBatch, 101, testBatchTxid(0xb1)) + h.fireSpend(t, consumedInput, testBatchTxid(0x9e), 102) + require.Equal( + t, StateConflictProvisional, + h.state(t, consumerBatch).Record.State, + ) + require.Empty( + t, rec.outpoints(), + "a provisional (non-final) conflict must not restore the "+ + "forfeited VTXO", + ) + + // Mature the conflicting spend past the reorg-safety depth: the batch + // is now permanently invalidated, so its forfeit is reversed. + h.fireSpendDone(t, consumedInput) + require.Equal( + t, StateConflictFinalized, + h.state(t, consumerBatch).Record.State, + ) + require.Equal( + t, []wire.OutPoint{forfeitedVTXO}, rec.outpoints(), + "a finalized conflict must restore the forfeited VTXO", + ) + + // The edge is dropped after restoring, so the restore fires at most + // once even if the state is re-derived. + remaining, err := h.store.ListProvisionalConsumersForBatch( + t.Context(), consumerBatch, + ) + require.NoError(t, err) + require.Empty(t, remaining, "edges must be cleared after restore") +} + +// TestManagerClearsForfeitEdgesOnFinalized proves the other half of the +// lifecycle: when the consumer batch becomes canonical and final, the forfeit +// is permanent, so the reverse-dependency edges are dropped WITHOUT restoring. +func TestManagerClearsForfeitEdgesOnFinalized(t *testing.T) { + t.Parallel() + + rec := &restoreRecorder{} + h := newManagerHarnessWithRestore(t, 100, rec.restore) + + consumerBatch := testBatchTxid(0xf2) + forfeitedVTXO := testOutpoint(0xa2, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: consumerBatch, + ConfirmationPkScript: []byte{0x51, 0x20, 0xf2}, + ForfeitedVTXOs: []wire.OutPoint{forfeitedVTXO}, + }) + + // Confirm then finalize the batch on the canonical chain. + h.fireConfirmed(t, consumerBatch, 101, testBatchTxid(0xb2)) + h.fireConfDone(t, consumerBatch) + require.Equal( + t, StateFinalized, h.state(t, consumerBatch).Record.State, + ) + + require.Empty( + t, rec.outpoints(), + "a canonically finalized batch must not restore its "+ + "forfeited VTXO", + ) + remaining, err := h.store.ListProvisionalConsumersForBatch( + t.Context(), consumerBatch, + ) + require.NoError(t, err) + require.Empty( + t, remaining, + "edges must be cleared once the forfeit is final and safe", + ) +} + +// TestManagerReconcileRestoresInterruptedForfeit proves the crash-recovery +// half of the restore lifecycle: if a batch's restore failed partway through +// (RestoreConsumedVTXO errored, so the edges were deliberately kept for a +// retry) and the daemon then restarted, the retained edges must still be +// re-driven. Because conflict_finalized is terminal, its watches are not +// re-armed on Reconcile and the live spend-done event that first triggered the +// restore will never fire again -- so Reconcile itself must sweep terminal +// conflicts and complete any interrupted restore, otherwise the consumed VTXOs +// would stay forfeited forever (a permanent lock). +func TestManagerReconcileRestoresInterruptedForfeit(t *testing.T) { + t.Parallel() + + store := newFakeStore() + consumerBatch := testBatchTxid(0xd1) + forfeitedVTXO := testOutpoint(0xa3, 0) + + // Seed the state a partial-failure restore leaves behind: the batch is + // already conflict_finalized (persisted), yet its reverse-dependency + // edge is still present because RestoreConsumedVTXO errored before the + // edge could be dropped. + require.NoError( + t, + store.UpsertBatch( + t.Context(), &Record{ + BatchTxID: consumerBatch, + State: StateConflictFinalized, + ConfirmationHeight: fn.Some[int32](101), + CSVExpiryDelta: 50, + }, + ), + ) + require.NoError( + t, + store.AddProvisionalConsumer( + t.Context(), forfeitedVTXO, consumerBatch, + ), + ) + + rec := &restoreRecorder{} + + mock := newMockChainSource(200) + mockActor := actor.NewActor(actor.ActorConfig[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]{ID: "mock", Behavior: mock, MailboxSize: 64}) + mockActor.Start() + t.Cleanup(mockActor.Stop) + + mgr := NewManager( + ManagerConfig{ + Store: store, + ChainSource: mockActor.Ref(), + RestoreConsumedVTXO: rec.restore, + }, + ) + mgrActor := actor.NewActor(actor.ActorConfig[ManagerMsg, ManagerResp]{ + ID: "mgr", Behavior: mgr, MailboxSize: 64, + }) + mgr.SetSelfRef(mgrActor.TellRef()) + mgrActor.Start() + t.Cleanup(mgrActor.Stop) + + require.NoError(t, mgr.Reconcile(t.Context())) + + // The interrupted restore is completed and the edge dropped. + require.Equal( + t, []wire.OutPoint{forfeitedVTXO}, rec.outpoints(), + "Reconcile must re-drive the interrupted restore for a "+ + "terminal conflict", + ) + remaining, err := store.ListProvisionalConsumersForBatch( + t.Context(), consumerBatch, + ) + require.NoError(t, err) + require.Empty(t, remaining, "edges must be cleared after restore") +} diff --git a/batchcanon/manager_test.go b/batchcanon/manager_test.go new file mode 100644 index 000000000..e91aff2b6 --- /dev/null +++ b/batchcanon/manager_test.go @@ -0,0 +1,918 @@ +package batchcanon + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/chainsource" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +const testTimeout = 5 * time.Second + +// --------------------------------------------------------------------------- +// In-memory fake Store (the real db store is tested separately; this keeps the +// manager unit test free of a batchcanon -> db import cycle). +// --------------------------------------------------------------------------- + +type fakeStore struct { + mu sync.Mutex + records map[chainhash.Hash]*Record + consumers map[chainhash.Hash][]wire.OutPoint +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + records: make(map[chainhash.Hash]*Record), + consumers: make(map[chainhash.Hash][]wire.OutPoint), + } +} + +// ci builds a ConsumedInput from an outpoint with a placeholder non-empty +// pkScript. The mock chainsource ignores the script's value, but it must be +// non-empty so the manager arms the spend watch rather than skipping it. +func ci(op wire.OutPoint) ConsumedInput { + return ConsumedInput{Outpoint: op, PkScript: []byte{0x51}} +} + +func cloneRecord(r *Record) *Record { + cp := *r + cp.ConsumedInputs = append([]ConsumedInput(nil), r.ConsumedInputs...) + cp.DependentVTXOs = append([]wire.OutPoint(nil), r.DependentVTXOs...) + cp.ConfirmationPkScript = append( + []byte(nil), r.ConfirmationPkScript..., + ) + + return &cp +} + +func (s *fakeStore) UpsertBatch(_ context.Context, r *Record) error { + s.mu.Lock() + defer s.mu.Unlock() + s.records[r.BatchTxID] = cloneRecord(r) + + return nil +} + +func (s *fakeStore) GetBatch(_ context.Context, txid chainhash.Hash) (*Record, + error) { + + s.mu.Lock() + defer s.mu.Unlock() + r, ok := s.records[txid] + if !ok { + return nil, ErrBatchNotFound + } + + return cloneRecord(r), nil +} + +func (s *fakeStore) ListBatchesByState(_ context.Context, state State) ( + []*Record, error) { + + s.mu.Lock() + defer s.mu.Unlock() + var out []*Record + for _, r := range s.records { + if r.State == state { + out = append(out, cloneRecord(r)) + } + } + + return out, nil +} + +func (s *fakeStore) UpdateBatchState(_ context.Context, txid chainhash.Hash, + state State) error { + + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.records[txid]; ok { + r.State = state + } + + return nil +} + +func (s *fakeStore) RecordInputConflict(_ context.Context, + batchTxid chainhash.Hash, op wire.OutPoint, conflicting, + conflictFinal bool) error { + + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.records[batchTxid]; ok { + for i := range r.ConsumedInputs { + in := &r.ConsumedInputs[i] + if in.Outpoint == op { + in.Conflicting = conflicting + in.ConflictFinal = conflictFinal + } + } + } + + return nil +} + +func (s *fakeStore) RecordConfirmation(_ context.Context, txid chainhash.Hash, + height int32, block chainhash.Hash) error { + + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.records[txid]; ok { + r.ConfirmationHeight = fn.Some(height) + r.ConfirmationBlock = fn.Some(block) + } + + return nil +} + +func (s *fakeStore) ClearConfirmation(_ context.Context, + txid chainhash.Hash) error { + + s.mu.Lock() + defer s.mu.Unlock() + if r, ok := s.records[txid]; ok { + r.ConfirmationHeight = fn.None[int32]() + r.ConfirmationBlock = fn.None[chainhash.Hash]() + } + + return nil +} + +func (s *fakeStore) FindBatchesConsumingOutpoint(_ context.Context, + op wire.OutPoint) ([]chainhash.Hash, error) { + + s.mu.Lock() + defer s.mu.Unlock() + var out []chainhash.Hash + for txid, r := range s.records { + for _, in := range r.ConsumedInputs { + if in.Outpoint == op { + out = append(out, txid) + } + } + } + + return out, nil +} + +func (s *fakeStore) AddProvisionalConsumer(_ context.Context, + consumedVTXO wire.OutPoint, consumerBatch chainhash.Hash) error { + + s.mu.Lock() + defer s.mu.Unlock() + s.consumers[consumerBatch] = append( + s.consumers[consumerBatch], consumedVTXO, + ) + + return nil +} + +func (s *fakeStore) ListProvisionalConsumersForBatch(_ context.Context, + consumerBatch chainhash.Hash) ([]wire.OutPoint, error) { + + s.mu.Lock() + defer s.mu.Unlock() + + return append([]wire.OutPoint(nil), s.consumers[consumerBatch]...), nil +} + +func (s *fakeStore) DeleteProvisionalConsumersForBatch(_ context.Context, + consumerBatch chainhash.Hash) error { + + s.mu.Lock() + defer s.mu.Unlock() + delete(s.consumers, consumerBatch) + + return nil +} + +var _ Store = (*fakeStore)(nil) + +// --------------------------------------------------------------------------- +// Mock chainsource actor: captures the reorg-aware notification refs from +// register requests and lets the test fire lifecycle events back at them. +// --------------------------------------------------------------------------- + +type confRefs struct { + confirmed actor.TellOnlyRef[chainsource.ConfirmationEvent] + reorged actor.TellOnlyRef[chainsource.ConfReorgedEvent] + done actor.TellOnlyRef[chainsource.ConfDoneEvent] +} + +type spendRefs struct { + spend actor.TellOnlyRef[chainsource.SpendEvent] + reorged actor.TellOnlyRef[chainsource.SpendReorgedEvent] + done actor.TellOnlyRef[chainsource.SpendDoneEvent] +} + +type mockChainSource struct { + mu sync.Mutex + bestHeight int32 + confByTxid map[chainhash.Hash]confRefs + spendByOp map[wire.OutPoint]spendRefs + confCancels map[chainhash.Hash]int + spendCancel map[wire.OutPoint]int +} + +func newMockChainSource(bestHeight int32) *mockChainSource { + return &mockChainSource{ + bestHeight: bestHeight, + confByTxid: make(map[chainhash.Hash]confRefs), + spendByOp: make(map[wire.OutPoint]spendRefs), + confCancels: make(map[chainhash.Hash]int), + spendCancel: make(map[wire.OutPoint]int), + } +} + +func (c *mockChainSource) Receive(_ context.Context, + msg chainsource.ChainSourceMsg) fn.Result[chainsource.ChainSourceResp] { + + switch v := msg.(type) { + case *chainsource.BestHeightRequest: + c.mu.Lock() + h := c.bestHeight + c.mu.Unlock() + + return fn.Ok[chainsource.ChainSourceResp]( + &chainsource.BestHeightResponse{ + Height: h, + }, + ) + + case *chainsource.RegisterConfRequest: + c.mu.Lock() + c.confByTxid[*v.Txid] = confRefs{ + confirmed: v.NotifyActor.UnwrapOr(nil), + reorged: v.NotifyReorged.UnwrapOr(nil), + done: v.NotifyDone.UnwrapOr(nil), + } + c.mu.Unlock() + + return fn.Ok[chainsource.ChainSourceResp]( + &chainsource.RegisterConfResponse{}, + ) + + case *chainsource.RegisterSpendRequest: + c.mu.Lock() + c.spendByOp[*v.Outpoint] = spendRefs{ + spend: v.NotifyActor.UnwrapOr(nil), + reorged: v.NotifyReorged.UnwrapOr(nil), + done: v.NotifyDone.UnwrapOr(nil), + } + c.mu.Unlock() + + return fn.Ok[chainsource.ChainSourceResp]( + &chainsource.RegisterSpendResponse{}, + ) + + case *chainsource.UnregisterConfRequest: + c.mu.Lock() + c.confCancels[*v.Txid]++ + c.mu.Unlock() + + return fn.Ok[chainsource.ChainSourceResp]( + &chainsource.UnregisterConfResponse{}, + ) + + case *chainsource.UnregisterSpendRequest: + c.mu.Lock() + c.spendCancel[*v.Outpoint]++ + c.mu.Unlock() + + return fn.Ok[chainsource.ChainSourceResp]( + &chainsource.UnregisterSpendResponse{}, + ) + + default: + return fn.Err[chainsource.ChainSourceResp]( + errUnexpected(msg), + ) + } +} + +func errUnexpected(msg chainsource.ChainSourceMsg) error { + return &unexpectedMsgErr{msg: msg.MessageType()} +} + +type unexpectedMsgErr struct{ msg string } + +func (e *unexpectedMsgErr) Error() string { + return "mock chainsource: unexpected message " + e.msg +} + +// getConfRefs waits until the manager has registered a conf watch for txid and +// returns the captured refs. +func (c *mockChainSource) getConfRefs(t *testing.T, + txid chainhash.Hash) confRefs { + + t.Helper() + var refs confRefs + require.Eventually(t, func() bool { + c.mu.Lock() + defer c.mu.Unlock() + r, ok := c.confByTxid[txid] + if ok { + refs = r + } + + return ok + }, testTimeout, 5*time.Millisecond, "conf watch never registered") + + return refs +} + +func (c *mockChainSource) getSpendRefs(t *testing.T, + op wire.OutPoint) spendRefs { + + t.Helper() + var refs spendRefs + require.Eventually(t, func() bool { + c.mu.Lock() + defer c.mu.Unlock() + r, ok := c.spendByOp[op] + if ok { + refs = r + } + + return ok + }, testTimeout, 5*time.Millisecond, "spend watch never registered") + + return refs +} + +func (c *mockChainSource) spendCancelCount(op wire.OutPoint) int { + c.mu.Lock() + defer c.mu.Unlock() + + return c.spendCancel[op] +} + +// --------------------------------------------------------------------------- +// Harness. +// --------------------------------------------------------------------------- + +type managerHarness struct { + mgrRef actor.ActorRef[ManagerMsg, ManagerResp] + mock *mockChainSource + store *fakeStore +} + +func newManagerHarness(t *testing.T, bestHeight int32) *managerHarness { + return newManagerHarnessWithRestore(t, bestHeight, nil) +} + +// newManagerHarnessWithRestore is newManagerHarness with a RestoreConsumedVTXO +// callback wired into the manager config, for the reverse-dependency +// (provisional-forfeit restore) tests. +func newManagerHarnessWithRestore(t *testing.T, bestHeight int32, + restore func(ctx context.Context, vtxo wire.OutPoint) error, +) *managerHarness { + + t.Helper() + + mock := newMockChainSource(bestHeight) + mockActor := actor.NewActor(actor.ActorConfig[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]{ + ID: "mock-chainsource", + Behavior: mock, + MailboxSize: 64, + }) + mockActor.Start() + t.Cleanup(mockActor.Stop) + + store := newFakeStore() + mgr := NewManager(ManagerConfig{ + Store: store, + ChainSource: mockActor.Ref(), + RestoreConsumedVTXO: restore, + }) + mgrActor := actor.NewActor(actor.ActorConfig[ManagerMsg, ManagerResp]{ + ID: "batch-canonicality", + Behavior: mgr, + MailboxSize: 64, + }) + mgr.SetSelfRef(mgrActor.TellRef()) + mgrActor.Start() + t.Cleanup(mgrActor.Stop) + + return &managerHarness{ + mgrRef: mgrActor.Ref(), + mock: mock, + store: store, + } +} + +// registerBatch registers a batch and waits for the synchronous response. +func (h *managerHarness) registerBatch(t *testing.T, + req *RegisterBatchRequest) { + + t.Helper() + _, err := h.mgrRef.Ask(t.Context(), req).Await(t.Context()).Unpack() + require.NoError(t, err) +} + +// state reads the persisted record for a batch via the manager. Because the +// manager mailbox is FIFO, issuing this Ask after a fired event guarantees the +// event was processed first. +func (h *managerHarness) state(t *testing.T, + txid chainhash.Hash) *GetBatchStateResponse { + + t.Helper() + resp, err := h.mgrRef.Ask( + t.Context(), &GetBatchStateRequest{BatchTxID: txid}, + ).Await(t.Context()).Unpack() + require.NoError(t, err) + got, ok := resp.(*GetBatchStateResponse) + require.True(t, ok) + + return got +} + +// fire helpers Tell the captured chainsource refs, synchronously enqueuing the +// re-wrapped event onto the manager mailbox. +func (h *managerHarness) fireConfirmed(t *testing.T, txid chainhash.Hash, + height int32, block chainhash.Hash) { + + t.Helper() + refs := h.mock.getConfRefs(t, txid) + require.NoError( + t, + refs.confirmed.Tell( + t.Context(), chainsource.ConfirmationEvent{ + Txid: txid, + BlockHeight: height, + BlockHash: block, + NumConfs: 1, + }, + ), + ) +} + +func (h *managerHarness) fireConfReorged(t *testing.T, txid chainhash.Hash) { + t.Helper() + refs := h.mock.getConfRefs(t, txid) + require.NoError( + t, + refs.reorged.Tell( + t.Context(), chainsource.ConfReorgedEvent{ + Txid: txid, + }, + ), + ) +} + +func (h *managerHarness) fireConfDone(t *testing.T, txid chainhash.Hash) { + t.Helper() + refs := h.mock.getConfRefs(t, txid) + require.NoError( + t, + refs.done.Tell( + t.Context(), chainsource.ConfDoneEvent{ + Txid: txid, + }, + ), + ) +} + +func (h *managerHarness) fireSpend(t *testing.T, op wire.OutPoint, + spender chainhash.Hash, height int32) { + + t.Helper() + refs := h.mock.getSpendRefs(t, op) + require.NoError( + t, + refs.spend.Tell( + t.Context(), chainsource.SpendEvent{ + Outpoint: op, + SpendingTxid: spender, + SpendingHeight: height, + }, + ), + ) +} + +func (h *managerHarness) fireSpendReorged(t *testing.T, op wire.OutPoint) { + t.Helper() + refs := h.mock.getSpendRefs(t, op) + require.NoError( + t, + refs.reorged.Tell( + t.Context(), chainsource.SpendReorgedEvent{ + Outpoint: op, + }, + ), + ) +} + +func (h *managerHarness) fireSpendDone(t *testing.T, op wire.OutPoint) { + t.Helper() + refs := h.mock.getSpendRefs(t, op) + require.NoError( + t, + refs.done.Tell( + t.Context(), chainsource.SpendDoneEvent{ + Outpoint: op, + }, + ), + ) +} + +// --------------------------------------------------------------------------- +// Tests. +// --------------------------------------------------------------------------- + +func testBatchTxid(b byte) chainhash.Hash { + var h chainhash.Hash + h[0] = b + + return h +} + +func testOutpoint(b byte, idx uint32) wire.OutPoint { + return wire.OutPoint{Hash: chainhash.Hash{b}, Index: idx} +} + +// TestManagerConfirmThenFinalize drives the happy path: a registered batch is +// unseen, becomes provisional on first confirmation (with a derived effective +// expiry), then finalized on the chainsource Done. +func TestManagerConfirmThenFinalize(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0xaa) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: []byte{0x51, 0x20, 0x01}, + CSVExpiryDelta: 144, + }) + + // Unseen before any observation. + got := h.state(t, txid) + require.True(t, got.Found) + require.Equal(t, StateUnseen, got.Record.State) + require.True(t, got.Record.EffectiveExpiry().IsNone()) + + // First confirmation -> provisional, effective expiry derived. + h.fireConfirmed(t, txid, 101, testBatchTxid(0xb1)) + got = h.state(t, txid) + require.Equal(t, StateProvisional, got.Record.State) + require.Equal(t, int32(101), got.Record.ConfirmationHeight.UnwrapOr(0)) + require.Equal(t, int32(245), got.Record.EffectiveExpiry().UnwrapOr(0)) + + // Policy finality -> finalized. + h.fireConfDone(t, txid) + got = h.state(t, txid) + require.Equal(t, StateFinalized, got.Record.State) +} + +// TestManagerReorgRecovers proves the core reorg-safety property: a confirmed +// batch that is reorged out moves to reorged_out (with expiry erased), then +// recovers to provisional on reconfirmation at a new height (with a fresh +// effective expiry), then finalizes. +func TestManagerReorgRecovers(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0xcc) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 100, + }) + + h.fireConfirmed(t, txid, 101, testBatchTxid(0xd1)) + got := h.state(t, txid) + require.Equal(t, StateProvisional, got.Record.State) + require.Equal(t, int32(201), got.Record.EffectiveExpiry().UnwrapOr(0)) + + // Reorg out: state reorged_out, confirmation (and effective expiry) + // cleared. + h.fireConfReorged(t, txid) + got = h.state(t, txid) + require.Equal(t, StateReorgedOut, got.Record.State) + require.True(t, got.Record.ConfirmationHeight.IsNone()) + require.True(t, got.Record.EffectiveExpiry().IsNone()) + + // Reconfirm at a higher height: provisional again, fresh expiry. + h.fireConfirmed(t, txid, 105, testBatchTxid(0xd2)) + got = h.state(t, txid) + require.Equal(t, StateProvisional, got.Record.State) + require.Equal(t, int32(205), got.Record.EffectiveExpiry().UnwrapOr(0)) + + h.fireConfDone(t, txid) + require.Equal(t, StateFinalized, h.state(t, txid).Record.State) +} + +// TestManagerInputConflict proves conflict detection: a consumed input spent +// by a transaction OTHER than the batch is a conflict (conflict_provisional), +// promoted to conflict_finalized once the conflicting spend matures. +func TestManagerInputConflict(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x11) + input := testOutpoint(0x22, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + + h.fireConfirmed(t, txid, 101, testBatchTxid(0x33)) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) + + // A different tx double-spends the consumed input. + conflictTx := testBatchTxid(0x99) + h.fireSpend(t, input, conflictTx, 102) + require.Equal( + t, StateConflictProvisional, h.state(t, txid).Record.State, + ) + + // The conflict matures -> conflict_finalized. + h.fireSpendDone(t, input) + require.Equal( + t, StateConflictFinalized, h.state(t, txid).Record.State, + ) +} + +// TestManagerConflictClearsOnSpendReorg proves a conflict is reversible: if the +// conflicting spend is itself reorged out, the batch returns to its +// confirmation-derived state. +func TestManagerConflictClearsOnSpendReorg(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x41) + input := testOutpoint(0x42, 1) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x43)) + + h.fireSpend(t, input, testBatchTxid(0x99), 102) + require.Equal( + t, StateConflictProvisional, h.state(t, txid).Record.State, + ) + + // The conflicting spend reorgs out -> conflict cleared, back to + // provisional (the batch is still confirmed). + h.fireSpendReorged(t, input) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) +} + +// TestManagerBatchSelfSpendNotConflict proves that the batch consuming its own +// input (the expected case) is not treated as a conflict. +func TestManagerBatchSelfSpendNotConflict(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x51) + input := testOutpoint(0x52, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x53)) + + // The spend is by the batch itself: not a conflict. + h.fireSpend(t, input, txid, 101) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) + + // And its maturation is the normal consumption, not a conflict. + h.fireSpendDone(t, input) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) +} + +// TestManagerConflictDominatesReorg proves the state priority: when a batch is +// both reorged out AND has a conflicting input spend, conflict dominates. +func TestManagerConflictDominatesReorg(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x61) + input := testOutpoint(0x62, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x63)) + h.fireConfReorged(t, txid) + require.Equal(t, StateReorgedOut, h.state(t, txid).Record.State) + + // A conflicting spend appears while the batch is reorged out: conflict + // dominates reorged_out. + h.fireSpend(t, input, testBatchTxid(0x99), 102) + require.Equal( + t, StateConflictProvisional, h.state(t, txid).Record.State, + ) +} + +// TestManagerFinalizeReleasesSpendWatches proves the manager releases the +// per-input spend watches once a batch finalizes. +func TestManagerFinalizeReleasesSpendWatches(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x71) + input := testOutpoint(0x72, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + ConsumedInputs: []ConsumedInput{ci(input)}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x73)) + h.fireConfDone(t, txid) + + // Drain via a state read, then assert the spend watch was released. + require.Equal(t, StateFinalized, h.state(t, txid).Record.State) + require.Eventually(t, func() bool { + return h.mock.spendCancelCount(input) == 1 + }, testTimeout, 5*time.Millisecond, + "input spend watch not released on finalize") +} + +// TestManagerRegisterIdempotentMergesDependents proves a repeat registration +// merges dependent VTXOs without re-arming or losing state. +func TestManagerRegisterIdempotentMergesDependents(t *testing.T) { + t.Parallel() + + h := newManagerHarness(t, 100) + txid := testBatchTxid(0x81) + depA := testOutpoint(0x8a, 0) + depB := testOutpoint(0x8b, 0) + + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + DependentVTXOs: []wire.OutPoint{depA}, + }) + h.fireConfirmed(t, txid, 101, testBatchTxid(0x83)) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) + + // Repeat with an additional dependent: merged, state preserved. + h.registerBatch(t, &RegisterBatchRequest{ + BatchTxID: txid, + CSVExpiryDelta: 50, + DependentVTXOs: []wire.OutPoint{depB}, + }) + got := h.state(t, txid) + require.Equal(t, StateProvisional, got.Record.State) + require.ElementsMatch( + t, []wire.OutPoint{depA, depB}, got.Record.DependentVTXOs, + ) +} + +// TestManagerReconcileReArmsWatches proves restart reconciliation: a manager +// started against a store with a persisted provisional batch re-arms its +// watches and does not downgrade the persisted state before re-observation. +func TestManagerReconcileReArmsWatches(t *testing.T) { + t.Parallel() + + store := newFakeStore() + txid := testBatchTxid(0x91) + input := testOutpoint(0x92, 0) + + // Seed a persisted provisional batch as if a prior run had observed it. + require.NoError( + t, + store.UpsertBatch( + t.Context(), &Record{ + BatchTxID: txid, + State: StateProvisional, + ConfirmationHeight: fn.Some[int32](90), + CSVExpiryDelta: 50, + ConsumedInputs: []ConsumedInput{ci(input)}, + }, + ), + ) + + mock := newMockChainSource(100) + mockActor := actor.NewActor(actor.ActorConfig[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]{ID: "mock", Behavior: mock, MailboxSize: 64}) + mockActor.Start() + t.Cleanup(mockActor.Stop) + + mgr := NewManager( + ManagerConfig{ + Store: store, + ChainSource: mockActor.Ref(), + }, + ) + mgrActor := actor.NewActor(actor.ActorConfig[ManagerMsg, ManagerResp]{ + ID: "mgr", Behavior: mgr, MailboxSize: 64, + }) + mgr.SetSelfRef(mgrActor.TellRef()) + mgrActor.Start() + t.Cleanup(mgrActor.Stop) + + require.NoError(t, mgr.Reconcile(t.Context())) + + // Watches re-armed for the persisted batch. + mock.getConfRefs(t, txid) + mock.getSpendRefs(t, input) + + // State not downgraded by reconcile. + h := &managerHarness{mgrRef: mgrActor.Ref(), mock: mock, store: store} + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) + + // A reorg after restart is still handled correctly. + h.fireConfReorged(t, txid) + require.Equal(t, StateReorgedOut, h.state(t, txid).Record.State) +} + +// TestManagerReconcileConflictNotDowngradedByConfReplay proves that a persisted +// conflict_provisional batch is NOT transiently downgraded to provisional when, +// after a restart, the confirmation is re-observed before the conflicting spend +// is re-observed. Reconciliation seeds the per-input conflict view from the +// persisted flags, so a bare re-confirmation cannot clear a conflict it did not +// resolve. Without the fix this asserted state 4 (conflict_provisional) but got +// state 1 (provisional) — a window in which the coin would be wrongly admitted. +func TestManagerReconcileConflictNotDowngradedByConfReplay(t *testing.T) { + t.Parallel() + + store := newFakeStore() + txid := testBatchTxid(0x93) + input := testOutpoint(0x94, 0) + + // Seed a persisted conflict_provisional batch whose consumed input was + // observed conflicting by a prior run: confirmed, but a foreign tx + // double-spent its input. + require.NoError( + t, + store.UpsertBatch( + t.Context(), &Record{ + BatchTxID: txid, + State: StateConflictProvisional, + ConfirmationHeight: fn.Some[int32](90), + CSVExpiryDelta: 50, + ConsumedInputs: []ConsumedInput{{ + Outpoint: input, + PkScript: []byte{0x51}, + Conflicting: true, + }}, + }, + ), + ) + + mock := newMockChainSource(100) + mockActor := actor.NewActor(actor.ActorConfig[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]{ID: "mock", Behavior: mock, MailboxSize: 64}) + mockActor.Start() + t.Cleanup(mockActor.Stop) + + mgr := NewManager( + ManagerConfig{ + Store: store, + ChainSource: mockActor.Ref(), + }, + ) + mgrActor := actor.NewActor(actor.ActorConfig[ManagerMsg, ManagerResp]{ + ID: "mgr", Behavior: mgr, MailboxSize: 64, + }) + mgr.SetSelfRef(mgrActor.TellRef()) + mgrActor.Start() + t.Cleanup(mgrActor.Stop) + + require.NoError(t, mgr.Reconcile(t.Context())) + + h := &managerHarness{mgrRef: mgrActor.Ref(), mock: mock, store: store} + + // Reconcile alone must preserve the persisted conflict. + require.Equal( + t, StateConflictProvisional, h.state(t, txid).Record.State, + ) + + // The confirmation is re-observed first (the batch is still mined). + // This must NOT clear the conflict: the conflicting spend has not been + // observed to reorg away. + h.fireConfirmed(t, txid, 90, testBatchTxid(0x95)) + require.Equal( + t, StateConflictProvisional, h.state(t, txid).Record.State, + "re-confirmation wrongly downgraded a persisted conflict", + ) + + // The conflict clears only when the conflicting spend is observed to + // reorg out — then, and only then, the batch returns to provisional. + h.fireSpendReorged(t, input) + require.Equal(t, StateProvisional, h.state(t, txid).Record.State) +} diff --git a/batchcanon/messages.go b/batchcanon/messages.go new file mode 100644 index 000000000..e04c2b18e --- /dev/null +++ b/batchcanon/messages.go @@ -0,0 +1,219 @@ +package batchcanon + +import ( + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/baselib/actor" +) + +// ManagerMsg is the sealed inbound message interface for the +// BatchCanonicalityManager. It covers both the public register/query API and +// the internal chain-observation messages re-wrapped from chainsource. +type ManagerMsg interface { + actor.Message + + managerMsgSealed() +} + +// ManagerResp is the sealed response interface for the manager. +type ManagerResp interface { + actor.Message + + managerRespSealed() +} + +// RegisterBatchRequest registers (or re-registers, idempotently) a batch with +// the manager: it persists a canonicality record, registers a reorg-aware +// confirmation watch on the batch tx, and a reorg-aware spend watch on every +// consumed input. Calling it again for the same batch txid merges the +// dependent VTXOs into the record without duplicating watches. +type RegisterBatchRequest struct { + actor.BaseMessage + + // BatchTxID is the batch (commitment) transaction id. + BatchTxID chainhash.Hash + + // ConfirmationPkScript is the pkScript of the batch-tx output the + // confirmation watch keys on. Required for light-client backends and + // persisted for restart re-registration. + ConfirmationPkScript []byte + + // CSVExpiryDelta is the batch's CSV-relative expiry timeout in blocks. + CSVExpiryDelta int32 + + // ConsumedInputs are the inputs the batch tx spends, each carrying the + // pkScript of the spent output. Each gets a reorg-aware spend watch so + // a conflicting double-spend is detected; the pkScript is required to + // register that watch (lnd's spend notifier filters by output script). + ConsumedInputs []ConsumedInput + + // DependentVTXOs are the VTXO outpoints anchored by this batch. + DependentVTXOs []wire.OutPoint + + // ForfeitedVTXOs are VTXOs from prior batches that this batch consumes + // via forfeit. The manager records a reverse-dependency edge for each + // so the VTXO is restored if this batch is later invalidated (its + // forfeit reversed by a finalized conflict). This is distinct from + // ConsumedInputs, which are the batch tx's own inputs watched for a + // conflicting spend; a forfeited VTXO is a tree leaf, not necessarily a + // direct input of this batch tx. + ForfeitedVTXOs []wire.OutPoint +} + +// MessageType returns the message type identifier. +func (m *RegisterBatchRequest) MessageType() string { + return "batchcanon.RegisterBatchRequest" +} + +func (m *RegisterBatchRequest) managerMsgSealed() {} + +// RegisterBatchResponse is the reply to RegisterBatchRequest. +type RegisterBatchResponse struct { + actor.BaseMessage +} + +// MessageType returns the message type identifier. +func (m *RegisterBatchResponse) MessageType() string { + return "batchcanon.RegisterBatchResponse" +} + +func (m *RegisterBatchResponse) managerRespSealed() {} + +// GetBatchStateRequest reads the current canonicality record for a batch. +type GetBatchStateRequest struct { + actor.BaseMessage + + // BatchTxID is the batch tx to look up. + BatchTxID chainhash.Hash +} + +// MessageType returns the message type identifier. +func (m *GetBatchStateRequest) MessageType() string { + return "batchcanon.GetBatchStateRequest" +} + +func (m *GetBatchStateRequest) managerMsgSealed() {} + +// GetBatchStateResponse carries the looked-up record, if present. +type GetBatchStateResponse struct { + actor.BaseMessage + + // Record is the canonicality record. Nil when Found is false. + Record *Record + + // Found reports whether a record existed for the batch. + Found bool +} + +// MessageType returns the message type identifier. +func (m *GetBatchStateResponse) MessageType() string { + return "batchcanon.GetBatchStateResponse" +} + +func (m *GetBatchStateResponse) managerRespSealed() {} + +// ackResponse is the no-op reply for internal Tell-delivered messages. +type ackResponse struct { + actor.BaseMessage +} + +// MessageType returns the message type identifier. +func (m *ackResponse) MessageType() string { + return "batchcanon.ackResponse" +} + +func (m *ackResponse) managerRespSealed() {} + +// batchConfirmedMsg is the internal re-wrap of a chainsource ConfirmationEvent +// for a watched batch tx. +type batchConfirmedMsg struct { + actor.BaseMessage + + txid chainhash.Hash + blockHeight int32 + blockHash chainhash.Hash +} + +// MessageType returns the message type identifier. +func (m *batchConfirmedMsg) MessageType() string { + return "batchcanon.batchConfirmedMsg" +} + +func (m *batchConfirmedMsg) managerMsgSealed() {} + +// batchReorgedMsg is the internal re-wrap of a chainsource ConfReorgedEvent. +type batchReorgedMsg struct { + actor.BaseMessage + + txid chainhash.Hash +} + +// MessageType returns the message type identifier. +func (m *batchReorgedMsg) MessageType() string { + return "batchcanon.batchReorgedMsg" +} + +func (m *batchReorgedMsg) managerMsgSealed() {} + +// batchDoneMsg is the internal re-wrap of a chainsource ConfDoneEvent: the +// batch confirmation has matured past the reorg-safety depth (policy +// finality). +type batchDoneMsg struct { + actor.BaseMessage + + txid chainhash.Hash +} + +// MessageType returns the message type identifier. +func (m *batchDoneMsg) MessageType() string { + return "batchcanon.batchDoneMsg" +} + +func (m *batchDoneMsg) managerMsgSealed() {} + +// inputSpentMsg is the internal re-wrap of a chainsource SpendEvent on a +// consumed batch input. +type inputSpentMsg struct { + actor.BaseMessage + + outpoint wire.OutPoint + spendingTxid chainhash.Hash + spendHeight int32 +} + +// MessageType returns the message type identifier. +func (m *inputSpentMsg) MessageType() string { + return "batchcanon.inputSpentMsg" +} + +func (m *inputSpentMsg) managerMsgSealed() {} + +// inputSpendReorgedMsg is the internal re-wrap of a chainsource +// SpendReorgedEvent: a previously observed spend left the best chain. +type inputSpendReorgedMsg struct { + actor.BaseMessage + + outpoint wire.OutPoint +} + +// MessageType returns the message type identifier. +func (m *inputSpendReorgedMsg) MessageType() string { + return "batchcanon.inputSpendReorgedMsg" +} + +func (m *inputSpendReorgedMsg) managerMsgSealed() {} + +// inputSpendDoneMsg is the internal re-wrap of a chainsource SpendDoneEvent: +// the spend observation matured past the reorg-safety depth. +type inputSpendDoneMsg struct { + actor.BaseMessage + + outpoint wire.OutPoint +} + +// MessageType returns the message type identifier. +func (m *inputSpendDoneMsg) MessageType() string { + return "batchcanon.inputSpendDoneMsg" +} + +func (m *inputSpendDoneMsg) managerMsgSealed() {} diff --git a/batchcanon/record.go b/batchcanon/record.go new file mode 100644 index 000000000..0e75398d3 --- /dev/null +++ b/batchcanon/record.go @@ -0,0 +1,120 @@ +package batchcanon + +import ( + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +// Record is the durable canonicality view of one batch (commitment) +// transaction, keyed by its txid. It bundles the interpreted State, the +// current confirmation observation, the recompute inputs for effective +// expiry, the consumed inputs and dependent VTXOs, and the reserved policy +// slot. +type Record struct { + // BatchTxID is the commitment transaction id and the record's + // identity. Identity is by txid, never by (txid, block hash): a reorg + // that re-mines the same tx in a different block is the same batch. + BatchTxID chainhash.Hash + + // State is the interpreted canonicality state. + State State + + // ConfirmationHeight is the best-chain height at which the batch tx + // is currently observed confirmed. None when the batch is not + // currently confirmed (unseen or reorged out). A reorg clears it; a + // reconfirmation sets it to the new height. + ConfirmationHeight fn.Option[int32] + + // ConfirmationBlock is the hash of the block currently confirming the + // batch tx. It is an observation attribute only and is NOT part of + // the batch identity. None when the batch is not currently confirmed. + ConfirmationBlock fn.Option[chainhash.Hash] + + // CSVExpiryDelta is the batch's CSV-relative expiry timeout, in + // blocks. The effective (absolute) expiry height is derived from this + // plus the current confirmation height, so it tracks reconfirmations + // after a reorg instead of being frozen at first confirmation. + CSVExpiryDelta int32 + + // ConfirmationPkScript is the pkScript of the batch-tx output the + // confirmation watch keys on. It is persisted so the manager can + // re-register the watch after a restart, since light-client backends + // (neutrino, Esplora) filter confirmation notifications by pkScript. + // May be empty for records seeded by descriptor backfill, which has no + // batch-output pkScript to derive. + ConfirmationPkScript []byte + + // PolicyState is the reserved policy classification slot. See + // PolicyState. + PolicyState PolicyState + + // ConsumedInputs are the inputs this batch tx spends. They are tracked + // so the manager can watch each one for a conflicting spend. + ConsumedInputs []ConsumedInput + + // DependentVTXOs are the VTXO outpoints anchored by this batch. Their + // derived availability follows this batch's canonicality. + DependentVTXOs []wire.OutPoint +} + +// ConsumedInput is one input a batch (commitment) tx spends, paired with the +// pkScript of the output being spent. The pkScript is required to register the +// reorg-aware spend watch: lnd's spend notifier filters by the output's script, +// so a bare outpoint is rejected ("an output script must be provided"). It is +// persisted alongside the outpoint so the watch can be re-armed after a +// restart. +type ConsumedInput struct { + // Outpoint is the spent output. + Outpoint wire.OutPoint + + // PkScript is the scriptPubKey of the spent output, used to register + // the spend watch. May be empty only for legacy/backfilled rows that + // predate script tracking; such inputs cannot be watched on + // light-client backends. + PkScript []byte + + // Conflicting is true while a conflicting spend of this input (a spend + // by a transaction other than the batch itself) is observed and has not + // been reorged out. Persisting it lets restart reconciliation rebuild + // the per-input conflict view so live re-observation cannot transiently + // downgrade a persisted conflict before the conflicting spend + // re-arrives. + Conflicting bool + + // ConflictFinal is true once a conflicting spend of this input has + // matured past the reorg-safety depth. Persisted for the same + // restart-reconciliation reason as Conflicting. + ConflictFinal bool +} + +// EffectiveExpiry derives the absolute expiry height from the current +// confirmation observation: ConfirmationHeight + CSVExpiryDelta. It returns +// None when the batch is not currently confirmed. +// +// Deriving expiry on demand (rather than persisting an absolute height) is +// what keeps expiry reorg-safe: a confirmation that is reorged out clears +// ConfirmationHeight and so erases the effective expiry, and a +// reconfirmation at a different height yields a fresh effective expiry. +// Expiry is therefore never a one-way terminal fact at this layer. +func (r *Record) EffectiveExpiry() fn.Option[int32] { + return fn.MapOption( + func(height int32) int32 { + return height + r.CSVExpiryDelta + })(r.ConfirmationHeight) +} + +// ProvisionalConsumer records that a (locally relevant) VTXO has been +// provisionally consumed by a not-yet-canonical consumer batch. It is the +// reverse-dependency edge that lets a provisionally consumed VTXO be restored +// if the consumer batch never becomes canonical — for example a round-2 +// forfeit whose commitment tx is reorged out, which must restore the round-1 +// VTXO it consumed. +type ProvisionalConsumer struct { + // ConsumedVTXO is the outpoint of the VTXO consumed by ConsumerBatch. + ConsumedVTXO wire.OutPoint + + // ConsumerBatch is the batch tx that provisionally consumes + // ConsumedVTXO. + ConsumerBatch chainhash.Hash +} diff --git a/batchcanon/record_test.go b/batchcanon/record_test.go new file mode 100644 index 000000000..bf217f87c --- /dev/null +++ b/batchcanon/record_test.go @@ -0,0 +1,70 @@ +package batchcanon + +import ( + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// TestEffectiveExpiryNoneWhenUnconfirmed verifies that a batch with no +// current confirmation observation has no effective expiry — the structural +// guarantee that expiry is not a standalone terminal fact. +func TestEffectiveExpiryNoneWhenUnconfirmed(t *testing.T) { + t.Parallel() + + rec := &Record{ + BatchTxID: chainhash.Hash{ + 0x01, + }, + State: StateUnseen, + ConfirmationHeight: fn.None[int32](), + CSVExpiryDelta: 144, + } + + require.True(t, rec.EffectiveExpiry().IsNone()) +} + +// TestEffectiveExpiryDerivesFromConfirmation verifies the effective expiry is +// the confirmation height plus the CSV-relative delta. +func TestEffectiveExpiryDerivesFromConfirmation(t *testing.T) { + t.Parallel() + + rec := &Record{ + BatchTxID: chainhash.Hash{ + 0x02, + }, + State: StateProvisional, + ConfirmationHeight: fn.Some[int32](100), + CSVExpiryDelta: 144, + } + + got := rec.EffectiveExpiry() + require.True(t, got.IsSome()) + require.Equal(t, int32(244), got.UnwrapOr(0)) +} + +// TestEffectiveExpiryRecomputesAfterReconfirm verifies that re-confirming the +// same batch at a different height (as happens after a reorg) yields a fresh +// effective expiry rather than a value frozen at first confirmation. +func TestEffectiveExpiryRecomputesAfterReconfirm(t *testing.T) { + t.Parallel() + + rec := &Record{ + BatchTxID: chainhash.Hash{ + 0x03, + }, + ConfirmationHeight: fn.Some[int32](100), + CSVExpiryDelta: 144, + } + require.Equal(t, int32(244), rec.EffectiveExpiry().UnwrapOr(0)) + + // Reorg: the confirmation leaves the best chain. + rec.ConfirmationHeight = fn.None[int32]() + require.True(t, rec.EffectiveExpiry().IsNone()) + + // Reconfirmation at a higher height on the new best chain. + rec.ConfirmationHeight = fn.Some[int32](103) + require.Equal(t, int32(247), rec.EffectiveExpiry().UnwrapOr(0)) +} diff --git a/batchcanon/state.go b/batchcanon/state.go new file mode 100644 index 000000000..803432a65 --- /dev/null +++ b/batchcanon/state.go @@ -0,0 +1,101 @@ +package batchcanon + +import "fmt" + +// State is the canonicality state of a batch (commitment) transaction as +// interpreted from raw chain observation. It is reorg-reversible: a batch may +// move between states any number of times (e.g. provisional -> finalized -> +// reorged_out -> provisional) as the chain evolves. No state at this layer is +// a one-way terminal verdict — even ConflictFinalized can be undone if the +// conflicting transaction itself later reorgs out. +// +// State is persisted as a typed INTEGER column. Values are append-only and +// MUST NOT be renumbered, because persisted rows reference them directly. +type State int + +const ( + // StateUnseen indicates the batch tx has not been observed confirmed + // on the best chain. This is the zero value: a freshly recorded + // batch with no confirmation observation is unseen. + StateUnseen State = iota + + // StateProvisional indicates the batch tx is confirmed but has not + // yet matured past the configured finality depth, so its + // confirmation may still be reorged out. + StateProvisional + + // StateFinalized indicates the batch tx confirmation has matured past + // the configured finality depth. This is policy finality at the + // configured depth, not a claim of absolute Bitcoin finality. + StateFinalized + + // StateReorgedOut indicates a previously observed confirmation left + // the best chain and no consumed input has been seen double-spent. + // The batch may reconfirm, so dependent VTXOs enter limbo rather than + // being invalidated. + StateReorgedOut + + // StateConflictProvisional indicates a consumed batch input was + // double-spent by a conflicting transaction on the best chain, and + // that conflicting spend has not yet matured past the finality depth. + StateConflictProvisional + + // StateConflictFinalized indicates a consumed-input conflict has + // matured past the finality depth. It is the strongest negative + // signal but, like every state here, remains reversible if the + // conflicting transaction is itself reorged out. + StateConflictFinalized +) + +// String returns a stable lower-snake-case name for the state, matching the +// vocabulary used in darepo#454 and the persisted-column documentation. +func (s State) String() string { + switch s { + case StateUnseen: + return "unseen" + + case StateProvisional: + return "provisional" + + case StateFinalized: + return "finalized" + + case StateReorgedOut: + return "reorged_out" + + case StateConflictProvisional: + return "conflict_provisional" + + case StateConflictFinalized: + return "conflict_finalized" + + default: + return fmt.Sprintf("unknown(%d)", int(s)) + } +} + +// PolicyState is a durable, reorg-independent policy classification slot for a +// batch. darepo#454 reserves this field in the data model; this layer +// persists and round-trips it but assigns no business meaning yet. The +// BatchCanonicalityManager and the admission gates in later tasks own its +// interpretation. +// +// Like State, it is persisted as an append-only typed INTEGER column. +type PolicyState int + +const ( + // PolicyStateDefault is the zero value and the only policy state + // defined at the data-model layer. + PolicyStateDefault PolicyState = iota +) + +// String returns a stable lower-snake-case name for the policy state. +func (p PolicyState) String() string { + switch p { + case PolicyStateDefault: + return "default" + + default: + return fmt.Sprintf("unknown(%d)", int(p)) + } +} diff --git a/batchcanon/state_test.go b/batchcanon/state_test.go new file mode 100644 index 000000000..6fcd89ee2 --- /dev/null +++ b/batchcanon/state_test.go @@ -0,0 +1,75 @@ +package batchcanon + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestStateValuesStable pins the integer value and string name of every +// canonicality state. These values are persisted as a typed INTEGER column, +// so a change here would silently re-interpret existing rows — the test +// exists to make any renumbering a deliberate, visible edit. +func TestStateValuesStable(t *testing.T) { + t.Parallel() + + cases := []struct { + state State + value int + name string + }{ + { + StateUnseen, + 0, + "unseen", + }, + { + StateProvisional, + 1, + "provisional", + }, + { + StateFinalized, + 2, + "finalized", + }, + { + StateReorgedOut, + 3, + "reorged_out", + }, + { + StateConflictProvisional, + 4, + "conflict_provisional", + }, + { + StateConflictFinalized, + 5, + "conflict_finalized", + }, + } + + for _, tc := range cases { + require.Equal(t, tc.value, int(tc.state), tc.name) + require.Equal(t, tc.name, tc.state.String()) + } +} + +// TestStateStringUnknown verifies an out-of-range state stringifies to a +// diagnosable unknown form rather than an empty string. +func TestStateStringUnknown(t *testing.T) { + t.Parallel() + + require.Equal(t, "unknown(99)", State(99).String()) +} + +// TestPolicyStateStable pins the policy-state value and name. PolicyState is +// also persisted as an append-only typed INTEGER column. +func TestPolicyStateStable(t *testing.T) { + t.Parallel() + + require.Equal(t, 0, int(PolicyStateDefault)) + require.Equal(t, "default", PolicyStateDefault.String()) + require.Equal(t, "unknown(7)", PolicyState(7).String()) +} diff --git a/batchcanon/store.go b/batchcanon/store.go new file mode 100644 index 000000000..c57bb034c --- /dev/null +++ b/batchcanon/store.go @@ -0,0 +1,87 @@ +package batchcanon + +import ( + "context" + "errors" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" +) + +// ErrBatchNotFound is returned by Store.GetBatch when no canonicality record +// exists for the requested batch txid. +var ErrBatchNotFound = errors.New("batch canonicality record not found") + +// Store is the durable query/update surface for batch canonicality records. +// It is intentionally behavior-free: it persists and retrieves observations +// and reverse-dependency edges, leaving all interpretation — state +// transitions, chain watching, and admission — to the BatchCanonicalityManager +// and the later tasks of the reorg-safety epic. +// +//nolint:interfacebloat +type Store interface { + // UpsertBatch inserts or replaces the canonicality record for a + // batch, including its consumed inputs and dependent VTXOs. It is the + // single entry point for first-seeing a batch and for wholesale + // rewrites; targeted mutations use the methods below. + UpsertBatch(ctx context.Context, record *Record) error + + // GetBatch returns the canonicality record for a batch txid. It + // returns ErrBatchNotFound when no record exists. + GetBatch(ctx context.Context, txid chainhash.Hash) (*Record, error) + + // ListBatchesByState returns every batch currently in the given + // state. Used by the manager to find batches needing a particular + // follow-up (e.g. all provisional batches to re-check for finality). + ListBatchesByState(ctx context.Context, state State) ([]*Record, error) + + // UpdateBatchState transitions a batch to a new canonicality state + // without touching its other fields. + UpdateBatchState(ctx context.Context, txid chainhash.Hash, + state State) error + + // RecordInputConflict persists the observed conflict status of one of a + // batch's consumed inputs (a spend by a transaction other than the + // batch itself). It exists so restart reconciliation can rebuild the + // per-input conflict view and not transiently downgrade a persisted + // conflict before the conflicting spend is re-observed. + RecordInputConflict(ctx context.Context, batchTxid chainhash.Hash, + outpoint wire.OutPoint, conflicting, conflictFinal bool) error + + // RecordConfirmation records that the batch tx is confirmed at the + // given best-chain height and block hash. A later RecordConfirmation + // at a different height (after a reorg) overwrites the observation so + // the effective expiry tracks the new confirmation. + RecordConfirmation(ctx context.Context, txid chainhash.Hash, + height int32, block chainhash.Hash) error + + // ClearConfirmation clears the confirmation observation for a batch, + // reflecting that its confirming block left the best chain. It does + // not set any terminal flag: the batch may reconfirm. + ClearConfirmation(ctx context.Context, txid chainhash.Hash) error + + // FindBatchesConsumingOutpoint returns the txids of every recorded + // batch that consumes the given outpoint. Used to detect input + // conflicts: two batches consuming the same outpoint are in conflict. + FindBatchesConsumingOutpoint(ctx context.Context, + outpoint wire.OutPoint) ([]chainhash.Hash, error) + + // AddProvisionalConsumer records a reverse-dependency edge: the given + // VTXO outpoint has been provisionally consumed by the given consumer + // batch. Idempotent on (consumedVTXO, consumerBatch). + AddProvisionalConsumer(ctx context.Context, consumedVTXO wire.OutPoint, + consumerBatch chainhash.Hash) error + + // ListProvisionalConsumersForBatch returns the VTXO outpoints that + // the given consumer batch provisionally consumes. Used to find the + // VTXOs to restore when the consumer batch is invalidated. + ListProvisionalConsumersForBatch(ctx context.Context, + consumerBatch chainhash.Hash) ([]wire.OutPoint, error) + + // DeleteProvisionalConsumersForBatch removes every reverse-dependency + // edge for the given consumer batch, used once the batch is canonical + // (the consumption is no longer provisional) or fully invalidated and + // reconciled. + DeleteProvisionalConsumersForBatch(ctx context.Context, + consumerBatch chainhash.Hash) error +} diff --git a/btcwbackend/chain_backend.go b/btcwbackend/chain_backend.go index 6d631daac..adf29bf4f 100644 --- a/btcwbackend/chain_backend.go +++ b/btcwbackend/chain_backend.go @@ -38,8 +38,13 @@ type ChainBackend struct { neutrinoCS *neutrino.ChainService // notifier provides chain notification services backed by - // neutrino's compact block filter scanning. - notifier *neutrinonotify.NeutrinoNotifier + // neutrino's compact block filter scanning. Stored under the + // chainntnfs interface (concrete type is + // *neutrinonotify.NeutrinoNotifier in production) so reorg-aware + // forwarder tests can substitute a stub that drives the full + // Confirmed / NegativeConf / Done lifecycle without spinning up a + // neutrino chain service. + notifier chainntnfs.ChainNotifier // feeEstimator provides fee estimation from a web API since // neutrino has no mempool visibility. @@ -571,40 +576,89 @@ func (b *ChainBackend) RegisterConf(ctx context.Context, txid *chainhash.Hash, cancelOnce.Do(event.Cancel) } + // Create channels to convert neutrino's confirmation lifecycle + // (which already drives chainntnfs's NegativeConf/Done channels + // directly — neutrino emits these natively from its compact- + // block-filter scanner) into our backend-agnostic types. + // NegativeConf carries a reorg depth that the cross-backend + // chainsource layer intentionally drops, so we forward a bare + // struct{} signal on reorgChan instead. confChan := make(chan *chainsource.TxConfirmation, 1) + reorgChan := make(chan uint64, 1) + doneChan := make(chan struct{}, 1) go func() { + // Defers run in LIFO order. event.Cancel() must run first + // so the upstream notifier stops writing to its internal + // channels before we cancel notifyCtx (which any in-flight + // downstream sends are still using) and finally close the + // outgoing chans. Reversing this order would race the + // upstream notifier against closed channels. defer close(confChan) + defer close(reorgChan) + defer close(doneChan) defer cancel() defer safeCancel() - select { - case lndConf, ok := <-event.Confirmed: - if !ok { - return - } + for { + select { + case lndConf, ok := <-event.Confirmed: + if !ok { + return + } - conf := &chainsource.TxConfirmation{ - BlockHash: lndConf.BlockHash, - BlockHeight: lndConf.BlockHeight, - TxIndex: lndConf.TxIndex, - Tx: lndConf.Tx, - Block: lndConf.Block, - } + conf := &chainsource.TxConfirmation{ + BlockHash: lndConf.BlockHash, + BlockHeight: lndConf.BlockHeight, + TxIndex: lndConf.TxIndex, + Tx: lndConf.Tx, + Block: lndConf.Block, + } + + select { + case confChan <- conf: + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.NegativeConf: + if !ok { + event.NegativeConf = nil + + continue + } + + select { + case reorgChan <- uint64(0): + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.Done: + if !ok { + event.Done = nil + + continue + } + + select { + case doneChan <- struct{}{}: + case <-notifyCtx.Done(): + return + } + + return - select { - case confChan <- conf: case <-notifyCtx.Done(): return } - - case <-notifyCtx.Done(): - return } }() return &chainsource.ConfRegistration{ Confirmed: confChan, + Reorged: reorgChan, + Done: doneChan, Cancel: func() { cancel() safeCancel() @@ -641,40 +695,83 @@ func (b *ChainBackend) RegisterSpend(ctx context.Context, cancelOnce.Do(event.Cancel) } + // Create channels to convert neutrino's spend lifecycle into our + // backend-agnostic types. neutrino's Reorg carries no payload, so + // we forward a bare struct{} signal. spendChan := make(chan *chainsource.SpendDetail, 1) + reorgChan := make(chan uint64, 1) + doneChan := make(chan struct{}, 1) go func() { + // LIFO defer: event.Cancel() first so the upstream notifier + // stops writing, then cancel notifyCtx so in-flight downstream + // sends unblock, and finally close the outgoing chans. defer close(spendChan) + defer close(reorgChan) + defer close(doneChan) defer cancel() defer safeCancel() - select { - case lndSpend, ok := <-event.Spend: - if !ok { - return - } + for { + select { + case lndSpend, ok := <-event.Spend: + if !ok { + return + } - spend := &chainsource.SpendDetail{ - SpentOutPoint: lndSpend.SpentOutPoint, - SpenderTxHash: lndSpend.SpenderTxHash, - SpendingTx: lndSpend.SpendingTx, - SpenderInputIndex: lndSpend.SpenderInputIndex, - SpendingHeight: lndSpend.SpendingHeight, - } + spend := &chainsource.SpendDetail{ + SpentOutPoint: lndSpend.SpentOutPoint, + SpenderTxHash: lndSpend.SpenderTxHash, + SpendingTx: lndSpend.SpendingTx, + SpenderInputIndex: lndSpend. + SpenderInputIndex, + SpendingHeight: lndSpend.SpendingHeight, + } + + select { + case spendChan <- spend: + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.Reorg: + if !ok { + event.Reorg = nil + + continue + } + + select { + case reorgChan <- uint64(0): + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.Done: + if !ok { + event.Done = nil + + continue + } + + select { + case doneChan <- struct{}{}: + case <-notifyCtx.Done(): + return + } + + return - select { - case spendChan <- spend: case <-notifyCtx.Done(): return } - - case <-notifyCtx.Done(): - return } }() return &chainsource.SpendRegistration{ - Spend: spendChan, + Spend: spendChan, + Reorged: reorgChan, + Done: doneChan, Cancel: func() { cancel() safeCancel() diff --git a/btcwbackend/chain_backend_reorg_test.go b/btcwbackend/chain_backend_reorg_test.go new file mode 100644 index 000000000..a3fcb89ad --- /dev/null +++ b/btcwbackend/chain_backend_reorg_test.go @@ -0,0 +1,359 @@ +package btcwbackend + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/chainsource" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/stretchr/testify/require" +) + +// reorgWaitTimeout is the per-step deadline used by the btcwbackend +// forwarder reorg tests. The forwarder is a tight goroutine, so the +// timeout exists only to make a hang surface as a fast failure on +// slow CI. +const reorgWaitTimeout = 2 * time.Second + +// fakeNotifier is a minimal chainntnfs.ChainNotifier whose Register* +// methods hand back a caller-supplied event struct. The neutrino-side +// of the real chainntnfs implementation produces events with all four +// channels populated (Confirmed/NegativeConf/Done for conf, +// Spend/Reorg/Done for spend), so the stub lets us drive a synthetic +// upstream lifecycle through the chainbackend forwarder without +// standing up a real neutrino chain service. +type fakeNotifier struct { + confEvent *chainntnfs.ConfirmationEvent + spendEvent *chainntnfs.SpendEvent +} + +// RegisterConfirmationsNtfn returns the stub's pre-built confirmation +// event. +func (n *fakeNotifier) RegisterConfirmationsNtfn(_ *chainhash.Hash, _ []byte, _, + _ uint32, _ ...chainntnfs.NotifierOption) ( + *chainntnfs.ConfirmationEvent, error) { + + return n.confEvent, nil +} + +// RegisterSpendNtfn returns the stub's pre-built spend event. +func (n *fakeNotifier) RegisterSpendNtfn(_ *wire.OutPoint, _ []byte, _ uint32) ( + *chainntnfs.SpendEvent, error) { + + return n.spendEvent, nil +} + +// RegisterBlockEpochNtfn returns an empty block-epoch event. The +// reorg lifecycle tests do not exercise block epochs, so the channels +// are intentionally never written to. +func (n *fakeNotifier) RegisterBlockEpochNtfn(_ *chainntnfs.BlockEpoch) ( + *chainntnfs.BlockEpochEvent, error) { + + return &chainntnfs.BlockEpochEvent{ + Epochs: make(chan *chainntnfs.BlockEpoch), + Cancel: func() {}, + }, nil +} + +// Start satisfies the ChainNotifier interface; the stub has no real +// lifecycle. +func (n *fakeNotifier) Start() error { return nil } + +// Started satisfies the ChainNotifier interface; the stub always +// reports started. +func (n *fakeNotifier) Started() bool { return true } + +// Stop satisfies the ChainNotifier interface; the stub has no real +// lifecycle. +func (n *fakeNotifier) Stop() error { return nil } + +// TestRegisterConfForwardsReorgAndDone drives the full confirmation +// lifecycle through neutrino's chainntnfs notifier into the btcwbackend +// forwarder and asserts each event arrives on the matching chainsource +// registration channel. The lifecycle is: +// +// Confirmed -> NegativeConf -> Confirmed -> Done +// +// and the test additionally verifies the forwarder exits after Done +// by observing that the chainsource channels close. +func TestRegisterConfForwardsReorgAndDone(t *testing.T) { + t.Parallel() + + confChan := make(chan *chainntnfs.TxConfirmation, 2) + negChan := make(chan int32, 1) + doneChan := make(chan struct{}, 1) + notifier := &fakeNotifier{ + confEvent: &chainntnfs.ConfirmationEvent{ + Confirmed: confChan, + NegativeConf: negChan, + Done: doneChan, + Cancel: func() {}, + }, + } + backend := &ChainBackend{notifier: notifier} + + reg, err := backend.RegisterConf( + t.Context(), &chainhash.Hash{0x42}, []byte{0x51}, 1, 100, false, + ) + require.NoError(t, err) + + // 1. First confirmation crosses the forwarder. + hash1 := chainhash.Hash{0xaa} + confChan <- &chainntnfs.TxConfirmation{ + BlockHash: &hash1, + BlockHeight: 123, + Tx: wire.NewMsgTx(2), + } + + conf1 := awaitConfForward(t, reg.Confirmed) + require.Equal(t, uint32(123), conf1.BlockHeight) + require.Equal(t, hash1, *conf1.BlockHash) + + // 2. Reorg ping is forwarded as a single struct{} on Reorged. The + // depth value carried on NegativeConf is intentionally dropped at + // this layer so neutrino and LND backends present the same wire + // shape to chainsource consumers. + negChan <- 1 + + awaitSeq(t, reg.Reorged, "Reorged forward") + + // 3. Transaction re-confirms in a different block on the new tip. + hash2 := chainhash.Hash{0xbb} + confChan <- &chainntnfs.TxConfirmation{ + BlockHash: &hash2, + BlockHeight: 124, + Tx: wire.NewMsgTx(2), + } + + conf2 := awaitConfForward(t, reg.Confirmed) + require.Equal(t, uint32(124), conf2.BlockHeight) + require.Equal(t, hash2, *conf2.BlockHash) + + // 4. Done signal is forwarded; the forwarder then exits and the + // chainsource channels close. + doneChan <- struct{}{} + + awaitStruct(t, reg.Done, "Done forward") + + // All three forwarded channels must close once the forwarder + // exits. + requireConfClosedSoon(t, reg.Confirmed) + requireSeqClosedSoon(t, reg.Reorged) + requireStructClosedSoon(t, reg.Done) +} + +// TestRegisterSpendForwardsReorgAndDone is the spend-side equivalent of +// the confirmation lifecycle test above. +func TestRegisterSpendForwardsReorgAndDone(t *testing.T) { + t.Parallel() + + spendChan := make(chan *chainntnfs.SpendDetail, 2) + reorgChan := make(chan struct{}, 1) + doneChan := make(chan struct{}, 1) + notifier := &fakeNotifier{ + spendEvent: &chainntnfs.SpendEvent{ + Spend: spendChan, + Reorg: reorgChan, + Done: doneChan, + Cancel: func() {}, + }, + } + backend := &ChainBackend{notifier: notifier} + + outpoint := &wire.OutPoint{Index: 1} + reg, err := backend.RegisterSpend( + t.Context(), outpoint, []byte{0x51}, 100, + ) + require.NoError(t, err) + + // 1. First spend. + hash1 := chainhash.Hash{0x10} + spendChan <- &chainntnfs.SpendDetail{ + SpentOutPoint: outpoint, + SpenderTxHash: &hash1, + SpendingTx: wire.NewMsgTx(2), + SpendingHeight: 150, + } + + spend1 := awaitSpendForward(t, reg.Spend) + require.Equal(t, int32(150), spend1.SpendingHeight) + require.Equal(t, hash1, *spend1.SpenderTxHash) + + // 2. Reorg evicts the spend. + reorgChan <- struct{}{} + + awaitSeq(t, reg.Reorged, "spend Reorged forward") + + // 3. A different spender wins the new chain. + hash2 := chainhash.Hash{0x20} + spendChan <- &chainntnfs.SpendDetail{ + SpentOutPoint: outpoint, + SpenderTxHash: &hash2, + SpendingTx: wire.NewMsgTx(2), + SpendingHeight: 151, + } + + spend2 := awaitSpendForward(t, reg.Spend) + require.Equal(t, int32(151), spend2.SpendingHeight) + require.Equal(t, hash2, *spend2.SpenderTxHash) + + // 4. Done. + doneChan <- struct{}{} + + awaitStruct(t, reg.Done, "spend Done forward") + + requireSpendClosedSoon(t, reg.Spend) + requireSeqClosedSoon(t, reg.Reorged) + requireStructClosedSoon(t, reg.Done) +} + +// awaitConfForward reads a single confirmation off the forwarded +// channel with a deadline, failing the test on timeout or unexpected +// close. +func awaitConfForward(t *testing.T, + ch <-chan *chainsource.TxConfirmation) *chainsource.TxConfirmation { + + t.Helper() + + select { + case conf, ok := <-ch: + if !ok { + t.Fatal("conf channel closed before delivery") + } + + return conf + + case <-time.After(reorgWaitTimeout): + t.Fatal("timeout waiting for conf forward") + + return nil + } +} + +// awaitSpendForward reads a single spend off the forwarded channel +// with a deadline, failing the test on timeout or unexpected close. +func awaitSpendForward(t *testing.T, + ch <-chan *chainsource.SpendDetail) *chainsource.SpendDetail { + + t.Helper() + + select { + case spend, ok := <-ch: + if !ok { + t.Fatal("spend channel closed before delivery") + } + + return spend + + case <-time.After(reorgWaitTimeout): + t.Fatal("timeout waiting for spend forward") + + return nil + } +} + +// awaitStruct reads a single struct{} off the forwarded channel with a +// deadline. Used for the Reorged and Done channels. +func awaitStruct(t *testing.T, ch <-chan struct{}, label string) { + t.Helper() + + select { + case _, ok := <-ch: + if !ok { + t.Fatalf("%s channel closed before delivery", label) + } + + case <-time.After(reorgWaitTimeout): + t.Fatalf("timeout waiting for %s", label) + } +} + +// requireConfClosedSoon asserts the confirmation channel closes within +// the reorg wait timeout. Used to verify the forwarder's defer-close +// chain runs after Done is delivered. +func requireConfClosedSoon(t *testing.T, + ch <-chan *chainsource.TxConfirmation) { + + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "conf channel did not close after Done") +} + +// requireSpendClosedSoon asserts the spend channel closes within the +// reorg wait timeout. +func requireSpendClosedSoon(t *testing.T, ch <-chan *chainsource.SpendDetail) { + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "spend channel did not close after Done") +} + +// requireStructClosedSoon asserts that a struct{} signal channel +// closes within the reorg wait timeout. +func requireStructClosedSoon(t *testing.T, ch <-chan struct{}) { + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "struct channel did not close after Done") +} + +// awaitSeq reads a single sequence value off the forwarded Reorged +// channel with a deadline. chainsource retyped Reorged from struct{} +// to the ordering sequence; this backend always sends 0. +func awaitSeq(t *testing.T, ch <-chan uint64, label string) { + t.Helper() + + select { + case _, ok := <-ch: + if !ok { + t.Fatalf("%s channel closed before delivery", label) + } + + case <-time.After(reorgWaitTimeout): + t.Fatalf("timeout waiting for %s", label) + } +} + +// requireSeqClosedSoon asserts that a sequence-carrying signal channel +// closes within the reorg wait timeout. +func requireSeqClosedSoon(t *testing.T, ch <-chan uint64) { + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "seq channel did not close after Done") +} diff --git a/chainbackends/lnd.go b/chainbackends/lnd.go index 8be668cf8..67f1ebbd6 100644 --- a/chainbackends/lnd.go +++ b/chainbackends/lnd.go @@ -27,6 +27,20 @@ import ( // unexported there and so must be matched by value. const lndNeutrinoBroadcastMsg = "broadcast-unverified" +// reorgSignalBufferSize bounds the buffered reorg notifications forwarded +// from lnd's notifier to a chainsource registration. The forwarder sends +// to this channel with a blocking send, so a full buffer head-of-line +// blocks delivery of unrelated Confirmed / Done events on the same +// forwarder goroutine. A reorg burst (e.g. a multi-block reorg emitting +// one NegativeConf per disconnected block) can produce several signals +// back-to-back; sizing the buffer to absorb a typical reorg depth keeps +// the forwarder moving. The signal is coalescing — the consumer +// re-queries chain state on any reorg — so exact depth need not be +// preserved; the buffer only needs to be deep enough to avoid stalling, +// not to record every event. Eight comfortably covers realistic reorg +// depths while staying negligible in memory. +const reorgSignalBufferSize = 8 + // TxBroadcaster is a minimal interface for broadcasting transactions. This // allows LNDBackend to work with both lnwallet.WalletController (in-process // lnd) and lndclient wrappers (remote lnd via gRPC). @@ -330,37 +344,98 @@ func (b *LNDBackend) RegisterConf(ctx context.Context, txid *chainhash.Hash, // context is released. notifyCtx, cancel := context.WithCancel(context.Background()) - // Create a channel to convert lnd's TxConfirmation to our type. + // Create channels to convert lnd's confirmation lifecycle to our + // backend-agnostic types. NegativeConf carries a reorg depth that + // the lndclient gRPC transport cannot preserve, so we forward the + // forwarder's sequence number instead (see below). confChan := make(chan *chainsource.TxConfirmation, 1) + reorgChan := make(chan uint64, reorgSignalBufferSize) + doneChan := make(chan struct{}, 1) go func() { + // seq is a per-registration monotonic counter stamped onto + // every Confirmed and Reorged signal in the order this single + // forwarder goroutine observes them. Confirmed and Reorged + // leave on separate channels, so a select over both at any + // downstream hop (here, and again in the chainsource conf + // actor) cannot recover their order; the shared sequence lets + // the final consumer apply highest-seq-wins and discard a + // stale signal that lost a cross-channel race. This goroutine + // is the single authoritative ordering point — whatever order + // it reads lnd's channels in is the order the consumer honors. + var seq uint64 + // Defers run in LIFO order. event.Cancel() must run first so + // the upstream notifier stops writing to its internal + // channels before we cancel notifyCtx (which any in-flight + // downstream sends are still using) and finally close the + // outgoing chans. Reversing this order would race the + // upstream notifier against closed channels. defer close(confChan) + defer close(reorgChan) + defer close(doneChan) defer cancel() defer event.Cancel() - select { - case lndConf, ok := <-event.Confirmed: - if !ok { - return - } + for { + select { + case lndConf, ok := <-event.Confirmed: + if !ok { + return + } - conf := &chainsource.TxConfirmation{ - BlockHash: lndConf.BlockHash, - BlockHeight: lndConf.BlockHeight, - TxIndex: lndConf.TxIndex, - Tx: lndConf.Tx, - Block: lndConf.Block, - } + seq++ + conf := &chainsource.TxConfirmation{ + BlockHash: lndConf.BlockHash, + BlockHeight: lndConf.BlockHeight, + TxIndex: lndConf.TxIndex, + Tx: lndConf.Tx, + Block: lndConf.Block, + Seq: seq, + } + + select { + case confChan <- conf: + case <-notifyCtx.Done(): + return + } - confChan <- conf + case _, ok := <-event.NegativeConf: + if !ok { + event.NegativeConf = nil + continue + } - case <-notifyCtx.Done(): - return + seq++ + select { + case reorgChan <- seq: + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.Done: + if !ok { + event.Done = nil + continue + } + + select { + case doneChan <- struct{}{}: + case <-notifyCtx.Done(): + return + } + + return + + case <-notifyCtx.Done(): + return + } } }() return &chainsource.ConfRegistration{ Confirmed: confChan, + Reorged: reorgChan, + Done: doneChan, Cancel: func() { cancel() event.Cancel() @@ -393,39 +468,97 @@ func (b *LNDBackend) RegisterSpend(ctx context.Context, outpoint *wire.OutPoint, // Keep spend delivery alive independently of the actor request context. notifyCtx, cancel := context.WithCancel(context.Background()) - // Create a channel to convert lnd's SpendDetail to our type. + // Create channels to convert lnd's spend lifecycle to our + // backend-agnostic types. lnd's spend Reorg carries no payload, so + // we forward the forwarder's sequence number instead (see below). spendChan := make(chan *chainsource.SpendDetail, 1) + reorgChan := make(chan uint64, reorgSignalBufferSize) + doneChan := make(chan struct{}, 1) - // Start a goroutine to convert and forward the spend. + // Start a goroutine to convert and forward spend lifecycle events. go func() { + // seq is a per-registration monotonic counter stamped onto + // every Spend and Reorged signal in the order this single + // forwarder observes them. Spend and Reorged leave on separate + // channels, so a downstream select cannot recover their order; + // the shared sequence lets the final consumer apply + // highest-seq-wins and discard a stale signal that lost a + // cross-channel race. This goroutine is the single + // authoritative ordering point. + var seq uint64 + // Defer order is LIFO: event.Cancel() stops the upstream + // notifier first, then cancel() ends notifyCtx, and only + // then are the outgoing channels closed. This avoids a race + // where the upstream notifier writes to a freshly-closed + // downstream channel. defer close(spendChan) + defer close(reorgChan) + defer close(doneChan) defer cancel() defer event.Cancel() - select { - case lndSpend, ok := <-event.Spend: - if !ok { - return - } + for { + select { + case lndSpend, ok := <-event.Spend: + if !ok { + return + } - // Convert to our type. - spend := &chainsource.SpendDetail{ - SpentOutPoint: lndSpend.SpentOutPoint, - SpenderTxHash: lndSpend.SpenderTxHash, - SpendingTx: lndSpend.SpendingTx, - SpenderInputIndex: lndSpend.SpenderInputIndex, - SpendingHeight: lndSpend.SpendingHeight, - } + // Convert to our type. + seq++ + spend := &chainsource.SpendDetail{ + SpentOutPoint: lndSpend.SpentOutPoint, + SpenderTxHash: lndSpend.SpenderTxHash, + SpendingTx: lndSpend.SpendingTx, + SpenderInputIndex: lndSpend. + SpenderInputIndex, + SpendingHeight: lndSpend.SpendingHeight, + Seq: seq, + } + + select { + case spendChan <- spend: + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.Reorg: + if !ok { + event.Reorg = nil + continue + } + + seq++ + select { + case reorgChan <- seq: + case <-notifyCtx.Done(): + return + } + + case _, ok := <-event.Done: + if !ok { + event.Done = nil + continue + } + + select { + case doneChan <- struct{}{}: + case <-notifyCtx.Done(): + return + } - spendChan <- spend + return - case <-notifyCtx.Done(): - return + case <-notifyCtx.Done(): + return + } } }() return &chainsource.SpendRegistration{ - Spend: spendChan, + Spend: spendChan, + Reorged: reorgChan, + Done: doneChan, Cancel: func() { cancel() event.Cancel() diff --git a/chainbackends/lnd_reorg_test.go b/chainbackends/lnd_reorg_test.go new file mode 100644 index 000000000..2028b9229 --- /dev/null +++ b/chainbackends/lnd_reorg_test.go @@ -0,0 +1,308 @@ +package chainbackends + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/chainsource" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/stretchr/testify/require" +) + +// reorgWaitTimeout is the per-step deadline used by the LNDBackend +// forwarder reorg tests. The forwarder is a tight goroutine, so the +// timeout exists only to make a hang surface as a fast failure on slow CI. +const reorgWaitTimeout = 2 * time.Second + +// TestRegisterConfForwardsReorgAndDone drives the full confirmation +// lifecycle through the chainntnfs notifier into the LNDBackend forwarder +// and asserts each event arrives on the matching chainsource registration +// channel. The lifecycle is: +// +// Confirmed -> NegativeConf -> Confirmed -> Done +// +// and the test additionally verifies the forwarder exits after Done by +// observing that the chainsource channels close. +func TestRegisterConfForwardsReorgAndDone(t *testing.T) { + t.Parallel() + + confChan := make(chan *chainntnfs.TxConfirmation, 2) + negChan := make(chan int32, 1) + doneChan := make(chan struct{}, 1) + notifier := &stubNotifier{ + confEvent: &chainntnfs.ConfirmationEvent{ + Confirmed: confChan, + NegativeConf: negChan, + Done: doneChan, + Cancel: func() {}, + }, + } + backend := NewLNDBackend( + notifier, &stubFeeEstimator{}, &stubBroadcaster{}, + ) + + reg, err := backend.RegisterConf( + t.Context(), &chainhash.Hash{0x42}, []byte{0x51}, 1, 100, false, + ) + require.NoError(t, err) + + // 1. First confirmation crosses the forwarder. + hash1 := chainhash.Hash{0xaa} + confChan <- &chainntnfs.TxConfirmation{ + BlockHash: &hash1, + BlockHeight: 123, + Tx: wire.NewMsgTx(2), + } + + conf1 := awaitConfForward(t, reg.Confirmed) + require.Equal(t, uint32(123), conf1.BlockHeight) + require.Equal(t, hash1, *conf1.BlockHash) + + // 2. Reorg ping is forwarded as a single struct{} on Reorged. The + // depth value carried on NegativeConf is intentionally dropped at + // this layer; consumers must not rely on it. + negChan <- 1 + + awaitSeq(t, reg.Reorged, "Reorged forward") + + // 3. Transaction re-confirms in a different block on the new tip. + hash2 := chainhash.Hash{0xbb} + confChan <- &chainntnfs.TxConfirmation{ + BlockHash: &hash2, + BlockHeight: 124, + Tx: wire.NewMsgTx(2), + } + + conf2 := awaitConfForward(t, reg.Confirmed) + require.Equal(t, uint32(124), conf2.BlockHeight) + require.Equal(t, hash2, *conf2.BlockHash) + + // 4. Done signal is forwarded; the forwarder then exits and the + // chainsource channels close. + doneChan <- struct{}{} + + awaitStruct(t, reg.Done, "Done forward") + + // All three forwarded channels must close once the forwarder exits. + requireConfClosedSoon(t, reg.Confirmed) + requireSeqClosedSoon(t, reg.Reorged) + requireStructClosedSoon(t, reg.Done) +} + +// TestRegisterSpendForwardsReorgAndDone is the spend-side equivalent of +// the confirmation lifecycle test above. +func TestRegisterSpendForwardsReorgAndDone(t *testing.T) { + t.Parallel() + + spendChan := make(chan *chainntnfs.SpendDetail, 2) + reorgChan := make(chan struct{}, 1) + doneChan := make(chan struct{}, 1) + notifier := &stubNotifier{ + spendEvent: &chainntnfs.SpendEvent{ + Spend: spendChan, + Reorg: reorgChan, + Done: doneChan, + Cancel: func() {}, + }, + } + backend := NewLNDBackend( + notifier, &stubFeeEstimator{}, &stubBroadcaster{}, + ) + + outpoint := &wire.OutPoint{Index: 1} + reg, err := backend.RegisterSpend( + t.Context(), outpoint, []byte{0x51}, 100, + ) + require.NoError(t, err) + + // 1. First spend. + hash1 := chainhash.Hash{0x10} + spendChan <- &chainntnfs.SpendDetail{ + SpentOutPoint: outpoint, + SpenderTxHash: &hash1, + SpendingTx: wire.NewMsgTx(2), + SpendingHeight: 150, + } + + spend1 := awaitSpendForward(t, reg.Spend) + require.Equal(t, int32(150), spend1.SpendingHeight) + require.Equal(t, hash1, *spend1.SpenderTxHash) + + // 2. Reorg evicts the spend. + reorgChan <- struct{}{} + + awaitSeq(t, reg.Reorged, "spend Reorged forward") + + // 3. A different spender wins the new chain. + hash2 := chainhash.Hash{0x20} + spendChan <- &chainntnfs.SpendDetail{ + SpentOutPoint: outpoint, + SpenderTxHash: &hash2, + SpendingTx: wire.NewMsgTx(2), + SpendingHeight: 151, + } + + spend2 := awaitSpendForward(t, reg.Spend) + require.Equal(t, int32(151), spend2.SpendingHeight) + require.Equal(t, hash2, *spend2.SpenderTxHash) + + // 4. Done. + doneChan <- struct{}{} + + awaitStruct(t, reg.Done, "spend Done forward") + + requireSpendClosedSoon(t, reg.Spend) + requireSeqClosedSoon(t, reg.Reorged) + requireStructClosedSoon(t, reg.Done) +} + +// awaitConfForward reads a single confirmation off the forwarded channel +// with a deadline, failing the test on timeout or unexpected close. +func awaitConfForward(t *testing.T, + ch <-chan *chainsource.TxConfirmation) *chainsource.TxConfirmation { + + t.Helper() + + select { + case conf, ok := <-ch: + if !ok { + t.Fatal("conf channel closed before delivery") + } + + return conf + + case <-time.After(reorgWaitTimeout): + t.Fatal("timeout waiting for conf forward") + + return nil + } +} + +// awaitSpendForward reads a single spend off the forwarded channel with a +// deadline, failing the test on timeout or unexpected close. +func awaitSpendForward(t *testing.T, + ch <-chan *chainsource.SpendDetail) *chainsource.SpendDetail { + + t.Helper() + + select { + case spend, ok := <-ch: + if !ok { + t.Fatal("spend channel closed before delivery") + } + + return spend + + case <-time.After(reorgWaitTimeout): + t.Fatal("timeout waiting for spend forward") + + return nil + } +} + +// awaitStruct reads a single struct{} off the forwarded channel with a +// deadline. Used for the Reorged and Done channels. +func awaitStruct(t *testing.T, ch <-chan struct{}, label string) { + t.Helper() + + select { + case _, ok := <-ch: + if !ok { + t.Fatalf("%s channel closed before delivery", label) + } + + case <-time.After(reorgWaitTimeout): + t.Fatalf("timeout waiting for %s", label) + } +} + +// requireConfClosedSoon asserts the confirmation channel closes within +// the reorg wait timeout. Used to verify the forwarder's defer-close +// chain runs after Done is delivered. +func requireConfClosedSoon(t *testing.T, + ch <-chan *chainsource.TxConfirmation) { + + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "conf channel did not close after Done") +} + +// requireSpendClosedSoon asserts the spend channel closes within the +// reorg wait timeout. +func requireSpendClosedSoon(t *testing.T, ch <-chan *chainsource.SpendDetail) { + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "spend channel did not close after Done") +} + +// requireStructClosedSoon asserts that a struct{} signal channel closes +// within the reorg wait timeout. +func requireStructClosedSoon(t *testing.T, ch <-chan struct{}) { + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "struct channel did not close after Done") +} + +// awaitSeq reads a single sequence number off the forwarded Reorged +// channel with a deadline. The reorg signal now carries the forwarder's +// monotonic sequence (see chainsource.TxConfirmation.Seq) rather than a +// bare struct{}. +func awaitSeq(t *testing.T, ch <-chan uint64, label string) { + t.Helper() + + select { + case _, ok := <-ch: + if !ok { + t.Fatalf("%s channel closed before delivery", label) + } + + case <-time.After(reorgWaitTimeout): + t.Fatalf("timeout waiting for %s", label) + } +} + +// requireSeqClosedSoon asserts that a sequence-carrying signal channel +// closes within the reorg wait timeout. +func requireSeqClosedSoon(t *testing.T, ch <-chan uint64) { + t.Helper() + + require.Eventually(t, func() bool { + select { + case _, ok := <-ch: + return !ok + + default: + return false + } + }, reorgWaitTimeout, 10*time.Millisecond, + "seq channel did not close after Done") +} diff --git a/chainbackends/lndclient_adapters.go b/chainbackends/lndclient_adapters.go index f0d4b1d31..c388fb644 100644 --- a/chainbackends/lndclient_adapters.go +++ b/chainbackends/lndclient_adapters.go @@ -17,6 +17,16 @@ import ( "github.com/lightningnetwork/lnd/lnwallet/chainfee" ) +// lndRegistrationTimeout bounds how long a conf/spend registration call into +// lndclient may block before we give up and return an error. lnd can be slow +// to answer a RegisterConfirmationsNtfn / RegisterSpendNtfn while it is busy +// processing a fresh block, and a registration that hangs forever would pin +// the chainsource sub-actor's Receive call and back-pressure the factory +// actor onto every other in-flight registration. Fifteen seconds is well +// above lnd's normal under-load response time yet short enough that a +// genuinely wedged backend surfaces as an error rather than a silent hang. +const lndRegistrationTimeout = 15 * time.Second + // LndClientTxBroadcaster implements TxBroadcaster using // lndclient.WalletKitClient. type LndClientTxBroadcaster struct { @@ -131,7 +141,16 @@ func (n *LndClientChainNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, opt(notifierOpts) } - var lndOpts []lndclient.NotifierOption + // Ask lndclient to keep the confirmation stream alive past the first + // Confirmed event and forward any subsequent reorg signal on a + // dedicated channel. Without WithReOrgChan, lndclient's receive + // loop tears the stream down after one delivery and any later + // reorg is silently dropped at the gRPC layer. + reorgPing := make(chan struct{}, 1) + + lndOpts := []lndclient.NotifierOption{ + lndclient.WithReOrgChan(reorgPing), + } if notifierOpts.IncludeBlock { lndOpts = append(lndOpts, lndclient.WithIncludeBlock()) } @@ -177,11 +196,11 @@ func (n *LndClientChainNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, confChan = r.confChan errChan = r.errChan - case <-time.After(15 * time.Second): + case <-time.After(lndRegistrationTimeout): cancel() - return nil, fmt.Errorf("register confirmations timed out " + - "after 15s") + return nil, fmt.Errorf("register confirmations timed out "+ + "after %s", lndRegistrationTimeout) } go func() { @@ -199,26 +218,147 @@ func (n *LndClientChainNotifier) RegisterConfirmationsNtfn(txid *chainhash.Hash, } }() + // Forward the confirmation lifecycle through a SINGLE goroutine so the + // reorg ping and the (re-)confirmation reach the downstream chainntnfs + // channels in lndclient's emission order. lndclient drives both off one + // ordered gRPC receive loop and writes the reorg ping before the + // replacement Confirmed, but it splits them across two channels + // (confChan and reorgPing); if we forwarded each on its own goroutine, + // the downstream ConfActor's select could consume a re-Confirmed before + // the Reorged, reset confirmHeight to 0 with no further Confirmed + // coming, and strand the watch. Draining a pending reorg with priority + // on each iteration, and handing every event off with a blocking send, + // makes the ConfActor observe exactly the forwarder's (lndclient's) + // order. + // + // lndclient does not preserve the reorg depth across the gRPC boundary, + // so a sentinel value of 0 is forwarded on NegativeConf; callers over + // this transport must not rely on the integer value. + orderedConfirmed := make(chan *chainntnfs.TxConfirmation, 1) + negativeConf := make(chan int32, 1) + go func() { + defer close(orderedConfirmed) + defer close(negativeConf) + + forwardOrderedReorg( + ctx, reorgPing, confChan, orderedConfirmed, + negativeConf, + ) + }() + + // Done is allocated but never written to because lnd's internal + // "past reorg-safety depth" signal is not surfaced through the + // lndclient gRPC transport. Consumers needing such a gate must + // compute it themselves from block height. return &chainntnfs.ConfirmationEvent{ - Confirmed: confChan, - Cancel: cancel, - Done: make(chan struct{}, 1), + Confirmed: orderedConfirmed, + NegativeConf: negativeConf, + Cancel: cancel, + Done: make(chan struct{}, 1), }, nil } +// forwardOrderedReorg copies confirmations and reorg pings from lndclient's two +// source channels onto the downstream Confirmed / NegativeConf channels, +// forwarding each event in the order this single goroutine observes it. It does +// not bias either channel: the authoritative lifecycle ordering is +// re-established downstream by the per-registration sequence number the +// LNDBackend forwarder stamps (see chainsource.TxConfirmation.Seq), so the +// consumer applies highest-seq-wins regardless of how near-simultaneous events +// interleave here. lndclient's two-channel split makes a perfectly ordered +// merge impossible at this layer, so forwarding in natural arrival order keeps +// the stamped sequence faithful to what was actually observed rather than +// injecting an artificial reorg-first bias. lndclient does not preserve the +// reorg depth across the gRPC boundary, so a sentinel value of 0 is forwarded +// on NegativeConf; callers over this transport must not rely on the value. +func forwardOrderedReorg(ctx context.Context, reorgPing <-chan struct{}, + confChan <-chan *chainntnfs.TxConfirmation, + outConfirmed chan<- *chainntnfs.TxConfirmation, + outNegConf chan<- int32) { + + for confChan != nil || reorgPing != nil { + select { + case _, ok := <-reorgPing: + if !ok { + reorgPing = nil + + continue + } + + select { + case outNegConf <- 0: + case <-ctx.Done(): + return + } + + case c, ok := <-confChan: + if !ok { + confChan = nil + + continue + } + + select { + case outConfirmed <- c: + case <-ctx.Done(): + return + } + + case <-ctx.Done(): + return + } + } +} + // RegisterSpendNtfn registers for spend notifications using lndclient's // ChainNotifier. func (n *LndClientChainNotifier) RegisterSpendNtfn(outpoint *wire.OutPoint, pkScript []byte, heightHint uint32) (*chainntnfs.SpendEvent, error) { + // Ask lndclient to keep the spend stream alive past the first + // Spend event so it can forward reorg pings. Without WithReOrgChan + // the stream is torn down after the first delivery and any later + // spend reorg is silently dropped at the gRPC layer. + reorgPing := make(chan struct{}, 1) + ctx, cancel := context.WithCancel(context.Background()) - spendChan, errChan, err := n.cfg.LND.ChainNotifier.RegisterSpendNtfn( - ctx, outpoint, pkScript, int32(heightHint), - ) - if err != nil { + + // Run the registration in a goroutine with a timeout to prevent + // hanging when LND is slow under block load, mirroring the conf path. + type regResult struct { + spendChan chan *chainntnfs.SpendDetail + errChan chan error + err error + } + + resultCh := make(chan regResult, 1) + go func() { + sc, ec, err := n.cfg.LND.ChainNotifier.RegisterSpendNtfn( + ctx, outpoint, pkScript, int32(heightHint), + lndclient.WithReOrgChan(reorgPing), + ) + resultCh <- regResult{sc, ec, err} + }() + + var spendChan chan *chainntnfs.SpendDetail + var errChan chan error + + select { + case r := <-resultCh: + if r.err != nil { + cancel() + + return nil, fmt.Errorf("register spend: %w", r.err) + } + + spendChan = r.spendChan + errChan = r.errChan + + case <-time.After(lndRegistrationTimeout): cancel() - return nil, fmt.Errorf("register spend: %w", err) + return nil, fmt.Errorf("register spend timed out after %s", + lndRegistrationTimeout) } go func() { @@ -236,14 +376,80 @@ func (n *LndClientChainNotifier) RegisterSpendNtfn(outpoint *wire.OutPoint, } }() + // Forward the spend lifecycle through a SINGLE goroutine so the reorg + // ping and the (re-)spend reach the downstream chainntnfs channels in + // lndclient's emission order, for the same reason as the conf path: the + // two-channel split (spendChan and reorgPing) would otherwise let the + // downstream SpendActor's select consume a re-Spend before the Reorged + // and strand the watch. Draining a pending reorg with priority and + // using blocking hand-offs makes the SpendActor observe lndclient's + // order. + orderedSpend := make(chan *chainntnfs.SpendDetail, 1) + reorgChan := make(chan struct{}, 1) + go func() { + defer close(orderedSpend) + defer close(reorgChan) + + forwardOrderedSpendReorg( + ctx, reorgPing, spendChan, orderedSpend, reorgChan, + ) + }() + + // Done is allocated but never written to because lnd's "past + // reorg-safety depth" signal is not surfaced through the lndclient + // gRPC transport. return &chainntnfs.SpendEvent{ - Spend: spendChan, - Reorg: make(chan struct{}, 1), + Spend: orderedSpend, + Reorg: reorgChan, Done: make(chan struct{}, 1), Cancel: cancel, }, nil } +// forwardOrderedSpendReorg is the spend-path analogue of forwardOrderedReorg: +// it copies spends and reorg pings from lndclient's two source channels onto +// the downstream Spend / Reorg channels in the order this single goroutine +// observes them, without biasing either channel. The authoritative lifecycle +// ordering is re-established downstream by the per-registration sequence number +// the LNDBackend forwarder stamps (see chainsource.SpendDetail.Seq). +func forwardOrderedSpendReorg(ctx context.Context, reorgPing <-chan struct{}, + spendChan <-chan *chainntnfs.SpendDetail, + outSpend chan<- *chainntnfs.SpendDetail, outReorg chan<- struct{}) { + + for spendChan != nil || reorgPing != nil { + select { + case _, ok := <-reorgPing: + if !ok { + reorgPing = nil + + continue + } + + select { + case outReorg <- struct{}{}: + case <-ctx.Done(): + return + } + + case sp, ok := <-spendChan: + if !ok { + spendChan = nil + + continue + } + + select { + case outSpend <- sp: + case <-ctx.Done(): + return + } + + case <-ctx.Done(): + return + } + } +} + // RegisterBlockEpochNtfn registers for block epoch notifications using // lndclient's ChainNotifier. func (n *LndClientChainNotifier) RegisterBlockEpochNtfn( diff --git a/chainsource/backend.go b/chainsource/backend.go index 3ef13b54e..7a6acfcaa 100644 --- a/chainsource/backend.go +++ b/chainsource/backend.go @@ -137,12 +137,41 @@ type ChainBackend interface { // ConfRegistration encapsulates the channels and control functions for a // confirmation registration. This mirrors lnd's chainntnfs.ConfirmationEvent // structure but provides a backend-agnostic interface. +// +// The registration is reorg-aware: after a confirmation has been delivered +// on Confirmed, a subsequent reorg that buries the original block can cause +// a fresh send on Reorged. Once the transaction re-confirms on the new +// canonical chain another event arrives on Confirmed. The lifecycle is +// therefore Confirmed -> Reorged -> Confirmed -> ... terminated by either a +// send on Done (the confirmation is past the backend's reorg-safety depth) +// or a caller-driven Cancel. Backends that cannot observe reorgs leave +// Reorged and Done as never-firing channels; callers must not assume either +// will ever fire. type ConfRegistration struct { - // Confirmed is a channel that fires once when the transaction reaches - // the target number of confirmations. The channel is buffered and will - // only send a single event. + // Confirmed fires every time the transaction reaches the target + // number of confirmations on the canonical chain. After a Reorged + // event it may fire again when the transaction re-confirms. The + // channel is buffered. Confirmed <-chan *TxConfirmation + // Reorged fires when a previously delivered confirmation is reorged + // out of the canonical chain. The payload is the backend forwarder's + // monotonic sequence number (see TxConfirmation.Seq) rather than + // reorg depth or block identity, which the lndclient gRPC transport + // cannot preserve; the sequence lets the consumer order this signal + // against confirmations sharing the same sequence space even though + // they arrive on a different channel. Backends that cannot observe + // reorgs leave this channel nil-equivalent (allocated but never + // written to); reading from it is always safe. + Reorged <-chan uint64 + + // Done fires once when the confirmation watch is past the backend's + // reorg-safety depth and will receive no further events. Callers + // must still invoke Cancel to release client-side resources. + // Backends that cannot synthesize a safety-depth signal leave this + // channel nil-equivalent (allocated but never written to). + Done <-chan struct{} + // Cancel is a function that can be called to cancel this registration // and clean up resources. After calling Cancel, no more events will be // sent on any channels. @@ -168,17 +197,54 @@ type TxConfirmation struct { // when the confirmation was registered with IncludeBlock=true. This // matches lnd's chainntnfs behavior. Block *wire.MsgBlock + + // Seq is a per-registration monotonic sequence number stamped by the + // backend forwarder in the order it observed lifecycle events. + // Confirmed and Reorged share one sequence space so the consumer can + // order them even though they arrive on separate channels (a select + // over two ready channels picks at random and cannot recover order). + // The consumer applies an event only when its Seq exceeds the highest + // Seq seen so far, discarding a stale event that lost a cross-channel + // race. Zero means the backend does not stamp sequences (it never + // reorgs, so ordering is moot); such events are always applied. + Seq uint64 } // SpendRegistration encapsulates the channels and control functions for a // spend registration. This mirrors lnd's chainntnfs.SpendEvent structure. +// +// The registration is reorg-aware: after a spend has been delivered on +// Spend, a subsequent reorg that buries the spending block can cause a +// fresh send on Reorged. If the outpoint is then re-spent on the new +// canonical chain (by the same or a different transaction) another event +// arrives on Spend. The lifecycle is therefore Spend -> Reorged -> Spend +// -> ... terminated by either a send on Done (the spend is past the +// backend's reorg-safety depth) or a caller-driven Cancel. Backends that +// cannot observe reorgs leave Reorged and Done as never-firing channels. type SpendRegistration struct { - // Spend is a channel that fires when the monitored outpoint is spent. - // The spending transaction must have at least one confirmation. The - // channel is buffered and will send an event for each spend (though - // typically only one unless reorgs occur). + // Spend fires every time the monitored outpoint is spent on the + // canonical chain. After a Reorged event it may fire again when a + // new spender confirms. The channel is buffered. Spend <-chan *SpendDetail + // Reorged fires when a previously delivered spend is reorged out of + // the canonical chain. The payload is the backend forwarder's + // monotonic sequence number (see SpendDetail.Seq) rather than reorg + // depth or block identity, which the lndclient gRPC transport cannot + // preserve; the sequence lets the consumer order this signal against + // spends sharing the same sequence space even though they arrive on a + // different channel. Backends that cannot observe spend reorgs leave + // this channel nil-equivalent (allocated but never written to); + // reading from it is always safe. + Reorged <-chan uint64 + + // Done fires once when the spend watch is past the backend's + // reorg-safety depth and will receive no further events. Callers + // must still invoke Cancel to release client-side resources. + // Backends that cannot synthesize a safety-depth signal leave this + // channel nil-equivalent (allocated but never written to). + Done <-chan struct{} + // Cancel is a function that can be called to cancel this registration // and clean up resources. Cancel func() @@ -203,6 +269,17 @@ type SpendDetail struct { // SpendingHeight is the block height where the spending transaction // was confirmed. SpendingHeight int32 + + // Seq is a per-registration monotonic sequence number stamped by the + // backend forwarder in the order it observed lifecycle events. Spend + // and Reorged share one sequence space so the consumer can order them + // even though they arrive on separate channels (a select over two + // ready channels picks at random and cannot recover order). The + // consumer applies an event only when its Seq exceeds the highest Seq + // seen so far, discarding a stale event that lost a cross-channel + // race. Zero means the backend does not stamp sequences (it never + // reorgs, so ordering is moot); such events are always applied. + Seq uint64 } // BlockRegistration encapsulates the channels and control functions for a diff --git a/chainsource/chainsource.go b/chainsource/chainsource.go index 47e0ee57d..86676dcd8 100644 --- a/chainsource/chainsource.go +++ b/chainsource/chainsource.go @@ -18,6 +18,16 @@ const ( // allows for buffering up to 10 blocks in transit, which should cover // normal block arrival patterns without blocking the backend. epochChannelSize = 10 + + // DefaultFinalityDepth is the conventional Bitcoin reorg-safety + // depth. After this many inclusive confirmations the ConfActor + // and SpendActor synthesize their Done events when the backend + // transport (notably lndclient over gRPC) does not surface one + // of its own. Six is the value the wider Lightning stack treats + // as final for the purposes of channel funding / settlement, so + // using it here keeps the unroll subsystem's finality threshold + // aligned with the rest of the daemon's chain assumptions. + DefaultFinalityDepth uint32 = 6 ) // ChainSourceConfig holds configuration for ChainSourceActor. @@ -32,6 +42,14 @@ type ChainSourceConfig struct { // falls back to extracting a logger from context via LoggerFromContext, // or uses btclog.Disabled if no logger is found. Log fn.Option[btclog.Logger] + + // FinalityDepth is forwarded to each spawned sub-actor as the + // number of confirmations past the first observed positive event + // that the actor uses to synthesize a Done signal when the backend + // transport (notably lndclient over gRPC) cannot deliver one. Zero + // disables height-based finality synthesis. See + // ConfActorConfig.FinalityDepth / SpendActorConfig.FinalityDepth. + FinalityDepth uint32 } // WithLogger returns a new config with the given logger set. @@ -318,8 +336,9 @@ func (a *ChainSourceActor) handleRegisterConf(ctx context.Context, ) confCfg := ConfActorConfig{ - Backend: a.cfg.Backend, - Log: fn.Some(a.logger(ctx)), + Backend: a.cfg.Backend, + Log: fn.Some(a.logger(ctx)), + FinalityDepth: a.cfg.FinalityDepth, } confActor := NewConfActor(confCfg) actorRef := serviceKey.Spawn(a.cfg.System, actorID, confActor) @@ -353,8 +372,9 @@ func (a *ChainSourceActor) handleRegisterSpend(ctx context.Context, ) spendCfg := SpendActorConfig{ - Backend: a.cfg.Backend, - Log: fn.Some(a.logger(ctx)), + Backend: a.cfg.Backend, + Log: fn.Some(a.logger(ctx)), + FinalityDepth: a.cfg.FinalityDepth, } spendActor := NewSpendActor(spendCfg) actorRef := serviceKey.Spawn(a.cfg.System, actorID, spendActor) diff --git a/chainsource/chainsource_test.go b/chainsource/chainsource_test.go index e039b4e9f..50a05a8c5 100644 --- a/chainsource/chainsource_test.go +++ b/chainsource/chainsource_test.go @@ -3,6 +3,7 @@ package chainsource import ( "context" "errors" + "sync/atomic" "testing" "time" @@ -17,12 +18,23 @@ import ( // mockBackend implements ChainBackend for testing. type mockBackend struct { - confChan chan *TxConfirmation - spendChan chan *SpendDetail - epochChan chan *BlockEpoch + confChan chan *TxConfirmation + confReorgedChan chan uint64 + confDoneChan chan struct{} + spendChan chan *SpendDetail + spendReorgedChan chan uint64 + spendDoneChan chan struct{} + epochChan chan *BlockEpoch epochCancel chan struct{} + // confCancelled / spendCancelled count Cancel invocations on the + // most recently issued registration. The reorg-aware lifecycle + // tests rely on these to assert that the actor released the + // registration after a Done event. + confCancelled atomic.Int32 + spendCancelled atomic.Int32 + feeRate btcutil.Amount bestHeight int32 bestHash chainhash.Hash @@ -42,12 +54,16 @@ type reconnectBlockBackend struct { // newMockBackend creates a new mock backend for testing. func newMockBackend() *mockBackend { return &mockBackend{ - confChan: make(chan *TxConfirmation, 1), - spendChan: make(chan *SpendDetail, 1), - epochChan: make(chan *BlockEpoch, 10), - epochCancel: make(chan struct{}, 10), - feeRate: 1000, - bestHeight: 100, + confChan: make(chan *TxConfirmation, 1), + confReorgedChan: make(chan uint64, 1), + confDoneChan: make(chan struct{}, 1), + spendChan: make(chan *SpendDetail, 1), + spendReorgedChan: make(chan uint64, 1), + spendDoneChan: make(chan struct{}, 1), + epochChan: make(chan *BlockEpoch, 10), + epochCancel: make(chan struct{}, 10), + feeRate: 1000, + bestHeight: 100, } } @@ -208,7 +224,11 @@ func (m *mockBackend) RegisterConf(ctx context.Context, txid *chainhash.Hash, return &ConfRegistration{ Confirmed: m.confChan, - Cancel: func() {}, + Reorged: m.confReorgedChan, + Done: m.confDoneChan, + Cancel: func() { + m.confCancelled.Add(1) + }, }, nil } @@ -217,8 +237,12 @@ func (m *mockBackend) RegisterSpend(ctx context.Context, *SpendRegistration, error) { return &SpendRegistration{ - Spend: m.spendChan, - Cancel: func() {}, + Spend: m.spendChan, + Reorged: m.spendReorgedChan, + Done: m.spendDoneChan, + Cancel: func() { + m.spendCancelled.Add(1) + }, }, nil } diff --git a/chainsource/conf_actor.go b/chainsource/conf_actor.go index 218005c45..75b6f53a1 100644 --- a/chainsource/conf_actor.go +++ b/chainsource/conf_actor.go @@ -22,6 +22,20 @@ type ConfActorConfig struct { // falls back to extracting a logger from context via LoggerFromContext, // or uses btclog.Disabled if no logger is found. Log fn.Option[btclog.Logger] + + // FinalityDepth is the number of confirmations past the first + // observed Confirmed event that the actor uses to synthesize a Done + // signal when the backend cannot deliver one. Zero disables + // height-based finality synthesis entirely; in that case the actor + // only fires ConfDoneEvent when the backend's own Done channel + // fires (e.g. an in-process lnd notifier). Non-zero values close + // the lndclient transport gap, where the gRPC layer does not + // surface lnd's internal "past reorg-safety depth" signal. + // + // The depth is counted inclusively (a tx confirmed at height H is + // at depth 1; height-based finality fires once the actor observes + // a block at H + FinalityDepth - 1). The conventional choice is 6. + FinalityDepth uint32 } // WithLogger returns a new config with the given logger set. @@ -67,9 +81,28 @@ type ConfActor struct { // mode. notifyActor fn.Option[actor.TellOnlyRef[ConfirmationEvent]] + // notifyReorged receives negative confirmation events in Actor mode. + notifyReorged fn.Option[actor.TellOnlyRef[ConfReorgedEvent]] + + // notifyDone receives finality events in Actor mode. + notifyDone fn.Option[actor.TellOnlyRef[ConfDoneEvent]] + // registration is the backend registration for this watch. registration *ConfRegistration + // blockReg is the block-epoch subscription used by height-based + // finality synthesis. Allocated lazily after the first Confirmed + // event when FinalityDepth > 0 and the registration is reorg-aware, + // torn down when the actor exits. + blockReg *BlockRegistration + + // confirmHeight records the block height the most recent + // Confirmed event arrived at. Used by the height-based finality + // synthesizer to compute current depth. Zero means there is no + // active confirmation to count from (either we have not yet seen + // one, or the last one was reorged out). + confirmHeight int32 + // ctx is the actor's internal context for cancellation, created from // context.Background() to ensure it outlives any request context. //nolint:containedctx @@ -143,6 +176,13 @@ func (a *ConfActor) handleRegisterConf(actorCtx context.Context, "than zero"), ) } + if req.NotifyActor.IsNone() && + (req.NotifyReorged.IsSome() || req.NotifyDone.IsSome()) { + return fn.Err[ConfResp]( + fmt.Errorf("confirmation reorg/done notifications " + + "require actor-mode NotifyActor"), + ) + } a.txid = req.Txid a.pkScript = req.PkScript @@ -150,6 +190,8 @@ func (a *ConfActor) handleRegisterConf(actorCtx context.Context, a.heightHint = req.HeightHint a.includeBlock = req.IncludeBlock a.notifyActor = req.NotifyActor + a.notifyReorged = req.NotifyReorged + a.notifyDone = req.NotifyDone // We're either in future or iterator mode, set the promise // accordingly. @@ -197,8 +239,8 @@ func (a *ConfActor) handleRegisterConf(actorCtx context.Context, } // monitorConfirmation runs in a background goroutine and waits for the target -// confirmation count to be reached. When reached, it delivers the event and -// exits. +// confirmation count to be reached. Legacy watches exit after confirmation, +// while reorg-aware actor-mode watches remain alive for reorg and done events. func (a *ConfActor) monitorConfirmation() { defer a.wg.Done() defer a.cancel() @@ -215,50 +257,324 @@ func (a *ConfActor) monitorConfirmation() { if a.registration != nil { a.registration.Cancel() } + if a.blockReg != nil { + a.blockReg.Cancel() + } }() - select { - case confDetails, ok := <-a.registration.Confirmed: - if !ok || confDetails == nil { - log.WarnS(a.ctx, "Confirmation subscription closed", - fmt.Errorf("channel closed or nil details"), + var lastEvent *ConfirmationEvent + reorgAware := a.notifyReorged.IsSome() || a.notifyDone.IsSome() + + // lastSeq is the highest backend forwarder sequence applied so far. + // Confirmed and Reorged signals arrive on separate channels and a + // select cannot order two ready channels, so we order them by the + // shared sequence instead: an event whose Seq does not exceed lastSeq + // lost a cross-channel race to a newer signal and is discarded. This + // makes the actor's view correct regardless of delivery interleaving + // — both reorg-then-reconfirm and confirm-then-reorg resolve to the + // highest-sequence outcome. Seq 0 means the backend does not stamp + // sequences (it never reorgs); those events are always applied. + var lastSeq uint64 + + // blockEpochs is rebound when height-based finality synthesis + // arms a block subscription. Until then a nil channel keeps the + // select arm parked. + var blockEpochs <-chan *BlockEpoch + + // blockRegCh hands a finality block subscription from the off-loop + // arming goroutine back to this loop; arming guards against launching + // more than one armer at a time. See armFinalityAsync for why arming + // runs off the select loop. + blockRegCh := make(chan finalityArmResult) + var arming bool + + for { + select { + case confDetails, ok := <-a.registration.Confirmed: + if !ok || confDetails == nil { + log.WarnS( + a.ctx, + "Confirmation subscription closed", + fmt.Errorf("channel closed or nil "+ + "details"), + ) + a.failConfirmation( + fmt.Errorf("confirmation " + + "subscription closed"), + ) + + return + } + + // Discard a confirmation that lost a cross-channel race + // to a newer reorg: an event whose sequence does not + // exceed the highest applied is stale. + if confDetails.Seq != 0 && confDetails.Seq <= lastSeq { + continue + } + if confDetails.Seq > lastSeq { + lastSeq = confDetails.Seq + } + + log.InfoS(a.ctx, "Received confirmation from backend", + "block_height", confDetails.BlockHeight, + "block_hash", confDetails.BlockHash, + ) + + event, err := buildConfirmationEvent(confDetails, a) + if err != nil { + log.WarnS( + a.ctx, + "Failed to build confirmation event", + err, + ) + a.failConfirmation(err) + + return + } + + log.InfoS(a.ctx, "Delivering confirmation event", + "txid", event.Txid, + "block_height", event.BlockHeight, ) - a.failConfirmation( - fmt.Errorf("confirmation subscription closed"), + a.deliverConfirmation(event) + lastEvent = &event + a.confirmHeight = event.BlockHeight + if !reorgAware || a.promise.IsSome() { + return + } + + // Arm height-based finality synthesis on the first + // confirmation if requested, off the select loop so + // the bounded RegisterBlocks retries cannot stall + // delivery of Reorged/Done/ctx.Done on this watch. A + // nil blockEpochs channel keeps the synthesis arm + // parked until the registration is handed back on + // blockRegCh. + if a.cfg.FinalityDepth > 0 && a.blockReg == nil && + !arming { + + arming = true + a.armFinalityAsync(blockRegCh, log) + } + + case armed := <-blockRegCh: + // Finality arming completed. Clear the flag; a nil reg + // only happens when the watch context was cancelled + // (arming otherwise retries until it succeeds). + arming = false + if armed.reg == nil { + continue + } + a.blockReg = armed.reg + blockEpochs = armed.reg.Epochs + + // The block-epoch subscription only delivers FUTURE + // epochs, but the confirmation that armed it may + // already be buried past FinalityDepth (it confirmed + // several blocks ago, or we re-armed after a restart). + // Use the tip observed at arm time to synthesize Done + // at once rather than hang until a fresh block is + // mined. The confirmHeight==0 / FinalityDepth==0 guards + // mirror the epoch handler below. + if a.confirmHeight == 0 || a.cfg.FinalityDepth == 0 { + continue + } + if armed.height-a.confirmHeight+1 < + int32(a.cfg.FinalityDepth) { + + continue + } + + log.InfoS(a.ctx, "Synthesizing confirmation done on arm "+ + "from height-based safety depth", + "confirm_height", a.confirmHeight, + "current_height", armed.height, + "finality_depth", int(a.cfg.FinalityDepth), ) + a.deliverConfDone(lastEvent) return - } - log.InfoS(a.ctx, "Received confirmation from backend", - "block_height", confDetails.BlockHeight, - "block_hash", confDetails.BlockHash, - ) + case seq, ok := <-a.registration.Reorged: + if !ok { + a.registration.Reorged = nil + continue + } + + // Discard a stale reorg that lost a cross-channel race + // to a newer confirmation. + if seq != 0 && seq <= lastSeq { + continue + } + if seq > lastSeq { + lastSeq = seq + } + + a.deliverConfReorged(lastEvent) + + // The previous confirmation is no longer on the + // canonical chain. Clear the cached event so a later + // Done cannot report the reorged-out txid, and reset + // the depth counter so the next re-confirmation starts + // a fresh window. + lastEvent = nil + a.confirmHeight = 0 + + case _, ok := <-a.registration.Done: + if !ok { + a.registration.Done = nil + continue + } + + a.deliverConfDone(lastEvent) - event, err := buildConfirmationEvent(confDetails, a) - if err != nil { - log.WarnS(a.ctx, "Failed to build confirmation event", - err, + return + + case epoch, ok := <-blockEpochs: + if !ok || epoch == nil { + blockEpochs = nil + continue + } + + // Coalesce any epochs already queued behind this one + // and evaluate finality against the most recent + // height only. With rapid-fire blocks (or a backend + // that re-delivers historical epochs) the channel can + // hold several epochs at once; processing them one per + // loop iteration would re-check the same monotonic + // Done condition repeatedly and risk synthesizing + // against a stale height. If the channel closed during + // the drain, park it so we stop selecting on it. + var closed bool + epoch, closed = drainToLatestEpoch(blockEpochs, epoch) + if closed { + blockEpochs = nil + } + + // The confirmHeight==0 guard is load-bearing: a reorg + // resets confirmHeight to 0 (see the Reorged case + // above), and a fresh epoch on the new tip would + // otherwise produce a negative depth (epoch.Height - 0 + // wraps to a large value); the depth comparison is + // meaningful only after a confirmation arms + // confirmHeight. FinalityDepth==0 disables synthesis + // entirely. + if a.confirmHeight == 0 || + a.cfg.FinalityDepth == 0 { + + continue + } + + depth := epoch.Height - a.confirmHeight + 1 + if depth < int32(a.cfg.FinalityDepth) { + continue + } + + log.InfoS(a.ctx, "Synthesizing confirmation done "+ + "from height-based safety depth", + "confirm_height", a.confirmHeight, + "current_height", epoch.Height, + "finality_depth", int(a.cfg.FinalityDepth), ) - a.failConfirmation(err) + + // Finality is terminal for the whole watch: deliver + // Done (a no-op when only NotifyReorged was set) and + // stop. A reorg-only watch therefore intentionally + // stops receiving reorg events once the confirmation is + // buried FinalityDepth deep — past that depth a reorg + // is beyond the safety threshold the watch was created + // to cover, so there is nothing left to observe. + a.deliverConfDone(lastEvent) return - } - log.InfoS(a.ctx, "Delivering confirmation event", - "txid", event.Txid, - "block_height", event.BlockHeight, - ) - a.deliverConfirmation(event) + case <-a.ctx.Done(): + log.InfoS(a.ctx, "ConfActor context cancelled") + a.failConfirmation(a.ctx.Err()) - case <-a.ctx.Done(): - log.InfoS(a.ctx, "ConfActor context cancelled") - a.failConfirmation(a.ctx.Err()) + return + } + } +} + +// drainToLatestEpoch non-blockingly consumes any block epochs already +// queued on ch and returns the most recent non-nil epoch, starting from +// cur. With rapid-fire blocks (or a backend that re-delivers historical +// epochs) several epochs can sit in the channel at once; height-based +// finality synthesis depends only on the highest observed height, so +// collapsing the backlog to the newest epoch avoids re-evaluating the +// same monotonic Done condition once per stale epoch. The returned bool +// reports whether the channel was observed closed during the drain so the +// caller can park its receive on a nil channel. +func drainToLatestEpoch(ch <-chan *BlockEpoch, + cur *BlockEpoch) (*BlockEpoch, bool) { + + latest := cur + for { + select { + case e, ok := <-ch: + if !ok { + return latest, true + } + if e != nil { + latest = e + } + + default: + return latest, false + } } } // deliverConfirmation sends the confirmation event to the subscriber and // completes the promise (Future mode) or sends to the actor (Actor mode). +// armFinalityAsync registers a block-epoch subscription for height-based +// finality synthesis off the actor's select loop. registerBlocksForFinality +// retries with a bounded backoff that can run for tens of seconds; doing it +// inline would block delivery of Reorged/Done/ctx.Done on this watch for the +// whole window. The registration (or nil on failure) is handed back on regCh, +// or cancelled if the actor exits before the loop reads it. The goroutine is +// tracked by the actor's wait group so Stop drains it. +func (a *ConfActor) armFinalityAsync(regCh chan<- finalityArmResult, + log btclog.Logger) { + + a.wg.Go(func() { + reg, err := registerBlocksForFinality(a.ctx, a.cfg.Backend, log) + if err != nil { + log.WarnS(a.ctx, "Giving up on height-based finality "+ + "synthesis; conf sub-actor will rely on "+ + "backend Done", err) + reg = nil + } + + // Capture the tip at arm time so the loop can finalize + // immediately when the arming confirmation is already buried + // past FinalityDepth (the block-epoch sub only delivers future + // epochs). A read failure is non-fatal: height stays zero and + // the loop falls back to waiting for the next epoch. + var height int32 + if reg != nil { + h, _, hErr := a.cfg.Backend.BestBlock(a.ctx) + if hErr != nil { + log.WarnS(a.ctx, "Failed to read best height "+ + "for on-arm finality check; will wait "+ + "for next epoch", hErr) + } else { + height = h + } + } + + select { + case regCh <- finalityArmResult{reg: reg, height: height}: + case <-a.ctx.Done(): + if reg != nil { + reg.Cancel() + } + } + }) +} + func (a *ConfActor) deliverConfirmation(event ConfirmationEvent) { a.promise.WhenSome(func(p actor.Promise[ConfirmationEvent]) { p.Complete(fn.Ok(event)) @@ -272,6 +588,53 @@ func (a *ConfActor) deliverConfirmation(event ConfirmationEvent) { }) } +// deliverConfReorged sends a reorg event to actor-mode subscribers. The +// correlation Txid is the registration's configured txid when set, since +// that is the identifier the caller asked us to watch; pkScript-only +// watches fall back to the txid carried on the most recent positive +// ConfirmationEvent. +func (a *ConfActor) deliverConfReorged(lastEvent *ConfirmationEvent) { + var event ConfReorgedEvent + switch { + case a.txid != nil: + event.Txid = *a.txid + + case lastEvent != nil: + event.Txid = lastEvent.Txid + } + + a.notifyReorged.WhenSome(func(ref actor.TellOnlyRef[ConfReorgedEvent]) { + log := a.logger(a.ctx) + if err := ref.Tell(a.ctx, event); err != nil { + log.WarnS(a.ctx, "Failed to deliver confirmation reorg", + err, + ) + } + }) +} + +// deliverConfDone sends a confirmation finality event to actor-mode +// subscribers. Txid follows the same precedence as deliverConfReorged. +func (a *ConfActor) deliverConfDone(lastEvent *ConfirmationEvent) { + var event ConfDoneEvent + switch { + case a.txid != nil: + event.Txid = *a.txid + + case lastEvent != nil: + event.Txid = lastEvent.Txid + } + + a.notifyDone.WhenSome(func(ref actor.TellOnlyRef[ConfDoneEvent]) { + log := a.logger(a.ctx) + if err := ref.Tell(a.ctx, event); err != nil { + log.WarnS(a.ctx, "Failed to deliver confirmation done", + err, + ) + } + }) +} + // failConfirmation completes the promise with an error (Future mode) or does // nothing (Actor mode - errors are not delivered in async mode). func (a *ConfActor) failConfirmation(err error) { diff --git a/chainsource/epoch_drain_test.go b/chainsource/epoch_drain_test.go new file mode 100644 index 000000000..9a439f84c --- /dev/null +++ b/chainsource/epoch_drain_test.go @@ -0,0 +1,76 @@ +package chainsource + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestDrainToLatestEpochCoalesces verifies that a backlog of queued block +// epochs collapses to the most recent one, so finality synthesis evaluates +// the highest observed height rather than re-checking once per stale epoch. +func TestDrainToLatestEpochCoalesces(t *testing.T) { + t.Parallel() + + ch := make(chan *BlockEpoch, 8) + cur := &BlockEpoch{Height: 100} + + // Queue several newer epochs behind the one already dequeued. + for h := int32(101); h <= 105; h++ { + ch <- &BlockEpoch{Height: h} + } + + got, closed := drainToLatestEpoch(ch, cur) + require.False(t, closed) + require.NotNil(t, got) + require.Equal(t, int32(105), got.Height) + + // The channel must be fully drained afterwards. + require.Len(t, ch, 0) +} + +// TestDrainToLatestEpochEmpty verifies that with nothing queued the helper +// returns the current epoch unchanged and reports the channel open. +func TestDrainToLatestEpochEmpty(t *testing.T) { + t.Parallel() + + ch := make(chan *BlockEpoch, 4) + cur := &BlockEpoch{Height: 42} + + got, closed := drainToLatestEpoch(ch, cur) + require.False(t, closed) + require.Same(t, cur, got) +} + +// TestDrainToLatestEpochSkipsNil verifies that nil epochs in the backlog are +// ignored: the most recent non-nil epoch wins. +func TestDrainToLatestEpochSkipsNil(t *testing.T) { + t.Parallel() + + ch := make(chan *BlockEpoch, 4) + cur := &BlockEpoch{Height: 10} + ch <- &BlockEpoch{Height: 11} + ch <- nil + + got, closed := drainToLatestEpoch(ch, cur) + require.False(t, closed) + require.NotNil(t, got) + require.Equal(t, int32(11), got.Height) +} + +// TestDrainToLatestEpochClosed verifies that a closed channel is reported so +// the caller can park its receive, while still returning the latest epoch +// observed before the close. +func TestDrainToLatestEpochClosed(t *testing.T) { + t.Parallel() + + ch := make(chan *BlockEpoch, 4) + cur := &BlockEpoch{Height: 7} + ch <- &BlockEpoch{Height: 8} + close(ch) + + got, closed := drainToLatestEpoch(ch, cur) + require.True(t, closed) + require.NotNil(t, got) + require.Equal(t, int32(8), got.Height) +} diff --git a/chainsource/finality.go b/chainsource/finality.go new file mode 100644 index 000000000..debed8eb5 --- /dev/null +++ b/chainsource/finality.go @@ -0,0 +1,124 @@ +package chainsource + +import ( + "context" + "time" + + "github.com/btcsuite/btclog/v2" +) + +const ( + // finalityArmInitialBackoff is the first delay between RegisterBlocks + // attempts when arming the block-epoch subscription that drives + // height-based finality synthesis. It doubles on each failure up to + // finalityArmMaxBackoff. + finalityArmInitialBackoff = 100 * time.Millisecond + + // finalityArmMaxBackoff caps the retry delay. Arming is retried + // indefinitely (until the watch's context is cancelled) rather than + // abandoned after a fixed count: for gRPC lndclient and lwwallet, + // height synthesis is the ONLY source of the terminal Done, so giving + // up would strand the round/exit in ProvisionallyConfirmed forever + // (a single-confirmation tx has no later event to trigger a re-arm). + // A brief backend outage at the arming moment therefore only delays + // finality, never permanently disables it. 30s stops a genuinely-down + // backend from spinning while bounding recovery latency once it heals. + finalityArmMaxBackoff = 30 * time.Second + + // finalityArmEscalateAfter is the consecutive-failure count past which + // the retry log escalates to an operator-visible "finality stalled" + // warning, so a persistently unarmable watch is detectable rather than + // silently stuck in provisional. + finalityArmEscalateAfter = 5 +) + +// finalityArmResult is handed from a conf/spend sub-actor's off-loop finality +// arming goroutine back to its select loop. reg is the block-epoch +// subscription (nil only when the watch context was cancelled). height is the +// best chain height observed at arm time (zero if it could not be read); the +// loop uses it to synthesize Done immediately when the arming +// confirmation/spend is already buried past FinalityDepth, rather than +// hanging until a fresh block epoch arrives (the subscription only delivers +// FUTURE epochs). +type finalityArmResult struct { + reg *BlockRegistration + height int32 +} + +// registerBlocksForFinality registers a block-epoch subscription used +// to synthesize a Done signal at FinalityDepth past an observed +// confirmation or spend. It retries RegisterBlocks indefinitely with a +// capped exponential backoff (until the passed context is cancelled) +// because finality synthesis is the only Done source for backends that +// do not write the upstream Done channel (notably lndclient over gRPC and +// lwwallet). Abandoning the arm after a fixed number of attempts would +// leak the per-watch sub-actor AND strand the round/exit in +// ProvisionallyConfirmed forever if the backend merely hiccups at the +// arming moment: a single-confirmation tx has no later event to trigger a +// re-arm, so the watch would only recover on a daemon restart. +// +// The retries run in a dedicated arming goroutine (not the sub-actor's +// select loop), so blocking here is safe: more confirmation/spend events +// on this specific watch are not expected during the retry window (we +// already consumed the one that triggered the arm), and ctx cancellation +// breaks out promptly. The goroutine is bounded by the watch's lifetime. +// +// The passed ctx MUST be the sub-actor's long-lived context, and it is +// handed to RegisterBlocks unwrapped: for in-process backends the +// block-epoch forwarder goroutine is tied to the ctx it receives, so +// bounding each attempt with a cancellable child ctx (and cancelling it +// once the call returns) would tear the subscription down the instant it +// was armed — starving finality synthesis of the very epochs it needs. +// +// Returns the registration on success, or a non-nil error only when the +// context is cancelled (the watch is shutting down). +func registerBlocksForFinality(ctx context.Context, backend ChainBackend, + log btclog.Logger) (*BlockRegistration, error) { + + backoff := finalityArmInitialBackoff + for attempt := 1; ; attempt++ { + reg, err := backend.RegisterBlocks(ctx) + if err == nil { + if attempt > 1 { + log.InfoS(ctx, "Finality block subscription "+ + "armed after retries", + "attempts", attempt, + ) + } + + return reg, nil + } + + // Height synthesis is the only finality source for gRPC + // lndclient / lwwallet, so a persistent arming failure stalls + // the round/exit in provisional. Escalate the log once retries + // pass finalityArmEscalateAfter so the stall is + // operator-visible rather than silent; the capped backoff + // throttles it. + if attempt >= finalityArmEscalateAfter { + log.WarnS(ctx, "Finality block subscription arming "+ + "persistently failing; round/exit finality is "+ + "stalled until the backend recovers", err, + "attempts", attempt, + "backoff", backoff, + ) + } else { + log.WarnS(ctx, "RegisterBlocks for finality synthesis "+ + "failed; retrying", err, + "attempt", attempt, + "backoff", backoff, + ) + } + + select { + case <-time.After(backoff): + case <-ctx.Done(): + return nil, ctx.Err() + } + + backoff *= 2 + if backoff > finalityArmMaxBackoff { + backoff = finalityArmMaxBackoff + } + } +} diff --git a/chainsource/finality_test.go b/chainsource/finality_test.go new file mode 100644 index 000000000..f3101089b --- /dev/null +++ b/chainsource/finality_test.go @@ -0,0 +1,107 @@ +package chainsource + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/btcsuite/btclog/v2" + "github.com/stretchr/testify/require" +) + +// flakyArmBackend fails RegisterBlocks the first failCount times, then +// succeeds. It models a backend that is briefly unavailable at the exact +// moment finality arming is attempted (the failure mode that previously +// stranded a watch once the bounded retry schedule was exhausted). +type flakyArmBackend struct { + *mockBackend + + mu sync.Mutex + failLeft int + attempts int +} + +// RegisterBlocks fails until failLeft is drained, then returns a live +// registration. +func (b *flakyArmBackend) RegisterBlocks(ctx context.Context) ( + *BlockRegistration, error) { + + b.mu.Lock() + defer b.mu.Unlock() + + b.attempts++ + if b.failLeft > 0 { + b.failLeft-- + + return nil, errors.New("backend temporarily unavailable") + } + + return &BlockRegistration{ + Epochs: make(chan *BlockEpoch, 1), + Cancel: func() {}, + }, nil +} + +// attemptCount returns how many RegisterBlocks calls have been made. +func (b *flakyArmBackend) attemptCount() int { + b.mu.Lock() + defer b.mu.Unlock() + + return b.attempts +} + +// TestRegisterBlocksForFinalityRetriesUntilArmed asserts that finality arming +// retries past the old fixed three-attempt cap and eventually arms once a +// transiently-unavailable backend recovers. This is the reorg-safety +// robustness property: for gRPC lndclient / lwwallet, height synthesis is the +// only Done source, so abandoning the arm would strand the round/exit in +// provisional until a daemon restart. failLeft is five (past both the old cap +// and finalityArmEscalateAfter) so the escalation-warning branch is exercised. +func TestRegisterBlocksForFinalityRetriesUntilArmed(t *testing.T) { + t.Parallel() + + backend := &flakyArmBackend{ + mockBackend: newMockBackend(), + failLeft: 5, + } + + reg, err := registerBlocksForFinality( + context.Background(), backend, btclog.Disabled, + ) + require.NoError(t, err, "arming must succeed once the backend recovers") + require.NotNil(t, reg, "a live block registration must be returned") + require.NotNil(t, reg.Epochs) + require.GreaterOrEqual( + t, backend.attemptCount(), 6, + "arming must retry past the old three-attempt cap", + ) +} + +// TestRegisterBlocksForFinalityStopsOnContextCancel asserts that the otherwise +// unbounded arming retry exits promptly when the watch's context is cancelled +// (daemon shutdown), returning the context error rather than looping forever. +func TestRegisterBlocksForFinalityStopsOnContextCancel(t *testing.T) { + t.Parallel() + + // A backend that never recovers, so arming would loop indefinitely + // were it not bounded by the context. + backend := &flakyArmBackend{ + mockBackend: newMockBackend(), + failLeft: 1 << 30, + } + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(150 * time.Millisecond) + cancel() + }() + + reg, err := registerBlocksForFinality(ctx, backend, btclog.Disabled) + require.Error( + t, err, "arming must return once the context is cancelled", + ) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, reg) +} diff --git a/chainsource/messages.go b/chainsource/messages.go index 856276fa6..3c5c4aeaf 100644 --- a/chainsource/messages.go +++ b/chainsource/messages.go @@ -261,6 +261,15 @@ type RegisterConfRequest struct { // events will be sent to this actor asynchronously. If None, a Future // is returned in the response for blocking await. NotifyActor fn.Option[actor.TellOnlyRef[ConfirmationEvent]] + + // NotifyReorged is an optional actor reference for negative + // confirmation events. It is only used in async actor mode. + NotifyReorged fn.Option[actor.TellOnlyRef[ConfReorgedEvent]] + + // NotifyDone is an optional actor reference notified when the + // confirmation watch is beyond the backend's reorg tracking horizon. + // It is only used in async actor mode. + NotifyDone fn.Option[actor.TellOnlyRef[ConfDoneEvent]] } // MessageType returns the message type identifier for logging and debugging. @@ -332,6 +341,48 @@ func (m ConfirmationEvent) MessageType() string { return "ConfirmationEvent" } +// ConfReorgedEvent is sent when a previously reported confirmation is +// reorged out of the canonical chain. After receiving this event a consumer +// should consider the prior confirmation no longer valid; if the transaction +// re-confirms on the new canonical chain a fresh ConfirmationEvent will +// follow on the same registration. +// +// No block hash, height, or reorg depth is carried because the lndclient +// gRPC transport does not preserve that information and we do not want to +// expose fields that are always zero in production. Consumers that need to +// invalidate cached block metadata should match on Txid and use the data +// from the most recent ConfirmationEvent they observed on this watch. +type ConfReorgedEvent struct { + actor.BaseMessage + + // Txid is the transaction ID whose confirmation was reorged. This + // matches the Txid carried on the originating ConfirmationEvent. + Txid chainhash.Hash +} + +// MessageType returns the message type identifier for logging and debugging. +func (m ConfReorgedEvent) MessageType() string { + return "ConfReorgedEvent" +} + +// ConfDoneEvent is sent when a confirmation watch has matured past the +// backend's reorg-safety depth and will receive no further events. +// Consumers may use this signal to drop any reorg-recovery bookkeeping they +// were holding for the registration. Not all backends synthesize this +// event; consumers must treat its absence as a normal operating condition +// rather than an error. +type ConfDoneEvent struct { + actor.BaseMessage + + // Txid is the transaction ID whose registration matured. + Txid chainhash.Hash +} + +// MessageType returns the message type identifier for logging and debugging. +func (m ConfDoneEvent) MessageType() string { + return "ConfDoneEvent" +} + // UnregisterConfRequest requests cancellation of a confirmation subscription. // The ChainSource actor uses the fields to construct the service key and // cancel the dedicated actor. @@ -417,6 +468,15 @@ type RegisterSpendRequest struct { // will be sent to this actor asynchronously. If None, a Future is // returned for blocking await. NotifyActor fn.Option[actor.TellOnlyRef[SpendEvent]] + + // NotifyReorged is an optional actor reference for spend reorg events. + // It is only used in async actor mode. + NotifyReorged fn.Option[actor.TellOnlyRef[SpendReorgedEvent]] + + // NotifyDone is an optional actor reference notified when the spend + // watch is beyond the backend's reorg tracking horizon. It is only used + // in async actor mode. + NotifyDone fn.Option[actor.TellOnlyRef[SpendDoneEvent]] } // MessageType returns the message type identifier for logging and debugging. @@ -481,6 +541,45 @@ func (m SpendEvent) MessageType() string { return "SpendEvent" } +// SpendReorgedEvent is sent when a previously reported spend is reorged out +// of the canonical chain. After receiving this event a consumer should +// consider the prior spend no longer valid; if the outpoint is re-spent on +// the new canonical chain a fresh SpendEvent will follow on the same +// registration. +// +// No spending txid, height, or block hash is carried because the lndclient +// gRPC transport does not preserve that information and we do not want to +// expose fields that are always zero in production. Consumers that need to +// invalidate cached spending metadata should match on Outpoint and use the +// data from the most recent SpendEvent they observed on this watch. +type SpendReorgedEvent struct { + actor.BaseMessage + + // Outpoint is the output whose spend was reorged. + Outpoint wire.OutPoint +} + +// MessageType returns the message type identifier for logging and debugging. +func (m SpendReorgedEvent) MessageType() string { + return "SpendReorgedEvent" +} + +// SpendDoneEvent is sent when a spend watch has matured past the backend's +// reorg-safety depth and will receive no further events. Not all backends +// synthesize this event; consumers must treat its absence as a normal +// operating condition rather than an error. +type SpendDoneEvent struct { + actor.BaseMessage + + // Outpoint is the output whose spend registration matured. + Outpoint wire.OutPoint +} + +// MessageType returns the message type identifier for logging and debugging. +func (m SpendDoneEvent) MessageType() string { + return "SpendDoneEvent" +} + // UnregisterSpendRequest requests cancellation of a spend subscription. // The ChainSource actor uses the fields to construct the service key and // cancel the dedicated actor. diff --git a/chainsource/reorg_test.go b/chainsource/reorg_test.go new file mode 100644 index 000000000..35571adbe --- /dev/null +++ b/chainsource/reorg_test.go @@ -0,0 +1,735 @@ +package chainsource + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// awaitTimeout is the per-step wait used by the reorg lifecycle tests. It +// is generous enough to absorb scheduling jitter on overloaded CI machines +// but still short enough that a hung actor surfaces as a fast failure. +const awaitTimeout = 5 * time.Second + +// TestConfActorReorgAwareForwardsFullLifecycle drives the full +// Confirmed -> Reorged -> Confirmed -> Done sequence through a reorg-aware +// ConfActor and asserts each event is forwarded to the correct notify ref +// in order, and that the actor releases the backend registration after +// Done. +func TestConfActorReorgAwareForwardsFullLifecycle(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + confActor := NewConfActor(ConfActorConfig{Backend: backend}) + defer confActor.Stop() + + txHash := chainhash.Hash{0x01} + confNotifier := actor.NewChannelTellOnlyRef[ConfirmationEvent]( + "conf-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[ConfReorgedEvent]( + "conf-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[ConfDoneEvent]( + "conf-done", 10, + ) + + var confRef actor.TellOnlyRef[ConfirmationEvent] = confNotifier + var reorgRef actor.TellOnlyRef[ConfReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[ConfDoneEvent] = doneNotifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-reorg-lifecycle", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyActor: fn.Some(confRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + resp, err := result.Unpack() + require.NoError(t, err) + confResp, ok := resp.(*RegisterConfResponse) + require.True(t, ok) + + // Reorg-aware mode is actor-only, so the response must not carry a + // Future. + require.Nil(t, confResp.Future) + + // 1. First confirmation on the canonical chain. + blockHash1 := chainhash.Hash{0xaa} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash1, + BlockHeight: 100, + Tx: wire.NewMsgTx(2), + } + + event1, ok := confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for first ConfirmationEvent") + require.Equal(t, int32(100), event1.BlockHeight) + require.Equal(t, blockHash1, event1.BlockHash) + + // 2. Reorg evicts that confirmation. + backend.confReorgedChan <- uint64(0) + + reorgEvt, ok := reorgNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for ConfReorgedEvent") + require.Equal(t, txHash, reorgEvt.Txid) + + // 3. Transaction re-confirms in a different block on the new tip. + blockHash2 := chainhash.Hash{0xbb} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash2, + BlockHeight: 101, + Tx: wire.NewMsgTx(2), + } + + event2, ok := confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for re-ConfirmationEvent") + require.Equal(t, int32(101), event2.BlockHeight) + require.Equal(t, blockHash2, event2.BlockHash) + + // 4. Registration matures past reorg safety; backend fires Done. + backend.confDoneChan <- struct{}{} + + doneEvt, ok := doneNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for ConfDoneEvent") + require.Equal(t, txHash, doneEvt.Txid) + + // After Done the actor must release the registration. + require.Eventually(t, func() bool { + return backend.confCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "registration Cancel was never invoked after Done") +} + +// TestConfActorSynthesizesDoneFromFinalityDepth verifies that a +// reorg-aware ConfActor with a non-zero FinalityDepth fires +// ConfDoneEvent on its own once enough blocks have been observed past +// the first Confirmed event, even when the backend's Done channel +// never fires. This closes the lndclient gRPC gap, where lnd's +// internal "past reorg-safety depth" signal does not survive the +// transport. +func TestConfActorSynthesizesDoneFromFinalityDepth(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + const finalityDepth = 6 + confActor := NewConfActor(ConfActorConfig{ + Backend: backend, + FinalityDepth: finalityDepth, + }) + defer confActor.Stop() + + txHash := chainhash.Hash{0x01} + confNotifier := actor.NewChannelTellOnlyRef[ConfirmationEvent]( + "conf-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[ConfReorgedEvent]( + "conf-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[ConfDoneEvent]( + "conf-done", 10, + ) + + var confRef actor.TellOnlyRef[ConfirmationEvent] = confNotifier + var reorgRef actor.TellOnlyRef[ConfReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[ConfDoneEvent] = doneNotifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-finality-depth", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyActor: fn.Some(confRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + + // 1. First confirmation at height 100. This arms the height-based + // finality synthesizer. + blockHash1 := chainhash.Hash{0xaa} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash1, + BlockHeight: 100, + Tx: wire.NewMsgTx(2), + } + _, ok := confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for ConfirmationEvent") + + // 2. Push blocks up to height 104. Inclusive depth at that point + // is 5 (heights 100..104), one short of the finality threshold, + // so ConfDoneEvent MUST NOT have fired yet. + for height := int32(101); height <= + int32(100+finalityDepth-2); height++ { + + backend.epochChan <- &BlockEpoch{Height: height} + } + _, ok = doneNotifier.AwaitMessage(50 * time.Millisecond) + require.False(t, ok, "ConfDoneEvent fired before finality depth") + + // 3. One more block brings the inclusive depth to exactly + // FinalityDepth (heights 100..105). Done must fire now. + backend.epochChan <- &BlockEpoch{ + Height: 100 + int32(finalityDepth) - 1, + } + doneEvt, ok := doneNotifier.AwaitMessage(awaitTimeout) + require.True( + t, ok, + "ConfDoneEvent never fired despite reaching finality depth", + ) + require.Equal(t, txHash, doneEvt.Txid) + + // 4. After Done the actor exits and releases the registration. + require.Eventually(t, func() bool { + return backend.confCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "registration Cancel was never invoked after synthesized "+ + "Done") +} + +// TestConfActorDiscardsStaleReorgBySeq verifies that a reorg signal which +// lost a cross-channel race to a newer re-confirmation is discarded by +// sequence number. The re-confirmation (seq 3) is observed before the +// older reorg (seq 2); because Confirmed and Reorged arrive on separate +// channels the actor cannot order them by arrival, so it must order them +// by Seq and ignore the stale reorg. The proof is that height-based +// finality still fires against the re-confirmation height — if the stale +// reorg had been applied it would have reset confirmHeight to 0 and Done +// would never synthesize. +func TestConfActorDiscardsStaleReorgBySeq(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + const finalityDepth = 6 + confActor := NewConfActor(ConfActorConfig{ + Backend: backend, + FinalityDepth: finalityDepth, + }) + defer confActor.Stop() + + txHash := chainhash.Hash{0x04} + confNotifier := actor.NewChannelTellOnlyRef[ConfirmationEvent]( + "conf-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[ConfReorgedEvent]( + "conf-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[ConfDoneEvent]( + "conf-done", 10, + ) + + var confRef actor.TellOnlyRef[ConfirmationEvent] = confNotifier + var reorgRef actor.TellOnlyRef[ConfReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[ConfDoneEvent] = doneNotifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-stale-reorg-seq", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyActor: fn.Some(confRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + + // 1. First confirmation at height 100, seq 1. + blockHash1 := chainhash.Hash{0xaa} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash1, + BlockHeight: 100, + Tx: wire.NewMsgTx(2), + Seq: 1, + } + _, ok := confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for first ConfirmationEvent") + + // 2. The re-confirmation (seq 3) is delivered before the older reorg + // (seq 2) — the cross-channel race. It arms finality at height 101. + blockHash2 := chainhash.Hash{0xbb} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash2, + BlockHeight: 101, + Tx: wire.NewMsgTx(2), + Seq: 3, + } + _, ok = confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for re-ConfirmationEvent") + + // 3. The stale reorg (seq 2 <= 3) arrives late and must be discarded: + // no ConfReorgedEvent is delivered and confirmHeight is untouched. + backend.confReorgedChan <- uint64(2) + _, ok = reorgNotifier.AwaitMessage(50 * time.Millisecond) + require.False(t, ok, "stale reorg (seq 2) was not discarded") + + // 4. Drive blocks to the finality depth past height 101. Done must + // fire, proving confirmHeight survived the stale reorg. + for height := int32(102); height <= + int32(101+finalityDepth)-1; height++ { + + backend.epochChan <- &BlockEpoch{Height: height} + } + doneEvt, ok := doneNotifier.AwaitMessage(awaitTimeout) + require.True( + t, ok, "ConfDoneEvent never fired; stale reorg wrongly "+ + "reset confirmHeight", + ) + require.Equal(t, txHash, doneEvt.Txid) +} + +// TestConfActorDiscardsStaleConfirmBySeq verifies the opposite race: a +// re-confirmation that lost a cross-channel race to a newer reorg is +// discarded by sequence number. The reorg (seq 3) is observed before the +// stale confirmation (seq 2); the actor must ignore the confirmation and +// leave the watch unconfirmed, so height-based finality must NOT fire. +// Without sequence ordering the stale confirmation would set a non-zero +// confirmHeight and synthesize a false Done for a tx that is gone. +func TestConfActorDiscardsStaleConfirmBySeq(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + const finalityDepth = 6 + confActor := NewConfActor(ConfActorConfig{ + Backend: backend, + FinalityDepth: finalityDepth, + }) + defer confActor.Stop() + + txHash := chainhash.Hash{0x05} + confNotifier := actor.NewChannelTellOnlyRef[ConfirmationEvent]( + "conf-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[ConfReorgedEvent]( + "conf-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[ConfDoneEvent]( + "conf-done", 10, + ) + + var confRef actor.TellOnlyRef[ConfirmationEvent] = confNotifier + var reorgRef actor.TellOnlyRef[ConfReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[ConfDoneEvent] = doneNotifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-stale-confirm-seq", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyActor: fn.Some(confRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + + // 1. First confirmation at height 100, seq 1. + blockHash1 := chainhash.Hash{0xaa} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash1, + BlockHeight: 100, + Tx: wire.NewMsgTx(2), + Seq: 1, + } + _, ok := confNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for first ConfirmationEvent") + + // 2. The newer reorg (seq 3) is observed first and resets the watch. + backend.confReorgedChan <- uint64(3) + _, ok = reorgNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for ConfReorgedEvent") + + // 3. The stale re-confirmation (seq 2 <= 3) arrives late and must be + // discarded: no ConfirmationEvent is delivered and the watch stays + // unconfirmed. + blockHash2 := chainhash.Hash{0xbb} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash2, + BlockHeight: 101, + Tx: wire.NewMsgTx(2), + Seq: 2, + } + _, ok = confNotifier.AwaitMessage(50 * time.Millisecond) + require.False(t, ok, "stale confirmation (seq 2) was not discarded") + + // 4. Drive many blocks well past any finality window. Done must NOT + // fire because confirmHeight was never re-armed. + for height := int32(101); height <= int32(120); height++ { + backend.epochChan <- &BlockEpoch{Height: height} + } + _, ok = doneNotifier.AwaitMessage(100 * time.Millisecond) + require.False( + t, ok, "ConfDoneEvent fired for a reorged-out tx; stale "+ + "confirmation was wrongly applied", + ) +} + +// TestConfActorReorgAwareRejectsWithoutNotifyActor verifies that opting in +// to reorg-aware mode without an actor-mode confirmation ref is rejected at +// admission. Allowing it would silently drop every re-confirmation after +// the first, since a Future can only complete once. +func TestConfActorReorgAwareRejectsWithoutNotifyActor(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + confActor := NewConfActor(ConfActorConfig{Backend: backend}) + defer confActor.Stop() + + txHash := chainhash.Hash{0x02} + reorgNotifier := actor.NewChannelTellOnlyRef[ConfReorgedEvent]( + "conf-reorged", 1, + ) + var reorgRef actor.TellOnlyRef[ConfReorgedEvent] = reorgNotifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-reorg-no-notify", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyReorged: fn.Some(reorgRef), + }) + require.True(t, result.IsErr()) + _, err := result.Unpack() + require.ErrorContains( + t, err, + "reorg/done notifications require actor-mode NotifyActor", + ) +} + +// TestConfActorLegacyExitsAfterFirstConfirm ensures legacy Actor-mode +// subscribers (no NotifyReorged / NotifyDone) keep their historical +// single-shot contract even when the backend would later emit Reorged or +// Done. The actor must cancel the registration after the first +// confirmation. +func TestConfActorLegacyExitsAfterFirstConfirm(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + confActor := NewConfActor(ConfActorConfig{Backend: backend}) + defer confActor.Stop() + + txHash := chainhash.Hash{0x03} + notifier := actor.NewChannelTellOnlyRef[ConfirmationEvent]( + "conf-notify", 10, + ) + var confRef actor.TellOnlyRef[ConfirmationEvent] = notifier + + result := confActor.Receive(ctx, &RegisterConfRequest{ + CallerID: "test-conf-legacy-single-shot", + Txid: &txHash, + PkScript: []byte{0x00, 0x14}, + TargetConfs: 1, + NotifyActor: fn.Some(confRef), + }) + require.True(t, result.IsOk()) + + // First confirmation goes through. + blockHash := chainhash.Hash{0x10} + backend.confChan <- &TxConfirmation{ + BlockHash: &blockHash, + BlockHeight: 200, + Tx: wire.NewMsgTx(2), + } + + event, ok := notifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for first ConfirmationEvent") + require.Equal(t, int32(200), event.BlockHeight) + + // Actor must have exited after the first confirmation, releasing the + // registration. + require.Eventually(t, func() bool { + return backend.confCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "legacy ConfActor did not cancel registration after first "+ + "confirmation") + + // No further events should reach the notifier. + _, ok = notifier.AwaitMessage(50 * time.Millisecond) + require.False( + t, ok, "legacy ConfActor delivered an unexpected second event", + ) +} + +// TestSpendActorReorgAwareForwardsFullLifecycle drives the spend lifecycle +// (Spend -> Reorged -> Spend -> Done) through a reorg-aware SpendActor and +// asserts every event is forwarded to the correct notify ref in order. +func TestSpendActorReorgAwareForwardsFullLifecycle(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + spendActor := NewSpendActor(SpendActorConfig{Backend: backend}) + defer spendActor.Stop() + + outpoint := wire.OutPoint{Hash: chainhash.Hash{0x11}, Index: 0} + + spendNotifier := actor.NewChannelTellOnlyRef[SpendEvent]( + "spend-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[SpendReorgedEvent]( + "spend-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[SpendDoneEvent]( + "spend-done", 10, + ) + + var spendRef actor.TellOnlyRef[SpendEvent] = spendNotifier + var reorgRef actor.TellOnlyRef[SpendReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[SpendDoneEvent] = doneNotifier + + result := spendActor.Receive(ctx, &RegisterSpendRequest{ + CallerID: "test-spend-reorg-lifecycle", + Outpoint: &outpoint, + PkScript: []byte{0x00, 0x14}, + NotifyActor: fn.Some(spendRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + resp, err := result.Unpack() + require.NoError(t, err) + spendResp, ok := resp.(*RegisterSpendResponse) + require.True(t, ok) + require.Nil(t, spendResp.Future) + + // 1. First spend confirms. + spendingTx1 := wire.NewMsgTx(2) + hash1 := spendingTx1.TxHash() + backend.spendChan <- &SpendDetail{ + SpentOutPoint: &outpoint, + SpenderTxHash: &hash1, + SpendingTx: spendingTx1, + SpendingHeight: 150, + } + + spend1, ok := spendNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for first SpendEvent") + require.Equal(t, outpoint, spend1.Outpoint) + require.Equal(t, int32(150), spend1.SpendingHeight) + require.Equal(t, hash1, spend1.SpendingTxid) + + // 2. Reorg evicts that spend. + backend.spendReorgedChan <- uint64(0) + + reorgEvt, ok := reorgNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for SpendReorgedEvent") + require.Equal(t, outpoint, reorgEvt.Outpoint) + + // 3. A different spender wins the new chain. + spendingTx2 := wire.NewMsgTx(2) + spendingTx2.AddTxIn(&wire.TxIn{Sequence: 1}) + hash2 := spendingTx2.TxHash() + require.NotEqual(t, hash1, hash2) + backend.spendChan <- &SpendDetail{ + SpentOutPoint: &outpoint, + SpenderTxHash: &hash2, + SpendingTx: spendingTx2, + SpendingHeight: 151, + } + + spend2, ok := spendNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for re-SpendEvent") + require.Equal(t, outpoint, spend2.Outpoint) + require.Equal(t, int32(151), spend2.SpendingHeight) + require.Equal(t, hash2, spend2.SpendingTxid) + + // 4. Registration matures past reorg safety. + backend.spendDoneChan <- struct{}{} + + doneEvt, ok := doneNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for SpendDoneEvent") + require.Equal(t, outpoint, doneEvt.Outpoint) + + require.Eventually(t, func() bool { + return backend.spendCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "registration Cancel was never invoked after Done") +} + +// TestSpendActorSynthesizesDoneFromFinalityDepth mirrors the conf-side +// height-based finality test for the spend watch. A reorg-aware +// SpendActor with non-zero FinalityDepth fires SpendDoneEvent on its +// own once enough blocks have been observed past the first Spend, +// closing the same lndclient gRPC gap that ConfActor closes for +// confirmations. +func TestSpendActorSynthesizesDoneFromFinalityDepth(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + const finalityDepth = 6 + spendActor := NewSpendActor(SpendActorConfig{ + Backend: backend, + FinalityDepth: finalityDepth, + }) + defer spendActor.Stop() + + outpoint := wire.OutPoint{Hash: chainhash.Hash{0x11}, Index: 0} + + spendNotifier := actor.NewChannelTellOnlyRef[SpendEvent]( + "spend-notify", 10, + ) + reorgNotifier := actor.NewChannelTellOnlyRef[SpendReorgedEvent]( + "spend-reorged", 10, + ) + doneNotifier := actor.NewChannelTellOnlyRef[SpendDoneEvent]( + "spend-done", 10, + ) + + var spendRef actor.TellOnlyRef[SpendEvent] = spendNotifier + var reorgRef actor.TellOnlyRef[SpendReorgedEvent] = reorgNotifier + var doneRef actor.TellOnlyRef[SpendDoneEvent] = doneNotifier + + result := spendActor.Receive(ctx, &RegisterSpendRequest{ + CallerID: "test-spend-finality-depth", + Outpoint: &outpoint, + PkScript: []byte{0x00, 0x14}, + NotifyActor: fn.Some(spendRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), + }) + require.True(t, result.IsOk()) + + // 1. First spend confirms at height 150. This arms the + // height-based finality synthesizer. + spendingTx := wire.NewMsgTx(2) + hash := spendingTx.TxHash() + backend.spendChan <- &SpendDetail{ + SpentOutPoint: &outpoint, + SpenderTxHash: &hash, + SpendingTx: spendingTx, + SpendingHeight: 150, + } + _, ok := spendNotifier.AwaitMessage(awaitTimeout) + require.True(t, ok, "timeout waiting for SpendEvent") + + // 2. Push blocks up to height 154. Inclusive depth at that point + // is 5 (heights 150..154), one short of the finality threshold. + // SpendDoneEvent MUST NOT have fired yet. + for height := int32(151); height <= + int32(150+finalityDepth-2); height++ { + + backend.epochChan <- &BlockEpoch{Height: height} + } + _, ok = doneNotifier.AwaitMessage(50 * time.Millisecond) + require.False(t, ok, "SpendDoneEvent fired before finality depth") + + // 3. One more block brings the inclusive depth to exactly + // FinalityDepth (heights 150..155). Done must fire now. + backend.epochChan <- &BlockEpoch{ + Height: 150 + int32(finalityDepth) - 1, + } + doneEvt, ok := doneNotifier.AwaitMessage(awaitTimeout) + require.True( + t, ok, + "SpendDoneEvent never fired despite reaching finality depth", + ) + require.Equal(t, outpoint, doneEvt.Outpoint) + + require.Eventually(t, func() bool { + return backend.spendCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "registration Cancel was never invoked after synthesized "+ + "Done") +} + +// TestSpendActorReorgAwareRejectsWithoutNotifyActor mirrors the conf-side +// admission check: opting in to reorg-aware spend forwarding without a +// NotifyActor must be rejected. +func TestSpendActorReorgAwareRejectsWithoutNotifyActor(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + spendActor := NewSpendActor(SpendActorConfig{Backend: backend}) + defer spendActor.Stop() + + outpoint := wire.OutPoint{Hash: chainhash.Hash{0x21}, Index: 0} + reorgNotifier := actor.NewChannelTellOnlyRef[SpendReorgedEvent]( + "spend-reorged", 1, + ) + var reorgRef actor.TellOnlyRef[SpendReorgedEvent] = reorgNotifier + + result := spendActor.Receive(ctx, &RegisterSpendRequest{ + CallerID: "test-spend-reorg-no-notify", + Outpoint: &outpoint, + PkScript: []byte{0x00, 0x14}, + NotifyReorged: fn.Some(reorgRef), + }) + require.True(t, result.IsErr()) + _, err := result.Unpack() + require.ErrorContains( + t, err, + "reorg/done notifications require actor-mode NotifyActor", + ) +} + +// TestSpendActorLegacyExitsAfterFirstSpend exercises the legacy Actor-mode +// path: a watch that did not opt into the reorg lifecycle +// (NotifyReorged/NotifyDone both unset) is single-shot, exiting and +// releasing its backend registration after the first spend. This mirrors +// ConfActor's legacy contract and the documented invariant in CLAUDE.md; +// without the reorgAware gate such a watch would run forever and, with +// FinalityDepth > 0, arm a block subscription it never requested. +func TestSpendActorLegacyExitsAfterFirstSpend(t *testing.T) { + t.Parallel() + + backend := newMockBackend() + ctx := t.Context() + spendActor := NewSpendActor(SpendActorConfig{Backend: backend}) + defer spendActor.Stop() + + outpoint := wire.OutPoint{Hash: chainhash.Hash{0x31}, Index: 0} + notifier := actor.NewChannelTellOnlyRef[SpendEvent]( + "spend-notify", 10, + ) + var spendRef actor.TellOnlyRef[SpendEvent] = notifier + + result := spendActor.Receive(ctx, &RegisterSpendRequest{ + CallerID: "test-spend-legacy-single-shot", + Outpoint: &outpoint, + PkScript: []byte{0x00, 0x14}, + NotifyActor: fn.Some(spendRef), + }) + require.True(t, result.IsOk()) + + // First spend goes through. + spendingTx := wire.NewMsgTx(2) + hash := spendingTx.TxHash() + backend.spendChan <- &SpendDetail{ + SpentOutPoint: &outpoint, + SpenderTxHash: &hash, + SpendingTx: spendingTx, + SpendingHeight: 300, + } + + first, ok := notifier.AwaitMessage(awaitTimeout) + require.True(t, ok) + require.Equal(t, int32(300), first.SpendingHeight) + + // The actor must have exited after the first spend, releasing the + // registration. + require.Eventually(t, func() bool { + return backend.spendCancelled.Load() >= 1 + }, awaitTimeout, 10*time.Millisecond, + "legacy SpendActor did not cancel registration after first "+ + "spend") + + // No further events should reach the notifier. + _, ok = notifier.AwaitMessage(50 * time.Millisecond) + require.False( + t, ok, "legacy SpendActor delivered an unexpected second event", + ) +} diff --git a/chainsource/spend_actor.go b/chainsource/spend_actor.go index 1a5c314ca..b5df68f61 100644 --- a/chainsource/spend_actor.go +++ b/chainsource/spend_actor.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "sync" + "time" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" @@ -23,6 +24,15 @@ type SpendActorConfig struct { // falls back to extracting a logger from context via LoggerFromContext, // or uses btclog.Disabled if no logger is found. Log fn.Option[btclog.Logger] + + // FinalityDepth is the number of confirmations past the first + // observed Spend event that the actor uses to synthesize a Done + // signal when the backend cannot deliver one. See the matching + // field on ConfActorConfig for the rationale; the same constraint + // applies on the spend watch — lnd's chainntnfs.SpendEvent.Done + // does not survive the lndclient gRPC transport, so consumers + // that gate eviction on Done would otherwise leak per-spend state. + FinalityDepth uint32 } // WithLogger returns a new config with the given logger set. @@ -61,9 +71,26 @@ type SpendActor struct { // mode. notifyActor fn.Option[actor.TellOnlyRef[SpendEvent]] + // notifyReorged receives spend reorg events in Actor mode. + notifyReorged fn.Option[actor.TellOnlyRef[SpendReorgedEvent]] + + // notifyDone receives spend finality events in Actor mode. + notifyDone fn.Option[actor.TellOnlyRef[SpendDoneEvent]] + // registration is the backend registration for this watch. registration *SpendRegistration + // blockReg is the block-epoch subscription used by height-based + // finality synthesis. Allocated lazily after the first Spend + // event when FinalityDepth > 0, torn down when the actor exits. + blockReg *BlockRegistration + + // spendHeight records the block height the most recent Spend + // event arrived at. Zero means there is no active spend to count + // from (either we have not yet seen one, or the last one was + // reorged out). + spendHeight int32 + // ctx is the actor's internal context for cancellation, created from // context.Background() to ensure it outlives any request context. //nolint:containedctx @@ -132,12 +159,21 @@ func (a *SpendActor) handleRegisterSpend(actorCtx context.Context, "provided"), ) } + if req.NotifyActor.IsNone() && + (req.NotifyReorged.IsSome() || req.NotifyDone.IsSome()) { + return fn.Err[SpendResp]( + fmt.Errorf("spend reorg/done notifications require " + + "actor-mode NotifyActor"), + ) + } // Configure the actor with request parameters. a.outpoint = req.Outpoint a.pkScript = req.PkScript a.heightHint = req.HeightHint a.notifyActor = req.NotifyActor + a.notifyReorged = req.NotifyReorged + a.notifyDone = req.NotifyDone // Create promise for Future mode. var promise fn.Option[actor.Promise[SpendEvent]] @@ -152,10 +188,17 @@ func (a *SpendActor) handleRegisterSpend(actorCtx context.Context, // Register with the backend to receive spend notifications. We do this // before starting the goroutine so we can return an error to the - // caller if registration fails. + // caller if registration fails. The bounded timeout mirrors + // ConfActor.handleRegisterConf: a backend (LND) that is slow under + // heavy block processing load must not pin the parent Receive call + // indefinitely, since that would back-pressure the chainsource + // factory actor onto every other in-flight registration. + regCtx, regCancel := context.WithTimeout(a.ctx, 10*time.Second) + defer regCancel() + //nolint:contextcheck // actor root context owns registration lifetime registration, err := a.cfg.Backend.RegisterSpend( - a.ctx, a.outpoint, a.pkScript, a.heightHint, + regCtx, a.outpoint, a.pkScript, a.heightHint, ) if err != nil { return fn.Err[SpendResp]( @@ -191,10 +234,47 @@ func (a *SpendActor) monitorSpend() { if a.registration != nil { a.registration.Cancel() } + if a.blockReg != nil { + a.blockReg.Cancel() + } }() + log := a.logger(a.ctx) + // Monitor for spends indefinitely until cancelled or shutdown. // This allows us to catch re-org events where a spend is replaced. + var lastEvent *SpendEvent + + // reorgAware reports whether the caller opted into the multi-shot + // reorg lifecycle (at least one of NotifyReorged/NotifyDone). When + // false the watch is single-shot for backwards compatibility: the + // actor exits after the first spend, mirroring ConfActor. Without + // this gate a plain actor-mode spend watch would run forever and, with + // FinalityDepth > 0, arm a block subscription it never asked for. + reorgAware := a.notifyReorged.IsSome() || a.notifyDone.IsSome() + + // lastSeq is the highest backend forwarder sequence applied so far. + // Spend and Reorged signals arrive on separate channels and a select + // cannot order two ready channels, so we order them by the shared + // sequence instead: an event whose Seq does not exceed lastSeq lost a + // cross-channel race to a newer signal and is discarded. This makes + // the actor's view correct regardless of delivery interleaving. Seq 0 + // means the backend does not stamp sequences (it never reorgs); those + // events are always applied. + var lastSeq uint64 + + // blockEpochs is rebound when height-based finality synthesis + // arms a block subscription. Until then a nil channel keeps the + // select arm parked. + var blockEpochs <-chan *BlockEpoch + + // blockRegCh hands a finality block subscription from the off-loop + // arming goroutine back to this loop; arming guards against launching + // more than one armer at a time. See armFinalityAsync for why arming + // runs off the select loop. + blockRegCh := make(chan finalityArmResult) + var arming bool + for { select { case spend, ok := <-a.registration.Spend: @@ -206,6 +286,16 @@ func (a *SpendActor) monitorSpend() { return } + // Discard a spend that lost a cross-channel race to a + // newer reorg: an event whose sequence does not exceed + // the highest applied is stale. + if spend.Seq != 0 && spend.Seq <= lastSeq { + continue + } + if spend.Seq > lastSeq { + lastSeq = spend.Seq + } + event, err := buildSpendEvent(spend, a) if err != nil { a.failSpend(err) @@ -215,13 +305,151 @@ func (a *SpendActor) monitorSpend() { // Deliver the event. a.deliverSpend(event) - - // In Future mode, exit after first event. In Actor - // mode, continue monitoring for re-org events. - if a.promise.IsSome() { + lastEvent = &event + a.spendHeight = event.SpendingHeight + + // Exit after the first event in Future mode, and in + // actor mode that did not opt into the reorg lifecycle + // (single-shot backwards-compatible contract). Only a + // reorg-aware actor watch keeps monitoring. + if !reorgAware || a.promise.IsSome() { return } + // Arm height-based finality synthesis on the first + // spend if requested, off the select loop so the + // bounded RegisterBlocks retries cannot stall delivery + // of Reorged/Done/ctx.Done on this watch. A nil + // blockEpochs channel keeps the synthesis arm parked + // until the registration is handed back on blockRegCh. + if a.cfg.FinalityDepth > 0 && a.blockReg == nil && + !arming { + + arming = true + a.armFinalityAsync(blockRegCh, log) + } + + case armed := <-blockRegCh: + // Finality arming completed. Clear the flag; a nil reg + // only happens when the watch context was cancelled + // (arming otherwise retries until it succeeds). + arming = false + if armed.reg == nil { + continue + } + a.blockReg = armed.reg + blockEpochs = armed.reg.Epochs + + // The block-epoch subscription only delivers FUTURE + // epochs, but the spend that armed it may already be + // buried past FinalityDepth (it confirmed several + // blocks ago, or we re-armed after a restart). Use the + // tip observed at arm time to synthesize Done at once + // rather than hang until a fresh block is mined. The + // spendHeight==0 / FinalityDepth==0 guards mirror the + // epoch handler below. + if a.spendHeight == 0 || a.cfg.FinalityDepth == 0 { + continue + } + if armed.height-a.spendHeight+1 < + int32(a.cfg.FinalityDepth) { + + continue + } + + log.InfoS(a.ctx, "Synthesizing spend done on arm from "+ + "height-based safety depth", + "spend_height", a.spendHeight, + "current_height", armed.height, + "finality_depth", int(a.cfg.FinalityDepth), + ) + a.deliverSpendDone(lastEvent) + + return + + case seq, ok := <-a.registration.Reorged: + if !ok { + a.registration.Reorged = nil + continue + } + + // Discard a stale reorg that lost a cross-channel race + // to a newer spend. + if seq != 0 && seq <= lastSeq { + continue + } + if seq > lastSeq { + lastSeq = seq + } + + a.deliverSpendReorged(lastEvent) + + // The previous spend is no longer on the canonical + // chain. Clear the cached event so a later Done cannot + // report the reorged-out outpoint, and reset the depth + // counter so the next re-spend starts a fresh window. + lastEvent = nil + a.spendHeight = 0 + + case _, ok := <-a.registration.Done: + if !ok { + a.registration.Done = nil + continue + } + + a.deliverSpendDone(lastEvent) + + return + + case epoch, ok := <-blockEpochs: + if !ok || epoch == nil { + blockEpochs = nil + continue + } + + // Coalesce any epochs already queued behind this one + // and evaluate finality against the most recent height + // only. With rapid-fire blocks the channel can hold + // several epochs at once; processing them one per loop + // iteration would re-check the same monotonic Done + // condition repeatedly and risk synthesizing against a + // stale height. If the channel closed during the drain, + // park it so we stop selecting on it. + var closed bool + epoch, closed = drainToLatestEpoch(blockEpochs, epoch) + if closed { + blockEpochs = nil + } + + // The spendHeight==0 guard is load-bearing: a reorg + // resets spendHeight to 0 (the Reorged arm above), so + // a fresh epoch arriving before the re-spend would + // otherwise compute depth against a zero base and + // could synthesize Done prematurely. While + // spendHeight==0 there is no active spend to count + // from, so the depth comparison is meaningless. + // FinalityDepth==0 disables synthesis entirely. + if a.spendHeight == 0 || + a.cfg.FinalityDepth == 0 { + + continue + } + + depth := epoch.Height - a.spendHeight + 1 + if depth < int32(a.cfg.FinalityDepth) { + continue + } + + log.InfoS(a.ctx, "Synthesizing spend done from "+ + "height-based safety depth", + "spend_height", a.spendHeight, + "current_height", epoch.Height, + "finality_depth", int(a.cfg.FinalityDepth), + ) + a.deliverSpendDone(lastEvent) + + return + case <-a.ctx.Done(): // Actor was cancelled. a.failSpend(a.ctx.Err()) @@ -231,6 +459,52 @@ func (a *SpendActor) monitorSpend() { } } +// armFinalityAsync registers a block-epoch subscription for height-based +// finality synthesis off the actor's select loop. registerBlocksForFinality +// retries with a bounded backoff that can run for tens of seconds; doing it +// inline would block delivery of Reorged/Done/ctx.Done on this watch for the +// whole window. The registration (or nil on failure) is handed back on regCh, +// or cancelled if the actor exits before the loop reads it. The goroutine is +// tracked by the actor's wait group so Stop drains it. +func (a *SpendActor) armFinalityAsync(regCh chan<- finalityArmResult, + log btclog.Logger) { + + a.wg.Go(func() { + reg, err := registerBlocksForFinality(a.ctx, a.cfg.Backend, log) + if err != nil { + log.WarnS(a.ctx, "Giving up on height-based finality "+ + "synthesis; spend sub-actor will rely on "+ + "backend Done", err) + reg = nil + } + + // Capture the tip at arm time so the loop can finalize + // immediately when the arming spend is already buried past + // FinalityDepth (the block-epoch sub only delivers future + // epochs). A read failure is non-fatal: height stays zero and + // the loop falls back to waiting for the next epoch. + var height int32 + if reg != nil { + h, _, hErr := a.cfg.Backend.BestBlock(a.ctx) + if hErr != nil { + log.WarnS(a.ctx, "Failed to read best height "+ + "for on-arm finality check; will wait "+ + "for next epoch", hErr) + } else { + height = h + } + } + + select { + case regCh <- finalityArmResult{reg: reg, height: height}: + case <-a.ctx.Done(): + if reg != nil { + reg.Cancel() + } + } + }) +} + // deliverSpend delivers a spend event to the subscriber. In Future mode, it // completes the promise. In Actor mode, it sends to the registered actor. func (a *SpendActor) deliverSpend(event SpendEvent) { @@ -249,6 +523,55 @@ func (a *SpendActor) deliverSpend(event SpendEvent) { }) } +// deliverSpendReorged delivers a spend reorg event to actor-mode +// subscribers. The correlation Outpoint is the registration's configured +// outpoint when set, since that is the identifier the caller asked us to +// watch; pkScript-only watches fall back to the outpoint carried on the +// most recent positive SpendEvent. +func (a *SpendActor) deliverSpendReorged(lastEvent *SpendEvent) { + var event SpendReorgedEvent + switch { + case a.outpoint != nil: + event.Outpoint = *a.outpoint + + case lastEvent != nil: + event.Outpoint = lastEvent.Outpoint + } + + a.notifyReorged.WhenSome( + func(ref actor.TellOnlyRef[SpendReorgedEvent]) { + log := a.logger(a.ctx) + if err := ref.Tell(a.ctx, event); err != nil { + log.WarnS( + a.ctx, + "Failed to deliver spend reorg", + err, + ) + } + }, + ) +} + +// deliverSpendDone delivers a spend finality event to actor-mode subscribers. +// Outpoint follows the same precedence as deliverSpendReorged. +func (a *SpendActor) deliverSpendDone(lastEvent *SpendEvent) { + var event SpendDoneEvent + switch { + case a.outpoint != nil: + event.Outpoint = *a.outpoint + + case lastEvent != nil: + event.Outpoint = lastEvent.Outpoint + } + + a.notifyDone.WhenSome(func(ref actor.TellOnlyRef[SpendDoneEvent]) { + log := a.logger(a.ctx) + if err := ref.Tell(a.ctx, event); err != nil { + log.WarnS(a.ctx, "Failed to deliver spend done", err) + } + }) +} + // failSpend completes the promise with an error (Future mode) or does nothing // (Actor mode - errors are not delivered in async mode). func (a *SpendActor) failSpend(err error) { diff --git a/chainsource/transform.go b/chainsource/transform.go index 63b9df71a..f79abfdd5 100644 --- a/chainsource/transform.go +++ b/chainsource/transform.go @@ -33,6 +33,25 @@ func MapConfirmationEvent[Out actor.Message]( return actor.NewMapInputRef(targetRef, mapFn) } +// MapConfReorgedEvent creates a transformed TellOnlyRef that accepts +// ConfReorgedEvent and transforms it to the caller's desired output message +// type. +func MapConfReorgedEvent[Out actor.Message]( + targetRef actor.TellOnlyRef[Out], mapFn func(ConfReorgedEvent) Out, +) actor.TellOnlyRef[ConfReorgedEvent] { + + return actor.NewMapInputRef(targetRef, mapFn) +} + +// MapConfDoneEvent creates a transformed TellOnlyRef that accepts +// ConfDoneEvent and transforms it to the caller's desired output message type. +func MapConfDoneEvent[Out actor.Message]( + targetRef actor.TellOnlyRef[Out], mapFn func(ConfDoneEvent) Out, +) actor.TellOnlyRef[ConfDoneEvent] { + + return actor.NewMapInputRef(targetRef, mapFn) +} + // MapSpendEvent creates a transformed TellOnlyRef that accepts SpendEvent and // transforms it to the caller's desired output message type. This is a // convenience wrapper for the common pattern of adapting chainsource spend @@ -64,6 +83,25 @@ func MapSpendEvent[Out actor.Message]( return actor.NewMapInputRef(targetRef, mapFn) } +// MapSpendReorgedEvent creates a transformed TellOnlyRef that accepts +// SpendReorgedEvent and transforms it to the caller's desired output message +// type. +func MapSpendReorgedEvent[Out actor.Message]( + targetRef actor.TellOnlyRef[Out], mapFn func(SpendReorgedEvent) Out, +) actor.TellOnlyRef[SpendReorgedEvent] { + + return actor.NewMapInputRef(targetRef, mapFn) +} + +// MapSpendDoneEvent creates a transformed TellOnlyRef that accepts +// SpendDoneEvent and transforms it to the caller's desired output message type. +func MapSpendDoneEvent[Out actor.Message]( + targetRef actor.TellOnlyRef[Out], mapFn func(SpendDoneEvent) Out, +) actor.TellOnlyRef[SpendDoneEvent] { + + return actor.NewMapInputRef(targetRef, mapFn) +} + // MapBlockEpoch creates a transformed TellOnlyRef that accepts BlockEpoch // and transforms it to the caller's desired output message type. This is a // convenience wrapper for the common pattern of adapting chainsource block diff --git a/cmd/waved/main.go b/cmd/waved/main.go index d1da04016..251124aa0 100644 --- a/cmd/waved/main.go +++ b/cmd/waved/main.go @@ -175,14 +175,7 @@ func newRootCmd() *cobra.Command { "run on mainnet (required when network=mainnet)", ) - // Cap the per-round operator fee the client is willing to pay - // under the #270 seal-time fee handshake. Zero is rejected at - // config-load time as an explicit misconfiguration. - f.Int64( - "maxoperatorfeesat", cfg.MaxOperatorFeeSat, "maximum "+ - "operator fee (sats) the client will accept per "+ - "seal-time quote; must be positive", - ) + registerProtocolSafetyFlags(f, cfg) // Bound concurrent MuSig2 work. Zero lets the daemon choose a safe // backend-aware default; one restores serial signing. @@ -352,6 +345,32 @@ func registerArkServerFlags(f *pflag.FlagSet, cfg *waved.Config) { ) } +// registerProtocolSafetyFlags registers the client-side protocol safety knobs: +// the per-round operator fee cap and the reorg-safety / finality depth. +func registerProtocolSafetyFlags(f *pflag.FlagSet, cfg *waved.Config) { + // Cap the per-round operator fee the client is willing to pay under the + // #270 seal-time fee handshake. Zero is rejected at config-load time as + // an explicit misconfiguration. + f.Int64( + "maxoperatorfeesat", cfg.MaxOperatorFeeSat, "maximum "+ + "operator fee (sats) the client will accept per "+ + "seal-time quote; must be positive", + ) + + // Reorg-safety / finality depth: the confirmation depth at which a + // batch is treated as final and its reorg-aware chain watches are + // released. Bounds the deepest reorg the daemon detects and recovers + // from. Zero selects a network-aware default (6, or 100 on testnet, + // whose minimum-difficulty rule produces deep reorgs). + f.Uint32( + "reorgsafetydepth", cfg.ReorgSafetyDepth, "confirmation "+ + "depth at which a batch is treated as final and "+ + "its reorg-aware chain watches are released; "+ + "bounds the deepest reorg the daemon recovers "+ + "from. 0 = network-aware default (6; 100 on testnet)", + ) +} + // registerSwapRuntimeFlags registers optional swapruntime flags. func registerSwapRuntimeFlags(f *pflag.FlagSet, cfg *waved.Config) { f.String( diff --git a/db/batch_canonicality_store.go b/db/batch_canonicality_store.go new file mode 100644 index 000000000..6a65785ea --- /dev/null +++ b/db/batch_canonicality_store.go @@ -0,0 +1,691 @@ +package db + +import ( + "context" + "database/sql" + "errors" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/db/sqlc" + "github.com/lightningnetwork/lnd/clock" + fn "github.com/lightningnetwork/lnd/fn/v2" +) + +// BatchCanonicalityStore groups the generated SQL methods needed to persist +// the batch canonicality data model. +// +//nolint:interfacebloat // One handle keeps all canonicality ExecTx closures. +type BatchCanonicalityStore interface { + UpsertBatchCanonicality(ctx context.Context, + arg sqlc.UpsertBatchCanonicalityParams) error + + GetBatchCanonicality(ctx context.Context, + batchTxid []byte) (sqlc.BatchCanonicality, error) + + ListBatchCanonicalityByState(ctx context.Context, + state int32) ([]sqlc.BatchCanonicality, error) + + UpdateBatchCanonicalityState(ctx context.Context, + arg sqlc.UpdateBatchCanonicalityStateParams) error + + RecordBatchConfirmation(ctx context.Context, + arg sqlc.RecordBatchConfirmationParams) error + + ClearBatchConfirmation(ctx context.Context, + arg sqlc.ClearBatchConfirmationParams) error + + InsertBatchConsumedInput(ctx context.Context, + arg sqlc.InsertBatchConsumedInputParams) error + + DeleteBatchConsumedInputs(ctx context.Context, batchTxid []byte) error + + ListBatchConsumedInputs(ctx context.Context, + batchTxid []byte) ([]sqlc.ListBatchConsumedInputsRow, error) + + RecordBatchInputConflict(ctx context.Context, + arg sqlc.RecordBatchInputConflictParams) error + + FindBatchesByConsumedOutpoint(ctx context.Context, + arg sqlc.FindBatchesByConsumedOutpointParams) ([][]byte, error) + + InsertBatchDependentVTXO(ctx context.Context, + arg sqlc.InsertBatchDependentVTXOParams) error + + DeleteBatchDependentVTXOs(ctx context.Context, batchTxid []byte) error + + ListBatchDependentVTXOs(ctx context.Context, + batchTxid []byte) ([]sqlc.ListBatchDependentVTXOsRow, error) + + InsertProvisionalConsumer(ctx context.Context, + arg sqlc.InsertProvisionalConsumerParams) error + + ListProvisionalConsumersForBatch(ctx context.Context, + consumerBatchTxid []byte) ( + []sqlc.ListProvisionalConsumersForBatchRow, error) + + DeleteProvisionalConsumersForBatch(ctx context.Context, + consumerBatchTxid []byte) error + + ListVTXOsForCanonicalityBackfill(ctx context.Context) ( + []sqlc.ListVTXOsForCanonicalityBackfillRow, error) +} + +// BatchedBatchCanonicalityStore combines the query surface with batched +// transaction execution. +type BatchedBatchCanonicalityStore interface { + BatchCanonicalityStore + BatchedTx[BatchCanonicalityStore] +} + +// BatchCanonicalityPersistenceStore persists the durable batch canonicality +// data model: per-batch canonicality records, the inputs each batch consumes, +// the VTXOs it anchors, and the reverse-dependency edges used to restore a +// provisionally consumed VTXO. It is behavior-free; interpretation lives in +// the batch canonicality manager. +type BatchCanonicalityPersistenceStore struct { + db BatchedBatchCanonicalityStore + clock clock.Clock +} + +// NewBatchCanonicalityPersistenceStore creates a batch canonicality store +// using the transaction executor pattern. +func NewBatchCanonicalityPersistenceStore(db BatchedBatchCanonicalityStore, + clk clock.Clock) *BatchCanonicalityPersistenceStore { + + return &BatchCanonicalityPersistenceStore{ + db: db, + clock: clk, + } +} + +// UpsertBatch inserts or replaces the canonicality record for a batch, +// including its consumed inputs and dependent VTXOs. The input and dependent +// sets are replaced wholesale (delete-then-insert) so the persisted edges +// always match the supplied record. +func (s *BatchCanonicalityPersistenceStore) UpsertBatch(ctx context.Context, + record *batchcanon.Record) error { + + now := s.clock.Now().Unix() + txid := record.BatchTxID + pkScript := record.ConfirmationPkScript + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + err := q.UpsertBatchCanonicality( + ctx, sqlc.UpsertBatchCanonicalityParams{ + BatchTxid: txid[:], + State: int32(record.State), + ConfirmationHeight: optionToNullInt32( + record.ConfirmationHeight, + ), + ConfirmationBlockHash: optionHashToBytes( + record.ConfirmationBlock, + ), + CsvExpiryDelta: record.CSVExpiryDelta, + PolicyState: int32(record.PolicyState), + ConfirmationPkScript: pkScript, + CreatedAt: now, + UpdatedAt: now, + }, + ) + if err != nil { + return err + } + + // Replace the consumed-input set. + if err := q.DeleteBatchConsumedInputs( + ctx, txid[:], + ); err != nil { + return err + } + for _, in := range record.ConsumedInputs { + err := q.InsertBatchConsumedInput( + ctx, sqlc.InsertBatchConsumedInputParams{ + BatchTxid: txid[:], + InputHash: in.Outpoint.Hash[:], + InputIndex: int32(in.Outpoint.Index), + InputPkScript: in.PkScript, + }, + ) + if err != nil { + return err + } + } + + // Replace the dependent-VTXO set. + err = q.DeleteBatchDependentVTXOs(ctx, txid[:]) + if err != nil { + return err + } + + return insertDependentVTXOs(ctx, q, txid, record.DependentVTXOs) + }) +} + +// insertDependentVTXOs links each dependent VTXO outpoint to a batch txid. +// Shared by UpsertBatch and BackfillFromVTXOs so neither nests the insert +// loop deeply enough to overflow the line-length budget. +func insertDependentVTXOs(ctx context.Context, q BatchCanonicalityStore, + txid chainhash.Hash, deps []wire.OutPoint) error { + + for _, dep := range deps { + err := q.InsertBatchDependentVTXO( + ctx, sqlc.InsertBatchDependentVTXOParams{ + BatchTxid: txid[:], + VtxoOutpointHash: dep.Hash[:], + VtxoOutpointIndex: int32(dep.Index), + }, + ) + if err != nil { + return err + } + } + + return nil +} + +// GetBatch returns the canonicality record for a batch txid, hydrating its +// consumed inputs and dependent VTXOs. It returns batchcanon.ErrBatchNotFound +// when no record exists. +func (s *BatchCanonicalityPersistenceStore) GetBatch(ctx context.Context, + txid chainhash.Hash) (*batchcanon.Record, error) { + + var record *batchcanon.Record + + err := s.db.ExecTx(ctx, ReadTxOption(), func( + q BatchCanonicalityStore) error { + + row, err := q.GetBatchCanonicality(ctx, txid[:]) + if errors.Is(err, sql.ErrNoRows) { + return batchcanon.ErrBatchNotFound + } + if err != nil { + return err + } + + rec, err := s.hydrateRecord(ctx, q, row) + if err != nil { + return err + } + record = rec + + return nil + }) + + return record, err +} + +// ListBatchesByState returns every batch currently in the given state, +// hydrating each record's consumed inputs and dependent VTXOs. +func (s *BatchCanonicalityPersistenceStore) ListBatchesByState( + ctx context.Context, state batchcanon.State) ([]*batchcanon.Record, + error) { + + var records []*batchcanon.Record + + err := s.db.ExecTx(ctx, ReadTxOption(), func( + q BatchCanonicalityStore) error { + + rows, err := q.ListBatchCanonicalityByState(ctx, int32(state)) + if err != nil { + return err + } + + records = make([]*batchcanon.Record, 0, len(rows)) + for _, row := range rows { + rec, err := s.hydrateRecord(ctx, q, row) + if err != nil { + return err + } + records = append(records, rec) + } + + return nil + }) + + return records, err +} + +// UpdateBatchState transitions a batch to a new canonicality state. +func (s *BatchCanonicalityPersistenceStore) UpdateBatchState( + ctx context.Context, txid chainhash.Hash, + state batchcanon.State) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return q.UpdateBatchCanonicalityState( + ctx, sqlc.UpdateBatchCanonicalityStateParams{ + BatchTxid: txid[:], + State: int32(state), + UpdatedAt: s.clock.Now().Unix(), + }, + ) + }) +} + +// RecordConfirmation records the best-chain height and block hash at which +// the batch tx is confirmed. +func (s *BatchCanonicalityPersistenceStore) RecordConfirmation( + ctx context.Context, txid chainhash.Hash, height int32, + block chainhash.Hash) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return q.RecordBatchConfirmation( + ctx, sqlc.RecordBatchConfirmationParams{ + BatchTxid: txid[:], + ConfirmationHeight: sql.NullInt32{ + Int32: height, + Valid: true, + }, + ConfirmationBlockHash: block[:], + UpdatedAt: s.clock.Now().Unix(), + }, + ) + }) +} + +// ClearConfirmation clears the confirmation observation for a batch. +func (s *BatchCanonicalityPersistenceStore) ClearConfirmation( + ctx context.Context, txid chainhash.Hash) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return q.ClearBatchConfirmation( + ctx, sqlc.ClearBatchConfirmationParams{ + BatchTxid: txid[:], + UpdatedAt: s.clock.Now().Unix(), + }, + ) + }) +} + +// RecordInputConflict persists the observed conflict status of one consumed +// input, so restart reconciliation can rebuild the per-input conflict view. +func (s *BatchCanonicalityPersistenceStore) RecordInputConflict( + ctx context.Context, batchTxid chainhash.Hash, outpoint wire.OutPoint, + conflicting, conflictFinal bool) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return q.RecordBatchInputConflict( + ctx, sqlc.RecordBatchInputConflictParams{ + BatchTxid: batchTxid[:], + InputHash: outpoint.Hash[:], + InputIndex: int32(outpoint.Index), + Conflicting: boolToInt32(conflicting), + ConflictFinal: boolToInt32(conflictFinal), + }, + ) + }) +} + +// FindBatchesConsumingOutpoint returns the txids of every recorded batch that +// consumes the given outpoint. +func (s *BatchCanonicalityPersistenceStore) FindBatchesConsumingOutpoint( + ctx context.Context, outpoint wire.OutPoint) ([]chainhash.Hash, error) { + + var txids []chainhash.Hash + + err := s.db.ExecTx(ctx, ReadTxOption(), func( + q BatchCanonicalityStore) error { + + rows, err := q.FindBatchesByConsumedOutpoint( + ctx, sqlc.FindBatchesByConsumedOutpointParams{ + InputHash: outpoint.Hash[:], + InputIndex: int32(outpoint.Index), + }, + ) + if err != nil { + return err + } + + txids = make([]chainhash.Hash, 0, len(rows)) + for _, raw := range rows { + hash, err := chainhash.NewHash(raw) + if err != nil { + return err + } + txids = append(txids, *hash) + } + + return nil + }) + + return txids, err +} + +// AddProvisionalConsumer records a reverse-dependency edge: the given VTXO +// outpoint is provisionally consumed by the given consumer batch. +func (s *BatchCanonicalityPersistenceStore) AddProvisionalConsumer( + ctx context.Context, consumedVTXO wire.OutPoint, + consumerBatch chainhash.Hash) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return q.InsertProvisionalConsumer( + ctx, sqlc.InsertProvisionalConsumerParams{ + ConsumedVtxoHash: consumedVTXO.Hash[:], + ConsumedVtxoIndex: int32(consumedVTXO.Index), + ConsumerBatchTxid: consumerBatch[:], + CreatedAt: s.clock.Now().Unix(), + }, + ) + }) +} + +// ListProvisionalConsumersForBatch returns the VTXO outpoints that the given +// consumer batch provisionally consumes. +func (s *BatchCanonicalityPersistenceStore) ListProvisionalConsumersForBatch( + ctx context.Context, consumerBatch chainhash.Hash) ([]wire.OutPoint, + error) { + + var outpoints []wire.OutPoint + + err := s.db.ExecTx(ctx, ReadTxOption(), func( + q BatchCanonicalityStore) error { + + rows, err := q.ListProvisionalConsumersForBatch( + ctx, consumerBatch[:], + ) + if err != nil { + return err + } + + outpoints = make([]wire.OutPoint, 0, len(rows)) + for _, row := range rows { + hash, err := chainhash.NewHash(row.ConsumedVtxoHash) + if err != nil { + return err + } + outpoints = append(outpoints, wire.OutPoint{ + Hash: *hash, + Index: uint32(row.ConsumedVtxoIndex), + }) + } + + return nil + }) + + return outpoints, err +} + +// DeleteProvisionalConsumersForBatch removes every reverse-dependency edge for +// the given consumer batch. +func (s *BatchCanonicalityPersistenceStore) DeleteProvisionalConsumersForBatch( + ctx context.Context, consumerBatch chainhash.Hash) error { + + return s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + return q.DeleteProvisionalConsumersForBatch( + ctx, consumerBatch[:], + ) + }) +} + +// backfillGroup accumulates the per-batch data derived from VTXO rows during +// backfill: the batch-level expiry and creation height (shared by every VTXO +// in the batch) plus the set of dependent VTXO outpoints. +type backfillGroup struct { + batchExpiry int32 + createdHeight int32 + dependents []wire.OutPoint +} + +// BackfillFromVTXOs derives an initial canonicality record for every distinct +// batch (commitment) txid present in the VTXO store that does not already have +// a record, so an upgrading node carries forward the batches its existing +// VTXOs depend on. It is idempotent: batches that already have a record are +// left untouched, so a re-run never clobbers state the manager has since +// advanced. It returns the number of batch records created. +// +// Classification uses the supplied best height and finality depth: a batch +// confirmed at least finalityDepth deep (inclusive) is finalized, otherwise +// provisional. The CSV-relative expiry delta is recovered as +// batch_expiry - created_height so the derived effective expiry matches the +// original absolute batch_expiry while remaining reorg-recomputable. Batches +// whose VTXOs carry no positive creation height are skipped: without a +// confirmation observation the manager will create them on first sight. +func (s *BatchCanonicalityPersistenceStore) BackfillFromVTXOs( + ctx context.Context, bestHeight int32, finalityDepth uint32) (int, + error) { + + now := s.clock.Now().Unix() + created := 0 + + err := s.db.ExecTx(ctx, WriteTxOption(), func( + q BatchCanonicalityStore) error { + + rows, err := q.ListVTXOsForCanonicalityBackfill(ctx) + if err != nil { + return err + } + + // Group the VTXO rows by commitment (batch) txid. + groups := make(map[chainhash.Hash]*backfillGroup) + for _, row := range rows { + txid, err := chainhash.NewHash(row.CommitmentTxid) + if err != nil { + return err + } + vtxoHash, err := chainhash.NewHash(row.OutpointHash) + if err != nil { + return err + } + + g, ok := groups[*txid] + if !ok { + g = &backfillGroup{ + batchExpiry: row.BatchExpiry, + createdHeight: row.CreatedHeight, + } + groups[*txid] = g + } + g.dependents = append(g.dependents, wire.OutPoint{ + Hash: *vtxoHash, + Index: uint32(row.OutpointIndex), + }) + } + + for txid, g := range groups { + // Skip batches with no positive confirmation height: + // the manager will create them once it observes a + // confirmation. + if g.createdHeight <= 0 { + continue + } + + // Skip batches that already have a record so a re-run + // never overwrites advanced state. + _, err := q.GetBatchCanonicality(ctx, txid[:]) + if err == nil { + continue + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + + csvDelta := g.batchExpiry - g.createdHeight + if csvDelta < 0 { + csvDelta = 0 + } + + state := batchcanon.StateProvisional + depth := bestHeight - g.createdHeight + 1 + if depth >= int32(finalityDepth) { + state = batchcanon.StateFinalized + } + + err = q.UpsertBatchCanonicality( + ctx, sqlc.UpsertBatchCanonicalityParams{ + BatchTxid: txid[:], + State: int32(state), + ConfirmationHeight: sql.NullInt32{ + Int32: g.createdHeight, + Valid: true, + }, + // The confirming block hash is not + // recorded on VTXO rows; it is an + // observation attribute the manager + // fills on its next confirmation + // sighting. + ConfirmationBlockHash: nil, + CsvExpiryDelta: csvDelta, + PolicyState: int32( + batchcanon.PolicyStateDefault, + ), + CreatedAt: now, + UpdatedAt: now, + }, + ) + if err != nil { + return err + } + + err = insertDependentVTXOs(ctx, q, txid, g.dependents) + if err != nil { + return err + } + + created++ + } + + return nil + }) + + return created, err +} + +// hydrateRecord builds a batchcanon.Record from a canonicality row, loading +// its consumed inputs and dependent VTXOs through the same query handle (and +// therefore the same transaction). +func (s *BatchCanonicalityPersistenceStore) hydrateRecord(ctx context.Context, + q BatchCanonicalityStore, row sqlc.BatchCanonicality) ( + *batchcanon.Record, error) { + + txid, err := chainhash.NewHash(row.BatchTxid) + if err != nil { + return nil, err + } + + confBlock, err := bytesToOptionHash(row.ConfirmationBlockHash) + if err != nil { + return nil, err + } + + inputRows, err := q.ListBatchConsumedInputs(ctx, row.BatchTxid) + if err != nil { + return nil, err + } + inputs := make([]batchcanon.ConsumedInput, 0, len(inputRows)) + for _, in := range inputRows { + hash, err := chainhash.NewHash(in.InputHash) + if err != nil { + return nil, err + } + inputs = append(inputs, batchcanon.ConsumedInput{ + Outpoint: wire.OutPoint{ + Hash: *hash, + Index: uint32(in.InputIndex), + }, + PkScript: in.InputPkScript, + Conflicting: in.Conflicting != 0, + ConflictFinal: in.ConflictFinal != 0, + }) + } + + depRows, err := q.ListBatchDependentVTXOs(ctx, row.BatchTxid) + if err != nil { + return nil, err + } + deps := make([]wire.OutPoint, 0, len(depRows)) + for _, dep := range depRows { + hash, err := chainhash.NewHash(dep.VtxoOutpointHash) + if err != nil { + return nil, err + } + deps = append(deps, wire.OutPoint{ + Hash: *hash, + Index: uint32(dep.VtxoOutpointIndex), + }) + } + + return &batchcanon.Record{ + BatchTxID: *txid, + State: batchcanon.State(row.State), + ConfirmationHeight: nullInt32ToOption(row.ConfirmationHeight), + ConfirmationBlock: confBlock, + CSVExpiryDelta: row.CsvExpiryDelta, + PolicyState: batchcanon.PolicyState(row.PolicyState), + ConfirmationPkScript: row.ConfirmationPkScript, + ConsumedInputs: inputs, + DependentVTXOs: deps, + }, nil +} + +// boolToInt32 maps a Go bool to the 0/1 INTEGER encoding used for the +// consumed-input conflict flags. +func boolToInt32(b bool) int32 { + if b { + return 1 + } + + return 0 +} + +// optionToNullInt32 maps an optional int32 to a sql.NullInt32. +func optionToNullInt32(o fn.Option[int32]) sql.NullInt32 { + if o.IsNone() { + return sql.NullInt32{} + } + + return sql.NullInt32{Int32: o.UnwrapOr(0), Valid: true} +} + +// nullInt32ToOption maps a sql.NullInt32 back to an optional int32. +func nullInt32ToOption(n sql.NullInt32) fn.Option[int32] { + if !n.Valid { + return fn.None[int32]() + } + + return fn.Some(n.Int32) +} + +// optionHashToBytes maps an optional hash to its raw bytes, or nil when None. +func optionHashToBytes(o fn.Option[chainhash.Hash]) []byte { + if o.IsNone() { + return nil + } + + h := o.UnwrapOr(chainhash.Hash{}) + + return h[:] +} + +// bytesToOptionHash maps a (possibly nil) raw hash to an optional hash. A nil +// or empty slice yields None; any other length is validated to 32 bytes. +func bytesToOptionHash(raw []byte) (fn.Option[chainhash.Hash], error) { + if len(raw) == 0 { + return fn.None[chainhash.Hash](), nil + } + + hash, err := chainhash.NewHash(raw) + if err != nil { + return fn.None[chainhash.Hash](), err + } + + return fn.Some(*hash), nil +} + +// Compile-time check that the persistence store satisfies the domain Store +// interface. +var _ batchcanon.Store = (*BatchCanonicalityPersistenceStore)(nil) diff --git a/db/batch_canonicality_store_test.go b/db/batch_canonicality_store_test.go new file mode 100644 index 000000000..c2024ed5e --- /dev/null +++ b/db/batch_canonicality_store_test.go @@ -0,0 +1,565 @@ +package db + +import ( + "database/sql" + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/lib/tree" + "github.com/lightninglabs/wavelength/round" + "github.com/lightningnetwork/lnd/clock" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// newBatchCanonicalityStoreForTest creates a batch canonicality store backed +// by a fresh test database. +func newBatchCanonicalityStoreForTest( + t *testing.T) *BatchCanonicalityPersistenceStore { + + t.Helper() + + db := NewTestDB(t) + + canonDB := NewTransactionExecutor( + db.BaseDB, + func(tx *sql.Tx) BatchCanonicalityStore { + return db.WithTx(tx) + }, + btclog.Disabled, + ) + + return NewBatchCanonicalityPersistenceStore( + canonDB, clock.NewDefaultClock(), + ) +} + +// outpoint is a small test helper building a deterministic outpoint. +func outpoint(b byte, index uint32) wire.OutPoint { + return wire.OutPoint{Hash: chainhash.Hash{b}, Index: index} +} + +// consumedInput builds a batchcanon.ConsumedInput from an outpoint with a +// deterministic non-empty pkScript so persistence round-trips can assert the +// script is stored and reloaded alongside the outpoint. +func consumedInput(op wire.OutPoint) batchcanon.ConsumedInput { + return batchcanon.ConsumedInput{ + Outpoint: op, + PkScript: []byte{ + 0x51, + 0x20, + op.Hash[0], + }, + } +} + +// TestBatchCanonicalityUpsertRoundTrip verifies a record survives an upsert +// and read with all of its fields, consumed inputs, and dependent VTXOs. +func TestBatchCanonicalityUpsertRoundTrip(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + txid := chainhash.Hash{0xaa} + rec := &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateProvisional, + ConfirmationHeight: fn.Some[int32](100), + ConfirmationBlock: fn.Some(chainhash.Hash{0xbb}), + CSVExpiryDelta: 144, + PolicyState: batchcanon.PolicyStateDefault, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(outpoint(0x01, 0)), + consumedInput(outpoint(0x02, 3)), + }, + DependentVTXOs: []wire.OutPoint{ + outpoint(0x03, 1), + }, + } + require.NoError(t, store.UpsertBatch(ctx, rec)) + + got, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal(t, txid, got.BatchTxID) + require.Equal(t, batchcanon.StateProvisional, got.State) + require.Equal(t, int32(100), got.ConfirmationHeight.UnwrapOr(0)) + require.True(t, got.ConfirmationBlock.IsSome()) + require.Equal(t, int32(144), got.CSVExpiryDelta) + require.Equal(t, batchcanon.PolicyStateDefault, got.PolicyState) + require.ElementsMatch(t, rec.ConsumedInputs, got.ConsumedInputs) + require.ElementsMatch(t, rec.DependentVTXOs, got.DependentVTXOs) + + // Effective expiry derives from the stored confirmation. + require.Equal(t, int32(244), got.EffectiveExpiry().UnwrapOr(0)) +} + +// TestBatchCanonicalityRecordInputConflict verifies that per-input conflict +// flags round-trip through the store, so restart reconciliation can rebuild +// the per-input conflict view (darepo#454 reconciliation-ordering fix). +func TestBatchCanonicalityRecordInputConflict(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + txid := chainhash.Hash{0xac} + inA := outpoint(0x01, 0) + inB := outpoint(0x02, 1) + rec := &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateConflictProvisional, + CSVExpiryDelta: 144, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(inA), + consumedInput(inB), + }, + } + require.NoError(t, store.UpsertBatch(ctx, rec)) + + // Freshly inserted inputs carry no conflict. + got, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + for _, in := range got.ConsumedInputs { + require.False(t, in.Conflicting) + require.False(t, in.ConflictFinal) + } + + // Mark input A conflicting (provisional), leave B untouched. + require.NoError( + t, store.RecordInputConflict(ctx, txid, inA, true, false), + ) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.True(t, inputFlags(t, got, inA).Conflicting) + require.False(t, inputFlags(t, got, inA).ConflictFinal) + require.False(t, inputFlags(t, got, inB).Conflicting) + + // Promote input A to a finalized conflict; the flag persists. + require.NoError( + t, store.RecordInputConflict(ctx, txid, inA, true, true), + ) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.True(t, inputFlags(t, got, inA).Conflicting) + require.True(t, inputFlags(t, got, inA).ConflictFinal) + + // Clearing the conflict (spend reorged away) resets both flags. + require.NoError( + t, store.RecordInputConflict(ctx, txid, inA, false, false), + ) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.False(t, inputFlags(t, got, inA).Conflicting) + require.False(t, inputFlags(t, got, inA).ConflictFinal) +} + +// inputFlags returns the consumed input matching op from a record, failing the +// test if it is absent. +func inputFlags(t *testing.T, rec *batchcanon.Record, + op wire.OutPoint) batchcanon.ConsumedInput { + + t.Helper() + for _, in := range rec.ConsumedInputs { + if in.Outpoint == op { + return in + } + } + t.Fatalf("consumed input %v not found", op) + + return batchcanon.ConsumedInput{} +} + +// TestBatchCanonicalityGetNotFound verifies the not-found sentinel. +func TestBatchCanonicalityGetNotFound(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + _, err := store.GetBatch(ctx, chainhash.Hash{0xff}) + require.ErrorIs(t, err, batchcanon.ErrBatchNotFound) +} + +// TestBatchCanonicalityUpsertReplacesEdges verifies a re-upsert replaces the +// consumed-input and dependent-VTXO sets rather than appending. +func TestBatchCanonicalityUpsertReplacesEdges(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + txid := chainhash.Hash{0xa1} + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateUnseen, + CSVExpiryDelta: 10, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(outpoint(0x01, 0)), + }, + DependentVTXOs: []wire.OutPoint{ + outpoint(0x02, 0), + }, + }, + ), + ) + + // Re-upsert with a different edge set. + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 10, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(outpoint(0x09, 2)), + }, + DependentVTXOs: nil, + }, + ), + ) + + got, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal( + t, []batchcanon.ConsumedInput{consumedInput(outpoint(0x09, 2))}, + got.ConsumedInputs, + ) + require.Empty(t, got.DependentVTXOs) +} + +// TestBatchCanonicalityReorgRecomputesExpiry verifies the reorg-aware expiry +// contract end to end through the store: a confirmation yields an effective +// expiry, a reorg (ClearConfirmation) erases it, and a reconfirmation at a new +// height yields a fresh effective expiry. Expiry is never frozen. +func TestBatchCanonicalityReorgRecomputesExpiry(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + txid := chainhash.Hash{0xc0} + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateUnseen, + CSVExpiryDelta: 144, + }, + ), + ) + + // Unconfirmed: no effective expiry. + got, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.True(t, got.EffectiveExpiry().IsNone()) + + // Confirm at height 100. + require.NoError( + t, + store.RecordConfirmation( + ctx, txid, 100, chainhash.Hash{0xc1}, + ), + ) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal(t, int32(244), got.EffectiveExpiry().UnwrapOr(0)) + + // Reorg out: confirmation cleared, effective expiry erased. + require.NoError(t, store.ClearConfirmation(ctx, txid)) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.True(t, got.ConfirmationHeight.IsNone()) + require.True(t, got.EffectiveExpiry().IsNone()) + + // Reconfirm at a higher height: fresh effective expiry. + require.NoError( + t, + store.RecordConfirmation( + ctx, txid, 105, chainhash.Hash{0xc2}, + ), + ) + got, err = store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal(t, int32(249), got.EffectiveExpiry().UnwrapOr(0)) +} + +// TestBatchCanonicalityStateNotTerminal verifies state can move freely in any +// direction (finalized -> reorged_out -> provisional), proving no state is +// persisted as an irreversible terminal verdict. +func TestBatchCanonicalityStateNotTerminal(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + txid := chainhash.Hash{0xd0} + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: txid, + State: batchcanon.StateFinalized, + CSVExpiryDelta: 10, + }, + ), + ) + + for _, want := range []batchcanon.State{ + batchcanon.StateReorgedOut, + batchcanon.StateConflictFinalized, + batchcanon.StateProvisional, + } { + require.NoError(t, store.UpdateBatchState(ctx, txid, want)) + got, err := store.GetBatch(ctx, txid) + require.NoError(t, err) + require.Equal(t, want, got.State) + } +} + +// TestBatchCanonicalityListByState verifies state-filtered listing. +func TestBatchCanonicalityListByState(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: chainhash.Hash{0xe0}, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 1, + }, + ), + ) + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: chainhash.Hash{0xe1}, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 1, + }, + ), + ) + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: chainhash.Hash{0xe2}, + State: batchcanon.StateFinalized, + CSVExpiryDelta: 1, + }, + ), + ) + + prov, err := store.ListBatchesByState(ctx, batchcanon.StateProvisional) + require.NoError(t, err) + require.Len(t, prov, 2) + + final, err := store.ListBatchesByState(ctx, batchcanon.StateFinalized) + require.NoError(t, err) + require.Len(t, final, 1) +} + +// TestBatchCanonicalityFindByConsumedOutpoint verifies input-conflict +// detection: two batches consuming the same outpoint are both found. +func TestBatchCanonicalityFindByConsumedOutpoint(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + shared := outpoint(0x55, 1) + batchA := chainhash.Hash{0xa0} + batchB := chainhash.Hash{0xb0} + + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: batchA, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 1, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(shared), + }, + }, + ), + ) + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: batchB, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 1, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(shared), + }, + }, + ), + ) + + found, err := store.FindBatchesConsumingOutpoint(ctx, shared) + require.NoError(t, err) + require.ElementsMatch(t, []chainhash.Hash{batchA, batchB}, found) + + none, err := store.FindBatchesConsumingOutpoint(ctx, outpoint(0x99, 0)) + require.NoError(t, err) + require.Empty(t, none) +} + +// TestBatchCanonicalityProvisionalConsumerRestore verifies the reverse- +// dependency lifecycle: a provisionally consumed VTXO is listed for its +// consumer batch (so it can be restored if the batch is invalidated), survives +// a batch state change, and is removed on delete. +func TestBatchCanonicalityProvisionalConsumerRestore(t *testing.T) { + t.Parallel() + + ctx := t.Context() + store := newBatchCanonicalityStoreForTest(t) + + consumerBatch := chainhash.Hash{0xf0} + require.NoError( + t, + store.UpsertBatch( + ctx, &batchcanon.Record{ + BatchTxID: consumerBatch, + State: batchcanon.StateProvisional, + CSVExpiryDelta: 1, + }, + ), + ) + + consumed := outpoint(0x44, 2) + require.NoError( + t, store.AddProvisionalConsumer( + ctx, consumed, consumerBatch, + ), + ) + + // Listed for the consumer batch. + got, err := store.ListProvisionalConsumersForBatch(ctx, consumerBatch) + require.NoError(t, err) + require.Equal(t, []wire.OutPoint{consumed}, got) + + // Idempotent re-add. + require.NoError( + t, store.AddProvisionalConsumer( + ctx, consumed, consumerBatch, + ), + ) + got, err = store.ListProvisionalConsumersForBatch(ctx, consumerBatch) + require.NoError(t, err) + require.Len(t, got, 1) + + // The edge survives the batch being marked reorged/invalidated — that + // is exactly when the restore caller needs to read it. + require.NoError( + t, store.UpdateBatchState( + ctx, consumerBatch, batchcanon.StateReorgedOut, + ), + ) + got, err = store.ListProvisionalConsumersForBatch(ctx, consumerBatch) + require.NoError(t, err) + require.Equal(t, []wire.OutPoint{consumed}, got) + + // Deleting clears the edges (e.g. once the consumption is canonical or + // fully reconciled). + require.NoError( + t, store.DeleteProvisionalConsumersForBatch( + ctx, consumerBatch, + ), + ) + got, err = store.ListProvisionalConsumersForBatch(ctx, consumerBatch) + require.NoError(t, err) + require.Empty(t, got) +} + +// TestBatchCanonicalityBackfillFromVTXOs verifies that backfill derives one +// canonicality record per distinct batch present in the VTXO store, with the +// CSV-relative expiry delta recovered from the stored absolute batch_expiry, +// the right provisional/finalized classification, and the dependent VTXO +// linked. It also verifies idempotency: a re-run creates nothing and does not +// clobber state the manager has since advanced. +func TestBatchCanonicalityBackfillFromVTXOs(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + vtxoStore, roundStore, baseDB := newVTXOStoreForTest(t) + + canonDB := NewTransactionExecutor( + baseDB, + func(tx *sql.Tx) BatchCanonicalityStore { + return baseDB.WithTx(tx) + }, + btclog.Disabled, + ) + canon := NewBatchCanonicalityPersistenceStore( + canonDB, clock.NewDefaultClock(), + ) + + // A round must exist to satisfy the VTXO foreign key. + roundID := testRoundIDDB("backfill-round") + testRound := createTestRound(t, roundID) + sigState := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + require.NoError(t, roundStore.CommitState(ctx, testRound, sigState)) + + // Two VTXOs in two distinct batches: + // idx 0: batch_expiry 1000, created_height 500 + // idx 1: batch_expiry 1100, created_height 510 + desc0 := createTestVTXODescriptor(t, roundID, 0) + desc1 := createTestVTXODescriptor(t, roundID, 1) + require.NoError(t, vtxoStore.SaveVTXO(ctx, desc0)) + require.NoError(t, vtxoStore.SaveVTXO(ctx, desc1)) + + // best height 505, finality depth 6: + // batch 0: depth = 505-500+1 = 6 >= 6 -> finalized + // batch 1: depth = 505-510+1 < 6 -> provisional + n, err := canon.BackfillFromVTXOs(ctx, 505, 6) + require.NoError(t, err) + require.Equal(t, 2, n) + + rec0, err := canon.GetBatch(ctx, desc0.CommitmentTxID) + require.NoError(t, err) + require.Equal(t, batchcanon.StateFinalized, rec0.State) + require.Equal(t, int32(500), rec0.ConfirmationHeight.UnwrapOr(0)) + require.Equal(t, int32(500), rec0.CSVExpiryDelta) + require.Equal(t, int32(1000), rec0.EffectiveExpiry().UnwrapOr(0)) + require.Equal(t, []wire.OutPoint{desc0.Outpoint}, rec0.DependentVTXOs) + + rec1, err := canon.GetBatch(ctx, desc1.CommitmentTxID) + require.NoError(t, err) + require.Equal(t, batchcanon.StateProvisional, rec1.State) + require.Equal(t, int32(590), rec1.CSVExpiryDelta) + + // Idempotency: advance one batch's state, re-run backfill, and verify + // it creates nothing new and leaves the advanced state untouched. + require.NoError( + t, canon.UpdateBatchState( + ctx, desc0.CommitmentTxID, batchcanon.StateReorgedOut, + ), + ) + n, err = canon.BackfillFromVTXOs(ctx, 505, 6) + require.NoError(t, err) + require.Equal(t, 0, n) + + rec0, err = canon.GetBatch(ctx, desc0.CommitmentTxID) + require.NoError(t, err) + require.Equal(t, batchcanon.StateReorgedOut, rec0.State) +} diff --git a/db/migrations.go b/db/migrations.go index e0ff1a51e..00c2d10f1 100644 --- a/db/migrations.go +++ b/db/migrations.go @@ -10,7 +10,7 @@ const ( // daemon. // // NOTE: This MUST be updated when a new migration is added. - LatestMigrationVersion uint = 13 + LatestMigrationVersion uint = 14 ) // MigrationTarget is a functional option that can be passed to applyMigrations diff --git a/db/sqlc/batch_canonicality.sql.go b/db/sqlc/batch_canonicality.sql.go new file mode 100644 index 000000000..dc2115b34 --- /dev/null +++ b/db/sqlc/batch_canonicality.sql.go @@ -0,0 +1,537 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: batch_canonicality.sql + +package sqlc + +import ( + "context" + "database/sql" +) + +const ClearBatchConfirmation = `-- name: ClearBatchConfirmation :exec +UPDATE batch_canonicality +SET confirmation_height = NULL, confirmation_block_hash = NULL, updated_at = $2 +WHERE batch_txid = $1 +` + +type ClearBatchConfirmationParams struct { + BatchTxid []byte + UpdatedAt int64 +} + +// ClearBatchConfirmation nulls the confirmation observation, reflecting that +// the confirming block left the best chain. It sets no terminal flag. +func (q *Queries) ClearBatchConfirmation(ctx context.Context, arg ClearBatchConfirmationParams) error { + _, err := q.db.ExecContext(ctx, ClearBatchConfirmation, arg.BatchTxid, arg.UpdatedAt) + return err +} + +const DeleteBatchConsumedInputs = `-- name: DeleteBatchConsumedInputs :exec +DELETE FROM batch_consumed_inputs WHERE batch_txid = $1 +` + +// DeleteBatchConsumedInputs removes every consumed-input row for a batch, +// used by the store's upsert to replace the set atomically. +func (q *Queries) DeleteBatchConsumedInputs(ctx context.Context, batchTxid []byte) error { + _, err := q.db.ExecContext(ctx, DeleteBatchConsumedInputs, batchTxid) + return err +} + +const DeleteBatchDependentVTXOs = `-- name: DeleteBatchDependentVTXOs :exec +DELETE FROM batch_dependent_vtxos WHERE batch_txid = $1 +` + +// DeleteBatchDependentVTXOs removes every dependent-VTXO row for a batch, +// used by the store's upsert to replace the set atomically. +func (q *Queries) DeleteBatchDependentVTXOs(ctx context.Context, batchTxid []byte) error { + _, err := q.db.ExecContext(ctx, DeleteBatchDependentVTXOs, batchTxid) + return err +} + +const DeleteProvisionalConsumersForBatch = `-- name: DeleteProvisionalConsumersForBatch :exec +DELETE FROM batch_provisional_consumers WHERE consumer_batch_txid = $1 +` + +// DeleteProvisionalConsumersForBatch removes every reverse-dependency edge +// for the given consumer batch. +func (q *Queries) DeleteProvisionalConsumersForBatch(ctx context.Context, consumerBatchTxid []byte) error { + _, err := q.db.ExecContext(ctx, DeleteProvisionalConsumersForBatch, consumerBatchTxid) + return err +} + +const FindBatchesByConsumedOutpoint = `-- name: FindBatchesByConsumedOutpoint :many +SELECT batch_txid +FROM batch_consumed_inputs +WHERE input_hash = $1 AND input_index = $2 +` + +type FindBatchesByConsumedOutpointParams struct { + InputHash []byte + InputIndex int32 +} + +// FindBatchesByConsumedOutpoint returns the txids of every batch that +// consumes the given outpoint. +func (q *Queries) FindBatchesByConsumedOutpoint(ctx context.Context, arg FindBatchesByConsumedOutpointParams) ([][]byte, error) { + rows, err := q.db.QueryContext(ctx, FindBatchesByConsumedOutpoint, arg.InputHash, arg.InputIndex) + if err != nil { + return nil, err + } + defer rows.Close() + var items [][]byte + for rows.Next() { + var batch_txid []byte + if err := rows.Scan(&batch_txid); err != nil { + return nil, err + } + items = append(items, batch_txid) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetBatchCanonicality = `-- name: GetBatchCanonicality :one +SELECT batch_txid, state, confirmation_height, confirmation_block_hash, + csv_expiry_delta, policy_state, created_at, updated_at, + confirmation_pk_script +FROM batch_canonicality +WHERE batch_txid = $1 +` + +// GetBatchCanonicality returns the canonicality row for a batch txid. The +// column order matches the table so sqlc reuses the BatchCanonicality model. +func (q *Queries) GetBatchCanonicality(ctx context.Context, batchTxid []byte) (BatchCanonicality, error) { + row := q.db.QueryRowContext(ctx, GetBatchCanonicality, batchTxid) + var i BatchCanonicality + err := row.Scan( + &i.BatchTxid, + &i.State, + &i.ConfirmationHeight, + &i.ConfirmationBlockHash, + &i.CsvExpiryDelta, + &i.PolicyState, + &i.CreatedAt, + &i.UpdatedAt, + &i.ConfirmationPkScript, + ) + return i, err +} + +const InsertBatchConsumedInput = `-- name: InsertBatchConsumedInput :exec +INSERT INTO batch_consumed_inputs ( + batch_txid, input_hash, input_index, input_pk_script +) +VALUES ($1, $2, $3, $4) +ON CONFLICT (batch_txid, input_hash, input_index) DO NOTHING +` + +type InsertBatchConsumedInputParams struct { + BatchTxid []byte + InputHash []byte + InputIndex int32 + InputPkScript []byte +} + +// InsertBatchConsumedInput records one input consumed by a batch, together +// with the pkScript of the spent output (needed to register the spend watch). +func (q *Queries) InsertBatchConsumedInput(ctx context.Context, arg InsertBatchConsumedInputParams) error { + _, err := q.db.ExecContext(ctx, InsertBatchConsumedInput, + arg.BatchTxid, + arg.InputHash, + arg.InputIndex, + arg.InputPkScript, + ) + return err +} + +const InsertBatchDependentVTXO = `-- name: InsertBatchDependentVTXO :exec +INSERT INTO batch_dependent_vtxos ( + batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index +) VALUES ($1, $2, $3) +ON CONFLICT (batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index) DO NOTHING +` + +type InsertBatchDependentVTXOParams struct { + BatchTxid []byte + VtxoOutpointHash []byte + VtxoOutpointIndex int32 +} + +// InsertBatchDependentVTXO records one VTXO outpoint anchored by a batch. +func (q *Queries) InsertBatchDependentVTXO(ctx context.Context, arg InsertBatchDependentVTXOParams) error { + _, err := q.db.ExecContext(ctx, InsertBatchDependentVTXO, arg.BatchTxid, arg.VtxoOutpointHash, arg.VtxoOutpointIndex) + return err +} + +const InsertProvisionalConsumer = `-- name: InsertProvisionalConsumer :exec +INSERT INTO batch_provisional_consumers ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid, created_at +) VALUES ($1, $2, $3, $4) +ON CONFLICT ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid +) DO NOTHING +` + +type InsertProvisionalConsumerParams struct { + ConsumedVtxoHash []byte + ConsumedVtxoIndex int32 + ConsumerBatchTxid []byte + CreatedAt int64 +} + +// InsertProvisionalConsumer records a reverse-dependency edge: consumed_vtxo +// is provisionally consumed by consumer_batch. Idempotent. +func (q *Queries) InsertProvisionalConsumer(ctx context.Context, arg InsertProvisionalConsumerParams) error { + _, err := q.db.ExecContext(ctx, InsertProvisionalConsumer, + arg.ConsumedVtxoHash, + arg.ConsumedVtxoIndex, + arg.ConsumerBatchTxid, + arg.CreatedAt, + ) + return err +} + +const ListBatchCanonicalityByState = `-- name: ListBatchCanonicalityByState :many +SELECT batch_txid, state, confirmation_height, confirmation_block_hash, + csv_expiry_delta, policy_state, created_at, updated_at, + confirmation_pk_script +FROM batch_canonicality +WHERE state = $1 +` + +// ListBatchCanonicalityByState returns every batch currently in the given +// state. +func (q *Queries) ListBatchCanonicalityByState(ctx context.Context, state int32) ([]BatchCanonicality, error) { + rows, err := q.db.QueryContext(ctx, ListBatchCanonicalityByState, state) + if err != nil { + return nil, err + } + defer rows.Close() + var items []BatchCanonicality + for rows.Next() { + var i BatchCanonicality + if err := rows.Scan( + &i.BatchTxid, + &i.State, + &i.ConfirmationHeight, + &i.ConfirmationBlockHash, + &i.CsvExpiryDelta, + &i.PolicyState, + &i.CreatedAt, + &i.UpdatedAt, + &i.ConfirmationPkScript, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListBatchConsumedInputs = `-- name: ListBatchConsumedInputs :many +SELECT input_hash, input_index, input_pk_script, conflicting, conflict_final +FROM batch_consumed_inputs +WHERE batch_txid = $1 +` + +type ListBatchConsumedInputsRow struct { + InputHash []byte + InputIndex int32 + InputPkScript []byte + Conflicting int32 + ConflictFinal int32 +} + +// ListBatchConsumedInputs returns the inputs a batch consumes, with the +// pkScript of each spent output and its persisted conflict observation. +func (q *Queries) ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) ([]ListBatchConsumedInputsRow, error) { + rows, err := q.db.QueryContext(ctx, ListBatchConsumedInputs, batchTxid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListBatchConsumedInputsRow + for rows.Next() { + var i ListBatchConsumedInputsRow + if err := rows.Scan( + &i.InputHash, + &i.InputIndex, + &i.InputPkScript, + &i.Conflicting, + &i.ConflictFinal, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListBatchDependentVTXOs = `-- name: ListBatchDependentVTXOs :many +SELECT vtxo_outpoint_hash, vtxo_outpoint_index +FROM batch_dependent_vtxos +WHERE batch_txid = $1 +` + +type ListBatchDependentVTXOsRow struct { + VtxoOutpointHash []byte + VtxoOutpointIndex int32 +} + +// ListBatchDependentVTXOs returns the VTXO outpoints a batch anchors. +func (q *Queries) ListBatchDependentVTXOs(ctx context.Context, batchTxid []byte) ([]ListBatchDependentVTXOsRow, error) { + rows, err := q.db.QueryContext(ctx, ListBatchDependentVTXOs, batchTxid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListBatchDependentVTXOsRow + for rows.Next() { + var i ListBatchDependentVTXOsRow + if err := rows.Scan(&i.VtxoOutpointHash, &i.VtxoOutpointIndex); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListProvisionalConsumersForBatch = `-- name: ListProvisionalConsumersForBatch :many +SELECT consumed_vtxo_hash, consumed_vtxo_index +FROM batch_provisional_consumers +WHERE consumer_batch_txid = $1 +` + +type ListProvisionalConsumersForBatchRow struct { + ConsumedVtxoHash []byte + ConsumedVtxoIndex int32 +} + +// ListProvisionalConsumersForBatch returns the VTXO outpoints that the given +// consumer batch provisionally consumes (the VTXOs to restore if the batch +// is invalidated). +func (q *Queries) ListProvisionalConsumersForBatch(ctx context.Context, consumerBatchTxid []byte) ([]ListProvisionalConsumersForBatchRow, error) { + rows, err := q.db.QueryContext(ctx, ListProvisionalConsumersForBatch, consumerBatchTxid) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListProvisionalConsumersForBatchRow + for rows.Next() { + var i ListProvisionalConsumersForBatchRow + if err := rows.Scan(&i.ConsumedVtxoHash, &i.ConsumedVtxoIndex); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const ListVTXOsForCanonicalityBackfill = `-- name: ListVTXOsForCanonicalityBackfill :many +SELECT outpoint_hash, outpoint_index, commitment_txid, batch_expiry, + created_height +FROM vtxos +WHERE length(commitment_txid) = 32 +` + +type ListVTXOsForCanonicalityBackfillRow struct { + OutpointHash []byte + OutpointIndex int32 + CommitmentTxid []byte + BatchExpiry int32 + CreatedHeight int32 +} + +// ListVTXOsForCanonicalityBackfill returns the columns needed to derive +// initial batch canonicality records from already-persisted VTXOs: each +// VTXO's outpoint, its commitment (batch) txid, the absolute batch expiry +// height, and the height at which it was created (confirmed). The backfill +// groups these by commitment txid in Go and recomputes the CSV-relative +// expiry delta as batch_expiry - created_height. +func (q *Queries) ListVTXOsForCanonicalityBackfill(ctx context.Context) ([]ListVTXOsForCanonicalityBackfillRow, error) { + rows, err := q.db.QueryContext(ctx, ListVTXOsForCanonicalityBackfill) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListVTXOsForCanonicalityBackfillRow + for rows.Next() { + var i ListVTXOsForCanonicalityBackfillRow + if err := rows.Scan( + &i.OutpointHash, + &i.OutpointIndex, + &i.CommitmentTxid, + &i.BatchExpiry, + &i.CreatedHeight, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const RecordBatchConfirmation = `-- name: RecordBatchConfirmation :exec +UPDATE batch_canonicality +SET confirmation_height = $2, confirmation_block_hash = $3, updated_at = $4 +WHERE batch_txid = $1 +` + +type RecordBatchConfirmationParams struct { + BatchTxid []byte + ConfirmationHeight sql.NullInt32 + ConfirmationBlockHash []byte + UpdatedAt int64 +} + +// RecordBatchConfirmation records the best-chain height and block hash at +// which the batch tx is confirmed. A later call at a different height (after +// a reorg) overwrites the observation so effective expiry tracks the new +// confirmation. +func (q *Queries) RecordBatchConfirmation(ctx context.Context, arg RecordBatchConfirmationParams) error { + _, err := q.db.ExecContext(ctx, RecordBatchConfirmation, + arg.BatchTxid, + arg.ConfirmationHeight, + arg.ConfirmationBlockHash, + arg.UpdatedAt, + ) + return err +} + +const RecordBatchInputConflict = `-- name: RecordBatchInputConflict :exec +UPDATE batch_consumed_inputs +SET conflicting = $4, conflict_final = $5 +WHERE batch_txid = $1 AND input_hash = $2 AND input_index = $3 +` + +type RecordBatchInputConflictParams struct { + BatchTxid []byte + InputHash []byte + InputIndex int32 + Conflicting int32 + ConflictFinal int32 +} + +// RecordBatchInputConflict persists the observed conflict status of one +// consumed input, so restart reconciliation can rebuild the per-input +// conflict view and not transiently downgrade a persisted conflict. +func (q *Queries) RecordBatchInputConflict(ctx context.Context, arg RecordBatchInputConflictParams) error { + _, err := q.db.ExecContext(ctx, RecordBatchInputConflict, + arg.BatchTxid, + arg.InputHash, + arg.InputIndex, + arg.Conflicting, + arg.ConflictFinal, + ) + return err +} + +const UpdateBatchCanonicalityState = `-- name: UpdateBatchCanonicalityState :exec +UPDATE batch_canonicality +SET state = $2, updated_at = $3 +WHERE batch_txid = $1 +` + +type UpdateBatchCanonicalityStateParams struct { + BatchTxid []byte + State int32 + UpdatedAt int64 +} + +// UpdateBatchCanonicalityState transitions a batch to a new state without +// touching its other fields. +func (q *Queries) UpdateBatchCanonicalityState(ctx context.Context, arg UpdateBatchCanonicalityStateParams) error { + _, err := q.db.ExecContext(ctx, UpdateBatchCanonicalityState, arg.BatchTxid, arg.State, arg.UpdatedAt) + return err +} + +const UpsertBatchCanonicality = `-- name: UpsertBatchCanonicality :exec + +INSERT INTO batch_canonicality ( + batch_txid, state, confirmation_height, confirmation_block_hash, + csv_expiry_delta, policy_state, created_at, updated_at, + confirmation_pk_script +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9 +) +ON CONFLICT (batch_txid) DO UPDATE SET + state = EXCLUDED.state, + confirmation_height = EXCLUDED.confirmation_height, + confirmation_block_hash = EXCLUDED.confirmation_block_hash, + csv_expiry_delta = EXCLUDED.csv_expiry_delta, + policy_state = EXCLUDED.policy_state, + confirmation_pk_script = EXCLUDED.confirmation_pk_script, + updated_at = EXCLUDED.updated_at +` + +type UpsertBatchCanonicalityParams struct { + BatchTxid []byte + State int32 + ConfirmationHeight sql.NullInt32 + ConfirmationBlockHash []byte + CsvExpiryDelta int32 + PolicyState int32 + CreatedAt int64 + UpdatedAt int64 + ConfirmationPkScript []byte +} + +// Batch canonicality queries. +// These maintain the durable, reorg-aware record of how each batch +// (commitment) transaction is faring against the best chain, the inputs it +// consumes, the VTXOs it anchors, and the reverse-dependency edges needed to +// restore a provisionally consumed VTXO. The queries are behavior-free; all +// interpretation lives in the batch canonicality manager. +// UpsertBatchCanonicality inserts or replaces the canonicality row for a +// batch. created_at is preserved on conflict; everything else is overwritten. +func (q *Queries) UpsertBatchCanonicality(ctx context.Context, arg UpsertBatchCanonicalityParams) error { + _, err := q.db.ExecContext(ctx, UpsertBatchCanonicality, + arg.BatchTxid, + arg.State, + arg.ConfirmationHeight, + arg.ConfirmationBlockHash, + arg.CsvExpiryDelta, + arg.PolicyState, + arg.CreatedAt, + arg.UpdatedAt, + arg.ConfirmationPkScript, + ) + return err +} diff --git a/db/sqlc/migrations/000014_batch_canonicality.down.sql b/db/sqlc/migrations/000014_batch_canonicality.down.sql new file mode 100644 index 000000000..c21529715 --- /dev/null +++ b/db/sqlc/migrations/000014_batch_canonicality.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS batch_provisional_consumers; +DROP TABLE IF EXISTS batch_dependent_vtxos; +DROP TABLE IF EXISTS batch_consumed_inputs; +DROP TABLE IF EXISTS batch_canonicality; diff --git a/db/sqlc/migrations/000014_batch_canonicality.up.sql b/db/sqlc/migrations/000014_batch_canonicality.up.sql new file mode 100644 index 000000000..60776df69 --- /dev/null +++ b/db/sqlc/migrations/000014_batch_canonicality.up.sql @@ -0,0 +1,145 @@ +-- batch_canonicality is the durable, reorg-aware record of how each batch +-- (commitment) transaction is faring against the best chain. It is keyed by +-- the batch txid: identity is by txid, never by (txid, block hash), so a +-- reorg that re-mines the same batch in a different block is the same row. +-- +-- Effective (absolute) expiry is intentionally NOT stored. The row keeps the +-- CSV-relative delta plus the current confirmation height; the effective +-- expiry is derived as confirmation_height + csv_expiry_delta and is therefore +-- recomputed on every (re)confirmation rather than frozen at first +-- confirmation. Expiry is never persisted as a one-way terminal fact. +CREATE TABLE IF NOT EXISTS batch_canonicality ( + -- batch_txid is the 32-byte commitment transaction id and primary key. + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + + -- state is the interpreted canonicality state (batchcanon.State): + -- 0 = unseen + -- 1 = provisional + -- 2 = finalized + -- 3 = reorged_out + -- 4 = conflict_provisional + -- 5 = conflict_finalized + -- Values are append-only and must never be renumbered. + state INTEGER NOT NULL DEFAULT 0, + + -- confirmation_height is the best-chain height at which the batch tx is + -- currently observed confirmed. NULL means the batch is not currently + -- confirmed (unseen or reorged out). A reorg clears it; a reconfirmation + -- sets it to the new height. + confirmation_height INTEGER, + + -- confirmation_block_hash is the hash of the block currently confirming + -- the batch tx. It is an observation attribute only and is NOT part of + -- the batch identity. NULL when not currently confirmed. + confirmation_block_hash BLOB + CHECK (confirmation_block_hash IS NULL + OR length(confirmation_block_hash) = 32), + + -- csv_expiry_delta is the batch's CSV-relative expiry timeout, in blocks. + -- Combined with confirmation_height it yields the effective expiry. + csv_expiry_delta INTEGER NOT NULL, + + -- policy_state is a reserved policy classification slot + -- (batchcanon.PolicyState); 0 = default. The data-model layer persists + -- and round-trips it but assigns no business meaning. + policy_state INTEGER NOT NULL DEFAULT 0, + + -- created_at / updated_at are unix timestamps. + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + + -- confirmation_pk_script is the scriptPubKey of the confirmed batch + -- output, needed to re-register the reorg-aware confirmation watch after + -- a restart: light-client backends (neutrino, Esplora) filter conf + -- watches by pkScript, so a txid alone is insufficient. NULL on rows + -- created by the descriptor backfill (no batch-output pkScript to + -- derive); those fall back to a txid-only re-registration. Kept last so + -- the generated model column order matches the query/store code. + confirmation_pk_script BLOB, + + PRIMARY KEY (batch_txid) +); + +-- Index supporting "find every batch in a given state" (e.g. all provisional +-- batches the manager must re-check for finality). +CREATE INDEX IF NOT EXISTS idx_batch_canonicality_state + ON batch_canonicality(state); + +-- batch_consumed_inputs records the outpoints each batch tx spends, so the +-- canonicality manager can watch every consumed input for a conflicting +-- spend. +CREATE TABLE IF NOT EXISTS batch_consumed_inputs ( + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + input_hash BLOB NOT NULL CHECK (length(input_hash) = 32), + input_index INTEGER NOT NULL CHECK (input_index >= 0), + + -- input_pk_script is the scriptPubKey of the spent output. It is + -- required to register the reorg-aware spend watch: lnd's spend + -- notifier filters by output script, so a bare outpoint is rejected + -- ("an output script must be provided"). Persisting it lets restart + -- reconciliation re-arm every watch. NULL only on legacy rows that + -- predate script tracking. + input_pk_script BLOB, + + -- conflicting / conflict_final persist the last observed conflict + -- status of this input (a spend by a tx other than the batch itself), + -- 0 = false, 1 = true. They let restart reconciliation rebuild the + -- per-input conflict view: without them, a reconciled conflict batch + -- whose confirmation is re-observed before its conflicting spend is + -- re-observed would transiently derive back to (non-conflict) + -- provisional and briefly admit the coin. Default 0: a freshly recorded + -- input has seen no conflict yet. + conflicting INTEGER NOT NULL DEFAULT 0, + conflict_final INTEGER NOT NULL DEFAULT 0, + + PRIMARY KEY (batch_txid, input_hash, input_index), + FOREIGN KEY (batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + +-- Index supporting input-conflict detection: given an outpoint, find every +-- batch that consumes it (two batches consuming the same outpoint conflict). +CREATE INDEX IF NOT EXISTS idx_batch_consumed_inputs_outpoint + ON batch_consumed_inputs(input_hash, input_index); + +-- batch_dependent_vtxos records the VTXO outpoints anchored by each batch. +-- Their derived availability follows the batch's canonicality. There is +-- intentionally no FK to vtxos: a batch may anchor VTXOs the local wallet +-- does not own or persist. +CREATE TABLE IF NOT EXISTS batch_dependent_vtxos ( + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + vtxo_outpoint_hash BLOB NOT NULL CHECK (length(vtxo_outpoint_hash) = 32), + vtxo_outpoint_index INTEGER NOT NULL CHECK (vtxo_outpoint_index >= 0), + + PRIMARY KEY (batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index), + FOREIGN KEY (batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + +-- Index supporting "given a VTXO outpoint, which batch anchors it". +CREATE INDEX IF NOT EXISTS idx_batch_dependent_vtxos_vtxo + ON batch_dependent_vtxos(vtxo_outpoint_hash, vtxo_outpoint_index); + +-- batch_provisional_consumers is the reverse-dependency table that lets a +-- provisionally consumed VTXO be restored if its consumer batch never becomes +-- canonical (e.g. a round-2 forfeit whose commitment tx is reorged out must +-- restore the round-1 VTXO it consumed). Each row says "consumed_vtxo is +-- provisionally consumed by consumer_batch". +CREATE TABLE IF NOT EXISTS batch_provisional_consumers ( + consumed_vtxo_hash BLOB NOT NULL CHECK (length(consumed_vtxo_hash) = 32), + consumed_vtxo_index INTEGER NOT NULL CHECK (consumed_vtxo_index >= 0), + consumer_batch_txid BLOB NOT NULL + CHECK (length(consumer_batch_txid) = 32), + created_at BIGINT NOT NULL, + + PRIMARY KEY ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid + ), + FOREIGN KEY (consumer_batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + +-- Index supporting "given an invalidated consumer batch, list the VTXOs to +-- restore". +CREATE INDEX IF NOT EXISTS idx_batch_prov_consumers_batch + ON batch_provisional_consumers(consumer_batch_txid); diff --git a/db/sqlc/models.go b/db/sqlc/models.go index 4f1d4557c..37c875778 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -61,6 +61,40 @@ type ActivityStatus struct { Name string } +type BatchCanonicality struct { + BatchTxid []byte + State int32 + ConfirmationHeight sql.NullInt32 + ConfirmationBlockHash []byte + CsvExpiryDelta int32 + PolicyState int32 + CreatedAt int64 + UpdatedAt int64 + ConfirmationPkScript []byte +} + +type BatchConsumedInput struct { + BatchTxid []byte + InputHash []byte + InputIndex int32 + InputPkScript []byte + Conflicting int32 + ConflictFinal int32 +} + +type BatchDependentVtxo struct { + BatchTxid []byte + VtxoOutpointHash []byte + VtxoOutpointIndex int32 +} + +type BatchProvisionalConsumer struct { + ConsumedVtxoHash []byte + ConsumedVtxoIndex int32 + ConsumerBatchTxid []byte + CreatedAt int64 +} + type BoardingAddress struct { PkScript []byte AddressString string diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 90b6d003f..f0278f2c2 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -15,6 +15,9 @@ type Querier interface { // contiguous). Callers use it as the resumable-subscribe cursor for the update. AppendActivityEvent(ctx context.Context, arg AppendActivityEventParams) (int64, error) CancelVHTLCRecoveryJob(ctx context.Context, arg CancelVHTLCRecoveryJobParams) (int64, error) + // ClearBatchConfirmation nulls the confirmation observation, reflecting that + // the confirming block left the best chain. It sets no terminal flag. + ClearBatchConfirmation(ctx context.Context, arg ClearBatchConfirmationParams) error ClearPendingIntentAnchorByOutpoint(ctx context.Context, arg ClearPendingIntentAnchorByOutpointParams) error CompleteVHTLCRecoveryJob(ctx context.Context, arg CompleteVHTLCRecoveryJobParams) (int64, error) // CountActivityEntriesByStatus returns the number of current-state rows in the @@ -33,6 +36,12 @@ type Querier interface { // CountVTXOsByStatus returns the count of VTXOs with the specified status. CountVTXOsByStatus(ctx context.Context, status int32) (int64, error) CountWalletUTXOLog(ctx context.Context) (int64, error) + // DeleteBatchConsumedInputs removes every consumed-input row for a batch, + // used by the store's upsert to replace the set atomically. + DeleteBatchConsumedInputs(ctx context.Context, batchTxid []byte) error + // DeleteBatchDependentVTXOs removes every dependent-VTXO row for a batch, + // used by the store's upsert to replace the set atomically. + DeleteBatchDependentVTXOs(ctx context.Context, batchTxid []byte) error DeleteClientTreeTxids(ctx context.Context, arg DeleteClientTreeTxidsParams) error DeleteOORPackageCheckpoints(ctx context.Context, sessionID []byte) error DeleteOrphanedPendingBoardIntents(ctx context.Context) error @@ -53,6 +62,9 @@ type Querier interface { DeletePendingIntentsByKind(ctx context.Context, kind string) error DeletePendingSendIntentByID(ctx context.Context, intentID []byte) error DeletePendingSendIntentsAll(ctx context.Context) error + // DeleteProvisionalConsumersForBatch removes every reverse-dependency edge + // for the given consumer batch. + DeleteProvisionalConsumersForBatch(ctx context.Context, consumerBatchTxid []byte) error // DeleteSpendingReservation removes the reservation for one outpoint. Called // when the VTXO leaves SpendingState (released or completed). DeleteSpendingReservation(ctx context.Context, arg DeleteSpendingReservationParams) error @@ -66,8 +78,14 @@ type Querier interface { EscalateVHTLCRecoveryJob(ctx context.Context, arg EscalateVHTLCRecoveryJobParams) (int64, error) FailVHTLCRecoveryJob(ctx context.Context, arg FailVHTLCRecoveryJobParams) (int64, error) FinalizeRound(ctx context.Context, arg FinalizeRoundParams) error + // FindBatchesByConsumedOutpoint returns the txids of every batch that + // consumes the given outpoint. + FindBatchesByConsumedOutpoint(ctx context.Context, arg FindBatchesByConsumedOutpointParams) ([][]byte, error) // GetActivityEntry returns one entry by its canonical id. GetActivityEntry(ctx context.Context, canonicalID string) (ActivityEntry, error) + // GetBatchCanonicality returns the canonicality row for a batch txid. The + // column order matches the table so sqlc reuses the BatchCanonicality model. + GetBatchCanonicality(ctx context.Context, batchTxid []byte) (BatchCanonicality, error) GetBoardingAddress(ctx context.Context, pkScript []byte) (BoardingAddress, error) GetBoardingIntent(ctx context.Context, arg GetBoardingIntentParams) (BoardingIntent, error) GetBoardingSweep(ctx context.Context, txid []byte) (BoardingSweep, error) @@ -114,6 +132,11 @@ type Querier interface { // GetVTXOReplacement retrieves the replacement VTXO outpoint for a forfeited // VTXO. Returns NULL if not forfeited or no replacement recorded. GetVTXOReplacement(ctx context.Context, arg GetVTXOReplacementParams) (GetVTXOReplacementRow, error) + // InsertBatchConsumedInput records one input consumed by a batch, together + // with the pkScript of the spent output (needed to register the spend watch). + InsertBatchConsumedInput(ctx context.Context, arg InsertBatchConsumedInputParams) error + // InsertBatchDependentVTXO records one VTXO outpoint anchored by a batch. + InsertBatchDependentVTXO(ctx context.Context, arg InsertBatchDependentVTXOParams) error // Boarding address queries. InsertBoardingAddress(ctx context.Context, arg InsertBoardingAddressParams) error // Boarding intent queries. @@ -144,6 +167,9 @@ type Querier interface { InsertExitFundingAddress(ctx context.Context, arg InsertExitFundingAddressParams) error InsertMacaroonRootKey(ctx context.Context, arg InsertMacaroonRootKeyParams) error InsertOORPackageCheckpoint(ctx context.Context, arg InsertOORPackageCheckpointParams) error + // InsertProvisionalConsumer records a reverse-dependency edge: consumed_vtxo + // is provisionally consumed by consumer_batch. Idempotent. + InsertProvisionalConsumer(ctx context.Context, arg InsertProvisionalConsumerParams) error // Round queries. InsertRound(ctx context.Context, arg InsertRoundParams) error // Round boarding intents queries. @@ -179,6 +205,14 @@ type Querier interface { ListAllCreditOperations(ctx context.Context) ([]CreditOperation, error) ListAllOORSessionRegistry(ctx context.Context) ([]OorSessionRegistry, error) ListAllVTXOs(ctx context.Context) ([]Vtxo, error) + // ListBatchCanonicalityByState returns every batch currently in the given + // state. + ListBatchCanonicalityByState(ctx context.Context, state int32) ([]BatchCanonicality, error) + // ListBatchConsumedInputs returns the inputs a batch consumes, with the + // pkScript of each spent output and its persisted conflict observation. + ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) ([]ListBatchConsumedInputsRow, error) + // ListBatchDependentVTXOs returns the VTXO outpoints a batch anchors. + ListBatchDependentVTXOs(ctx context.Context, batchTxid []byte) ([]ListBatchDependentVTXOsRow, error) ListBoardingIntentOutpoints(ctx context.Context) ([]ListBoardingIntentOutpointsRow, error) ListBoardingIntentsByConfHeight(ctx context.Context, confHeight int32) ([]BoardingIntent, error) ListBoardingIntentsByPkScript(ctx context.Context, pkScript []byte) ([]BoardingIntent, error) @@ -238,6 +272,10 @@ type Querier interface { // Only status = 'pending' rows replay; a 'failed' intent is terminally // retired and must not be re-submitted on restart. ListPendingSendIntents(ctx context.Context) ([]ListPendingSendIntentsRow, error) + // ListProvisionalConsumersForBatch returns the VTXO outpoints that the given + // consumer batch provisionally consumes (the VTXOs to restore if the batch + // is invalidated). + ListProvisionalConsumersForBatch(ctx context.Context, consumerBatchTxid []byte) ([]ListProvisionalConsumersForBatchRow, error) ListRoundsByStatus(ctx context.Context, status string) ([]Round, error) // ListRoundsPaginated returns rounds ordered by round_id with cursor- // based pagination. When cursor is empty, returns from the beginning. @@ -282,6 +320,13 @@ type Querier interface { // unknown (all non-forfeited VTXOs, and forfeited ones whose round row is // absent), so consumers must treat them as optional. ListVTXOsByStatus(ctx context.Context, status int32) ([]ListVTXOsByStatusRow, error) + // ListVTXOsForCanonicalityBackfill returns the columns needed to derive + // initial batch canonicality records from already-persisted VTXOs: each + // VTXO's outpoint, its commitment (batch) txid, the absolute batch expiry + // height, and the height at which it was created (confirmed). The backfill + // groups these by commitment txid in Go and recomputes the CSV-relative + // expiry delta as batch_expiry - created_height. + ListVTXOsForCanonicalityBackfill(ctx context.Context) ([]ListVTXOsForCanonicalityBackfillRow, error) ListWalletUTXOLog(ctx context.Context, arg ListWalletUTXOLogParams) ([]WalletUtxoLog, error) ListWalletUTXOLogByBlock(ctx context.Context, blockHeight int32) ([]WalletUtxoLog, error) ListWalletUTXOLogByClassification(ctx context.Context, arg ListWalletUTXOLogByClassificationParams) ([]WalletUtxoLog, error) @@ -326,8 +371,20 @@ type Querier interface { // PullActivityEvents returns transition rows strictly after the cursor in // event_seq order, the resumable-subscribe replay primitive. PullActivityEvents(ctx context.Context, arg PullActivityEventsParams) ([]ActivityEvent, error) + // RecordBatchConfirmation records the best-chain height and block hash at + // which the batch tx is confirmed. A later call at a different height (after + // a reorg) overwrites the observation so effective expiry tracks the new + // confirmation. + RecordBatchConfirmation(ctx context.Context, arg RecordBatchConfirmationParams) error + // RecordBatchInputConflict persists the observed conflict status of one + // consumed input, so restart reconciliation can rebuild the per-input + // conflict view and not transiently downgrade a persisted conflict. + RecordBatchInputConflict(ctx context.Context, arg RecordBatchInputConflictParams) error SumBoardingIntentAmountsByStatus(ctx context.Context, status string) (interface{}, error) SumUnspentVTXOAmounts(ctx context.Context) (interface{}, error) + // UpdateBatchCanonicalityState transitions a batch to a new state without + // touching its other fields. + UpdateBatchCanonicalityState(ctx context.Context, arg UpdateBatchCanonicalityStateParams) error UpdateBoardingIntentStatus(ctx context.Context, arg UpdateBoardingIntentStatusParams) error UpdateRoundBoardingIntentSignature(ctx context.Context, arg UpdateRoundBoardingIntentSignatureParams) error UpdateRoundStatus(ctx context.Context, arg UpdateRoundStatusParams) error @@ -344,6 +401,15 @@ type Querier interface { // correlation handles are COALESCEd so an early projection that does not yet // know a txid never clobbers one a later projection already recorded. UpsertActivityEntry(ctx context.Context, arg UpsertActivityEntryParams) error + // Batch canonicality queries. + // These maintain the durable, reorg-aware record of how each batch + // (commitment) transaction is faring against the best chain, the inputs it + // consumes, the VTXOs it anchors, and the reverse-dependency edges needed to + // restore a provisionally consumed VTXO. The queries are behavior-free; all + // interpretation lives in the batch canonicality manager. + // UpsertBatchCanonicality inserts or replaces the canonicality row for a + // batch. created_at is preserved on conflict; everything else is overwritten. + UpsertBatchCanonicality(ctx context.Context, arg UpsertBatchCanonicalityParams) error UpsertChainInfo(ctx context.Context, arg UpsertChainInfoParams) error // Credit operations control-plane queries. UpsertCreditOperation(ctx context.Context, arg UpsertCreditOperationParams) error diff --git a/db/sqlc/queries/batch_canonicality.sql b/db/sqlc/queries/batch_canonicality.sql new file mode 100644 index 000000000..5004b3ec7 --- /dev/null +++ b/db/sqlc/queries/batch_canonicality.sql @@ -0,0 +1,155 @@ +-- Batch canonicality queries. +-- These maintain the durable, reorg-aware record of how each batch +-- (commitment) transaction is faring against the best chain, the inputs it +-- consumes, the VTXOs it anchors, and the reverse-dependency edges needed to +-- restore a provisionally consumed VTXO. The queries are behavior-free; all +-- interpretation lives in the batch canonicality manager. + +-- name: UpsertBatchCanonicality :exec +-- UpsertBatchCanonicality inserts or replaces the canonicality row for a +-- batch. created_at is preserved on conflict; everything else is overwritten. +INSERT INTO batch_canonicality ( + batch_txid, state, confirmation_height, confirmation_block_hash, + csv_expiry_delta, policy_state, created_at, updated_at, + confirmation_pk_script +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9 +) +ON CONFLICT (batch_txid) DO UPDATE SET + state = EXCLUDED.state, + confirmation_height = EXCLUDED.confirmation_height, + confirmation_block_hash = EXCLUDED.confirmation_block_hash, + csv_expiry_delta = EXCLUDED.csv_expiry_delta, + policy_state = EXCLUDED.policy_state, + confirmation_pk_script = EXCLUDED.confirmation_pk_script, + updated_at = EXCLUDED.updated_at; + +-- name: GetBatchCanonicality :one +-- GetBatchCanonicality returns the canonicality row for a batch txid. The +-- column order matches the table so sqlc reuses the BatchCanonicality model. +SELECT batch_txid, state, confirmation_height, confirmation_block_hash, + csv_expiry_delta, policy_state, created_at, updated_at, + confirmation_pk_script +FROM batch_canonicality +WHERE batch_txid = $1; + +-- name: ListBatchCanonicalityByState :many +-- ListBatchCanonicalityByState returns every batch currently in the given +-- state. +SELECT batch_txid, state, confirmation_height, confirmation_block_hash, + csv_expiry_delta, policy_state, created_at, updated_at, + confirmation_pk_script +FROM batch_canonicality +WHERE state = $1; + +-- name: UpdateBatchCanonicalityState :exec +-- UpdateBatchCanonicalityState transitions a batch to a new state without +-- touching its other fields. +UPDATE batch_canonicality +SET state = $2, updated_at = $3 +WHERE batch_txid = $1; + +-- name: RecordBatchConfirmation :exec +-- RecordBatchConfirmation records the best-chain height and block hash at +-- which the batch tx is confirmed. A later call at a different height (after +-- a reorg) overwrites the observation so effective expiry tracks the new +-- confirmation. +UPDATE batch_canonicality +SET confirmation_height = $2, confirmation_block_hash = $3, updated_at = $4 +WHERE batch_txid = $1; + +-- name: ClearBatchConfirmation :exec +-- ClearBatchConfirmation nulls the confirmation observation, reflecting that +-- the confirming block left the best chain. It sets no terminal flag. +UPDATE batch_canonicality +SET confirmation_height = NULL, confirmation_block_hash = NULL, updated_at = $2 +WHERE batch_txid = $1; + +-- name: InsertBatchConsumedInput :exec +-- InsertBatchConsumedInput records one input consumed by a batch, together +-- with the pkScript of the spent output (needed to register the spend watch). +INSERT INTO batch_consumed_inputs ( + batch_txid, input_hash, input_index, input_pk_script +) +VALUES ($1, $2, $3, $4) +ON CONFLICT (batch_txid, input_hash, input_index) DO NOTHING; + +-- name: DeleteBatchConsumedInputs :exec +-- DeleteBatchConsumedInputs removes every consumed-input row for a batch, +-- used by the store's upsert to replace the set atomically. +DELETE FROM batch_consumed_inputs WHERE batch_txid = $1; + +-- name: ListBatchConsumedInputs :many +-- ListBatchConsumedInputs returns the inputs a batch consumes, with the +-- pkScript of each spent output and its persisted conflict observation. +SELECT input_hash, input_index, input_pk_script, conflicting, conflict_final +FROM batch_consumed_inputs +WHERE batch_txid = $1; + +-- name: RecordBatchInputConflict :exec +-- RecordBatchInputConflict persists the observed conflict status of one +-- consumed input, so restart reconciliation can rebuild the per-input +-- conflict view and not transiently downgrade a persisted conflict. +UPDATE batch_consumed_inputs +SET conflicting = $4, conflict_final = $5 +WHERE batch_txid = $1 AND input_hash = $2 AND input_index = $3; + +-- name: FindBatchesByConsumedOutpoint :many +-- FindBatchesByConsumedOutpoint returns the txids of every batch that +-- consumes the given outpoint. +SELECT batch_txid +FROM batch_consumed_inputs +WHERE input_hash = $1 AND input_index = $2; + +-- name: InsertBatchDependentVTXO :exec +-- InsertBatchDependentVTXO records one VTXO outpoint anchored by a batch. +INSERT INTO batch_dependent_vtxos ( + batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index +) VALUES ($1, $2, $3) +ON CONFLICT (batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index) DO NOTHING; + +-- name: DeleteBatchDependentVTXOs :exec +-- DeleteBatchDependentVTXOs removes every dependent-VTXO row for a batch, +-- used by the store's upsert to replace the set atomically. +DELETE FROM batch_dependent_vtxos WHERE batch_txid = $1; + +-- name: ListBatchDependentVTXOs :many +-- ListBatchDependentVTXOs returns the VTXO outpoints a batch anchors. +SELECT vtxo_outpoint_hash, vtxo_outpoint_index +FROM batch_dependent_vtxos +WHERE batch_txid = $1; + +-- name: InsertProvisionalConsumer :exec +-- InsertProvisionalConsumer records a reverse-dependency edge: consumed_vtxo +-- is provisionally consumed by consumer_batch. Idempotent. +INSERT INTO batch_provisional_consumers ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid, created_at +) VALUES ($1, $2, $3, $4) +ON CONFLICT ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid +) DO NOTHING; + +-- name: ListProvisionalConsumersForBatch :many +-- ListProvisionalConsumersForBatch returns the VTXO outpoints that the given +-- consumer batch provisionally consumes (the VTXOs to restore if the batch +-- is invalidated). +SELECT consumed_vtxo_hash, consumed_vtxo_index +FROM batch_provisional_consumers +WHERE consumer_batch_txid = $1; + +-- name: DeleteProvisionalConsumersForBatch :exec +-- DeleteProvisionalConsumersForBatch removes every reverse-dependency edge +-- for the given consumer batch. +DELETE FROM batch_provisional_consumers WHERE consumer_batch_txid = $1; + +-- name: ListVTXOsForCanonicalityBackfill :many +-- ListVTXOsForCanonicalityBackfill returns the columns needed to derive +-- initial batch canonicality records from already-persisted VTXOs: each +-- VTXO's outpoint, its commitment (batch) txid, the absolute batch expiry +-- height, and the height at which it was created (confirmed). The backfill +-- groups these by commitment txid in Go and recomputes the CSV-relative +-- expiry delta as batch_expiry - created_height. +SELECT outpoint_hash, outpoint_index, commitment_txid, batch_expiry, + created_height +FROM vtxos +WHERE length(commitment_txid) = 32; diff --git a/db/sqlc/schemas/generated_schema.sql b/db/sqlc/schemas/generated_schema.sql index a9eaedad3..19220a06a 100644 --- a/db/sqlc/schemas/generated_schema.sql +++ b/db/sqlc/schemas/generated_schema.sql @@ -98,6 +98,111 @@ CREATE TABLE ask_results ( expires_at BIGINT NOT NULL ); +CREATE TABLE batch_canonicality ( + -- batch_txid is the 32-byte commitment transaction id and primary key. + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + + -- state is the interpreted canonicality state (batchcanon.State): + -- 0 = unseen + -- 1 = provisional + -- 2 = finalized + -- 3 = reorged_out + -- 4 = conflict_provisional + -- 5 = conflict_finalized + -- Values are append-only and must never be renumbered. + state INTEGER NOT NULL DEFAULT 0, + + -- confirmation_height is the best-chain height at which the batch tx is + -- currently observed confirmed. NULL means the batch is not currently + -- confirmed (unseen or reorged out). A reorg clears it; a reconfirmation + -- sets it to the new height. + confirmation_height INTEGER, + + -- confirmation_block_hash is the hash of the block currently confirming + -- the batch tx. It is an observation attribute only and is NOT part of + -- the batch identity. NULL when not currently confirmed. + confirmation_block_hash BLOB + CHECK (confirmation_block_hash IS NULL + OR length(confirmation_block_hash) = 32), + + -- csv_expiry_delta is the batch's CSV-relative expiry timeout, in blocks. + -- Combined with confirmation_height it yields the effective expiry. + csv_expiry_delta INTEGER NOT NULL, + + -- policy_state is a reserved policy classification slot + -- (batchcanon.PolicyState); 0 = default. The data-model layer persists + -- and round-trips it but assigns no business meaning. + policy_state INTEGER NOT NULL DEFAULT 0, + + -- created_at / updated_at are unix timestamps. + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + + -- confirmation_pk_script is the scriptPubKey of the confirmed batch + -- output, needed to re-register the reorg-aware confirmation watch after + -- a restart: light-client backends (neutrino, Esplora) filter conf + -- watches by pkScript, so a txid alone is insufficient. NULL on rows + -- created by the descriptor backfill (no batch-output pkScript to + -- derive); those fall back to a txid-only re-registration. Kept last so + -- the generated model column order matches the query/store code. + confirmation_pk_script BLOB, + + PRIMARY KEY (batch_txid) +); + +CREATE TABLE batch_consumed_inputs ( + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + input_hash BLOB NOT NULL CHECK (length(input_hash) = 32), + input_index INTEGER NOT NULL CHECK (input_index >= 0), + + -- input_pk_script is the scriptPubKey of the spent output. It is + -- required to register the reorg-aware spend watch: lnd's spend + -- notifier filters by output script, so a bare outpoint is rejected + -- ("an output script must be provided"). Persisting it lets restart + -- reconciliation re-arm every watch. NULL only on legacy rows that + -- predate script tracking. + input_pk_script BLOB, + + -- conflicting / conflict_final persist the last observed conflict + -- status of this input (a spend by a tx other than the batch itself), + -- 0 = false, 1 = true. They let restart reconciliation rebuild the + -- per-input conflict view: without them, a reconciled conflict batch + -- whose confirmation is re-observed before its conflicting spend is + -- re-observed would transiently derive back to (non-conflict) + -- provisional and briefly admit the coin. Default 0: a freshly recorded + -- input has seen no conflict yet. + conflicting INTEGER NOT NULL DEFAULT 0, + conflict_final INTEGER NOT NULL DEFAULT 0, + + PRIMARY KEY (batch_txid, input_hash, input_index), + FOREIGN KEY (batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + +CREATE TABLE batch_dependent_vtxos ( + batch_txid BLOB NOT NULL CHECK (length(batch_txid) = 32), + vtxo_outpoint_hash BLOB NOT NULL CHECK (length(vtxo_outpoint_hash) = 32), + vtxo_outpoint_index INTEGER NOT NULL CHECK (vtxo_outpoint_index >= 0), + + PRIMARY KEY (batch_txid, vtxo_outpoint_hash, vtxo_outpoint_index), + FOREIGN KEY (batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + +CREATE TABLE batch_provisional_consumers ( + consumed_vtxo_hash BLOB NOT NULL CHECK (length(consumed_vtxo_hash) = 32), + consumed_vtxo_index INTEGER NOT NULL CHECK (consumed_vtxo_index >= 0), + consumer_batch_txid BLOB NOT NULL + CHECK (length(consumer_batch_txid) = 32), + created_at BIGINT NOT NULL, + + PRIMARY KEY ( + consumed_vtxo_hash, consumed_vtxo_index, consumer_batch_txid + ), + FOREIGN KEY (consumer_batch_txid) + REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE +); + CREATE TABLE boarding_addresses ( -- pk_script is the raw output script (P2TR script) and serves as the -- primary key since it uniquely identifies an address. @@ -429,6 +534,18 @@ CREATE INDEX idx_activity_events_canonical CREATE INDEX idx_ask_results_expires ON ask_results(expires_at); +CREATE INDEX idx_batch_canonicality_state + ON batch_canonicality(state); + +CREATE INDEX idx_batch_consumed_inputs_outpoint + ON batch_consumed_inputs(input_hash, input_index); + +CREATE INDEX idx_batch_dependent_vtxos_vtxo + ON batch_dependent_vtxos(vtxo_outpoint_hash, vtxo_outpoint_index); + +CREATE INDEX idx_batch_prov_consumers_batch + ON batch_provisional_consumers(consumer_batch_txid); + CREATE INDEX idx_boarding_addresses_creation_time ON boarding_addresses(creation_time DESC); diff --git a/db/store.go b/db/store.go index 1524da573..21143400b 100644 --- a/db/store.go +++ b/db/store.go @@ -347,6 +347,29 @@ func (s *Store) NewActivityStore(clk clock.Clock) *ActivityPersistenceStore { return NewActivityPersistenceStore(activityDB, clk) } +// NewBatchCanonicalityStore builds the batch canonicality persistence store +// with transactional query execution. +// +// The store holds the durable, reorg-aware record of how each batch +// (commitment) tx is faring against the best chain, plus the reverse +// dependencies needed to restore a provisionally consumed VTXO. It is +// behavior-free; interpretation lives in the batch canonicality manager. +func (s *Store) NewBatchCanonicalityStore( + clk clock.Clock) *BatchCanonicalityPersistenceStore { + + baseDB := s.BaseDB() + + canonDB := NewTransactionExecutor( + baseDB, + func(tx *sql.Tx) BatchCanonicalityStore { + return s.queries.WithTx(tx) + }, + s.log, + ) + + return NewBatchCanonicalityPersistenceStore(canonDB, clk) +} + // NewUnilateralExitStore builds the unilateral-exit persistence store with // transactional query execution. func (s *Store) NewUnilateralExitStore( diff --git a/harness/harness.go b/harness/harness.go index 5bdf2a970..fa799e0ca 100644 --- a/harness/harness.go +++ b/harness/harness.go @@ -9,6 +9,7 @@ import ( "bytes" "context" crand "crypto/rand" + "encoding/hex" "encoding/json" "flag" "fmt" @@ -26,8 +27,13 @@ import ( "testing" "time" + btcaddr "github.com/btcsuite/btcd/address/v2" "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/taproot-assets/taprpc" "github.com/lightninglabs/wavelength/chain" @@ -1623,10 +1629,47 @@ func (h *Harness) ReorgDepth(depth int) ReorgResult { // Reorg invalidates the current tip's last depth blocks, mines newBlocks on // top of the fork point, and waits for the primary LND node to resync. The -// harness must be fully started before calling Reorg. +// harness must be fully started before calling Reorg. The replacement branch +// is mined with generatetoaddress, which sweeps the mempool, so a transaction +// from the disconnected branch re-confirms on the first replacement block. Use +// ReorgExcludingMempool when the disconnected transaction must stay off-chain. func (h *Harness) Reorg(depth, newBlocks int) ReorgResult { h.T.Helper() + return h.reorgWith(depth, newBlocks, "", h.Generate) +} + +// ReorgExcludingMempool performs a reorg like Reorg, but mines the replacement +// branch with EMPTY blocks (via the generateblock RPC) so transactions from the +// disconnected branch are NOT automatically re-confirmed on the new branch. +// This lets a caller observe the post-reorg "transaction off-chain" window +// deterministically -- e.g. a confirmation watch staying in its reorged-out +// state, or a canonicality record holding ReorgedOut -- instead of the tx +// silently re-confirming on the first replacement block as it would under +// Reorg's generatetoaddress (which pulls the mempool). The stranded tx stays in +// the mempool; mine a normal block afterwards with Generate to re-confirm it. +// +// newBlocks must be > depth so the replacement branch strictly outweighs the +// disconnected one and becomes active. +func (h *Harness) ReorgExcludingMempool(depth, newBlocks int) ReorgResult { + h.T.Helper() + + return h.reorgWith( + depth, newBlocks, " (empty replacement)", h.generateEmptyBlocks, + ) +} + +// reorgWith is the shared reorg driver behind Reorg and ReorgExcludingMempool. +// It invalidates the last depth blocks, waits for bitcoind to roll the active +// chain back to the fork point, mines the replacement branch via the supplied +// generate function, asserts the new branch became active, and waits for the +// primary LND node to resync. logSuffix is appended to the progress log line so +// the variant is identifiable. +func (h *Harness) reorgWith(depth, newBlocks int, logSuffix string, + generate func(int) []BlockHeader) ReorgResult { + + h.T.Helper() + require.Positive(h.T, depth, "reorg depth must be positive") require.Greater( h.T, newBlocks, depth, @@ -1651,13 +1694,14 @@ func (h *Harness) Reorg(depth, newBlocks int) ReorgResult { invalidateHash := disconnected[0].Hash h.Logf( - "Reorging depth=%d from old_tip=%s fork_point=%s "+ - "invalidate=%s new_blocks=%d", depth, oldTip.Hash, - forkPoint.Hash, invalidateHash, newBlocks, + "Reorging%s depth=%d from old_tip=%s fork_point=%s "+ + "invalidate=%s new_blocks=%d", logSuffix, depth, + oldTip.Hash, forkPoint.Hash, invalidateHash, newBlocks, ) _, err := h.bitcoinRPCCall("invalidateblock", invalidateHash) require.NoError(h.T, err, "invalidateblock %s", invalidateHash) + // forkHeight is validated non-negative above. expectedForkHeight := uint32(forkHeight) require.Eventually( @@ -1667,7 +1711,7 @@ func (h *Harness) Reorg(depth, newBlocks int) ReorgResult { "bitcoind did not roll back to fork height %d", forkHeight, ) - connected := h.Generate(newBlocks) + connected := generate(newBlocks) newTip := h.BestBlockHeader() require.Equal( h.T, connected[len(connected)-1].Hash, newTip.Hash, @@ -1688,6 +1732,39 @@ func (h *Harness) Reorg(depth, newBlocks int) ReorgResult { } } +// generateEmptyBlocks mines the given number of blocks that contain only their +// coinbase, using the generateblock RPC with an empty transaction list so the +// mempool is NOT swept into them. Returns the new block headers in height +// order. Used by ReorgExcludingMempool to build a replacement branch that does +// not re-confirm the disconnected branch's transactions. +func (h *Harness) generateEmptyBlocks(blocks int) []BlockHeader { + h.T.Helper() + + addr := h.bitcoindGetNewAddress() + + headers := make([]BlockHeader, 0, blocks) + for range blocks { + // generateblock mines a single block containing only the + // listed transactions (plus coinbase); an empty list yields an + // empty block that ignores the mempool entirely. + res, err := h.bitcoinRPCCall( + "generateblock", addr, []string{}, + ) + require.NoError(h.T, err, "generateblock rpc failed") + + var out struct { + Hash string `json:"hash"` + } + require.NoError( + h.T, json.Unmarshal(res, &out), + "generateblock unmarshal failed", + ) + headers = append(headers, h.BlockHeader(out.Hash)) + } + + return headers +} + // ReconsiderBlock asks bitcoind to reconsider a previously invalidated block. func (h *Harness) ReconsiderBlock(hash string) { h.T.Helper() @@ -1773,6 +1850,272 @@ func (h *Harness) BlockHeader(hash string) BlockHeader { return hdr } +// GetRawTransaction fetches the raw transaction body for txid from +// bitcoind and deserializes it into a wire.MsgTx. The transaction must +// exist either in the current chain or in bitcoind's mempool; +// `getrawtransaction` returns it via the wallet's lookup path in either +// case for a faucet-sent tx, so callers do not need to mine first. +func (h *Harness) GetRawTransaction(txid string) *wire.MsgTx { + h.T.Helper() + + res, err := h.bitcoinRPCCall("getrawtransaction", txid) + require.NoError(h.T, err, "getrawtransaction rpc failed") + + var hexStr string + err = json.Unmarshal(res, &hexStr) + require.NoError(h.T, err, "getrawtransaction unmarshal failed") + + rawBytes, err := hex.DecodeString(hexStr) + require.NoError(h.T, err, "decode raw tx hex failed") + + tx := wire.NewMsgTx(2) + err = tx.Deserialize(bytes.NewReader(rawBytes)) + require.NoError(h.T, err, "deserialize raw tx failed") + + return tx +} + +// SignedV3Tx builds a TRUC (v3) transaction that sends `amount` to +// `destPkScript`, uses one of bitcoind's wallet UTXOs as input, and +// returns it fully signed but NOT broadcast. The change output goes +// back to a fresh address from bitcoind's wallet. +// +// This helper exists for reorg systests that need to feed a v3 tx into +// txconfirm: bitcoind's `sendtoaddress` faucet path produces v2 txs, +// and txconfirm's CPFP broadcaster enforces v3/TRUC at the version +// gate. Building the v3 tx ourselves and letting txconfirm broadcast +// it is the cleanest way to exercise the full tracked-tx lifecycle on +// a freshly-mined chain entry. +// +// Fee rate is fixed at 5 sat/vB — well above any regtest min-relay-fee +// floor and small enough that the leftover change is reusable. +func (h *Harness) SignedV3Tx(destPkScript []byte, + amount btcutil.Amount) *wire.MsgTx { + + h.T.Helper() + + h.bitcoindEnsureWallet() + + // Find a confirmed wallet UTXO with enough value to cover the + // destination + change + a generous fee. + utxoTxid, utxoVout, utxoValueBTC, _ := h.bitcoindFirstSpendableUTXO() + utxoValue := btcutil.Amount(utxoValueBTC * btcutil.SatoshiPerBitcoin) + + feeSat := btcutil.Amount(2_000) // ~5 sat/vB on a ~400 vB tx. + require.Greater( + h.T, utxoValue, amount+feeSat, + "selected UTXO not large enough for destination + fee", + ) + changeSat := utxoValue - amount - feeSat + + // Fresh change address. + changeAddrRes, err := h.bitcoinRPCCall("getnewaddress") + require.NoError(h.T, err, "getnewaddress for change failed") + var changeAddrStr string + require.NoError( + h.T, json.Unmarshal(changeAddrRes, &changeAddrStr), + "getnewaddress unmarshal failed", + ) + changeAddr, err := btcaddr.DecodeAddress( + changeAddrStr, &chaincfg.RegressionNetParams, + ) + require.NoError(h.T, err, "decode change address failed") + changePkScript, err := txscript.PayToAddrScript(changeAddr) + require.NoError(h.T, err, "derive change pkScript failed") + + // Build the unsigned v3 tx. + tx := wire.NewMsgTx(3) + prevHash, err := chainhash.NewHashFromStr(utxoTxid) + require.NoError(h.T, err, "parse selected UTXO txid") + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: *prevHash, Index: utxoVout, + }, + Sequence: wire.MaxTxInSequenceNum - 1, + }) + tx.AddTxOut(&wire.TxOut{ + Value: int64(amount), + PkScript: destPkScript, + }) + tx.AddTxOut(&wire.TxOut{ + Value: int64(changeSat), + PkScript: changePkScript, + }) + + // Hex-encode and ask bitcoind to sign. + var unsignedBuf bytes.Buffer + require.NoError( + h.T, tx.Serialize(&unsignedBuf), + "serialize unsigned v3 tx", + ) + unsignedHex := hex.EncodeToString(unsignedBuf.Bytes()) + + signRes, err := h.bitcoinRPCCall( + "signrawtransactionwithwallet", unsignedHex, + ) + require.NoError(h.T, err, "signrawtransactionwithwallet failed") + + var signResult struct { + Hex string `json:"hex"` + Complete bool `json:"complete"` + } + require.NoError( + h.T, json.Unmarshal(signRes, &signResult), + "signrawtransactionwithwallet unmarshal failed", + ) + require.True( + h.T, signResult.Complete, "tx signing incomplete: %s", + signResult.Hex, + ) + + signedBytes, err := hex.DecodeString(signResult.Hex) + require.NoError(h.T, err, "decode signed v3 tx hex failed") + + signed := wire.NewMsgTx(3) + require.NoError( + h.T, + signed.Deserialize( + bytes.NewReader(signedBytes), + ), + "deserialize signed v3 tx failed", + ) + require.Equal( + h.T, int32(3), signed.Version, + "signing must preserve v3 version", + ) + + return signed +} + +// bitcoindFirstSpendableUTXO picks the first confirmed wallet UTXO via +// `listunspent` and returns its txid, vout, and amount in BTC. +func (h *Harness) bitcoindFirstSpendableUTXO() (string, uint32, float64, + string) { + + h.T.Helper() + + // Restrict to confirmed and spendable; bitcoind defaults are + // already minconf=1, maxconf=9999999. + res, err := h.bitcoinRPCCall("listunspent") + require.NoError(h.T, err, "listunspent rpc failed") + + var utxos []struct { + Txid string `json:"txid"` + Vout uint32 `json:"vout"` + Amount float64 `json:"amount"` + ScriptPubKey string `json:"scriptPubKey"` + } + require.NoError( + h.T, json.Unmarshal(res, &utxos), + "listunspent unmarshal failed", + ) + require.NotEmpty(h.T, utxos, "no spendable wallet UTXOs") + + first := utxos[0] + + return first.Txid, first.Vout, first.Amount, first.ScriptPubKey +} + +// FirstSpendableOutpoint returns a confirmed, spendable wallet outpoint along +// with its value in BTC and the pkScript of the output, WITHOUT spending it. It +// is used to obtain an outpoint a test can register as a batch's consumed input +// (the pkScript is needed to arm the spend watch) and later double-spend (via +// SpendOutpoint) to exercise conflict detection. +func (h *Harness) FirstSpendableOutpoint() (wire.OutPoint, float64, []byte) { + h.T.Helper() + + h.bitcoindEnsureWallet() + + txid, vout, valueBTC, scriptHex := h.bitcoindFirstSpendableUTXO() + hash, err := chainhash.NewHashFromStr(txid) + require.NoError(h.T, err, "parse spendable utxo txid") + + pkScript, err := hex.DecodeString(scriptHex) + require.NoError(h.T, err, "decode spendable utxo pkScript") + + return wire.OutPoint{Hash: *hash, Index: vout}, valueBTC, pkScript +} + +// SpendOutpoint builds, signs, and broadcasts a transaction that spends the +// given wallet-owned outpoint (worth valueBTC) to a fresh wallet address, +// returning the spending txid. The output value is valueBTC minus a flat fee. +// The transaction is broadcast to the mempool but NOT mined -- the caller mines +// it -- so a test can register watches before the spend confirms. Used to +// create a controlled double-spend of a batch's registered consumed input. +func (h *Harness) SpendOutpoint(op wire.OutPoint, valueBTC float64) string { + h.T.Helper() + + h.bitcoindEnsureWallet() + + value := btcutil.Amount(valueBTC * btcutil.SatoshiPerBitcoin) + feeSat := btcutil.Amount(2_000) // ~5 sat/vB on a ~400 vB tx. + require.Greater( + h.T, value, feeSat, "outpoint value too small to cover fee", + ) + + destAddrRes, err := h.bitcoinRPCCall("getnewaddress") + require.NoError(h.T, err, "getnewaddress for spend dest failed") + var destAddrStr string + require.NoError( + h.T, json.Unmarshal(destAddrRes, &destAddrStr), + "getnewaddress unmarshal failed", + ) + destAddr, err := btcaddr.DecodeAddress( + destAddrStr, &chaincfg.RegressionNetParams, + ) + require.NoError(h.T, err, "decode spend dest address failed") + destPkScript, err := txscript.PayToAddrScript(destAddr) + require.NoError(h.T, err, "derive spend dest pkScript failed") + + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: op, + Sequence: wire.MaxTxInSequenceNum, + }) + tx.AddTxOut(&wire.TxOut{ + Value: int64(value - feeSat), + PkScript: destPkScript, + }) + + var buf bytes.Buffer + require.NoError(h.T, tx.Serialize(&buf), "serialize spend tx failed") + + signRes, err := h.bitcoinRPCCall( + "signrawtransactionwithwallet", + hex.EncodeToString( + buf.Bytes(), + ), + ) + require.NoError(h.T, err, "signrawtransactionwithwallet failed") + + var signResult struct { + Hex string `json:"hex"` + Complete bool `json:"complete"` + } + require.NoError( + h.T, json.Unmarshal(signRes, &signResult), + "signrawtransactionwithwallet unmarshal failed", + ) + require.True( + h.T, signResult.Complete, "spend tx signing incomplete: %s", + signResult.Hex, + ) + + sendRes, err := h.bitcoinRPCCall( + "sendrawtransaction", signResult.Hex, + ) + require.NoError(h.T, err, "sendrawtransaction failed") + var txid string + require.NoError( + h.T, json.Unmarshal(sendRes, &txid), + "sendrawtransaction unmarshal failed", + ) + + h.Logf("Spent outpoint %s in tx %s", op, txid) + + return txid +} + // Faucet funds a test address by sending the specified amount from bitcoind's // default wallet, creating unconfirmed UTXOs for tests to spend. This mimics // external funding without requiring manual transaction construction. diff --git a/lib/actormsg/vtxo_admission.go b/lib/actormsg/vtxo_admission.go index 63cb89a31..c1736fc5e 100644 --- a/lib/actormsg/vtxo_admission.go +++ b/lib/actormsg/vtxo_admission.go @@ -177,6 +177,38 @@ type ReleaseForfeitResponse struct { // VTXOManagerResp implements the VTXOManagerResp marker interface. func (r *ReleaseForfeitResponse) VTXOManagerResp() {} +// RestoreForfeitedVTXORequest asks the VTXO manager to roll a forfeited VTXO +// back to a spendable (Live) state because the batch that consumed it via +// forfeit has been invalidated (its forfeit reversed by a finalized reorg / +// conflict). The manager re-materializes the VTXO from its persisted +// descriptor, mirroring unilateral-exit recovery. It is idempotent: a VTXO +// that is not currently forfeited is left untouched. +type RestoreForfeitedVTXORequest struct { + actor.BaseMessage + + // Outpoint identifies the forfeited VTXO to restore. + Outpoint wire.OutPoint +} + +// VTXOManagerMsg implements the VTXOManagerMsg marker interface. +func (m *RestoreForfeitedVTXORequest) VTXOManagerMsg() {} + +// MessageType returns the message type for logging. +func (m *RestoreForfeitedVTXORequest) MessageType() string { + return "RestoreForfeitedVTXORequest" +} + +// RestoreForfeitedVTXOResponse confirms the restore request was processed. +// Restored reports whether the VTXO was actually rolled back to Live (false +// when it was not in a forfeited state, i.e. the request was a no-op). +type RestoreForfeitedVTXOResponse struct { + // Restored is true when the VTXO was rolled back to Live. + Restored bool +} + +// VTXOManagerResp implements the VTXOManagerResp marker interface. +func (r *RestoreForfeitedVTXOResponse) VTXOManagerResp() {} + // CustomForfeitInput describes a caller-supplied VTXO that is not part of the // wallet's live coin set but still needs a local VTXO actor to sign the exact // round forfeit transaction once connector details are known. diff --git a/lwwallet/chain_backend.go b/lwwallet/chain_backend.go index 1c5c65d41..1f98dd83e 100644 --- a/lwwallet/chain_backend.go +++ b/lwwallet/chain_backend.go @@ -16,6 +16,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/wavelength/chainsource" + fn "github.com/lightningnetwork/lnd/fn/v2" "golang.org/x/sync/singleflight" ) @@ -42,6 +43,29 @@ const ( // raise Esplora load over the default poll cadence. const recheckHeartbeatInterval = 60 * time.Second +// regState is the lifecycle state of a conf/spend registration. +// +// stateWatching: no positive event has been delivered yet (or the +// last positive event was reorged out). The next checkSingle... that +// finds the tx confirmed / outpoint spent will emit Confirmed / +// Spend and transition to statePositive. +// +// statePositive: a Confirmed / Spend event has been delivered and the +// associated block-hash is cached on the registration. The next +// checkSingle... that does NOT find the tx confirmed / outpoint +// spent in the same block leaves the registration alone (the +// chainsource actor handles Done synthesis at FinalityDepth via +// block epochs). A reorg event that names the cached block-hash in +// its Disconnected set fires Reorged, resets cached state, and +// transitions back to stateWatching so the next re-check can fire +// Confirmed / Spend again on the new chain. +type regState uint8 + +const ( + stateWatching regState = iota + statePositive +) + // confRegistration tracks a pending confirmation registration within the // polling loop. type confRegistration struct { @@ -63,8 +87,37 @@ type confRegistration struct { // confChan is the channel to send the confirmation on. confChan chan *chainsource.TxConfirmation + // reorgChan is the channel that fires when a previously delivered + // confirmation is reorged out of the canonical chain. Buffered to + // 1; never written by the backend before stateWatching has been + // re-entered. The value is the chainsource ordering sequence; this + // backend does not stamp sequences (it emits conf/reorg from a + // single ordered handler goroutine), so it always sends 0, which + // the chainsource actor treats as always-apply. + reorgChan chan uint64 + + // doneChan is allocated for API symmetry with the chainsource + // contract. The Esplora-backed backend does not write to it: the + // chainsource ConfActor synthesizes Done at FinalityDepth from + // block epochs once the backend stops re-firing events. + doneChan chan struct{} + // cancelCh signals that this registration has been cancelled. cancelCh chan struct{} + + // regMu guards the fields below that mutate over the + // registration's lifetime. It is held only briefly during state + // transitions; channel sends are performed without it. + regMu sync.Mutex + + // state is the current lifecycle state. + state regState + + // lastBlockHash is the BlockHash of the last delivered + // confirmation when state == statePositive. Used to detect when + // a reorg's Disconnected hash list invalidates this + // registration's last event. + lastBlockHash chainhash.Hash } // spendRegistration tracks a pending spend registration within the @@ -82,8 +135,34 @@ type spendRegistration struct { // spendChan is the channel to send the spend detail on. spendChan chan *chainsource.SpendDetail + // reorgChan is the channel that fires when a previously delivered + // spend is reorged out of the canonical chain. Buffered to 1. + reorgChan chan uint64 + + // doneChan is allocated for API symmetry with the chainsource + // contract. The Esplora-backed backend does not write to it: the + // chainsource SpendActor synthesizes Done at FinalityDepth. + doneChan chan struct{} + // cancelCh signals that this registration has been cancelled. cancelCh chan struct{} + + // regMu guards the fields below. + regMu sync.Mutex + + // state is the current lifecycle state. + state regState + + // lastSpenderHash is the SpenderTxHash of the last delivered + // spend when state == statePositive. + lastSpenderHash chainhash.Hash + + // lastSpendingBlockHash is the block hash that the last + // delivered spend confirmed in, parsed from the same + // outspend response that confirmed the spend. Reorg + // comparison against ReorgEvent.Disconnected uses this so + // the spend-watch path does not need an extra HTTP round-trip. + lastSpendingBlockHash chainhash.Hash } // blockRegistration tracks a block epoch subscription. @@ -205,7 +284,8 @@ func (b *ChainBackend) Start() error { } } - height, hash, _, sub, err := b.tipPoller.BestBlockAndSubscribe() + height, hash, _, chainSub, err := + b.tipPoller.BestBlockAndSubscribeChain() if err != nil { return fmt.Errorf("subscribe to tip poller: %w", err) } @@ -216,7 +296,7 @@ func (b *ChainBackend) Start() error { b.mu.Unlock() b.wg.Add(1) - go b.handleTipEvents(sub) + go b.handleChainEvents(chainSub) b.log.InfoS(context.Background(), "Chain backend started", slog.Int("tip_height", int(height)), @@ -388,11 +468,20 @@ func (b *ChainBackend) SubmitPackage(ctx context.Context, parents []*wire.MsgTx, } // RegisterConf registers for confirmation notifications of a transaction. +// The registration is reorg-aware: the returned ConfRegistration's +// Reorged channel fires when a previously delivered confirmation is +// reorged out of the canonical chain, and the Confirmed channel may +// fire again when the tx re-confirms on the new chain. The Done +// channel is allocated but never written by this backend; the +// chainsource ConfActor synthesizes Done at its configured +// FinalityDepth using block epochs. func (b *ChainBackend) RegisterConf(ctx context.Context, txid *chainhash.Hash, pkScript []byte, numConfs uint32, heightHint uint32, includeBlock bool) (*chainsource.ConfRegistration, error) { confChan := make(chan *chainsource.TxConfirmation, 1) + reorgChan := make(chan uint64, 1) + doneChan := make(chan struct{}, 1) cancelCh := make(chan struct{}) reg := &confRegistration{ @@ -402,6 +491,8 @@ func (b *ChainBackend) RegisterConf(ctx context.Context, txid *chainhash.Hash, heightHint: heightHint, includeBlock: includeBlock, confChan: confChan, + reorgChan: reorgChan, + doneChan: doneChan, cancelCh: cancelCh, } @@ -424,11 +515,7 @@ func (b *ChainBackend) RegisterConf(ctx context.Context, txid *chainhash.Hash, ) cancelFn := func() { - close(cancelCh) - - b.mu.Lock() - delete(b.confRegs, id) - b.mu.Unlock() + b.cancelConfReg(id, reg) } // Run an immediate single-shot check scoped to JUST this @@ -450,16 +537,41 @@ func (b *ChainBackend) RegisterConf(ctx context.Context, txid *chainhash.Hash, return &chainsource.ConfRegistration{ Confirmed: confChan, + Reorged: reorgChan, + Done: doneChan, Cancel: cancelFn, }, nil } +// cancelConfReg tears down a confirmation registration. It is safe +// to invoke from any state; the close(cancelCh) is guarded by a +// once-style check to make double-Cancel a no-op. +func (b *ChainBackend) cancelConfReg(id uint64, reg *confRegistration) { + reg.regMu.Lock() + select { + case <-reg.cancelCh: + // Already cancelled. + reg.regMu.Unlock() + + return + + default: + } + close(reg.cancelCh) + reg.regMu.Unlock() + + b.mu.Lock() + delete(b.confRegs, id) + b.mu.Unlock() +} + // runConfOneShot performs the per-registration confirmation check // triggered at RegisterConf time. It snapshots the current best // height under the chain backend's lock, asks Esplora for this one // registration's status, and delivers the result if confirmed. The -// goroutine exits on any of: cancellation, stopCh closure, a -// successful delivery, or a non-confirmed status. +// registration is NOT deleted after delivery: a reorg may later +// reset it to stateWatching and the next re-check must fire +// Confirmed again on the new chain. func (b *ChainBackend) runConfOneShot(id uint64, reg *confRegistration) { defer b.wg.Done() @@ -477,11 +589,49 @@ func (b *ChainBackend) runConfOneShot(id uint64, reg *confRegistration) { currentHeight := b.bestHeight b.mu.Unlock() + b.deliverConfIfNew(id, reg, currentHeight) +} + +// deliverConfIfNew runs a confirmation re-check and fires the +// Confirmed channel iff the registration is in stateWatching and a +// confirmation is now available. It is the single delivery path +// shared by the one-shot at registration time and the broad +// re-check driven by tip / reorg events. The registration's regMu +// is held only across the state read and the state transition; the +// channel send happens outside the lock so a slow consumer never +// blocks the broad re-check goroutine. +func (b *ChainBackend) deliverConfIfNew(id uint64, reg *confRegistration, + currentHeight int32) { + + reg.regMu.Lock() + if reg.state == statePositive { + reg.regMu.Unlock() + + return + } + reg.regMu.Unlock() + conf := b.checkSingleConf(reg, currentHeight) if conf == nil { return } + // Re-check state under the lock: a concurrent reorg handler + // could have flipped us back to stateWatching after the check + // started, but it could not have flipped us forward to + // statePositive (that's exclusively this function's job). + reg.regMu.Lock() + if reg.state == statePositive { + reg.regMu.Unlock() + + return + } + reg.state = statePositive + if conf.BlockHash != nil { + reg.lastBlockHash = *conf.BlockHash + } + reg.regMu.Unlock() + select { case reg.confChan <- conf: case <-reg.cancelCh: @@ -493,22 +643,26 @@ func (b *ChainBackend) runConfOneShot(id uint64, reg *confRegistration) { b.log.DebugS( context.Background(), - "Confirmation registration fulfilled (one-shot)", + "Confirmation registration fulfilled", slog.Uint64("reg_id", id), slog.Int("block_height", int(conf.BlockHeight)), ) - - b.mu.Lock() - delete(b.confRegs, id) - b.mu.Unlock() } // RegisterSpend registers for spend notifications of a transaction output. +// The registration is reorg-aware: the returned SpendRegistration's +// Reorged channel fires when a previously delivered spend is reorged +// out of the canonical chain, and the Spend channel may fire again +// when the outpoint is re-spent on the new chain. The Done channel +// is allocated but never written by this backend; the chainsource +// SpendActor synthesizes Done at its configured FinalityDepth. func (b *ChainBackend) RegisterSpend(ctx context.Context, outpoint *wire.OutPoint, pkScript []byte, heightHint uint32) ( *chainsource.SpendRegistration, error) { spendChan := make(chan *chainsource.SpendDetail, 1) + reorgChan := make(chan uint64, 1) + doneChan := make(chan struct{}, 1) cancelCh := make(chan struct{}) reg := &spendRegistration{ @@ -516,6 +670,8 @@ func (b *ChainBackend) RegisterSpend(ctx context.Context, pkScript: pkScript, heightHint: heightHint, spendChan: spendChan, + reorgChan: reorgChan, + doneChan: doneChan, cancelCh: cancelCh, } @@ -537,11 +693,7 @@ func (b *ChainBackend) RegisterSpend(ctx context.Context, ) cancelFn := func() { - close(cancelCh) - - b.mu.Lock() - delete(b.spendRegs, id) - b.mu.Unlock() + b.cancelSpendReg(id, reg) } // Per-registration one-shot to handle outpoints that are @@ -552,16 +704,37 @@ func (b *ChainBackend) RegisterSpend(ctx context.Context, go b.runSpendOneShot(id, reg) return &chainsource.SpendRegistration{ - Spend: spendChan, - Cancel: cancelFn, + Spend: spendChan, + Reorged: reorgChan, + Done: doneChan, + Cancel: cancelFn, }, nil } +// cancelSpendReg tears down a spend registration. Idempotent: a +// double-Cancel is treated as a no-op. +func (b *ChainBackend) cancelSpendReg(id uint64, reg *spendRegistration) { + reg.regMu.Lock() + select { + case <-reg.cancelCh: + reg.regMu.Unlock() + + return + + default: + } + close(reg.cancelCh) + reg.regMu.Unlock() + + b.mu.Lock() + delete(b.spendRegs, id) + b.mu.Unlock() +} + // runSpendOneShot performs the per-registration spend check -// triggered at RegisterSpend time. It exits on cancellation, stopCh -// closure, a successful delivery, or any non-spent / unconfirmed -// status; the broad checkSpends called from processTipEvent re-runs -// it on every tip advance. +// triggered at RegisterSpend time. The registration is NOT deleted +// after delivery: a reorg may later reset it to stateWatching and +// the next re-check must fire Spend again on the new chain. func (b *ChainBackend) runSpendOneShot(id uint64, reg *spendRegistration) { defer b.wg.Done() @@ -579,11 +752,46 @@ func (b *ChainBackend) runSpendOneShot(id uint64, reg *spendRegistration) { return } - detail := b.checkSingleSpend(reg) + b.deliverSpendIfNew(id, reg) +} + +// deliverSpendIfNew runs a spend re-check and fires the Spend +// channel iff the registration is in stateWatching and a spend is +// now available. Mirror of deliverConfIfNew; see that function's +// comment for the regMu / channel-send ordering rationale. +func (b *ChainBackend) deliverSpendIfNew(id uint64, reg *spendRegistration) { + reg.regMu.Lock() + if reg.state == statePositive { + reg.regMu.Unlock() + + return + } + reg.regMu.Unlock() + + // checkSingleSpend returns the spending block hash alongside + // the detail (parsed from the same /outspend response that + // confirmed the spend). A zero hash here means the response + // did not parse and reorgSpendReg will fall back to a + // conservative re-check rather than try to match against + // ReorgEvent.Disconnected. + detail, spendingBlockHash := b.checkSingleSpend(reg) if detail == nil { return } + reg.regMu.Lock() + if reg.state == statePositive { + reg.regMu.Unlock() + + return + } + reg.state = statePositive + if detail.SpenderTxHash != nil { + reg.lastSpenderHash = *detail.SpenderTxHash + } + reg.lastSpendingBlockHash = spendingBlockHash + reg.regMu.Unlock() + select { case reg.spendChan <- detail: case <-reg.cancelCh: @@ -594,15 +802,11 @@ func (b *ChainBackend) runSpendOneShot(id uint64, reg *spendRegistration) { } b.log.DebugS(context.Background(), - "Spend registration fulfilled (one-shot)", + "Spend registration fulfilled", slog.Uint64("reg_id", id), slog.String("outpoint", reg.outpoint.String()), slog.String("spender_txid", detail.SpenderTxHash.String())) - - b.mu.Lock() - delete(b.spendRegs, id) - b.mu.Unlock() } // RegisterBlocks registers for new block notifications. @@ -637,17 +841,258 @@ func (b *ChainBackend) RegisterBlocks(_ context.Context) ( }, nil } -// handleTipEvents drains TipBlock events from the shared poller and -// translates them into chain backend work: emit a BlockEpoch to each -// block-registration subscriber, advance the cached tip, and re-check -// pending confirmation/spend registrations. -// -// On stopCh the loop exits and cancels its subscription so the -// poller does not waste effort fanning to a dead consumer. The -// subscription's Quit channel covers the inverse direction: if the -// poller is shut down externally, we exit promptly without waiting -// for stopCh. -func (b *ChainBackend) handleTipEvents(sub *TipSubscription) { +// processReorgEvent reconciles all active registrations with one +// ReorgEvent. It is invoked from the unified handleChainEvents +// goroutine in producer order: a ReorgEvent is fully processed +// (registration state reset, Reorged channels fired) before any +// subsequent TipBlock dispatches block epochs or runs broad +// re-check on the replacement chain. Registrations whose last +// positive event names a disconnected hash are reset and +// re-checked; all others are left alone (the broad tip-driven +// re-check still runs separately). +func (b *ChainBackend) processReorgEvent(event *ReorgEvent) { + if event == nil { + return + } + + b.log.InfoS(context.Background(), "Processing reorg", + slog.Int("fork_height", int(event.ForkHeight)), + slog.Int("disconnected", len(event.Disconnected)), + slog.Int("connected", len(event.Connected)), + ) + + disconnectedSet := make( + map[chainhash.Hash]struct{}, len(event.Disconnected), + ) + for _, hash := range event.Disconnected { + disconnectedSet[hash] = struct{}{} + } + + // Snapshot the registration sets under the chain backend + // lock so we don't hold it across the per-registration + // channel sends below. + b.mu.Lock() + confRegs := make(map[uint64]*confRegistration, len(b.confRegs)) + for id, reg := range b.confRegs { + confRegs[id] = reg + } + spendRegs := make(map[uint64]*spendRegistration, len(b.spendRegs)) + for id, reg := range b.spendRegs { + spendRegs[id] = reg + } + currentHeight := b.bestHeight + b.mu.Unlock() + + for id, reg := range confRegs { + b.reorgConfReg(id, reg, disconnectedSet, currentHeight) + } + for id, reg := range spendRegs { + b.reorgSpendReg(id, reg, disconnectedSet) + } +} + +// reorgConfReg processes one confirmation registration against a +// reorg event. If the registration is in statePositive and its +// last-known block hash is in disconnectedSet, fire Reorged, reset +// to stateWatching, and run a fresh check that may immediately +// fire Confirmed against the new chain. +func (b *ChainBackend) reorgConfReg(id uint64, reg *confRegistration, + disconnectedSet map[chainhash.Hash]struct{}, currentHeight int32) { + + select { + case <-reg.cancelCh: + return + + default: + } + + reg.regMu.Lock() + if reg.state != statePositive { + reg.regMu.Unlock() + + return + } + cachedHash := reg.lastBlockHash + reg.regMu.Unlock() + + // Fast path: the registration's cached block hash appears in + // the reorg event's disconnected set. This covers every + // reorg of a block we previously broadcast. + _, fastHit := disconnectedSet[cachedHash] + + if !fastHit { + // Fallback: the registration may have delivered against + // a block older than the poller's seeded hash history + // (e.g. on a fresh daemon where RegisterConf landed an + // immediate historical positive). The reorg's + // disconnected set is bounded by recentHashes, so a + // reorg deep enough to invalidate that historical block + // would not appear here. Re-query canonical status and + // compare against the cached block hash. + // + // confirmedBlockHash returns a tri-state so we never + // fire a spurious reorg: Some(h) means the tx is still + // confirmed in block h (independent of the numConfs + // threshold), None means it is definitively unconfirmed, + // and a non-nil error means canonical status could not + // be determined right now. + gotHash, err := b.confirmedBlockHash(reg) + switch { + // Transient backend failure: we cannot tell whether this + // is a reorg. Leave the registration in statePositive and + // bail; a later tip or reorg event re-evaluates once the + // backend recovers. Firing Reorged here would strand the + // consumer on a false alarm. + case err != nil: + b.log.DebugS( + context.Background(), + "Skipping conf reorg eval; canonical "+ + "status undeterminable", + slog.Uint64("reg_id", id), + btclog.Fmt("err", "%v", err), + ) + + return + + // Still confirmed in the same block: definitively not a + // reorg of this registration. + case gotHash.IsSome() && + gotHash.UnsafeFromSome() == cachedHash: + return + } + + // Otherwise the tx is confirmed elsewhere or definitively + // unconfirmed: fall through and fire Reorged. + } + + reg.regMu.Lock() + if reg.state != statePositive { + reg.regMu.Unlock() + + return + } + reg.state = stateWatching + reg.lastBlockHash = chainhash.Hash{} + reg.regMu.Unlock() + + // Fire Reorged non-blocking: the channel is buffered to 1 + // and the consumer (chainsource conf actor) is single- + // threaded so a missed coalesced reorg is correct — the + // consumer will re-query state anyway. + select { + case reg.reorgChan <- uint64(0): + case <-reg.cancelCh: + return + + default: + b.log.DebugS( + context.Background(), + "Conf reorged signal coalesced", + slog.Uint64("reg_id", id), + ) + } + + b.log.InfoS( + context.Background(), + "Conf registration reorged; re-checking", + slog.Uint64("reg_id", id), + ) + + // Re-check now so a re-confirmation on the new chain fires + // Confirmed in the same reorg-handler turn. + b.deliverConfIfNew(id, reg, currentHeight) +} + +// reorgSpendReg is the spend-side equivalent of reorgConfReg. +func (b *ChainBackend) reorgSpendReg(id uint64, reg *spendRegistration, + disconnectedSet map[chainhash.Hash]struct{}) { + + select { + case <-reg.cancelCh: + return + + default: + } + + reg.regMu.Lock() + if reg.state != statePositive { + reg.regMu.Unlock() + + return + } + cachedBlockHash := reg.lastSpendingBlockHash + cachedSpenderHash := reg.lastSpenderHash + reg.regMu.Unlock() + + // Fast path: the cached spending-block hash appears in the + // reorg event's disconnected set. Empty cachedBlockHash + // (e.g. delivered before the outspend response was parseable) + // falls through to the fallback re-query rather than being + // treated as an automatic hit. + fastHit := false + if cachedBlockHash != (chainhash.Hash{}) { + _, fastHit = disconnectedSet[cachedBlockHash] + } + + if !fastHit { + // Fallback: re-query canonical chain. Covers (a) regs + // delivered against a block older than the poller's + // seeded hash history, and (b) regs whose + // cachedBlockHash is zero because the outspend response + // was unparseable at delivery time. If the outpoint is + // still spent by the same spender in the same block, + // no reorg for this registration. + current, currentBlock := b.checkSingleSpend(reg) + if current != nil && + current.SpenderTxHash != nil && + *current.SpenderTxHash == cachedSpenderHash && + cachedBlockHash != (chainhash.Hash{}) && + currentBlock == cachedBlockHash { + return + } + } + + reg.regMu.Lock() + if reg.state != statePositive { + reg.regMu.Unlock() + + return + } + reg.state = stateWatching + reg.lastSpenderHash = chainhash.Hash{} + reg.lastSpendingBlockHash = chainhash.Hash{} + reg.regMu.Unlock() + + select { + case reg.reorgChan <- uint64(0): + case <-reg.cancelCh: + return + + default: + b.log.DebugS( + context.Background(), + "Spend reorged signal coalesced", + slog.Uint64("reg_id", id), + ) + } + + b.log.InfoS( + context.Background(), + "Spend registration reorged; re-checking", + slog.Uint64("reg_id", id), + ) + + b.deliverSpendIfNew(id, reg) +} + +// handleChainEvents drains the unified chain stream and dispatches +// each event in producer order. Using a single subscription + single +// goroutine guarantees that a ReorgEvent is fully processed +// (registration state reset, Reorged channels fired) before any +// post-reorg TipBlock drives block-epoch dispatch or broad re-check +// — otherwise a block-epoch driven Confirmed could land on a stale +// registration before reorgConfReg has a chance to reset it. +func (b *ChainBackend) handleChainEvents(sub *ChainSubscription) { defer b.wg.Done() defer sub.Cancel() @@ -661,18 +1106,15 @@ func (b *ChainBackend) handleTipEvents(sub *TipSubscription) { return } - b.processTipEvent(event) + switch { + case event.Reorg != nil: + b.processReorgEvent(event.Reorg) + + case event.Tip != nil: + b.processTipEvent(event.Tip) + } case <-heartbeat.C: - // Re-run the broad checks even when the tip - // hasn't moved. Esplora's status indexer lags - // the block-found event by 1-3 seconds, so a - // processTipEvent that ran before the indexer - // caught up would otherwise not retry until - // the next block lands. Coalesced via the same - // singleflight keys used by processTipEvent so - // a tip event arriving on the same tick does - // not produce two parallel scans. b.runRecheckHeartbeat() case <-sub.Quit(): @@ -774,8 +1216,13 @@ func (b *ChainBackend) processTipEvent(event *TipBlock) { }) } -// checkConfirmations iterates over all pending confirmation registrations -// and checks their status via the Esplora API. +// checkConfirmations iterates over all pending confirmation +// registrations and re-checks their status via the Esplora API. +// Registrations already in statePositive are skipped: the reorg +// handler is responsible for transitioning them back to +// stateWatching, and an unconditional re-check would just burn +// Esplora calls for no behavior change (the chainsource actor's +// FinalityDepth synthesis handles Done from block epochs). func (b *ChainBackend) checkConfirmations() { b.mu.Lock() regs := make(map[uint64]*confRegistration, len(b.confRegs)) @@ -794,28 +1241,7 @@ func (b *ChainBackend) checkConfirmations() { default: } - conf := b.checkSingleConf(reg, currentHeight) - if conf == nil { - continue - } - - // Send the confirmation. - select { - case reg.confChan <- conf: - case <-reg.cancelCh: - continue - } - - b.log.DebugS(context.Background(), - "Confirmation registration fulfilled", - slog.Uint64("reg_id", id), - slog.Int("block_height", - int(conf.BlockHeight))) - - // Remove the fulfilled registration. - b.mu.Lock() - delete(b.confRegs, id) - b.mu.Unlock() + b.deliverConfIfNew(id, reg, currentHeight) } } @@ -948,8 +1374,73 @@ func (b *ChainBackend) checkConfByScript(reg *confRegistration, return nil } -// checkSpends iterates over all pending spend registrations and checks -// their status via the Esplora API. +// confirmedBlockHash reports the block hash a registration's transaction is +// currently confirmed in, independent of the registration's numConfs +// threshold. It exists for the reorg-detection fallback, which must +// distinguish three states that checkSingleConf collapses into a single nil: +// +// - Some(hash), nil: the tx is confirmed in block hash (any depth); +// - None, nil: the tx is definitively unconfirmed (genuine reorg); +// - None, err: canonical status could not be determined right now +// (transient backend error) — callers MUST NOT treat this as a reorg. +// +// Unlike checkSingleConf it never gates on numConfs, because a reorg that +// merely reduces a tx's confirmation depth without moving it out of its block +// is not a reorg of that registration. +func (b *ChainBackend) confirmedBlockHash(reg *confRegistration) ( + fn.Option[chainhash.Hash], error) { + + none := fn.None[chainhash.Hash]() + + // Explicit-txid registrations resolve via a direct status lookup. + if reg.txid != nil { + status, err := b.esplora.GetTxStatus( + context.Background(), *reg.txid, + ) + if err != nil { + return none, err + } + if !status.Confirmed { + return none, nil + } + + hash, err := chainhash.NewHashFromStr(status.BlockHash) + if err != nil { + return none, err + } + + return fn.Some(*hash), nil + } + + // Script registrations resolve via the first confirmed UTXO paying + // the watched script. + utxos, err := b.esplora.GetScriptUtxos( + context.Background(), reg.pkScript, + ) + if err != nil { + return none, err + } + + for _, utxo := range utxos { + if !utxo.Status.Confirmed { + continue + } + + hash, err := chainhash.NewHashFromStr(utxo.Status.BlockHash) + if err != nil { + return none, err + } + + return fn.Some(*hash), nil + } + + return none, nil +} + +// checkSpends iterates over all pending spend registrations and +// re-checks their status via the Esplora API. Registrations in +// statePositive are skipped — see checkConfirmations for the +// rationale. func (b *ChainBackend) checkSpends() { b.mu.Lock() regs := make(map[uint64]*spendRegistration, len(b.spendRegs)) @@ -971,68 +1462,59 @@ func (b *ChainBackend) checkSpends() { continue } - detail := b.checkSingleSpend(reg) - if detail == nil { - continue - } - - // Send the spend detail. - select { - case reg.spendChan <- detail: - case <-reg.cancelCh: - continue - } - - b.log.DebugS(context.Background(), - "Spend registration fulfilled", - slog.Uint64("reg_id", id), - slog.String("outpoint", - reg.outpoint.String()), - slog.String( - "spender_txid", detail.SpenderTxHash.String(), - )) - - // Remove the fulfilled registration. - b.mu.Lock() - delete(b.spendRegs, id) - b.mu.Unlock() + b.deliverSpendIfNew(id, reg) } } // checkSingleSpend resolves the spend status of a single spend // registration via Esplora. Returns nil when the outpoint is not yet // confirmed-spent, when any HTTP / parse error occurs, or when the -// registration has no outpoint. The caller is responsible for +// registration has no outpoint. The second return value is the block +// hash containing the spending tx (parsed from outspend.Status); the +// zero hash indicates the spending block hash was unparseable but the +// spend itself is still valid. The caller is responsible for // delivery, logging, and removing the fulfilled registration; this // helper only resolves the on-chain question. -func (b *ChainBackend) checkSingleSpend( - reg *spendRegistration) *chainsource.SpendDetail { +func (b *ChainBackend) checkSingleSpend(reg *spendRegistration) ( + *chainsource.SpendDetail, chainhash.Hash) { if reg.outpoint == nil { - return nil + return nil, chainhash.Hash{} } outspend, err := b.esplora.GetOutspend( context.Background(), reg.outpoint.Hash, reg.outpoint.Index, ) if err != nil { - return nil + return nil, chainhash.Hash{} } if !outspend.Spent || !outspend.Status.Confirmed { - return nil + return nil, chainhash.Hash{} } spenderHash, err := chainhash.NewHashFromStr(outspend.Txid) if err != nil { - return nil + return nil, chainhash.Hash{} } spendingTx, err := b.esplora.GetRawTx( context.Background(), *spenderHash, ) if err != nil { - return nil + return nil, chainhash.Hash{} + } + + // outspend.Status.BlockHash is hex from the same /outspend + // response that confirmed the spend; an unparseable value is + // not fatal — the spend itself is valid and the reorg path can + // fall back to a conservative re-check (see reorgSpendReg). + var spendingBlockHash chainhash.Hash + if outspend.Status.BlockHash != "" { + h, err := chainhash.NewHashFromStr(outspend.Status.BlockHash) + if err == nil { + spendingBlockHash = *h + } } return &chainsource.SpendDetail{ @@ -1041,7 +1523,7 @@ func (b *ChainBackend) checkSingleSpend( SpendingTx: spendingTx, SpenderInputIndex: outspend.Vin, SpendingHeight: int32(outspend.Status.BlockHeight), - } + }, spendingBlockHash } // Compile-time check that ChainBackend implements diff --git a/lwwallet/chain_backend_reorg_test.go b/lwwallet/chain_backend_reorg_test.go new file mode 100644 index 000000000..f7d875e1f --- /dev/null +++ b/lwwallet/chain_backend_reorg_test.go @@ -0,0 +1,1136 @@ +package lwwallet + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "runtime" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/chainsource" + "github.com/stretchr/testify/require" +) + +// reorgTestTimeout is the per-step wait used by the lwwallet reorg +// tests. Generous enough to absorb scheduling jitter on overloaded +// CI machines but short enough that a hung backend surfaces as a +// fast failure. +const reorgTestTimeout = 3 * time.Second + +// fakeChain is a mutable chain fixture for the reorg tests. Each +// height maps to a block whose hash, raw header, and contents the +// embedded HTTP handler will serve. Hashes are computable so the +// EsploraClient's content-hash verification passes. +// +// fakeChain is intentionally orthogonal to stubChain (used by the +// existing tip-poller tests): it supports same-height hash +// replacement (reorg), per-tx status overrides, per-outpoint +// outspend overrides, and serves /block//header so the tip +// poller's continuity check can resolve PrevBlock. +type fakeChain struct { + t *testing.T + mu sync.Mutex + + // tip is the current tip height. + tip int32 + + // blocks holds the per-height block model. Replacing the entry + // at height h simulates a same-height reorg; appending new + // heights simulates chain advance. + blocks map[int32]*fakeBlock + + // txStatus is the response to /tx//status. + txStatus map[chainhash.Hash]esploraTxStatus + + // rawTx is the response to /tx//raw. + rawTx map[chainhash.Hash][]byte + + // outspends is the response to /tx//outspend/. + outspends map[wire.OutPoint]esploraOutspend + + // failRawHeader holds block hashes for which the + // /block//header endpoint should return 500. Used to + // simulate a transient Esplora flake during continuity + // checking. + failRawHeader map[chainhash.Hash]struct{} +} + +// fakeBlock describes one height's block in the fakeChain. The hash +// is derived from a synthetic 80-byte header so the EsploraClient's +// content-hash verification accepts the raw-header response. +type fakeBlock struct { + height int32 + hash chainhash.Hash + prevHash chainhash.Hash + header *wire.BlockHeader + timestamp int64 +} + +// newFakeChain seeds a fakeChain with a single block at the given +// tip height. tag is mixed into the block hash so independent +// fakeChains in parallel tests produce distinct hashes. +func newFakeChain(t *testing.T, tip int32, tag string) *fakeChain { + t.Helper() + + c := &fakeChain{ + t: t, + tip: tip, + blocks: make(map[int32]*fakeBlock), + txStatus: make(map[chainhash.Hash]esploraTxStatus), + rawTx: make(map[chainhash.Hash][]byte), + outspends: make(map[wire.OutPoint]esploraOutspend), + failRawHeader: make(map[chainhash.Hash]struct{}), + } + + c.blocks[tip] = c.mintBlock(tip, chainhash.Hash{}, tag) + + return c +} + +// mintBlock builds a fakeBlock at height with the given prev hash +// and a tag that varies the resulting block hash. We synthesize a +// minimal 80-byte header with a unique nonce so BlockHash() varies +// reliably across invocations. +func (c *fakeChain) mintBlock(height int32, prev chainhash.Hash, + tag string) *fakeBlock { + + c.t.Helper() + + hdr := &wire.BlockHeader{ + Version: 1, + PrevBlock: prev, + MerkleRoot: chainhash.HashH([]byte(tag + "-merkle")), + Timestamp: time.Unix(int64(height)*600, 0), + Bits: 0x207fffff, + Nonce: uint32(height) ^ + uint32(chainhash.HashH([]byte(tag)).String()[0])<<16, + } + + // Make the nonce truly unique per (height, tag) so two + // adjacent tags do not collide on PrevBlock=zero replays. + salt := chainhash.HashH([]byte(fmt.Sprintf("%d-%s", height, tag))) + hdr.Nonce = uint32(salt[0])<<24 | uint32(salt[1])<<16 | + uint32(salt[2])<<8 | uint32(salt[3]) + + return &fakeBlock{ + height: height, + hash: hdr.BlockHash(), + prevHash: prev, + header: hdr, + timestamp: hdr.Timestamp.Unix(), + } +} + +// replaceTip swaps in a brand-new block at the current tip height +// (same height, different hash) and returns the new block. Used to +// drive a same-height reorg. +func (c *fakeChain) replaceTip(tag string) *fakeBlock { + c.mu.Lock() + defer c.mu.Unlock() + + prev, ok := c.blocks[c.tip-1] + var prevHash chainhash.Hash + if ok { + prevHash = prev.hash + } + + blk := c.mintBlock(c.tip, prevHash, tag) + c.blocks[c.tip] = blk + + return blk +} + +// extend appends a new block on top of the current tip and returns +// it. tag varies the resulting hash for parallel tests. +func (c *fakeChain) extend(tag string) *fakeBlock { + c.mu.Lock() + defer c.mu.Unlock() + + tipBlk := c.blocks[c.tip] + blk := c.mintBlock(c.tip+1, tipBlk.hash, tag) + c.tip++ + c.blocks[c.tip] = blk + + return blk +} + +// rewriteFrom rebuilds the chain from height start upward, giving +// each new block a different hash than the previous occupant at +// the same height. Used to simulate a deeper reorg where multiple +// heights diverge at once. +func (c *fakeChain) rewriteFrom(start int32, tagPrefix string) { + c.mu.Lock() + defer c.mu.Unlock() + + var prev chainhash.Hash + if prior, ok := c.blocks[start-1]; ok { + prev = prior.hash + } + for h := start; h <= c.tip; h++ { + blk := c.mintBlock(h, prev, + fmt.Sprintf("%s-%d", tagPrefix, h)) + c.blocks[h] = blk + prev = blk.hash + } +} + +// setTxStatus pins the response for /tx//status. +func (c *fakeChain) setTxStatus(txid chainhash.Hash, status esploraTxStatus) { + c.mu.Lock() + defer c.mu.Unlock() + + c.txStatus[txid] = status +} + +// setRawTx pins the response for /tx//raw. +func (c *fakeChain) setRawTx(txid chainhash.Hash, raw []byte) { + c.mu.Lock() + defer c.mu.Unlock() + + c.rawTx[txid] = raw +} + +// setOutspend pins the response for /tx//outspend/. +func (c *fakeChain) setOutspend(op wire.OutPoint, outspend esploraOutspend) { + c.mu.Lock() + defer c.mu.Unlock() + + c.outspends[op] = outspend +} + +// handler returns an http.HandlerFunc that serves the routes the +// chain backend / tip poller exercise. Anything else returns 404. +func (c *fakeChain) handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + c.mu.Lock() + defer c.mu.Unlock() + + path := r.URL.Path + switch { + case path == "/blocks/tip/height": + _, _ = fmt.Fprint(w, c.tip) + + case path == "/blocks/tip/hash": + blk, ok := c.blocks[c.tip] + if !ok { + http.Error(w, "no tip", http.StatusNotFound) + + return + } + + _, _ = fmt.Fprint(w, blk.hash.String()) + + case strings.HasPrefix(path, "/block-height/"): + heightStr := strings.TrimPrefix( + path, "/block-height/", + ) + height, err := strconv.ParseInt(heightStr, 10, 32) + if err != nil { + http.Error( + w, "bad height", http.StatusBadRequest, + ) + + return + } + blk, ok := c.blocks[int32(height)] + if !ok { + http.Error(w, "not found", + http.StatusNotFound) + + return + } + + _, _ = fmt.Fprint(w, blk.hash.String()) + + case strings.HasPrefix(path, "/block/"): + c.serveBlockReq(w, r, path) + + case strings.HasPrefix(path, "/tx/"): + c.serveTxReq(w, r, path) + + default: + http.Error(w, "not found", http.StatusNotFound) + } + } +} + +// serveBlockReq handles /block/ and /block//header. +// Caller holds c.mu. +func (c *fakeChain) serveBlockReq(w http.ResponseWriter, _ *http.Request, + path string) { + + rest := strings.TrimPrefix(path, "/block/") + hashStr := rest + suffix := "" + if idx := strings.Index(rest, "/"); idx >= 0 { + hashStr = rest[:idx] + suffix = rest[idx:] + } + + hash, err := chainhash.NewHashFromStr(hashStr) + if err != nil { + http.Error(w, "bad hash", http.StatusBadRequest) + + return + } + + var found *fakeBlock + for _, b := range c.blocks { + if b.hash == *hash { + found = b + + break + } + } + if found == nil { + http.Error(w, "not found", http.StatusNotFound) + + return + } + + switch suffix { + case "": + // JSON header. + _, _ = fmt.Fprintf( + w, `{"id":%q,"height":%d,"timestamp":%d}`, + found.hash.String(), found.height, found.timestamp, + ) + + case "/header": + // Raw 80-byte header, hex-encoded. + if _, fail := c.failRawHeader[found.hash]; fail { + http.Error( + w, "raw header unavailable", + http.StatusInternalServerError, + ) + + return + } + var buf bytes.Buffer + require.NoError(c.t, found.header.Serialize(&buf)) + _, _ = fmt.Fprint(w, hex.EncodeToString(buf.Bytes())) + + default: + http.Error(w, "not implemented", + http.StatusNotImplemented) + } +} + +// serveTxReq handles /tx//status, /tx//raw, and +// /tx//outspend/. Caller holds c.mu. +func (c *fakeChain) serveTxReq(w http.ResponseWriter, _ *http.Request, + path string) { + + rest := strings.TrimPrefix(path, "/tx/") + parts := strings.SplitN(rest, "/", 3) + if len(parts) < 2 { + http.Error(w, "not found", http.StatusNotFound) + + return + } + + txid, err := chainhash.NewHashFromStr(parts[0]) + if err != nil { + http.Error(w, "bad txid", http.StatusBadRequest) + + return + } + + switch parts[1] { + case "status": + status, ok := c.txStatus[*txid] + if !ok { + // Default to unconfirmed when no override exists. + status = esploraTxStatus{Confirmed: false} + } + + err := json.NewEncoder(w).Encode(status) + require.NoError(c.t, err) + + case "raw": + raw, ok := c.rawTx[*txid] + if !ok { + http.Error(w, "not found", + http.StatusNotFound) + + return + } + _, err := w.Write(raw) + require.NoError(c.t, err) + + case "outspend": + if len(parts) < 3 { + http.Error(w, "not found", + http.StatusNotFound) + + return + } + vout, err := strconv.ParseUint(parts[2], 10, 32) + if err != nil { + http.Error(w, "bad vout", + http.StatusBadRequest) + + return + } + op := wire.OutPoint{ + Hash: *txid, Index: uint32(vout), + } + outspend, ok := c.outspends[op] + if !ok { + outspend = esploraOutspend{Spent: false} + } + err = json.NewEncoder(w).Encode(outspend) + require.NoError(c.t, err) + + default: + http.Error(w, "not found", http.StatusNotFound) + } +} + +// fakeChainServer wraps a fakeChain with an httptest.Server for +// drop-in use by the backend tests. The server is auto-closed via +// t.Cleanup. +func fakeChainServer(t *testing.T, chain *fakeChain) *httptest.Server { + t.Helper() + + srv := httptest.NewServer(chain.handler()) + t.Cleanup(srv.Close) + + return srv +} + +// awaitConf reads one TxConfirmation with a deadline. +func awaitConf(t *testing.T, ch <-chan *chainsourceConf) *chainsourceConf { + t.Helper() + + select { + case c, ok := <-ch: + require.True(t, ok, "conf channel closed unexpectedly") + + return c + + case <-time.After(reorgTestTimeout): + t.Fatal("timeout waiting for confirmation") + + return nil + } +} + +// awaitSpend reads one SpendDetail with a deadline. +func awaitSpend(t *testing.T, ch <-chan *chainsourceSpend) *chainsourceSpend { + t.Helper() + + select { + case s, ok := <-ch: + require.True(t, ok, "spend channel closed unexpectedly") + + return s + + case <-time.After(reorgTestTimeout): + t.Fatal("timeout waiting for spend") + + return nil + } +} + +// awaitSeqSignal is awaitSignal for the sequence-carrying Reorged +// channel (chainsource retyped it from struct{} to the ordering +// sequence; this backend always sends 0). +func awaitSeqSignal(t *testing.T, ch <-chan uint64, label string) { + t.Helper() + + select { + case _, ok := <-ch: + require.True(t, ok, + "%s channel closed unexpectedly", label) + + case <-time.After(reorgTestTimeout): + t.Fatalf("timeout waiting for %s", label) + } +} + +// requireQuiet asserts that ch has no event for a short window. +// Used to ensure registrations are NOT double-firing. +func requireQuiet(t *testing.T, ch <-chan struct{}, label string, + dur time.Duration) { + + t.Helper() + + select { + case <-ch: + t.Fatalf("unexpected %s signal", label) + + case <-time.After(dur): + } +} + +// chainsourceConf / chainsourceSpend are type aliases to keep the +// test helper signatures short. +type ( + chainsourceConf = chainsource.TxConfirmation + chainsourceSpend = chainsource.SpendDetail +) + +// TestChainBackendSameHeightHashDrift verifies that a same-height +// hash replacement is detected by the tip poller and routed to the +// chain backend as a reorg, even when the chain height does not +// advance. This is the core "same-height reorgs invisible" gap the +// PR closes. +func TestChainBackendSameHeightHashDrift(t *testing.T) { + t.Parallel() + + chain := newFakeChain(t, 100, "drift-init") + srv := fakeChainServer(t, chain) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + backend := NewChainBackend( + esplora, 20*time.Millisecond, btclog.Disabled, + ) + require.NoError(t, backend.Start()) + t.Cleanup(func() { + require.NoError(t, backend.Stop()) + }) + + // Subscribe directly to the poller's reorg stream to verify + // detection. The backend also subscribes; both subscribers + // must observe the reorg. + reorgSub, err := backend.tipPoller.SubscribeReorgs() + require.NoError(t, err) + defer reorgSub.Cancel() + + // Replace the tip block at the SAME height with a new hash. + chain.replaceTip("drift-new") + + select { + case ev := <-reorgSub.Updates(): + require.NotNil(t, ev) + require.Equal(t, int32(99), ev.ForkHeight) + require.Len(t, ev.Disconnected, 1) + require.Len(t, ev.Connected, 1) + require.Equal(t, int32(100), ev.Connected[0].Height) + + case <-time.After(reorgTestTimeout): + t.Fatal("timed out waiting for same-height reorg") + } +} + +// TestChainBackendConfReorgRoundTrip drives a registration through +// Confirmed -> Reorged -> Confirmed by replacing the block that +// confirmed the tx with a new block at the same height that still +// confirms the tx (different BlockHash). +func TestChainBackendConfReorgRoundTrip(t *testing.T) { + t.Parallel() + + chain := newFakeChain(t, 100, "conf-init") + srv := fakeChainServer(t, chain) + + // Use minimalRawTx so the EsploraClient's content-hash + // verification accepts the raw-tx response. + rawTx := minimalRawTx() + txid := minimalRawTxID(t) + chain.setRawTx(txid, rawTx) + + confBlock := chain.blocks[100] + chain.setTxStatus(txid, esploraTxStatus{ + Confirmed: true, + BlockHeight: 100, + BlockHash: confBlock.hash.String(), + }) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + backend := NewChainBackend( + esplora, 20*time.Millisecond, btclog.Disabled, + ) + require.NoError(t, backend.Start()) + t.Cleanup(func() { + require.NoError(t, backend.Stop()) + }) + + reg, err := backend.RegisterConf( + t.Context(), &txid, nil, 1, 99, false, + ) + require.NoError(t, err) + defer reg.Cancel() + + conf1 := awaitConf(t, reg.Confirmed) + require.Equal(t, uint32(100), conf1.BlockHeight) + require.Equal(t, confBlock.hash, *conf1.BlockHash) + + // Same-height reorg: replace the confirming block with a + // different one at height 100, and pin the tx status to + // the new block hash so the post-reorg re-check finds the + // tx confirmed in the new block. + newBlock := chain.replaceTip("conf-replaced") + chain.setTxStatus(txid, esploraTxStatus{ + Confirmed: true, + BlockHeight: 100, + BlockHash: newBlock.hash.String(), + }) + + awaitSeqSignal(t, reg.Reorged, "conf Reorged") + + conf2 := awaitConf(t, reg.Confirmed) + require.Equal(t, uint32(100), conf2.BlockHeight) + require.Equal(t, newBlock.hash, *conf2.BlockHash) + require.NotEqual( + t, conf1.BlockHash.String(), conf2.BlockHash.String(), + "reorg should surface a different block hash", + ) +} + +// TestChainBackendConfReorgEvictedDoesNotReConfirm verifies that +// when the post-reorg chain no longer contains the tx, Reorged +// fires but Confirmed does not re-fire. +func TestChainBackendConfReorgEvictedDoesNotReConfirm(t *testing.T) { + t.Parallel() + + chain := newFakeChain(t, 100, "evict-init") + srv := fakeChainServer(t, chain) + + rawTx := minimalRawTx() + txid := minimalRawTxID(t) + chain.setRawTx(txid, rawTx) + + confBlock := chain.blocks[100] + chain.setTxStatus(txid, esploraTxStatus{ + Confirmed: true, + BlockHeight: 100, + BlockHash: confBlock.hash.String(), + }) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + backend := NewChainBackend( + esplora, 20*time.Millisecond, btclog.Disabled, + ) + require.NoError(t, backend.Start()) + t.Cleanup(func() { + require.NoError(t, backend.Stop()) + }) + + reg, err := backend.RegisterConf( + t.Context(), &txid, nil, 1, 99, false, + ) + require.NoError(t, err) + defer reg.Cancel() + + awaitConf(t, reg.Confirmed) + + // Reorg evicts the confirming block AND the tx is no longer + // found on the new chain. + chain.replaceTip("evict-new") + chain.setTxStatus(txid, esploraTxStatus{Confirmed: false}) + + awaitSeqSignal(t, reg.Reorged, "conf Reorged") + + // No re-confirmation: assert silence. + requireQuiet( + t, structOnlyChan(reg.Confirmed), + "unexpected re-Confirmed", 500*time.Millisecond, + ) +} + +// structOnlyChan adapts a TxConfirmation channel to a struct{} +// channel for use with requireQuiet. The forwarder goroutine is +// short-lived; once the test ends both channels go out of scope. +func structOnlyChan(in <-chan *chainsourceConf) <-chan struct{} { + out := make(chan struct{}, 4) + + go func() { + for c := range in { + if c == nil { + continue + } + + select { + case out <- struct{}{}: + default: + } + } + }() + + return out +} + +// TestChainBackendSpendReorgRoundTrip drives a spend registration +// through Spend -> Reorged -> Spend by reorging the block that +// confirmed the spending tx and pinning a different (still spent) +// outspend on the new chain. +func TestChainBackendSpendReorgRoundTrip(t *testing.T) { + t.Parallel() + + chain := newFakeChain(t, 100, "spend-init") + srv := fakeChainServer(t, chain) + + rawTx := minimalRawTx() + spenderTxid := minimalRawTxID(t) + chain.setRawTx(spenderTxid, rawTx) + + fundingTxid := chainhash.HashH([]byte("funding")) + outpoint := wire.OutPoint{Hash: fundingTxid, Index: 0} + + chain.setOutspend(outpoint, esploraOutspend{ + Spent: true, Txid: spenderTxid.String(), Vin: 0, + Status: esploraStatus{ + Confirmed: true, BlockHeight: 100, + }, + }) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + backend := NewChainBackend( + esplora, 20*time.Millisecond, btclog.Disabled, + ) + require.NoError(t, backend.Start()) + t.Cleanup(func() { + require.NoError(t, backend.Stop()) + }) + + reg, err := backend.RegisterSpend( + t.Context(), &outpoint, nil, 90, + ) + require.NoError(t, err) + defer reg.Cancel() + + spend1 := awaitSpend(t, reg.Spend) + require.Equal(t, spenderTxid, *spend1.SpenderTxHash) + require.Equal(t, int32(100), spend1.SpendingHeight) + + // Replace the confirming block AND swap the spender on the + // new chain. The spender tx id is the same (we're reusing + // minimalRawTx) but the block hash differs, which is what + // the reorg handler keys off. + chain.replaceTip("spend-replaced") + chain.setOutspend(outpoint, esploraOutspend{ + Spent: true, Txid: spenderTxid.String(), Vin: 0, + Status: esploraStatus{ + Confirmed: true, BlockHeight: 100, + }, + }) + + awaitSeqSignal(t, reg.Reorged, "spend Reorged") + + spend2 := awaitSpend(t, reg.Spend) + require.Equal(t, spenderTxid, *spend2.SpenderTxHash) + require.Equal(t, int32(100), spend2.SpendingHeight) +} + +// TestTipPollerSeedsHashHistoryOnStart pins the property that the +// poller seeds its recent-hash ring back through historySize-1 +// heights at Start. Without this, a fresh poller would only ever +// cache the initial tip, leaving any reorg-event consumer with an +// incomplete disconnected set for reorgs deeper than 1 block but +// within the configured history. The chain.Interface consumer +// (EsploraChainService) depends on a complete disconnected set to +// emit a BlockDisconnected for every height btcwallet must roll +// back. +func TestTipPollerSeedsHashHistoryOnStart(t *testing.T) { + t.Parallel() + + // Build a chain at tip 100 plus three lower heights. The poller + // starts at tip 100; with seed-history, recentHashes will be + // pre-populated with {97, 98, 99, 100}. + chain := newFakeChain(t, 100, "seed-100") + // fakeChain only populates the tip block; the test needs lower + // heights in the response. Extend backwards by minting blocks + // at 99, 98, 97 with the correct PrevBlock chain so the + // poller's seed walk resolves each height. + chain.mu.Lock() + var prev chainhash.Hash + for h := int32(97); h <= 100; h++ { + blk := chain.mintBlock(h, prev, fmt.Sprintf("seed-%d", h)) + chain.blocks[h] = blk + prev = blk.hash + } + chain.mu.Unlock() + + srv := fakeChainServer(t, chain) + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + + // Cap historySize at 4 so the seed walk fills exactly the + // heights we care about. + tipPoller := NewTipPollerWithConfig( + esplora, 20*time.Millisecond, 4, btclog.Disabled, + ) + require.NoError(t, tipPoller.Start()) + t.Cleanup(tipPoller.Stop) + + reorgSub, err := tipPoller.SubscribeReorgs() + require.NoError(t, err) + defer reorgSub.Cancel() + + // Reorg the deepest 3 heights (98, 99, 100). With seed-history + // the poller's recentHashes contains 97, 98, 99, 100 so the + // walk-back terminates at fork point 97 and disconnects 3 + // hashes. Without the seed, recentHashes would only contain + // 100; walk-back would stop at 99 (no cached hash) and the + // disconnected list would carry only 1 hash. + chain.rewriteFrom(98, "seed-rewritten") + + select { + case ev := <-reorgSub.Updates(): + require.NotNil(t, ev) + require.Equal( + t, int32(97), ev.ForkHeight, "fork point should be "+ + "the deepest unchanged height; "+ + "seed-history failed if it lands above 97", + ) + require.Len( + t, ev.Disconnected, 3, "all three reorged heights "+ + "must appear in Disconnected; seed-history "+ + "is what makes this property hold for "+ + "reorgs of depth > 1 against a freshly "+ + "started poller", + ) + + case <-time.After(reorgTestTimeout): + t.Fatal("timed out waiting for reorg event") + } +} + +// TestChainBackendDeeperReorgDetected verifies that a multi-block +// reorg (where height advances AND PrevBlock continuity is broken) +// produces a ReorgEvent with the correct fork point. +// +// The test first advances the chain past the eventual fork point so +// the poller's recent-hash ring buffer contains the to-be-rewritten +// heights. Without that, the walk-back would terminate early on the +// first !haveCached probe and report a shallower fork than the test +// intends to exercise. +func TestChainBackendDeeperReorgDetected(t *testing.T) { + t.Parallel() + + chain := newFakeChain(t, 100, "deep-init") + + srv := fakeChainServer(t, chain) + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + tp := NewTipPoller(esplora, 20*time.Millisecond, btclog.Disabled) + require.NoError(t, tp.Start()) + t.Cleanup(tp.Stop) + + // Subscribe BEFORE the rewrite so we receive the reorg + // event live. + reorgSub, err := tp.SubscribeReorgs() + require.NoError(t, err) + defer reorgSub.Cancel() + + tipSub, err := tp.Subscribe() + require.NoError(t, err) + defer tipSub.Cancel() + + // Advance the chain through the poller so it caches + // heights 101 and 102 in its ring buffer. + chain.extend("init-101") + chain.extend("init-102") + + for expected := int32(101); expected <= 102; expected++ { + select { + case ev := <-tipSub.Updates(): + require.Equal(t, expected, ev.Height) + + case <-time.After(reorgTestTimeout): + t.Fatalf("timed out waiting for height %d to be cached", + expected) + } + } + + // Now rewrite from height 101 upward (heights 101 and 102 + // get new hashes) and extend by one to push the new tip + // past the old. + chain.rewriteFrom(101, "fork") + chain.extend("fork-103") + + // Wait for ReorgEvent. ForkHeight should be 100 because + // the rewrite started at 101 (so 100 is the last height + // that agrees between old and new chains). + select { + case ev := <-reorgSub.Updates(): + require.NotNil(t, ev) + require.Equal(t, int32(100), ev.ForkHeight) + require.GreaterOrEqual(t, len(ev.Connected), 2) + require.GreaterOrEqual(t, len(ev.Disconnected), 2) + + case <-time.After(reorgTestTimeout): + t.Fatal("timed out waiting for deeper reorg") + } +} + +// TestChainBackendCancelCleanup verifies that Cancel on a +// conf/spend registration removes it from internal maps and does +// not leak goroutines. +func TestChainBackendCancelCleanup(t *testing.T) { + t.Parallel() + + chain := newFakeChain(t, 100, "cancel-init") + srv := fakeChainServer(t, chain) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + backend := NewChainBackend( + esplora, time.Hour, btclog.Disabled, + ) + require.NoError(t, backend.Start()) + t.Cleanup(func() { + require.NoError(t, backend.Stop()) + }) + + txid := minimalRawTxID(t) + outpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte("funding")), Index: 0, + } + + // Sample the baseline goroutine count AFTER the backend is + // started so the poller / tip handler / reorg handler + // goroutines are already included. + time.Sleep(50 * time.Millisecond) + baseline := runtime.NumGoroutine() + + const iterations = 20 + for i := 0; i < iterations; i++ { + confReg, err := backend.RegisterConf( + t.Context(), &txid, nil, 1, 99, false, + ) + require.NoError(t, err) + + spendReg, err := backend.RegisterSpend( + t.Context(), &outpoint, nil, 90, + ) + require.NoError(t, err) + + confReg.Cancel() + spendReg.Cancel() + + // Double Cancel must be a safe no-op. + confReg.Cancel() + spendReg.Cancel() + } + + // Give the spawned one-shot goroutines time to exit. + require.Eventually(t, func() bool { + + // Allow a small tolerance for scheduling jitter; the + // upper bound here is generous (20) versus the + // per-iteration spawn count (2) to absorb any + // in-flight Esplora request goroutines that have not + // yet returned. The key invariant is that the count + // does not GROW unbounded. + return runtime.NumGoroutine() <= baseline+5 + }, 2*time.Second, 50*time.Millisecond, + "goroutines leaked after Cancel: baseline=%d current=%d", + baseline, runtime.NumGoroutine()) + + // After Cancel, internal maps should be empty. + backend.mu.Lock() + confLen := len(backend.confRegs) + spendLen := len(backend.spendRegs) + backend.mu.Unlock() + require.Equal(t, 0, confLen, + "confRegs not cleaned up after Cancel") + require.Equal(t, 0, spendLen, + "spendRegs not cleaned up after Cancel") +} + +// TestChainBackendConfStaysAliveAfterFirstFire verifies that the +// confirmation registration is NOT deleted after the first +// Confirmed delivery, since the chainsource ConfActor needs the +// registration to remain alive to receive future Reorged events +// and finally a synthesized Done. +func TestChainBackendConfStaysAliveAfterFirstFire(t *testing.T) { + t.Parallel() + + chain := newFakeChain(t, 100, "alive-init") + srv := fakeChainServer(t, chain) + + rawTx := minimalRawTx() + txid := minimalRawTxID(t) + chain.setRawTx(txid, rawTx) + chain.setTxStatus(txid, esploraTxStatus{ + Confirmed: true, + BlockHeight: 100, + BlockHash: chain.blocks[100].hash.String(), + }) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + backend := NewChainBackend( + esplora, 50*time.Millisecond, btclog.Disabled, + ) + require.NoError(t, backend.Start()) + t.Cleanup(func() { + require.NoError(t, backend.Stop()) + }) + + reg, err := backend.RegisterConf( + t.Context(), &txid, nil, 1, 99, false, + ) + require.NoError(t, err) + defer reg.Cancel() + + awaitConf(t, reg.Confirmed) + + // Give the polling loop time to fire several heartbeats. + // The registration must still be present. + time.Sleep(200 * time.Millisecond) + + backend.mu.Lock() + confLen := len(backend.confRegs) + backend.mu.Unlock() + + require.Equal( + t, 1, confLen, "reg deleted after first fire; reorg-aware "+ + "contract requires it to stay alive", + ) +} + +// TestChainBackendConfReorgBelowSeededHistory pins the property that a +// reorg deeper than the poller's seeded hash history still surfaces +// Reorged for a registration that delivered against the +// now-out-of-window block. The poller's disconnected set is bounded +// by the cached history, so without a canonical re-query fallback the +// reorg signal would be silently dropped. +func TestChainBackendConfReorgBelowSeededHistory(t *testing.T) { + t.Parallel() + + // Seed a chain at height 100 and extend up to 105 so the + // walk-back has live block hashes to compare against. Use a + // tiny history size so the registration's conf block falls + // outside the seeded window from the poller's perspective. + chain := newFakeChain(t, 100, "below-100") + chain.extend("below-101") + chain.extend("below-102") + chain.extend("below-103") + chain.extend("below-104") + chain.extend("below-105") + + confBlock := chain.blocks[100] + srv := fakeChainServer(t, chain) + + rawTx := minimalRawTx() + txid := minimalRawTxID(t) + chain.setRawTx(txid, rawTx) + chain.setTxStatus(txid, esploraTxStatus{ + Confirmed: true, + BlockHeight: 100, + BlockHash: confBlock.hash.String(), + }) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + // historySize=3 means the poller only retains the last three + // canonical heights it has observed. The conf block at height + // 100 will be five heights below the seeded tip (105) and is + // guaranteed never to enter recentHashes. + tipPoller := NewTipPollerWithConfig( + esplora, 20*time.Millisecond, 3, btclog.Disabled, + ) + require.NoError(t, tipPoller.Start()) + t.Cleanup(tipPoller.Stop) + backend, err := NewChainBackendWithPoller( + esplora, tipPoller, btclog.Disabled, + ) + require.NoError(t, err) + require.NoError(t, backend.Start()) + t.Cleanup(func() { + require.NoError(t, backend.Stop()) + }) + + reg, err := backend.RegisterConf( + t.Context(), &txid, nil, 1, 99, false, + ) + require.NoError(t, err) + defer reg.Cancel() + + conf1 := awaitConf(t, reg.Confirmed) + require.Equal(t, confBlock.hash, *conf1.BlockHash) + + // Deep reorg: rewrite every height from 100 upward with + // different hashes. The new block at height 100 has a new + // hash, but the tx is still pinned there so the canonical + // re-query in reorgConfReg can fire a fresh Confirmed. + chain.rewriteFrom(100, "below-new") + newConfBlock := chain.blocks[100] + chain.setTxStatus(txid, esploraTxStatus{ + Confirmed: true, + BlockHeight: 100, + BlockHash: newConfBlock.hash.String(), + }) + + awaitSeqSignal(t, reg.Reorged, "conf Reorged on below-history reorg") + + conf2 := awaitConf(t, reg.Confirmed) + require.Equal(t, newConfBlock.hash, *conf2.BlockHash) + require.NotEqual( + t, conf1.BlockHash.String(), conf2.BlockHash.String(), + "re-confirmation must surface the new block hash", + ) +} + +// TestChainBackendAbortsOnRawHeaderFailure pins the property that a +// failed raw-header fetch during continuity check aborts the poll +// cycle rather than optimistically advancing. The optimistic path +// would permanently hide a reorg that crossed the old-tip boundary +// if the raw-header fetch flaked at exactly the wrong moment. +func TestChainBackendAbortsOnRawHeaderFailure(t *testing.T) { + t.Parallel() + + chain := newFakeChain(t, 100, "abort-init") + srv := fakeChainServer(t, chain) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + tipPoller := NewTipPoller( + esplora, 20*time.Millisecond, btclog.Disabled, + ) + require.NoError(t, tipPoller.Start()) + t.Cleanup(tipPoller.Stop) + + tipSub, err := tipPoller.Subscribe() + require.NoError(t, err) + defer tipSub.Cancel() + reorgSub, err := tipPoller.SubscribeReorgs() + require.NoError(t, err) + defer reorgSub.Cancel() + + // Add a height-101 block whose PrevBlock does NOT chain off + // the seeded tip at height 100. Then poison the raw-header + // endpoint for that block so the continuity check cannot + // resolve. The poller must abort the cycle: not broadcast + // the new block, not update its cached tip, not fire a reorg. + chain.mu.Lock() + stranger := chain.mintBlock( + 101, chainhash.Hash{0xde, 0xad}, "abort-stranger", + ) + chain.blocks[101] = stranger + chain.tip = 101 + chain.failRawHeader[stranger.hash] = struct{}{} + chain.mu.Unlock() + + // Sleep long enough for a few poll cycles to fire; verify + // neither tip advance nor reorg ever fires. + select { + case ev := <-tipSub.Updates(): + t.Fatalf("poller broadcast a tip event despite raw-header "+ + "failure: height=%d hash=%s", ev.Height, ev.Hash) + + case ev := <-reorgSub.Updates(): + t.Fatalf("poller broadcast a reorg event despite raw-header "+ + "failure: fork=%d", ev.ForkHeight) + + case <-time.After(200 * time.Millisecond): + // Expected: silent. + } + + height, hash, _ := tipPoller.BestBlock() + require.Equal( + t, int32(100), height, + "cached tip height advanced despite aborted cycle", + ) + require.Equal( + t, chain.blocks[100].hash, hash, + "cached tip hash advanced despite aborted cycle", + ) +} diff --git a/lwwallet/chain_backend_test.go b/lwwallet/chain_backend_test.go index bcea6be99..6ee39c5ad 100644 --- a/lwwallet/chain_backend_test.go +++ b/lwwallet/chain_backend_test.go @@ -2,6 +2,7 @@ package lwwallet import ( "bytes" + "encoding/hex" "encoding/json" "fmt" "net/http" @@ -80,15 +81,20 @@ func TestChainBackendBlockNotification(t *testing.T) { mu sync.Mutex tipHeight int32 = 100 blockHashes = make(map[int32]chainhash.Hash) + blockHdrs = make(map[int32]*wire.BlockHeader) ) - // Pre-generate block hashes. + // Pre-generate real chained block headers so the tip poller's + // PrevBlock continuity check during advance can resolve the raw + // header endpoint with a header that actually links back to the + // previous height. + var prevHash chainhash.Hash for h := int32(100); h <= 103; h++ { - blockHashes[h] = chainhash.HashH( - []byte( - fmt.Sprintf("block-%d", h), - ), - ) + hdr := mintStubHeader(h, prevHash) + blockHdrs[h] = hdr + hash := hdr.BlockHash() + blockHashes[h] = hash + prevHash = hash } srv := mockEsploraServer( @@ -109,7 +115,7 @@ func TestChainBackendBlockNotification(t *testing.T) { default: handleBlockReqs( - t, w, r, &mu, blockHashes, + t, w, r, &mu, blockHashes, blockHdrs, ) } }, @@ -147,10 +153,13 @@ func TestChainBackendBlockNotification(t *testing.T) { } } -// handleBlockReqs handles /block-height/:height and /block/:hash -// requests in the mock Esplora server. +// handleBlockReqs handles /block-height/:height, /block/:hash, and +// /block/:hash/header requests in the mock Esplora server. The +// blockHdrs map supplies real wire.BlockHeaders so the raw-header +// continuity check the tip poller runs on advance can succeed. func handleBlockReqs(t *testing.T, w http.ResponseWriter, r *http.Request, - mu *sync.Mutex, blockHashes map[int32]chainhash.Hash) { + mu *sync.Mutex, blockHashes map[int32]chainhash.Hash, + blockHdrs map[int32]*wire.BlockHeader) { t.Helper() @@ -176,20 +185,40 @@ func handleBlockReqs(t *testing.T, w http.ResponseWriter, r *http.Request, return } - // Handle /block/:hash (block header). - for _, h := range blockHashes { + // Handle /block/:hash and /block/:hash/header. + mu.Lock() + defer mu.Unlock() + for height, h := range blockHashes { hashStr := h.String() path := "/block/" + hashStr - if r.URL.Path == path { + headerPath := path + "/header" + + switch r.URL.Path { + case path: resp := esploraBlock{ ID: hashStr, - Height: 100, + Height: height, Timestamp: 1700000000, } - err := json.NewEncoder(w).Encode(resp) require.NoError(t, err) + return + + case headerPath: + hdr := blockHdrs[height] + if hdr == nil { + http.Error(w, "no header", + http.StatusNotFound) + + return + } + var buf bytes.Buffer + require.NoError(t, hdr.Serialize(&buf)) + _, _ = fmt.Fprint( + w, hex.EncodeToString(buf.Bytes()), + ) + return } } diff --git a/lwwallet/esplora_chain.go b/lwwallet/esplora_chain.go index bd37534ee..2d1c944fc 100644 --- a/lwwallet/esplora_chain.go +++ b/lwwallet/esplora_chain.go @@ -110,12 +110,15 @@ func NewEsploraChainService(esplora *EsploraClient, tipPoller *TipPoller, // Start seeds the initial chain tip from the configured TipPoller // (which the caller must have started already) and spawns the -// goroutine that translates each TipBlock event into btcwallet -// chain notifications. +// goroutine that translates each chain event into btcwallet chain +// notifications. The service subscribes to the unified chain stream +// so reorg and tip updates arrive on a single producer-ordered +// channel; this is what lets chain.BlockDisconnected reliably precede +// the replacement chain.BlockConnected events for the new tip. func (s *EsploraChainService) Start(ctx context.Context) error { //nolint:contextcheck // tip subscription lifecycle is owned by Stop - tipHeight, tipHash, tipTime, sub, err := - s.tipPoller.BestBlockAndSubscribe() + tipHeight, tipHash, tipTime, chainSub, err := + s.tipPoller.BestBlockAndSubscribeChain() if err != nil { return fmt.Errorf("subscribe to tip poller: %w", err) } @@ -134,7 +137,7 @@ func (s *EsploraChainService) Start(ctx context.Context) error { s.notifications <- chain.ClientConnected{} s.wg.Add(1) - go s.handleTipEvents(ctx, sub) + go s.handleChainEvents(ctx, chainSub) s.log.InfoS(ctx, "Esplora chain service started", slog.Int("tip_height", int(tipHeight)), @@ -685,26 +688,35 @@ func (s *EsploraChainService) MapRPCErr(err error) error { return err } -// handleTipEvents drains TipBlock events from the shared poller and -// translates each event into the FilteredBlockConnected + -// BlockConnected notification pair that btcwallet's wallet syncer -// expects. The loop exits when the chain service is stopped, when -// the poller signals shutdown via Quit, or when the subscription's -// Updates channel is closed by Cancel. -func (s *EsploraChainService) handleTipEvents(ctx context.Context, - sub *TipSubscription) { +// handleChainEvents drains the unified chain stream and translates +// each event into btcwallet chain notifications. Using a single +// subscription delivers ReorgEvent and TipBlock updates in producer +// order through one channel, which is what guarantees +// BlockDisconnected lands on btcwallet's notification queue before +// the BlockConnected events for the replacement chain (btcwallet's +// disconnectBlock would otherwise refuse the rollback if the cached +// hash at that height had already been overwritten by a stale +// BlockConnected). +func (s *EsploraChainService) handleChainEvents(ctx context.Context, + sub *ChainSubscription) { defer s.wg.Done() defer sub.Cancel() for { select { - case event, ok := <-sub.Updates(): + case ev, ok := <-sub.Updates(): if !ok { return } - s.processTipEvent(ctx, event) + switch { + case ev.Reorg != nil: + s.processReorgEvent(ctx, ev.Reorg) + + case ev.Tip != nil: + s.processTipEvent(ctx, ev.Tip) + } case <-sub.Quit(): return @@ -729,6 +741,63 @@ func (s *EsploraChainService) handleTipEvents(ctx context.Context, // per-event cap branch without producing 256+ heights of traffic. const defaultMaxGapFillPerTipEvent int32 = 256 +// processReorgEvent translates a ReorgEvent into chain.BlockDisconnected +// notifications. The replacement Connected blocks arrive separately on +// the tip stream and are processed by processTipEvent in the usual way; +// emitting Disconnected here is what lets btcwallet roll back the +// reorged-out heights via its disconnectBlock path before it re-sees +// the canonical chain. Newest height is disconnected first so btcwallet +// walks back from its own cached tip. +// +// After emitting the disconnects, s.bestBlock is rolled back to +// event.ForkHeight so the subsequent replacement-chain TipBlock events +// (which arrive at heights forkHeight+1..newTipHeight) pass +// processTipEvent's "event.Height <= lastDelivered" duplicate guard. +// Without this rollback, the replacement BlockConnected events would +// silently be dropped because s.bestBlock still points at the old tip. +// The Hash is intentionally cleared because the reorg event does not +// carry the fork-point hash; the next BlockConnected fully repopulates +// s.bestBlock and only the Height is load-bearing for the duplicate +// guard. +func (s *EsploraChainService) processReorgEvent(ctx context.Context, + event *ReorgEvent) { + + if event == nil { + return + } + + for i := len(event.Disconnected) - 1; i >= 0; i-- { + hash := event.Disconnected[i] + height := event.ForkHeight + int32(i) + 1 + + meta := wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Hash: hash, + Height: height, + }, + } + + select { + case s.notifications <- chain.BlockDisconnected(meta): + case <-s.quit: + return + } + } + + s.mu.Lock() + if s.bestBlock.Height > event.ForkHeight { + s.bestBlock = waddrmgr.BlockStamp{ + Height: event.ForkHeight, + } + } + s.mu.Unlock() + + s.log.InfoS(ctx, "Chain service emitted BlockDisconnected", + slog.Int("fork_height", int(event.ForkHeight)), + slog.Int("disconnected", len(event.Disconnected)), + ) +} + // processTipEvent applies one TipBlock to btcwallet's notification // channel. The chain service owns its own delivery cursor // (s.bestBlock); this function first walks any gap between diff --git a/lwwallet/esplora_chain_reorg_test.go b/lwwallet/esplora_chain_reorg_test.go new file mode 100644 index 000000000..a4b22c1c1 --- /dev/null +++ b/lwwallet/esplora_chain_reorg_test.go @@ -0,0 +1,175 @@ +package lwwallet + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btclog/v2" + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/require" +) + +// awaitNotification pulls the next notification from the chain +// service or fails the test on timeout. +func awaitNotification(t *testing.T, s *EsploraChainService) interface{} { + t.Helper() + + select { + case n := <-s.Notifications(): + return n + + case <-time.After(reorgTestTimeout): + t.Fatalf("timed out waiting for chain notification") + + return nil + } +} + +// drainUntilConnected drains notifications until a BlockConnected +// event at the given height is observed (or the test times out). +// Used to skip over the startup ClientConnected and initial connected +// events so the reorg-specific assertions can run on a known cursor. +func drainUntilConnected(t *testing.T, s *EsploraChainService, height int32) { + t.Helper() + + deadline := time.After(reorgTestTimeout) + + for { + select { + case n := <-s.Notifications(): + conn, ok := n.(chain.BlockConnected) + if !ok { + continue + } + if conn.Block.Height == height { + return + } + + case <-deadline: + t.Fatalf("timed out waiting for BlockConnected at %d", + height) + } + } +} + +// TestEsploraChainServiceReorgEmitsBlockDisconnected pins the property +// that a chain reorg surfaces chain.BlockDisconnected notifications +// before btcwallet sees the BlockConnected events that announce the +// new canonical chain, AND that the disconnect events arrive in +// newest-height-first order (the order btcwallet's disconnectBlock +// expects when walking back from its cached tip). Both ordering +// properties are load-bearing: without disconnect-before-connect, +// btcwallet would refuse the rollback because the cached hash at +// each height would already be overwritten; without +// newest-first, btcwallet's per-step rollback could trip on a +// height it hasn't seen disconnected yet. +func TestEsploraChainServiceReorgEmitsBlockDisconnected(t *testing.T) { + t.Parallel() + + chainModel := newFakeChain(t, 100, "svc-reorg-100") + chainModel.extend("svc-reorg-101") + chainModel.extend("svc-reorg-102") + + srv := fakeChainServer(t, chainModel) + + esplora := NewEsploraClient(srv.URL, btclog.Disabled) + tipPoller := NewTipPoller( + esplora, 20*time.Millisecond, btclog.Disabled, + ) + require.NoError(t, tipPoller.Start()) + t.Cleanup(tipPoller.Stop) + + service := NewEsploraChainService( + esplora, tipPoller, btclog.Disabled, + ) + require.NoError(t, service.Start(t.Context())) + t.Cleanup(func() { + service.Stop() + service.WaitForShutdown() + }) + + // First notification is always ClientConnected. + first := awaitNotification(t, service) + _, ok := first.(chain.ClientConnected) + require.True(t, ok, "expected ClientConnected first, got %T", first) + + // Drain forward to a known tip + a couple of new blocks so the + // service has a known set of recently-emitted connected blocks + // before the reorg fires. + chainModel.extend("svc-reorg-103") + drainUntilConnected(t, service, 103) + + // Stash hashes of the blocks the reorg is about to invalidate. + oldHash102 := chainModel.blocks[102].hash + oldHash103 := chainModel.blocks[103].hash + + // Rewrite heights 102..103 with a new chain. The tip stays at + // 103 but with a different hash, so the poller detects a + // same-height drift at 103 and walks back to fork point 101. + chainModel.rewriteFrom(102, "svc-reorg-new") + newHash102 := chainModel.blocks[102].hash + newHash103 := chainModel.blocks[103].hash + + // Drain notifications and pin the strict ordering. Each + // BlockConnected for a new-chain hash must arrive AFTER both + // BlockDisconnected events for the old chain — the unified + // chain stream guarantees this by serializing reorg + tip + // events through a single producer-ordered channel. + disconnectsSeen := 0 + gotDisconnected := []chainhash.Hash{} + gotConnectedNew := map[chainhash.Hash]struct{}{} + deadline := time.After(reorgTestTimeout) + for len(gotConnectedNew) < 2 { + select { + case n := <-service.Notifications(): + switch ev := n.(type) { + case chain.BlockDisconnected: + gotDisconnected = append( + gotDisconnected, ev.Block.Hash, + ) + disconnectsSeen++ + + case chain.BlockConnected: + meta := wtxmgr.BlockMeta(ev) + if meta.Hash != newHash102 && + meta.Hash != newHash103 { + + continue + } + + require.Equal( + t, 2, disconnectsSeen, "BlockConnect"+ + "ed for replacement chain "+ + "arrived before all "+ + "BlockDisconnected events: "+ + "saw %d disconnects so far", + disconnectsSeen, + ) + gotConnectedNew[meta.Hash] = struct{}{} + } + + case <-deadline: + t.Fatalf("timed out: disconnects=%v connected_new=%v", + gotDisconnected, gotConnectedNew) + } + } + + require.Equal( + t, oldHash103, gotDisconnected[0], "first "+ + "BlockDisconnected should be the newest old-chain "+ + "height (103) for btcwallet's per-step walk-back", + ) + require.Equal( + t, oldHash102, gotDisconnected[1], + "second BlockDisconnected should be height 102", + ) + + _, sawConn102 := gotConnectedNew[newHash102] + _, sawConn103 := gotConnectedNew[newHash103] + require.True(t, sawConn102, + "BlockConnected for new 102 not emitted") + require.True(t, sawConn103, + "BlockConnected for new 103 not emitted") +} diff --git a/lwwallet/esplora_chain_test.go b/lwwallet/esplora_chain_test.go index ee43b022f..151363cc0 100644 --- a/lwwallet/esplora_chain_test.go +++ b/lwwallet/esplora_chain_test.go @@ -2,6 +2,7 @@ package lwwallet import ( "bytes" + "encoding/hex" "encoding/json" "fmt" "net/http" @@ -299,6 +300,16 @@ func (c *rawBlockStubChain) serveBlockRoute(t *testing.T, w http.ResponseWriter, require.NoError(t, block.Serialize(&buf)) _, _ = w.Write(buf.Bytes()) + case "/header": + // Hex-encoded 80-byte block header. The TipPoller fetches + // this per new tip to verify PrevBlock continuity against + // the cached tip hash; without it the poller cannot + // distinguish a clean tip advance from a reorg crossing the + // boundary and aborts the cycle. + var buf bytes.Buffer + require.NoError(t, block.Header.Serialize(&buf)) + _, _ = fmt.Fprint(w, hex.EncodeToString(buf.Bytes())) + default: http.Error(w, "not implemented", http.StatusNotImplemented) diff --git a/lwwallet/tip_poller.go b/lwwallet/tip_poller.go index dbce6c97d..f2fd3557f 100644 --- a/lwwallet/tip_poller.go +++ b/lwwallet/tip_poller.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "sort" "sync" "time" @@ -11,6 +12,13 @@ import ( "github.com/btcsuite/btclog/v2" ) +// DefaultHashHistorySize is the default upper bound on entries retained +// in the TipPoller's bounded height -> hash history map. It is sized to +// at least twice the conventional Bitcoin reorg-safety depth (6) so a +// reorg at finality depth still has its disconnected hashes available +// for walk-back, with headroom. Configurable via NewTipPollerWithConfig. +const DefaultHashHistorySize = 100 + // TipBlock describes a newly detected block emitted by TipPoller. // Each subscriber receives one TipBlock per advance: when the tip // moves from oldHeight to newHeight the poller fans out (newHeight - @@ -38,6 +46,62 @@ type TipBlock struct { // for ergonomic call-site naming. type TipSubscription = Subscription[*TipBlock] +// ReorgEvent describes a chain reorganization observed by the +// TipPoller. The poller emits one ReorgEvent every time it detects +// that one or more previously broadcast blocks are no longer in the +// canonical chain. Disconnected hashes are listed in canonical +// (low-height first) order; Connected blocks are the new tip's path +// from ForkHeight+1 onward, also in canonical order. Either slice may +// be empty: a same-height hash-replacement reorg has Disconnected of +// length 1 and Connected of length 1. +type ReorgEvent struct { + // ForkHeight is the highest height at which the old chain and the + // new chain agree on the block hash. The first disconnected / + // connected block is at ForkHeight+1. + ForkHeight int32 + + // Disconnected lists block hashes that were previously broadcast + // as part of the canonical chain and are no longer on it, in + // ascending height order (ForkHeight+1 first). + Disconnected []chainhash.Hash + + // Connected lists blocks now on the canonical chain starting at + // ForkHeight+1, in ascending height order. The poller will also + // fan these out individually as TipBlock events on the standard + // Subscribe channel after the ReorgEvent is delivered. + Connected []*TipBlock +} + +// ReorgSubscription is the typed handle returned by +// TipPoller.SubscribeReorgs. +type ReorgSubscription = Subscription[*ReorgEvent] + +// ChainEvent is the unified update delivered on the ordered chain +// stream returned by SubscribeChain. Exactly one of Reorg and Tip is +// non-nil per event: +// +// - Reorg-only events announce a reorg's disconnected range and +// precede the replacement tip events on the same stream. +// - Tip-only events announce a new block (either a forward advance +// or a post-reorg connected block). +// +// Cross-event ordering on this stream is producer-ordered: the +// embedded EventServer delivers updates in SendUpdate order to a +// single subscriber goroutine, so a downstream consumer that +// dispatches on event type from one channel observes the same order +// the TipPoller emitted. That is the load-bearing property +// downstream needs to emit BlockDisconnected before BlockConnected +// for the replacement chain (btcwallet's disconnectBlock requires +// it; chainsource finality synthesis requires it too). +type ChainEvent struct { + Reorg *ReorgEvent + Tip *TipBlock +} + +// ChainSubscription is the typed handle returned by +// TipPoller.SubscribeChain. It carries the unified ChainEvent stream. +type ChainSubscription = Subscription[*ChainEvent] + // TipPoller is the single source of truth for the lwwallet chain // tip. Exactly one polling goroutine periodically asks the Esplora // backend for the current best height. When new blocks are detected @@ -58,18 +122,46 @@ type TipPoller struct { pollInterval time.Duration log btclog.Logger + // historySize caps the bounded height -> hash history map that + // lets the poller resolve old-chain hashes during reorg walk-back. + historySize int + // events is the typed event server that fans TipBlock updates // out to all active subscribers. Its Start/Stop are driven by // TipPoller.Start/Stop. events *EventServer[*TipBlock] + // reorgs is the typed event server that fans ReorgEvent updates + // out to subscribers that opt in via SubscribeReorgs. Reorgs are + // rare enough that a separate server is cheaper than re-fitting + // every TipBlock consumer with reorg-aware logic. + reorgs *EventServer[*ReorgEvent] + + // chain is the unified event server that fans both tip and reorg + // updates out on a single ordered stream. Consumers that need + // strict reorg-before-replacement-tip ordering (chain.Interface + // adapter, chainsource backend) subscribe here rather than to + // the separate events / reorgs servers, which would race across + // two independent translator goroutines. + chain *EventServer[*ChainEvent] + // mu guards the cached tip so BestBlock readers see a - // consistent height/hash/timestamp triple. + // consistent height/hash/timestamp triple. It also guards + // recentHashes, which is sized small enough that linear scans + // under the lock are cheap. mu sync.Mutex tipHeight int32 tipHash chainhash.Hash tipTime time.Time + // recentHashes maps a recent canonical-chain height to the hash + // the poller broadcast for it. It is bounded to historySize + // entries: on every insert we prune any entry whose height is + // more than historySize below the current tip. The buffer lets + // reorg detection walk back to the fork point using the cached + // hashes rather than re-fetching the entire pre-fork chain. + recentHashes map[int32]chainhash.Hash + // started gates re-entrant Start calls; the underlying // EventServer is also idempotent on Start. started bool @@ -82,15 +174,35 @@ type TipPoller struct { // NewTipPoller constructs a TipPoller bound to the given Esplora // client. The poll interval controls how often the goroutine asks // Esplora for the latest tip height; subscribers do not influence -// the cadence. +// the cadence. The bounded hash-history map is sized to +// DefaultHashHistorySize. func NewTipPoller(esplora *EsploraClient, pollInterval time.Duration, log btclog.Logger) *TipPoller { + return NewTipPollerWithConfig( + esplora, pollInterval, DefaultHashHistorySize, log, + ) +} + +// NewTipPollerWithConfig is the explicit-history-size constructor. +// historySize <= 0 falls back to DefaultHashHistorySize so callers +// cannot accidentally disable reorg walk-back by passing a zero value. +func NewTipPollerWithConfig(esplora *EsploraClient, pollInterval time.Duration, + historySize int, log btclog.Logger) *TipPoller { + + if historySize <= 0 { + historySize = DefaultHashHistorySize + } + return &TipPoller{ esplora: esplora, pollInterval: pollInterval, + historySize: historySize, log: log, events: NewEventServer[*TipBlock](log), + reorgs: NewEventServer[*ReorgEvent](log), + chain: NewEventServer[*ChainEvent](log), + recentHashes: make(map[int32]chainhash.Hash), quit: make(chan struct{}), } } @@ -170,12 +282,60 @@ func (t *TipPoller) Start() error { return fmt.Errorf("start event server: %w", err) } + if err := t.reorgs.Start(); err != nil { + // Roll back the tip-event server so a partial Start does + // not leave a half-running poller behind. + _ = t.events.Stop() + resetStarted() + + return fmt.Errorf("start reorg event server: %w", err) + } + + if err := t.chain.Start(); err != nil { + _ = t.events.Stop() + _ = t.reorgs.Stop() + resetStarted() + + return fmt.Errorf("start chain event server: %w", err) + } + + // Seed recentHashes by walking back historySize-1 heights so a + // reorg whose disconnected range extends below the seeded tip + // but within the configured history can still resolve every + // disconnected hash from the cache. Without this, a fresh + // poller would only ever cache the initial tip, leaving a + // downstream chain.Interface consumer unable to enumerate every + // hash btcwallet must roll back on a multi-block reorg below + // the seeded tip. The walk-back is best-effort: a single + // per-height fetch failure ends the seed loop early; the next + // poll tick still drives the cache forward as the chain grows. t.mu.Lock() t.tipHeight = height t.tipHash = hash t.tipTime = tipTime + t.recordHashLocked(height, hash) t.mu.Unlock() + for h := height - 1; h > height-int32(t.historySize) && h >= 0; h-- { + liveHash, err := t.esplora.GetBlockHashByHeight( + context.Background(), h, + ) + if err != nil { + t.log.WarnS( + context.Background(), + "Tip poller history seed fetch failed", + err, + slog.Int("height", int(h)), + ) + + break + } + + t.mu.Lock() + t.recordHashLocked(h, liveHash) + t.mu.Unlock() + } + t.wg.Add(1) go t.pollLoop() @@ -197,7 +357,7 @@ func (t *TipPoller) Stop() { t.wg.Wait() - // Stop the event server after the poll loop has exited so + // Stop the event servers after the poll loop has exited so // that no SendUpdate is in flight when the server tears down // its subscriber handler. if err := t.events.Stop(); err != nil { @@ -207,6 +367,22 @@ func (t *TipPoller) Stop() { err, ) } + + if err := t.reorgs.Stop(); err != nil { + t.log.WarnS( + context.Background(), + "Tip poller reorg event server stop returned error", + err, + ) + } + + if err := t.chain.Stop(); err != nil { + t.log.WarnS( + context.Background(), + "Tip poller chain event server stop returned error", + err, + ) + } } // BestBlock returns a snapshot of the currently cached tip. Callers @@ -233,6 +409,18 @@ func (t *TipPoller) Subscribe() (*TipSubscription, error) { return t.events.Subscribe() } +// SubscribeReorgs returns a typed subscription that receives a +// ReorgEvent every time the poller detects that one or more +// previously broadcast blocks have been replaced on the canonical +// chain. Callers that need both tip events and reorg events should +// subscribe to both streams; reorg events are delivered BEFORE the +// connected blocks are fanned out on the TipBlock stream so that +// consumers can mark their registrations dirty before the +// re-confirmation arrives. +func (t *TipPoller) SubscribeReorgs() (*ReorgSubscription, error) { + return t.reorgs.Subscribe() +} + // BestBlockAndSubscribe atomically reads the current cached tip and // registers a new subscription. The poll goroutine holds t.mu // during the {update tip + SendUpdate} pair, and this function @@ -257,6 +445,85 @@ func (t *TipPoller) BestBlockAndSubscribe() (int32, chainhash.Hash, time.Time, return t.tipHeight, t.tipHash, t.tipTime, sub, nil } +// BestBlockAndSubscribeAll atomically reads the cached tip and +// registers both a TipBlock subscription and a ReorgEvent +// subscription. Reorg-aware consumers (e.g. ChainBackend) use this +// to avoid the small window where a reorg could land between +// independently registering on the two streams. +func (t *TipPoller) BestBlockAndSubscribeAll() (int32, chainhash.Hash, + time.Time, *TipSubscription, *ReorgSubscription, error) { + + t.mu.Lock() + defer t.mu.Unlock() + + sub, err := t.events.Subscribe() + if err != nil { + return 0, chainhash.Hash{}, time.Time{}, nil, nil, + fmt.Errorf("subscribe to tip events: %w", err) + } + + reorgSub, err := t.reorgs.Subscribe() + if err != nil { + sub.Cancel() + + return 0, chainhash.Hash{}, time.Time{}, nil, nil, + fmt.Errorf("subscribe to reorg events: %w", err) + } + + return t.tipHeight, t.tipHash, t.tipTime, sub, reorgSub, nil +} + +// SubscribeChain returns a typed subscription on the unified chain +// event stream. Updates arrive in producer order through a single +// channel; consumers that need strict reorg-before-replacement-tip +// ordering must subscribe here rather than to the separate tip / +// reorg streams (which race across two independent translator +// goroutines). +func (t *TipPoller) SubscribeChain() (*ChainSubscription, error) { + return t.chain.Subscribe() +} + +// BestBlockAndSubscribeChain atomically reads the cached tip and +// registers a ChainSubscription. Same atomicity guarantee as +// BestBlockAndSubscribe, applied to the unified stream. +func (t *TipPoller) BestBlockAndSubscribeChain() (int32, chainhash.Hash, + time.Time, *ChainSubscription, error) { + + t.mu.Lock() + defer t.mu.Unlock() + + sub, err := t.chain.Subscribe() + if err != nil { + return 0, chainhash.Hash{}, time.Time{}, nil, + fmt.Errorf("subscribe to chain events: %w", err) + } + + return t.tipHeight, t.tipHash, t.tipTime, sub, nil +} + +// recordHashLocked inserts a (height, hash) pair into the recent- +// hash history map and prunes any entry whose height has fallen out +// of the historySize window relative to the new entry. Caller must +// hold t.mu. +func (t *TipPoller) recordHashLocked(height int32, hash chainhash.Hash) { + t.recentHashes[height] = hash + + cutoff := height - int32(t.historySize) + for h := range t.recentHashes { + if h <= cutoff { + delete(t.recentHashes, h) + } + } +} + +// hashAtHeightLocked returns the cached hash for a height plus +// whether it was present. Caller must hold t.mu. +func (t *TipPoller) hashAtHeightLocked(height int32) (chainhash.Hash, bool) { + h, ok := t.recentHashes[height] + + return h, ok +} + // pollLoop is the single tip-polling goroutine. It ticks at // pollInterval, asks Esplora for the latest tip, and walks the // gap from the cached tip to the new tip emitting one TipBlock per @@ -278,12 +545,26 @@ func (t *TipPoller) pollLoop() { } } -// poll performs one tip-detection cycle. On detected progress it -// fetches the hash and header for each new height, broadcasts a -// TipBlock to every subscriber, and advances the cached tip -// monotonically. A failure to fetch any single block aborts the -// remainder of the cycle so subscribers never see an out-of-order -// event; the next tick re-attempts from the same starting point. +// poll performs one tip-detection cycle. The cycle is reorg-aware: +// +// 1. Query (tip height, tip hash). +// 2. If the tip is at the same height as the cached tip but the hash +// differs, walk back through cached hashes to find the fork point +// and emit a ReorgEvent + a re-broadcast TipBlock at the same +// height. This is the "same-height reorg" case that earlier +// versions of the poller could not detect. +// 3. If the new height is strictly greater than the cached height, +// fetch each intervening hash; if the first new block's PrevBlock +// header field does not point at the cached tip hash, walk back +// to find the fork point, emit a ReorgEvent for the disconnected +// range, and then fan TipBlock events for the new chain in +// ascending order. If the new chain extends the old tip cleanly +// (the common case), no ReorgEvent fires and TipBlock events are +// dispatched as before. +// +// A failure to fetch any single block aborts the remainder of the +// cycle so subscribers never see an out-of-order event; the next +// tick re-attempts from the same starting point. func (t *TipPoller) poll() { newHeight, err := t.esplora.GetTipHeight(context.Background()) if err != nil { @@ -298,23 +579,123 @@ func (t *TipPoller) poll() { t.mu.Lock() oldHeight := t.tipHeight + oldHash := t.tipHash t.mu.Unlock() - // Known limitation: a same-height reorg (block at height N - // replaced by a different block at height N) is invisible to - // this loop until the chain advances to N+1, because we gate - // progress on height alone rather than (height, hash). This - // matches the behavior of the per-component pollers that - // preceded the unified TipPoller and has historically been - // acceptable for lwwallet's confirmation-target use case - // (downstream callers re-check status against Esplora on every - // tip event, so a stale hash at height N converges within one - // extra tip advance). Documented here so it is not filed as a - // regression by a future reader. - if newHeight <= oldHeight { + switch { + case newHeight < oldHeight: + // The remote reports fewer blocks than we have cached. + // Two distinct cases hide behind a lower height: + // + // 1. A transient indexer hiccup where the remote + // momentarily lags our cached tip but is still on + // the same chain (our tip remains canonical). + // 2. A genuine reorg onto a SHORTER but higher-work + // chain: blocks above newHeight were orphaned and + // the surviving chain has fewer total blocks. Bitcoin + // follows most-work, not most-blocks, so a shorter + // chain can legitimately win. + // + // Disambiguate by comparing the live hash at newHeight to + // our cached hash for that height. Agreement means the + // remote is merely behind (case 1, no-op); divergence + // means the chain reorged out from under us (case 2) and + // we must walk back to the fork point. Without this an + // orphaning reorg to a shorter chain would be silently + // ignored until the new chain grew past our stale tip. + newTipHash, err := t.esplora.GetBlockHashByHeight( + context.Background(), newHeight, + ) + if err != nil { + t.log.WarnS( + context.Background(), + "Tip poller shorter-chain hash check failed", + err, + slog.Int("height", int(newHeight)), + ) + + return + } + + t.mu.Lock() + cachedHash, haveCached := t.hashAtHeightLocked(newHeight) + t.mu.Unlock() + + switch { + // newHeight predates our retained history floor, so we + // cannot prove a reorg by hash comparison. A reorg this + // deep exceeds historySize and is an operator-level + // problem rather than a productionally recoverable one + // (mirrors handleReorg's own deep-reorg guard); log and + // re-check next tick rather than walk a pruned history. + case !haveCached: + t.log.WarnS( + context.Background(), + "Tip poller remote tip below retained "+ + "history floor; ignoring", + fmt.Errorf("history floor exceeded"), + slog.Int("new_height", int(newHeight)), + slog.Int("old_height", int(oldHeight)), + ) + + return + + // Same hash at newHeight: the remote is merely lagging + // our cached tip on the same chain. No-op. + case newTipHash == cachedHash: + return + } + + // Divergent hash at newHeight: a reorg onto a shorter, + // higher-work chain. handleReorg builds Disconnected up + // to our old cached tip (covering the orphaned blocks + // above newHeight) and Connected up to newHeight. + t.handleReorg(newHeight, newTipHash) + + return + + case newHeight == oldHeight: + // Same-height: query the hash at this height (which + // the BlockHashByHeight endpoint resolves directly). + // If it differs from the cached hash at the same + // height we have a same-height reorg; if it matches + // the chain has not moved. + newTipHash, err := t.esplora.GetBlockHashByHeight( + context.Background(), newHeight, + ) + if err != nil { + t.log.WarnS( + context.Background(), + "Tip poller same-height hash check failed", + err, + slog.Int("height", int(newHeight)), + ) + + return + } + + if newTipHash == oldHash { + return + } + + t.handleReorg(newHeight, newTipHash) + return + + default: + // newHeight > oldHeight: forward advance, possibly with + // a deeper reorg if the first new height's predecessor + // is not the cached tip hash. + t.advance(oldHeight, newHeight) } +} +// advance walks the chain forward from oldHeight+1 to newHeight, +// detecting reorgs along the way. The very first new height's raw +// header is consulted to verify its PrevBlock matches the cached +// tip hash; mismatch triggers a walk-back to the fork point and a +// reorg emission before any new TipBlock events fire. +func (t *TipPoller) advance(oldHeight, newHeight int32) { t.log.DebugS(context.Background(), "Tip poller advancing", slog.Int("old_height", int(oldHeight)), slog.Int("new_height", int(newHeight)), @@ -349,44 +730,397 @@ func (t *TipPoller) poll() { return } + // On the FIRST new height, verify continuity against + // the cached tip. We use the raw 80-byte header (which + // carries PrevBlock) because the JSON header endpoint + // does not include the previous-block hash. If + // PrevBlock does not match the cached tip hash a reorg + // crossed the boundary; walk back to the fork point + // and emit a ReorgEvent before continuing. + if height == oldHeight+1 { + rawHdr, err := t.esplora.GetRawBlockHeader( + context.Background(), hash, + ) + if err != nil { + // Without the raw header we cannot verify + // that the new block connects to the cached + // tip. Abort the cycle without broadcasting + // or updating the cached tip; the next tick + // will retry. Optimistically advancing here + // would permanently hide a reorg that + // crossed the oldHeight boundary if the + // raw-header fetch flaked exactly when the + // reorg happened. + t.log.WarnS( + context.Background(), + "Tip poller raw header fetch "+ + "failed; aborting cycle", + err, + slog.String("hash", hash.String()), + ) + + return + } + + t.mu.Lock() + cachedOldHash, ok := t.hashAtHeightLocked( + oldHeight, + ) + t.mu.Unlock() + + if ok && rawHdr.PrevBlock != cachedOldHash { + // Deeper reorg: handle it as a new-tip + // reorg from oldHeight, pointing at the + // actual new tip at newHeight. handleReorg + // emits ReorgEvent + TipBlock events + // itself. + tipHash, tipErr := t.esplora. + GetBlockHashByHeight( + context.Background(), newHeight, + ) + if tipErr != nil { + t.log.WarnS( + context.Background(), + "Tip poller "+ + "reorg tip "+ + "fetch failed", + tipErr, + slog.Int( + "height", + int(newHeight), + ), + ) + + return + } + + t.handleReorg(newHeight, tipHash) + + return + } + } + event := &TipBlock{ Height: height, Hash: hash, Header: header, } - // Hold t.mu across the {update tip + SendUpdate} - // pair so BestBlockAndSubscribe can serialize against - // it: a subscriber that acquires t.mu before us reads - // the OLD tip and is guaranteed to receive THIS event - // once it Subscribes (subscribe.Server's handler is - // single-threaded over Subscribe and SendUpdate, so - // our SendUpdate enqueues behind their Subscribe). A - // subscriber that acquires t.mu after us reads the - // NEW tip and will see only events strictly newer - // than this one. Without holding t.mu here a tip - // reader+subscriber pair has a small window where it - // can read the new tip but miss this event entirely. + if !t.broadcastTipBlock(event) { + return + } + } +} + +// broadcastTipBlock updates the cached tip, records the hash in the +// history map, and fans the TipBlock out to subscribers. Returns +// false if the send failed (server shutting down), telling the +// caller to abort the cycle so the cached tip is not advanced past +// an event subscribers did not receive. +func (t *TipPoller) broadcastTipBlock(event *TipBlock) bool { + // Hold t.mu across the {update tip + SendUpdate} pair so + // BestBlockAndSubscribe can serialize against it: a subscriber + // that acquires t.mu before us reads the OLD tip and is + // guaranteed to receive THIS event once it Subscribes + // (subscribe.Server's handler is single-threaded over + // Subscribe and SendUpdate, so our SendUpdate enqueues behind + // their Subscribe). A subscriber that acquires t.mu after us + // reads the NEW tip and will see only events strictly newer + // than this one. + t.mu.Lock() + t.tipHeight = event.Height + t.tipHash = event.Hash + if event.Header != nil { + t.tipTime = time.Unix(event.Header.Timestamp, 0) + } + t.recordHashLocked(event.Height, event.Hash) + tipErr := t.events.SendUpdate(event) + chainErr := t.chain.SendUpdate(&ChainEvent{Tip: event}) + t.mu.Unlock() + + if tipErr != nil { + t.log.WarnS( + context.Background(), + "Tip poller send update failed", + tipErr, + slog.Int("height", int(event.Height)), + ) + + return false + } + + if chainErr != nil { + t.log.WarnS( + context.Background(), + "Tip poller chain stream send failed", + chainErr, + slog.Int("height", int(event.Height)), + ) + + return false + } + + return true +} + +// handleReorg walks back through the recent-hash history map until it +// finds the fork point between the cached chain and the new chain +// whose tip is (newTipHeight, newTipHash). It then constructs a +// ReorgEvent listing the disconnected hashes and connected blocks, +// broadcasts the reorg, and finally fans out the connected blocks +// as TipBlock events in ascending height order so consumers can +// re-check registrations against each new block. +func (t *TipPoller) handleReorg(newTipHeight int32, newTipHash chainhash.Hash) { + t.log.InfoS(context.Background(), "Tip poller detected reorg", + slog.Int("new_tip_height", int(newTipHeight)), + slog.String("new_tip_hash", newTipHash.String()), + ) + + // Walk back to find the fork point. We probe heights from + // newTipHeight downwards, comparing the live Esplora hash at + // each height to the cached hash. The first height at which + // they agree is the fork point. We bound the search by the + // retained history depth so a misbehaving Esplora cannot drag + // us into an unbounded loop. + t.mu.Lock() + cachedTipHeight := t.tipHeight + cutoff := cachedTipHeight - int32(t.historySize) + t.mu.Unlock() + + if cutoff < 0 { + cutoff = 0 + } + + // connectedByHeight collects new-chain blocks discovered + // during walk-back so we can re-broadcast them in ascending + // order after the fork point is found. + connectedByHeight := make(map[int32]chainhash.Hash) + connectedByHeight[newTipHeight] = newTipHash + + forkHeight := int32(-1) + probeHeight := newTipHeight + for probeHeight > cutoff { + probeHeight-- + + // Heights strictly above our cached tip cannot be a fork + // point: we never broadcast a block there, so the live + // block is a NEW connected block on a longer chain (a + // forward reorg that both reorganized old blocks AND + // extended past our tip). Record it and keep walking + // down toward the real fork. Without this, the + // !haveCached branch below would mistake the first such + // height for the fork point and compute a forkHeight + // above the cached tip, underflowing the Disconnected + // slice capacity. + if probeHeight > cachedTipHeight { + liveHash, err := t.esplora.GetBlockHashByHeight( + context.Background(), probeHeight, + ) + if err != nil { + t.log.WarnS( + context.Background(), + "Tip poller reorg walk-back failed", + err, + slog.Int("height", int(probeHeight)), + ) + + return + } + + connectedByHeight[probeHeight] = liveHash + + continue + } + t.mu.Lock() - t.tipHeight = height - t.tipHash = hash - t.tipTime = time.Unix(header.Timestamp, 0) - sendErr := t.events.SendUpdate(event) + cachedHash, haveCached := t.hashAtHeightLocked(probeHeight) t.mu.Unlock() - // SendUpdate failures only happen when the embedded - // subscribe.Server is shutting down; log and exit so - // we do not advance the cached tip past an event we - // failed to fan out. - if sendErr != nil { + if !haveCached { + // We never broadcast a block at this height + // (typically because it's older than our + // retained history's start, e.g. on a fresh + // poller that only ever cached the initial + // tip). Treat probeHeight as the fork point: + // anything at or below it is not part of our + // old broadcast set, so it cannot be + // "disconnected" from a downstream consumer's + // point of view. The live hash at this height + // is not a new connected block we owe + // subscribers either; only blocks strictly + // above probeHeight that we previously + // broadcast (and that have now changed) form + // the reorg boundary. + forkHeight = probeHeight + + break + } + + liveHash, err := t.esplora.GetBlockHashByHeight( + context.Background(), probeHeight, + ) + if err != nil { t.log.WarnS( context.Background(), - "Tip poller send update failed", - sendErr, - slog.Int("height", int(height)), + "Tip poller reorg walk-back failed", + err, + slog.Int("height", int(probeHeight)), + ) + + return + } + + if liveHash == cachedHash { + // Genuine fork point: the new chain agrees with + // our cached canonical chain at this height. + forkHeight = probeHeight + + break + } + + // haveCached && liveHash != cachedHash: this height is + // part of the reorg. Record the live hash so we can + // rebroadcast it after the fork point is found. + connectedByHeight[probeHeight] = liveHash + } + + if forkHeight < 0 { + // We exhausted the retained history without finding a + // fork point. Be conservative: log and bail. A future + // tick will re-attempt with the now-pruned history; in + // practice a reorg deeper than DefaultHashHistorySize + // is a developer / operator problem, not a + // productionally recoverable condition. + t.log.WarnS( + context.Background(), + "Tip poller reorg deeper than retained history; "+ + "giving up walk-back", + fmt.Errorf("history exhausted"), + slog.Int("new_tip_height", int(newTipHeight)), + slog.Int("history_size", t.historySize), + ) + + return + } + + // Build the Disconnected slice from cached hashes between + // forkHeight+1 and the cached tipHeight in one critical + // section. The walk is already in ascending order so no sort + // is needed; we cap at the history size to bound the worst + // case under t.mu. + t.mu.Lock() + cachedTipHeight = t.tipHeight + disconnected := make( + []chainhash.Hash, 0, int(cachedTipHeight-forkHeight), + ) + for h := forkHeight + 1; h <= cachedTipHeight; h++ { + if hash, ok := t.hashAtHeightLocked(h); ok { + disconnected = append(disconnected, hash) + } + } + t.mu.Unlock() + + // Build the Connected slice. Heights range from forkHeight+1 + // to newTipHeight; for each, fetch the header so the + // TipBlock carries a populated Header field for downstream + // consumers. + connectedHeights := make([]int32, 0, len(connectedByHeight)) + for h := range connectedByHeight { + if h <= forkHeight { + continue + } + connectedHeights = append(connectedHeights, h) + } + sort.Slice(connectedHeights, func(i, j int) bool { + return connectedHeights[i] < connectedHeights[j] + }) + + connected := make([]*TipBlock, 0, len(connectedHeights)) + for _, h := range connectedHeights { + hash := connectedByHeight[h] + header, err := t.esplora.GetBlockHeader( + context.Background(), hash, + ) + if err != nil { + t.log.WarnS( + context.Background(), + "Tip poller reorg header fetch failed", + err, + slog.String("hash", hash.String()), ) return } + + connected = append(connected, &TipBlock{ + Height: h, + Hash: hash, + Header: header, + }) + } + + // Prune the now-stale hashes from the history before we + // broadcast the reorg so a subscriber that immediately calls + // back into BestBlock sees the new tip. + t.mu.Lock() + for h := forkHeight + 1; h <= cachedTipHeight; h++ { + delete(t.recentHashes, h) + } + t.mu.Unlock() + + reorgEvent := &ReorgEvent{ + ForkHeight: forkHeight, + Disconnected: disconnected, + Connected: connected, + } + + t.log.InfoS(context.Background(), "Tip poller emitting reorg", + slog.Int("fork_height", int(forkHeight)), + slog.Int("disconnected", len(disconnected)), + slog.Int("connected", len(connected)), + ) + + if err := t.reorgs.SendUpdate(reorgEvent); err != nil { + t.log.WarnS( + context.Background(), + "Tip poller reorg send failed", + err, + ) + + return + } + + // Also emit the reorg on the unified chain stream BEFORE any + // connected-block tip events land on it, so a single consumer + // reading the chain subscription sees Reorged before any of + // the replacement Connected blocks. This is the load-bearing + // ordering property the chain.Interface adapter needs to emit + // BlockDisconnected before BlockConnected, and the chainsource + // backend needs to reset registrations before block-epoch + // driven re-checks run on the replacement chain. + if err := t.chain.SendUpdate( + &ChainEvent{Reorg: reorgEvent}, + ); err != nil { + + t.log.WarnS( + context.Background(), + "Tip poller chain reorg send failed", + err, + ) + + return + } + + // Finally, fan the connected blocks out on the standard + // TipBlock stream so existing consumers re-check on each + // new block exactly as they would on a non-reorg advance. + // broadcastTipBlock also pushes each block onto the unified + // chain stream so the single-channel consumer observes the + // connected blocks immediately after the reorg event in the + // same producer-ordered sequence. + for _, block := range connected { + if !t.broadcastTipBlock(block) { + return + } } } diff --git a/lwwallet/tip_poller_reorg_test.go b/lwwallet/tip_poller_reorg_test.go new file mode 100644 index 000000000..0b9dd2cbc --- /dev/null +++ b/lwwallet/tip_poller_reorg_test.go @@ -0,0 +1,274 @@ +package lwwallet + +import ( + "fmt" + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/stretchr/testify/require" +) + +// mintStubHeaderGen is like mintStubHeader but mixes a fork generation +// into the salt so re-minting the same height on a forked chain yields +// a distinct BlockHash. This lets a test rebuild a height range on a +// different chain whose hashes provably diverge from the original. +func mintStubHeaderGen(height, gen int32, + prev chainhash.Hash) *wire.BlockHeader { + + salt := chainhash.HashH( + fmt.Appendf(nil, "stub-block-%d-gen-%d", height, gen), + ) + + return &wire.BlockHeader{ + Version: 1, + PrevBlock: prev, + MerkleRoot: chainhash.HashH( + fmt.Appendf(nil, "merkle-%d-gen-%d", height, gen), + ), + Timestamp: time.Unix(int64(height)*600, 0), + Bits: 0x207fffff, + Nonce: uint32(salt[0])<<24 | uint32(salt[1])<<16 | + uint32(salt[2])<<8 | uint32(salt[3]), + } +} + +// reorgTo rewrites the chain onto a fork that branches at forkHeight and +// extends to newTip. newTip may be lower than the current tip (a shorter +// but higher-work chain), equal to it (a same-height hash replacement), +// or higher (a deeper forward reorg). Heights above forkHeight are +// replaced with generation-salted blocks whose hashes diverge from the +// old chain; any old heights above newTip are orphaned (removed) so the +// stubbed backend no longer serves them. +func (c *stubChain) reorgTo(t *testing.T, forkHeight, newTip, gen int32) { + t.Helper() + + c.mu.Lock() + defer c.mu.Unlock() + + // Drop every height above the fork point; the new chain rebuilds + // forkHeight+1 .. newTip and orphans anything beyond newTip. + for h := range c.hashAt { + if h > forkHeight { + delete(c.hashAt, h) + delete(c.blocks, h) + } + } + + prev := c.hashAt[forkHeight] + for h := forkHeight + 1; h <= newTip; h++ { + hdr := mintStubHeaderGen(h, gen, prev) + c.blocks[h] = hdr + hash := hdr.BlockHash() + c.hashAt[h] = hash + prev = hash + } + + c.tipHeight = newTip +} + +// setTipHeight lowers (or raises) the reported tip height WITHOUT +// touching any cached hashes. It models a transient indexer hiccup +// where the remote momentarily reports fewer blocks than it served a +// moment ago, but is still on the same chain. +func (c *stubChain) setTipHeight(h int32) { + c.mu.Lock() + defer c.mu.Unlock() + + c.tipHeight = h +} + +// requireTipEventually blocks until the poller's BestBlock height +// reaches want, failing the test if it does not within the deadline. +func requireTipEventually(t *testing.T, tp *TipPoller, want int32) { + t.Helper() + + require.Eventually(t, func() bool { + h, _, _ := tp.BestBlock() + + return h == want + }, 2*time.Second, 5*time.Millisecond, + "poller never reached tip height %d", want) +} + +// newReorgTestPoller spins up a TipPoller over a stubChain seeded at the +// given height and returns both so a test can drive reorgs. +func newReorgTestPoller(t *testing.T, + seedHeight int32) (*TipPoller, *stubChain) { + + t.Helper() + + chain := newStubChain(seedHeight) + srv := mockEsploraServer(t, stubEsploraHandler(t, chain)) + + tp := NewTipPoller( + NewEsploraClient(srv.URL, btclog.Disabled), 10*time.Millisecond, + btclog.Disabled, + ) + require.NoError(t, tp.Start()) + t.Cleanup(tp.Stop) + + return tp, chain +} + +// TestTipPollerShorterChainReorg covers the case Roasbeef flagged: a +// reorg onto a SHORTER but higher-work chain, where blocks above the new +// tip are orphaned. The poller must detect this even though the remote +// reports a lower height than the cached tip, and roll the tip back to +// the shorter chain's tip. +func TestTipPollerShorterChainReorg(t *testing.T) { + t.Parallel() + + tp, chain := newReorgTestPoller(t, 100) + + reorgSub, err := tp.SubscribeReorgs() + require.NoError(t, err) + defer reorgSub.Cancel() + + // Grow to 110 so the poller caches 101..110, then confirm it + // observed the advance before we reorg out from under it. + chain.advance(t, 10) + requireTipEventually(t, tp, 110) + + // Fork at 105 onto a shorter chain that tips at 108 (< 110). + chain.reorgTo(t, 105, 108, 1) + + select { + case ev := <-reorgSub.Updates(): + require.NotNil(t, ev) + require.Equal(t, int32(105), ev.ForkHeight) + + // Old chain 106..110 disconnects (5 blocks), even though + // the new chain only carries 106..108. + require.Len(t, ev.Disconnected, 5) + + // New chain connects 106..108 (3 blocks) in ascending + // order, tipping at 108. + require.Len(t, ev.Connected, 3) + require.Equal( + t, int32(106), ev.Connected[0].Height, + ) + require.Equal( + t, int32(108), ev.Connected[len(ev.Connected)-1].Height, + ) + + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for shorter-chain reorg event") + } + + // The poller's tip must now be the shorter chain's tip. + requireTipEventually(t, tp, 108) +} + +// TestTipPollerShorterHeightSameChainNoReorg verifies the no-op half of +// the shorter-height branch: a transient indexer hiccup where the remote +// reports fewer blocks but is still on the same chain (the hash at the +// reported height matches our cache). No reorg must fire and the cached +// tip must NOT roll back. +func TestTipPollerShorterHeightSameChainNoReorg(t *testing.T) { + t.Parallel() + + tp, chain := newReorgTestPoller(t, 100) + + reorgSub, err := tp.SubscribeReorgs() + require.NoError(t, err) + defer reorgSub.Cancel() + + chain.advance(t, 10) + requireTipEventually(t, tp, 110) + + // Remote briefly reports height 108 with the SAME hash history + // (no fork). This is lag, not a reorg. + chain.setTipHeight(108) + + select { + case ev := <-reorgSub.Updates(): + t.Fatalf("unexpected reorg on transient lag: %+v", ev) + + case <-time.After(300 * time.Millisecond): + // No reorg fired: correct. + } + + // The tip must remain at 110: we never roll back on a lagging + // remote that is still on our chain. + h, _, _ := tp.BestBlock() + require.Equal(t, int32(110), h) +} + +// TestTipPollerSameHeightReorg covers a same-height hash replacement: the +// tip height does not change but the block at the tip is replaced by a +// different block on a competing chain. +func TestTipPollerSameHeightReorg(t *testing.T) { + t.Parallel() + + tp, chain := newReorgTestPoller(t, 100) + + reorgSub, err := tp.SubscribeReorgs() + require.NoError(t, err) + defer reorgSub.Cancel() + + chain.advance(t, 10) + requireTipEventually(t, tp, 110) + + oldHash := chain.hashAt[110] + + // Replace block 110 with a different block at the same height. + chain.reorgTo(t, 109, 110, 1) + + select { + case ev := <-reorgSub.Updates(): + require.NotNil(t, ev) + require.Equal(t, int32(109), ev.ForkHeight) + require.Len(t, ev.Disconnected, 1) + require.Equal(t, oldHash, ev.Disconnected[0]) + require.Len(t, ev.Connected, 1) + require.Equal(t, int32(110), ev.Connected[0].Height) + + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for same-height reorg event") + } + + requireTipEventually(t, tp, 110) +} + +// TestTipPollerDeeperForwardReorg covers a reorg that also extends the +// chain: the new chain forks below the old tip yet ends higher than it. +// The forward-advance path must notice the broken PrevBlock continuity +// at the old tip boundary and emit a reorg before the new tip events. +func TestTipPollerDeeperForwardReorg(t *testing.T) { + t.Parallel() + + tp, chain := newReorgTestPoller(t, 100) + + reorgSub, err := tp.SubscribeReorgs() + require.NoError(t, err) + defer reorgSub.Cancel() + + chain.advance(t, 10) + requireTipEventually(t, tp, 110) + + // Fork at 107 onto a longer chain that tips at 113. + chain.reorgTo(t, 107, 113, 1) + + select { + case ev := <-reorgSub.Updates(): + require.NotNil(t, ev) + require.Equal(t, int32(107), ev.ForkHeight) + + // Old chain 108..110 disconnects (3 blocks). + require.Len(t, ev.Disconnected, 3) + + // New chain connects 108..113 (6 blocks). + require.Len(t, ev.Connected, 6) + require.Equal( + t, int32(113), ev.Connected[len(ev.Connected)-1].Height, + ) + + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for deeper forward reorg event") + } + + requireTipEventually(t, tp, 113) +} diff --git a/lwwallet/tip_poller_test.go b/lwwallet/tip_poller_test.go index e4aed4a9c..cf75fa16b 100644 --- a/lwwallet/tip_poller_test.go +++ b/lwwallet/tip_poller_test.go @@ -1,6 +1,8 @@ package lwwallet import ( + "bytes" + "encoding/hex" "fmt" "net/http" "sync" @@ -9,6 +11,7 @@ import ( "time" "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btclog/v2" "github.com/stretchr/testify/require" ) @@ -16,28 +19,59 @@ import ( // stubChain is a tiny test fixture that simulates an Esplora chain // where the tip height can be advanced under test control. It is // independent of the larger mockEsploraServer helper so tip-poller -// tests can drive the chain forward synchronously. +// tests can drive the chain forward synchronously. Each height holds +// a real wire.BlockHeader whose PrevBlock chains back to the previous +// height; the poller's PrevBlock continuity check on advance therefore +// passes naturally rather than aborting. type stubChain struct { mu sync.Mutex tipHeight int32 - // hashAt[h] is the hash for height h. We pre-populate as the - // tip advances so GetBlockHashByHeight resolves consistently. + // blocks[h] holds the wire.BlockHeader for height h. Pre- + // populated through 0..tipHeight at construction and grown + // monotonically by advance. + blocks map[int32]*wire.BlockHeader + + // hashAt[h] is the chainhash.Hash for height h. hashAt map[int32]chainhash.Hash } +// mintStubHeader builds a deterministic wire.BlockHeader for a given +// height, chaining off the supplied previous hash. The salted nonce +// makes the resulting BlockHash stable per (height, prev) and unique +// across heights. +func mintStubHeader(height int32, prev chainhash.Hash) *wire.BlockHeader { + salt := chainhash.HashH([]byte(fmt.Sprintf("stub-block-%d", height))) + + return &wire.BlockHeader{ + Version: 1, + PrevBlock: prev, + MerkleRoot: chainhash.HashH( + []byte( + fmt.Sprintf("merkle-%d", height), + ), + ), + Timestamp: time.Unix(int64(height)*600, 0), + Bits: 0x207fffff, + Nonce: uint32(salt[0])<<24 | uint32(salt[1])<<16 | + uint32(salt[2])<<8 | uint32(salt[3]), + } +} + func newStubChain(tipHeight int32) *stubChain { c := &stubChain{ tipHeight: tipHeight, + blocks: make(map[int32]*wire.BlockHeader), hashAt: make(map[int32]chainhash.Hash), } + var prev chainhash.Hash for h := int32(0); h <= tipHeight; h++ { - c.hashAt[h] = chainhash.HashH( - []byte( - fmt.Sprintf("block-%d", h), - ), - ) + hdr := mintStubHeader(h, prev) + c.blocks[h] = hdr + hash := hdr.BlockHash() + c.hashAt[h] = hash + prev = hash } return c @@ -49,13 +83,14 @@ func (c *stubChain) advance(t *testing.T, n int32) { c.mu.Lock() defer c.mu.Unlock() + prev := c.hashAt[c.tipHeight] for i := int32(1); i <= n; i++ { h := c.tipHeight + i - c.hashAt[h] = chainhash.HashH( - []byte( - fmt.Sprintf("block-%d", h), - ), - ) + hdr := mintStubHeader(h, prev) + c.blocks[h] = hdr + hash := hdr.BlockHash() + c.hashAt[h] = hash + prev = hash } c.tipHeight += n @@ -147,19 +182,29 @@ func stubEsploraHandler(t *testing.T, chain *stubChain) http.HandlerFunc { h.String(), height, int64(height)*600) + case "/header": + // Raw 80-byte header, hex-encoded. + chain.mu.Lock() + hdr := chain.blocks[height] + chain.mu.Unlock() + if hdr == nil { + http.Error( + w, "not found", + http.StatusNotFound, + ) + + return + } + var buf bytes.Buffer + require.NoError(t, hdr.Serialize(&buf)) + _, _ = fmt.Fprint( + w, + hex.EncodeToString( + buf.Bytes(), + ), + ) + default: - // Raw header / raw block — synthesize a - // header whose serialized bytes hash to - // h. We take the simple route of using - // h's bytes themselves: the header - // hash-verifier compares header.BlockHash() - // to the requested h, so any header that - // happens to round-trip works. Synthesizing - // such a header from a target hash is - // effectively impossible, so for these - // suffix variants we return 501 — tests - // that need them must use the cache - // pre-fill path. http.Error( w, "not implemented", http.StatusNotImplemented, diff --git a/oor/lineage_batch_canon_test.go b/oor/lineage_batch_canon_test.go new file mode 100644 index 000000000..44f3a2873 --- /dev/null +++ b/oor/lineage_batch_canon_test.go @@ -0,0 +1,165 @@ +package oor + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/lib/tree" + "github.com/lightninglabs/wavelength/vtxo" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// oorBCRef aliases the canonicality manager tell-ref to keep test literals +// within the line limit. +type oorBCRef = actor.TellOnlyRef[batchcanon.ManagerMsg] + +// lineageFragment builds an ancestry fragment anchored at the given commitment +// txid whose tree root carries the supplied batch-output pkScript. +func lineageFragment(txid chainhash.Hash, pkScript []byte) vtxo.Ancestry { + return vtxo.Ancestry{ + CommitmentTxID: txid, + TreePath: &tree.Tree{ + BatchOutput: &wire.TxOut{ + PkScript: pkScript, + }, + }, + } +} + +func lineageOutpoint(seed byte) wire.OutPoint { + var h chainhash.Hash + h[0] = seed + + return wire.OutPoint{Hash: h, Index: uint32(seed)} +} + +// TestRegisterLineageBatchesRegistersDistinctAncestors verifies that receiving +// OOR VTXOs registers one RegisterBatchRequest per distinct ancestor commitment +// tx, carrying the tree-root batch pkScript and the dependent VTXO outpoints, +// and that a batch shared across two received VTXOs accumulates both. +func TestRegisterLineageBatchesRegistersDistinctAncestors(t *testing.T) { + t.Parallel() + + ref := actor.NewChannelTellOnlyRef[batchcanon.ManagerMsg]( + "oor-batchcanon-test", 8, + ) + b := &sessionBehavior{ + cfg: SessionActorConfig{ + BatchCanonicality: fn.Some[oorBCRef](ref), + }, + log: btclog.Disabled, + } + + txA := chainhash.Hash{0xaa} + txB := chainhash.Hash{0xbb} + scriptA := []byte{0x51, 0x20, 0xaa} + scriptB := []byte{0x51, 0x20, 0xbb} + + vtxo1 := lineageOutpoint(1) + vtxo2 := lineageOutpoint(2) + + descs := []*vtxo.Descriptor{ + { + Outpoint: vtxo1, + Ancestry: []vtxo.Ancestry{ + lineageFragment(txA, scriptA), + }, + }, + { + // Multi-parent: shares txA and adds txB. + Outpoint: vtxo2, + Ancestry: []vtxo.Ancestry{ + lineageFragment(txA, scriptA), + lineageFragment(txB, scriptB), + }, + }, + } + + b.registerLineageBatches(t.Context(), descs) + + got := make(map[chainhash.Hash]*batchcanon.RegisterBatchRequest) + for range 2 { + msg, ok := ref.AwaitMessage(time.Second) + require.True(t, ok, "expected a RegisterBatchRequest") + req, ok := msg.(*batchcanon.RegisterBatchRequest) + require.True(t, ok) + got[req.BatchTxID] = req + } + + // No third registration. + _, extra := ref.AwaitMessage(100 * time.Millisecond) + require.False(t, extra, "expected exactly two distinct batches") + + require.Contains(t, got, txA) + require.Equal(t, scriptA, got[txA].ConfirmationPkScript) + require.ElementsMatch( + t, []wire.OutPoint{vtxo1, vtxo2}, got[txA].DependentVTXOs, + ) + + require.Contains(t, got, txB) + require.Equal(t, scriptB, got[txB].ConfirmationPkScript) + require.Equal(t, []wire.OutPoint{vtxo2}, got[txB].DependentVTXOs) +} + +// TestRegisterLineageBatchesDormantWhenUnwired verifies registration is a no-op +// when no canonicality manager ref is configured. +func TestRegisterLineageBatchesDormantWhenUnwired(t *testing.T) { + t.Parallel() + + b := &sessionBehavior{ + cfg: SessionActorConfig{ + BatchCanonicality: fn.None[oorBCRef](), + }, + log: btclog.Disabled, + } + + // Must not panic with a populated lineage and no ref. + b.registerLineageBatches(t.Context(), []*vtxo.Descriptor{ + { + Outpoint: lineageOutpoint(1), + Ancestry: []vtxo.Ancestry{ + lineageFragment( + chainhash.Hash{0xaa}, []byte{0x51}, + ), + }, + }, + }) +} + +// TestRegisterLineageBatchesSkipsIncompleteFragments verifies fragments with no +// tree path / batch output or a zero txid are skipped (they cannot be watched), +// leaving the gate permissive rather than registering an unwatchable batch. +func TestRegisterLineageBatchesSkipsIncompleteFragments(t *testing.T) { + t.Parallel() + + ref := actor.NewChannelTellOnlyRef[batchcanon.ManagerMsg]( + "oor-batchcanon-skip", 4, + ) + b := &sessionBehavior{ + cfg: SessionActorConfig{ + BatchCanonicality: fn.Some[oorBCRef](ref), + }, + log: btclog.Disabled, + } + + b.registerLineageBatches(t.Context(), []*vtxo.Descriptor{ + { + Outpoint: lineageOutpoint(1), + Ancestry: []vtxo.Ancestry{ + // Nil tree path: skipped. + {CommitmentTxID: chainhash.Hash{0xaa}}, + // Zero txid: skipped. + lineageFragment(chainhash.Hash{}, []byte{0x51}), + }, + }, + }) + + _, ok := ref.AwaitMessage(200 * time.Millisecond) + require.False(t, ok, "no registration expected for incomplete lineage") +} diff --git a/oor/registry.go b/oor/registry.go index 4eb61d611..83cc2bdea 100644 --- a/oor/registry.go +++ b/oor/registry.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/build" clientdb "github.com/lightninglabs/wavelength/db" "github.com/lightninglabs/wavelength/ledger" @@ -85,6 +86,11 @@ type OORRegistryConfig struct { TimeoutActor actor.TellOnlyRef[timeout.Msg] CallbackRef actor.TellOnlyRef[*timeout.ExpiredMsg] ActorSystem actor.SystemContext + + // BatchCanonicality, when set, is forwarded to every spawned session + // actor so a received VTXO's lineage batches get registered with the + // reorg-safety gate as they materialize. None disables registration. + BatchCanonicality fn.Option[actor.TellOnlyRef[batchcanon.ManagerMsg]] } // OORRegistryActor is the thin coordinator over per-session OOR actors. It @@ -1479,6 +1485,7 @@ func (r *oorRegistryBehavior) childConfig(sessionID SessionID, Limits: normalizeReceiveLimits(r.cfg.Limits), TimeoutActor: r.cfg.TimeoutActor, CallbackRef: r.cfg.CallbackRef, + BatchCanonicality: r.cfg.BatchCanonicality, Registry: r.selfRef, } } diff --git a/oor/session_actor.go b/oor/session_actor.go index 0f417572f..6d2a8ae55 100644 --- a/oor/session_actor.go +++ b/oor/session_actor.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/build" clientdb "github.com/lightninglabs/wavelength/db" "github.com/lightninglabs/wavelength/ledger" @@ -64,6 +65,14 @@ type SessionActorConfig struct { // materialized so it can spawn monitoring actors. VTXOManager actor.TellOnlyRef[vtxo.ManagerMsg] + // BatchCanonicality, when set, receives a RegisterBatchRequest for + // each commitment batch in a received OOR VTXO's lineage so the + // reorg-safety availability gate (darepo#454) can govern the received + // VTXO: a reorg-out or invalidation of any ancestor batch marks the + // VTXO limbo. None disables registration (the gate stays dormant), + // matching the C5/C6 dormancy contract. + BatchCanonicality fn.Option[actor.TellOnlyRef[batchcanon.ManagerMsg]] + // SpendCompleter routes outgoing input-spend completion through the // VTXO manager. The manager's status write commits in the VTXO actor's // own transaction, so it does NOT join this actor's turn: the spend is diff --git a/oor/session_actor_handlers.go b/oor/session_actor_handlers.go index 888338adf..c7729f40f 100644 --- a/oor/session_actor_handlers.go +++ b/oor/session_actor_handlers.go @@ -9,6 +9,7 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/batchcanon" clientdb "github.com/lightninglabs/wavelength/db" "github.com/lightninglabs/wavelength/ledger" libtypes "github.com/lightninglabs/wavelength/lib/types" @@ -757,10 +758,111 @@ func (b *sessionBehavior) notifyMaterialized(ctx context.Context, event Event) { "observer failed", err) } } + + b.registerLineageBatches(ctx, descs) }() }) } +// registerLineageBatches registers every commitment batch in the received +// VTXOs' lineage with the canonicality manager so the multi-parent admission +// gate can govern these OOR-received VTXOs: a reorg-out or invalidation of any +// ancestor batch marks the dependent VTXO limbo, excluding it from selection +// until its lineage is canonical again (darepo#454, F4/F5). +// +// It is a no-op when no manager ref is wired (the gate stays dormant). It runs +// on the post-commit best-effort goroutine alongside the VTXO-manager +// notification: a dropped registration only leaves the gate permissive for +// these VTXOs (the safe default), and the manager's RegisterBatch is +// idempotent so a re-materialization re-registers harmlessly. +// +// The batch-output pkScript comes from each ancestry fragment's tree root +// (BatchOutput), which is what script-filtering light-client backends filter +// on, so confirmation detection of the ancestor batch works on Esplora/Neutrino +// receivers too. ConsumedInputs are left empty: the receiver does not hold the +// ancestor commitment txs' inputs at this seam, so per-input double-spend +// watches are a follow-up — a reorg-out of an ancestor batch is still detected +// via its confirmation watch and marks the VTXO limbo. +func (b *sessionBehavior) registerLineageBatches(ctx context.Context, + descs []*vtxo.Descriptor) { + + if b.cfg.BatchCanonicality.IsNone() { + return + } + ref := b.cfg.BatchCanonicality.UnsafeFromSome() + + // Collect, per distinct ancestor commitment tx, its batch-output + // pkScript and the deduped set of received VTXO outpoints that depend + // on it (a desc may carry the same commitment txid across more than one + // ancestry fragment). + type batchReg struct { + pkScript []byte + depSeen map[wire.OutPoint]struct{} + dependents []wire.OutPoint + } + batches := make(map[chainhash.Hash]*batchReg) + order := make([]chainhash.Hash, 0) + + for _, desc := range descs { + for i := range desc.Ancestry { + frag := desc.Ancestry[i] + if frag.TreePath == nil || + frag.TreePath.BatchOutput == nil { + + continue + } + txid := frag.CommitmentTxID + if txid == (chainhash.Hash{}) { + continue + } + + reg, ok := batches[txid] + if !ok { + reg = &batchReg{ + pkScript: frag.TreePath. + BatchOutput.PkScript, + depSeen: make( + map[wire.OutPoint]struct{}, + ), + } + batches[txid] = reg + order = append(order, txid) + } + if _, dup := reg.depSeen[desc.Outpoint]; !dup { + reg.depSeen[desc.Outpoint] = struct{}{} + reg.dependents = append( + reg.dependents, desc.Outpoint, + ) + } + } + } + + for _, txid := range order { + reg := batches[txid] + + // CSVExpiryDelta is intentionally left zero here: the + // admission gate consults only the batch State (reorged / + // conflict), never EffectiveExpiry, and the per-batch + // effective expiry is not consumed for OOR-registered ancestor + // batches (each received VTXO carries its own BatchExpiry). + // Threading the real per-fragment CSV delta is a follow-up + // alongside ConsumedInputs. + err := ref.Tell(ctx, &batchcanon.RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: reg.pkScript, + DependentVTXOs: reg.dependents, + }) + if err != nil { + b.log.WarnS(ctx, "Failed to register OOR lineage "+ + "batch canonicality", err, + slog.String("commitment_txid", txid.String()), + slog.Int("dependent_vtxos", + len(reg.dependents)), + ) + } + } +} + // queueVTXOsReceived stages one VTXOReceivedMsg per materialized incoming // VTXO for the durable outbox enqueue in commitAck. func (b *sessionBehavior) queueVTXOsReceived(ctx context.Context, diff --git a/round/actor.go b/round/actor.go index 2536a4db4..a1172df7d 100644 --- a/round/actor.go +++ b/round/actor.go @@ -20,6 +20,7 @@ import ( "github.com/google/uuid" "github.com/lightninglabs/wavelength/baselib/actor" "github.com/lightninglabs/wavelength/baselib/protofsm" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/chainsource" "github.com/lightninglabs/wavelength/ledger" "github.com/lightninglabs/wavelength/lib/actormsg" @@ -45,7 +46,7 @@ const defaultForfeitCollectionTimeout = 2 * time.Minute // seal-time quote), so it should arrive within a network round-trip plus brief // server-side queuing; 60s is generous enough to tolerate a slow server or // link while still bounding how long forfeit-reserved inputs sit stranded in -// pending-forfeit when the server never responds (wavelength#653). It is +// pending-forfeit when the server never responds (darepo-client#653). It is // configurable via RoundClientConfig.RegistrationTimeout for operators and // tests that need a different bound. const defaultRegistrationTimeout = 60 * time.Second @@ -229,6 +230,29 @@ type RoundClientActor struct { // keys for routing confirmation events. commitmentTxIndex map[chainhash.Hash]RoundKeyStr + // pendingCommitmentConfs caches the most recent ConfirmationEvent + // for each tracked commitment tx, so handleCommitmentFinalized can + // build the BoardingConfirmed FSM event using the canonical-chain + // confirmation height observed before finality. The entry is + // installed on every ConfirmationEvent (first conf or any + // re-confirmation after a reorg) and consumed on the matching + // CommitmentFinalizedEvent. A reorg without a follow-up + // re-confirmation leaves the stale entry in place, but the + // chainsource finality synthesizer requires a non-zero + // confirmHeight to fire a Done event, so finality cannot land on + // the stale entry; the next re-confirmation overwrites it before + // any Done could be synthesized. + // + // The cache exists because the round FSM intentionally delays its + // terminal transition (InputSigSent -> ConfirmedState) until the + // commitment-tx confirmation is past the chainsource backend's + // reorg-safety depth. The first ConfirmationEvent on its own is + // not sufficient to commit user-visible state (VTXOs marked live, + // ledger emissions, indexer notifications) because a reorg of the + // confirmation block would otherwise leave that state inconsistent + // with the canonical chain. + pendingCommitmentConfs map[chainhash.Hash]*ConfirmationEvent + // pendingQuotes buffers JoinRoundQuoteReceived envelopes that // arrive before the matching RoundJoined re-keys the FSM. The // mailbox contract (docs/RPC_MAILBOX_CONTRACT.md:90-98) allows @@ -306,6 +330,15 @@ type RoundClientConfig struct { // Optional - if nil, notifications are not forwarded. VTXOManager actor.TellOnlyRef[VTXOManagerMsg] + // BatchCanonicality, when set, receives a RegisterBatchRequest for + // each confirmed round-born batch so the reorg-safety availability + // gate (darepo#454) can track the batch's canonicality and exclude its + // VTXOs from coin selection if the batch reorgs out or a consumed input + // is double-spent. None disables registration (the gate stays dormant), + // which preserves pre-C6 behavior for hosts that have not wired the + // canonicality manager. + BatchCanonicality fn.Option[actor.TellOnlyRef[batchcanon.ManagerMsg]] + // DropCustomForfeitSigningContexts clears daemon-local signing // metadata for custom refresh inputs when a round fails before the // connector-bound forfeit signing request is produced. When nil, only @@ -431,8 +464,11 @@ func NewRoundClientActor(cfg *RoundClientConfig) fn.Result[*RoundClientActor] { log: actorLog, rounds: make(map[RoundKeyStr]*RoundFSM), commitmentTxIndex: make(map[chainhash.Hash]RoundKeyStr), - pendingQuotes: make(map[RoundID]*JoinRoundQuoteReceived), - env: env, + pendingCommitmentConfs: make( + map[chainhash.Hash]*ConfirmationEvent, + ), + pendingQuotes: make(map[RoundID]*JoinRoundQuoteReceived), + env: env, } // The base env is used as a template for per-round FSM environments. @@ -476,6 +512,54 @@ 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, + ForfeitedVTXOs: n.ForfeitedVTXOs, + 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) { @@ -1067,6 +1151,25 @@ func (a *RoundClientActor) registerCommitmentConfirmation(ctx context.Context, }, ) + // Reorg-aware lifecycle refs. The actor currently logs these + // rather than reversing state: see the doc on CommitmentReorgedEvent + // for why FSM-level rollback is a follow-up. Wiring the refs now + // means the chainsource conf sub-actor stays alive past first + // confirmation, height-based finality synthesis fires, and a + // future FSM-rollback patch only has to consume the events. + reorgedRef := chainsource.MapConfReorgedEvent( + a.cfg.SelfRef, + func(ev chainsource.ConfReorgedEvent) actormsg.RoundReceivable { + return &CommitmentReorgedEvent{Txid: ev.Txid} + }, + ) + finalizedRef := chainsource.MapConfDoneEvent( + a.cfg.SelfRef, + func(ev chainsource.ConfDoneEvent) actormsg.RoundReceivable { + return &CommitmentFinalizedEvent{Txid: ev.Txid} + }, + ) + // Extract the pkScript LND needs for confirmation tracking. Watch the // validated batch output (the output that receives this client's // funds) rather than assuming output 0; confirmationWatchScript falls @@ -1102,12 +1205,14 @@ func (a *RoundClientActor) registerCommitmentConfirmation(ctx context.Context, } confReq := &chainsource.RegisterConfRequest{ - CallerID: callerID, - Txid: &txid, - PkScript: pkScript, - TargetConfs: a.cfg.OperatorTerms.MinConfirmations, - HeightHint: heightHint, - NotifyActor: fn.Some(mappedRef), + CallerID: callerID, + Txid: &txid, + PkScript: pkScript, + TargetConfs: a.cfg.OperatorTerms.MinConfirmations, + HeightHint: heightHint, + NotifyActor: fn.Some(mappedRef), + NotifyReorged: fn.Some(reorgedRef), + NotifyDone: fn.Some(finalizedRef), } if err := a.cfg.ChainSource.Tell( @@ -1404,6 +1509,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) @@ -2163,6 +2274,13 @@ func (a *RoundClientActor) handleCancelRound(ctx context.Context, if _, exists := a.rounds[keyStr]; exists { targetFSM.FSM.Stop() delete(a.rounds, keyStr) + delete(a.commitmentTxIndex, targetFSM.TxID) + + // Drop any cached provisional confirmation so a cancelled round + // does not leave an entry behind (the zero txid of a round + // cancelled before its commitment was seen is a harmless + // no-op). + delete(a.pendingCommitmentConfs, targetFSM.TxID) } a.log.InfoS(ctx, "Round participation cancelled successfully") @@ -2190,6 +2308,10 @@ func (a *RoundClientActor) onRoundComplete(ctx context.Context, roundID RoundID, } delete(a.commitmentTxIndex, txid) + // Drop any cached provisional confirmation for this commitment so the + // map does not retain an entry for a round that is no longer tracked. + delete(a.pendingCommitmentConfs, txid) + return a.cfg.RoundStore.FinalizeRound(ctx, roundID, txid, confInfo) } @@ -2210,7 +2332,7 @@ func (a *RoundClientActor) onRoundComplete(ctx context.Context, roundID RoundID, // ListRounds surface it backs) must be able to report a round as FAILED at // least until the client moves on to a fresh round. Reaping on entry made the // terminal state vanish within the same actor turn, so a poller could never -// see it (wavelength#602 systests). Sweeping at the start of the next +// see it (darepo-client#602 systests). Sweeping at the start of the next // assembly keeps the window open while still bounding accumulation to the // failures since the last new round. // @@ -2237,6 +2359,13 @@ func (a *RoundClientActor) reapFailedRounds(ctx context.Context) { roundFSM.FSM.Stop() delete(a.rounds, keyStr) delete(a.commitmentTxIndex, roundFSM.TxID) + + // A round that confirmed provisionally but then failed (e.g. a + // reorg dropped the commitment past the point of no return, or + // forfeit collection timed out after confirmation) leaves a + // cached conf behind. Drop it so the map does not grow without + // bound across the daemon's lifetime. + delete(a.pendingCommitmentConfs, roundFSM.TxID) } } @@ -2244,12 +2373,27 @@ func (a *RoundClientActor) reapFailedRounds(ctx context.Context) { // from ChainSource. Boarding address confirmations are now handled via // WalletBoardingConfirmed events from the wallet actor. // +// The commitment-tx confirmation is treated as PROVISIONAL: the FSM +// is NOT transitioned to terminal ConfirmedState on first conf. The +// event is cached on pendingCommitmentConfs so that the matching +// CommitmentFinalizedEvent (synthesized by the chainsource backend at +// the reorg-safety horizon, default six blocks past the latest +// confirmation) can replay it as BoardingConfirmed. A re-confirmation +// after a reorg overwrites the cache entry with the new canonical- +// chain height, and the finality synthesizer's depth counter resets +// on the reorg, so finality is only ever reported for the latest +// re-confirmation. This preserves the property the round FSM relies +// on for safety: user-visible side effects (VTXOs marked live in the +// local store, ledger entries, indexer notifications) only fire once +// the commitment is past the reorg-safety horizon. +// // Concurrency: The actor framework serializes all messages through Receive(), // so no synchronization is needed for rounds map access. func (a *RoundClientActor) handleConfirmation(ctx context.Context, event *ConfirmationEvent) fn.Result[actormsg.RoundActorResp] { - a.log.InfoS(ctx, "Received commitment transaction confirmation", + a.log.InfoS(ctx, "Received provisional commitment-tx confirmation; "+ + "deferring FSM terminal transition to finality", slog.String("txid", event.Txid.String()), slog.Int("block_height", int(event.BlockHeight)), slog.Int("confirmations", int(event.Confirmations)), @@ -2269,7 +2413,90 @@ func (a *RoundClientActor) handleConfirmation(ctx context.Context, return fn.Ok[actormsg.RoundActorResp](nil) } - // Route to the specific round's FSM. + // Sanity-check the routing target exists, but do NOT advance the + // FSM yet. handleCommitmentFinalized is the trigger for the + // terminal transition. + if _, exists := a.rounds[keyStr]; !exists { + return fn.Err[actormsg.RoundActorResp]( + fmt.Errorf("round FSM not found for key %s", keyStr), + ) + } + + // Cache the conf so the matching finality event can replay it. + // Every ConfirmationEvent overwrites — the cache always reflects + // the LATEST positive confirmation, which is what the finality + // synthesizer counts depth from. + cached := *event + a.pendingCommitmentConfs[event.Txid] = &cached + + return fn.Ok[actormsg.RoundActorResp](nil) +} + +// handleCommitmentReorged processes a chainsource ConfReorgedEvent on +// a commitment transaction. The provisional/finalized split means the +// FSM is still in its pre-confirmation state when a reorg lands — +// user-visible side effects have not yet committed — so there is +// nothing to roll back. The cached ConfirmationEvent stays in place; +// either a follow-up re-confirmation overwrites it before the +// chainsource finality synthesizer can fire (the synthesizer's depth +// counter resets on the reorg), or the chain genuinely abandons the +// confirmation and no Done event is ever synthesized. +func (a *RoundClientActor) handleCommitmentReorged(ctx context.Context, + event *CommitmentReorgedEvent) fn.Result[actormsg.RoundActorResp] { + + keyStr, tracked := a.commitmentTxIndex[event.Txid] + if !tracked { + // Round is no longer tracked: either it finalized cleanly + // and the cleanup path already ran, or it was never one of + // ours. Either way there's nothing to undo here. + a.log.DebugS(ctx, "Commitment-tx reorged on untracked txid", + slog.String("txid", event.Txid.String()), + ) + + return fn.Ok[actormsg.RoundActorResp](nil) + } + + a.log.InfoS(ctx, "Commitment-tx provisional confirmation reorged "+ + "out; FSM remains pre-confirmation, awaiting "+ + "re-confirmation or finality on the canonical chain", + slog.String("txid", event.Txid.String()), + slog.String("round_key", string(keyStr)), + ) + + return fn.Ok[actormsg.RoundActorResp](nil) +} + +// handleCommitmentFinalized processes a chainsource ConfDoneEvent on a +// commitment transaction. This is the trigger for the FSM's +// InputSigSent -> ConfirmedState transition: the cached +// ConfirmationEvent's height/hash/numConfs are replayed as the +// BoardingConfirmed FSM event so the terminal-state side effects +// (ledger emission, indexer publish, onRoundComplete cleanup) fire +// only after the chainsource backend has reported the confirmation is +// past the reorg-safety horizon. +// +// If the cache is empty for this txid (no prior ConfirmationEvent +// observed, e.g. a late or duplicate Done event after the round was +// already cleaned up), the handler logs and acks without touching +// the FSM. +func (a *RoundClientActor) handleCommitmentFinalized(ctx context.Context, + event *CommitmentFinalizedEvent) fn.Result[actormsg.RoundActorResp] { + + a.log.InfoS(ctx, "Commitment-tx confirmation finalized; promoting "+ + "round FSM to ConfirmedState", + slog.String("txid", event.Txid.String()), + ) + + keyStr, tracked := a.commitmentTxIndex[event.Txid] + if !tracked { + a.log.DebugS(ctx, "Commitment-tx finalized on untracked txid; "+ + "round already cleaned up", + slog.String("txid", event.Txid.String()), + ) + + return fn.Ok[actormsg.RoundActorResp](nil) + } + roundFSM, exists := a.rounds[keyStr] if !exists { return fn.Err[actormsg.RoundActorResp]( @@ -2277,23 +2504,37 @@ func (a *RoundClientActor) handleConfirmation(ctx context.Context, ) } - a.log.InfoS(ctx, "Routing confirmation to round FSM", - slog.String("key", string(keyStr)), - slog.String("round_id", roundFSM.RoundID.String()), - ) + cached, ok := a.pendingCommitmentConfs[event.Txid] + if !ok { + // Defensive: chainsource should not synthesize a Done event + // without a prior positive ConfirmationEvent (the + // finality-depth synthesizer is gated on a non-zero + // confirmHeight). If we see one anyway, ack without + // transitioning — the FSM cannot reach ConfirmedState + // without the confirmation's height/hash. + a.log.WarnS(ctx, "Commitment-tx finalized without a cached "+ + "prior ConfirmationEvent; skipping FSM transition", + nil, + slog.String("txid", event.Txid.String()), + slog.String("round_key", string(keyStr)), + ) + + return fn.Ok[actormsg.RoundActorResp](nil) + } + delete(a.pendingCommitmentConfs, event.Txid) confirmEvt := &BoardingConfirmed{ - TxID: event.Txid, - BlockHeight: event.BlockHeight, - BlockHash: event.BlockHash, - Confirmations: int32(event.Confirmations), + TxID: cached.Txid, + BlockHeight: cached.BlockHeight, + BlockHash: cached.BlockHash, + Confirmations: int32(cached.Confirmations), } err := a.askEventAndProcessOutbox(ctx, roundFSM, confirmEvt) if err != nil { return fn.Err[actormsg.RoundActorResp]( - fmt.Errorf("FSM error processing commitment "+ - "confirmation: %w", err), + fmt.Errorf("FSM error promoting round on finality: %w", + err), ) } @@ -2527,6 +2768,12 @@ func (a *RoundClientActor) processOutbox(ctx context.Context, } } + // Register the batch's reorg-safety lineage so the + // canonicality gate governs these round-born VTXOs and + // the consumed-input spend watches detect a + // double-spend. + a.registerBatchCanonicality(ctx, m) + // Mirror each newly-confirmed VTXO into the client // ledger so vtxo_balance follows round confirmation. // Source is posted as SourceRoundTransfer with the @@ -2801,6 +3048,22 @@ func (a *RoundClientActor) processConfirmationRequest( }, ) + // Reorg-aware lifecycle refs — see registerCommitmentConfirmation + // for the rationale. Detection-only today; FSM rollback is a + // follow-up. + reorgedRef := chainsource.MapConfReorgedEvent( + a.cfg.SelfRef, + func(ev chainsource.ConfReorgedEvent) actormsg.RoundReceivable { + return &CommitmentReorgedEvent{Txid: ev.Txid} + }, + ) + finalizedRef := chainsource.MapConfDoneEvent( + a.cfg.SelfRef, + func(ev chainsource.ConfDoneEvent) actormsg.RoundReceivable { + return &CommitmentFinalizedEvent{Txid: ev.Txid} + }, + ) + // Query ChainSource for current block height to use as // HeightHint. LND requires HeightHint > 0 for confirmation // scanning. @@ -2826,12 +3089,14 @@ func (a *RoundClientActor) processConfirmationRequest( // Build the complete RegisterConfRequest with the mapper as // the NotifyActor target. confReq := &chainsource.RegisterConfRequest{ - CallerID: callerID, - Txid: m.Txid, - PkScript: m.PkScript, - TargetConfs: m.TargetConfs, - HeightHint: heightHint, - NotifyActor: fn.Some(mappedRef), + CallerID: callerID, + Txid: m.Txid, + PkScript: m.PkScript, + TargetConfs: m.TargetConfs, + HeightHint: heightHint, + NotifyActor: fn.Some(mappedRef), + NotifyReorged: fn.Some(reorgedRef), + NotifyDone: fn.Some(finalizedRef), } a.log.InfoS(ctx, "Sending RegisterConfRequest to ChainSource", diff --git a/round/actor_messages.go b/round/actor_messages.go index f9df95322..4d46fd32f 100644 --- a/round/actor_messages.go +++ b/round/actor_messages.go @@ -268,6 +268,55 @@ func (m *ConfirmationEvent) MessageType() string { // RoundReceivable implements actormsg.RoundReceivable marker interface. func (m *ConfirmationEvent) RoundReceivable() {} +// CommitmentReorgedEvent wraps a chainsource ConfReorgedEvent that +// reports a previously delivered ConfirmationEvent for a commitment +// transaction was rolled back by a reorg of the canonical chain. +// +// Reorg semantics for the round FSM are not yet implemented: the +// commitment-tx confirmation drives the FSM's `InputSigSent -> +// Confirmed` transition (and the actor's `onRoundComplete` cleanup), +// both of which are terminal. Until the FSM gains a provisional/ +// finalized split, the actor-level handler for this event can only +// log the divergence so an operator notices and the future systests +// have something to assert against. Routing to the (now stopped) FSM +// would be a no-op even if the round were still tracked, because +// ConfirmedState has no transition for a "commitment reorged" event. +type CommitmentReorgedEvent struct { + actor.BaseMessage + + // Txid identifies the commitment transaction whose previously + // observed confirmation has been rolled back. + Txid chainhash.Hash +} + +func (m *CommitmentReorgedEvent) MessageType() string { + return "CommitmentReorgedEvent" +} + +// RoundReceivable implements actormsg.RoundReceivable marker interface. +func (m *CommitmentReorgedEvent) RoundReceivable() {} + +// CommitmentFinalizedEvent wraps a chainsource ConfDoneEvent that +// reports a commitment-tx confirmation is past the backend's reorg- +// safety depth and is no longer reversible. The current FSM treats +// the first confirmation as terminal; once the provisional/finalized +// FSM split lands, this is the signal that promotes the round from +// provisional to truly final. +type CommitmentFinalizedEvent struct { + actor.BaseMessage + + // Txid identifies the commitment transaction whose confirmation + // is now past the reorg-safety horizon. + Txid chainhash.Hash +} + +func (m *CommitmentFinalizedEvent) MessageType() string { + return "CommitmentFinalizedEvent" +} + +// RoundReceivable implements actormsg.RoundReceivable marker interface. +func (m *CommitmentFinalizedEvent) RoundReceivable() {} + // TimeoutMsg is sent to the round actor when a timeout expires. type TimeoutMsg struct { actor.BaseMessage diff --git a/round/actor_test.go b/round/actor_test.go index da8d493bb..968f5a025 100644 --- a/round/actor_test.go +++ b/round/actor_test.go @@ -346,6 +346,24 @@ func TestActorRecovery(t *testing.T) { reg := h.chainSource.registrations[0] require.NotNil(t, reg.Txid) require.True(t, reg.Txid.IsEqual(&txid)) + + // Reorg-aware lifecycle refs must be wired so the chainsource + // conf sub-actor keeps the registration alive past first + // confirmation, synthesizes a Done at the reorg-safety + // horizon, and surfaces TxReorged on rollback. Without these + // refs, the actor would never see reorg or finality signals + // for the commitment tx. + require.True( + t, reg.NotifyReorged.IsSome(), + "RegisterConfRequest must wire NotifyReorged so "+ + "the commitment-tx reorg lifecycle reaches "+ + "the actor", + ) + require.True( + t, reg.NotifyDone.IsSome(), + "RegisterConfRequest must wire NotifyDone so the "+ + "finality horizon reaches the actor", + ) }) t.Run("multiple_active_rounds", func(t *testing.T) { @@ -1043,6 +1061,235 @@ func TestActorGetStateWithActiveRounds(t *testing.T) { } } +// TestActorRoundCommitmentLifecycleGatedOnFinality pins the round's +// reorg-safety contract end-to-end at the actor level: +// +// - A ConfirmationEvent is PROVISIONAL. It caches the event on +// pendingCommitmentConfs[txid] and does NOT advance the FSM — +// user-visible side effects (VTXOs marked live in the local +// store, ledger emissions, indexer notifications, onRoundComplete +// cleanup) must not fire until the commitment is past the +// reorg-safety horizon. +// +// - A CommitmentReorgedEvent leaves the FSM in its pre-confirmation +// state (no rollback needed; nothing was committed). The cached +// entry is retained so a follow-up re-confirmation overwrites it +// before the chainsource finality synthesizer can fire. +// +// - A second ConfirmationEvent (re-confirmation after a reorg) +// overwrites the cached entry with the new canonical-chain +// height; the finality synthesizer's depth counter resets on +// reorg, so the eventual Done event always reflects the latest +// re-confirmation. +// +// - A CommitmentFinalizedEvent consumes the cached entry, drives +// BoardingConfirmed into the FSM, and the FSM's terminal-state +// transition fires onRoundComplete which clears the round from +// the actor's tracking maps. +// +// - A CommitmentFinalizedEvent without a cached prior conf +// (defensive: chainsource should not synthesize Done without a +// prior positive event) is a no-op rather than a crash. +// +// - Events for untracked / already-cleaned-up txids ack without +// affecting any other rounds. +func TestActorRoundCommitmentLifecycleGatedOnFinality(t *testing.T) { + t.Parallel() + + t.Run("untracked_txid_is_benign", func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + require.NoError(t, h.start()) + + untrackedTxid := chainhash.Hash{0xfe} + + require.True( + t, h.receive( + &ConfirmationEvent{Txid: untrackedTxid}, + ).IsOk(), + "ConfirmationEvent on untracked txid must ack", + ) + require.True( + t, h.receive( + &CommitmentReorgedEvent{Txid: untrackedTxid}, + ).IsOk(), + "Reorged on untracked txid must ack", + ) + require.True( + t, h.receive( + &CommitmentFinalizedEvent{Txid: untrackedTxid}, + ).IsOk(), + "Finalized on untracked txid must ack", + ) + + // No cache entries should have been installed for the + // untracked txid (the index gate prevents it). + require.NotContains( + t, h.actor.pendingCommitmentConfs, untrackedTxid, + ) + }) + + t.Run("confirmation_caches_without_fsm_transition", + func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + require.NoError(t, h.start()) + + roundID := testRoundID("test-round-conf") + packet := h.setupRoundInInputSigSentState(roundID) + txid := packet.UnsignedTx.TxHash() + h.actor.commitmentTxIndex[txid] = RoundKeyStr( + roundID.KeyString(), + ) + + require.True( + t, h.receive(&ConfirmationEvent{ + Txid: txid, + BlockHeight: 105, + Confirmations: 1, + }).IsOk(), + ) + + // Cache populated, FSM untouched: the round is still + // in the actor's tracking maps because onRoundComplete + // has not run. + cached, ok := h.actor.pendingCommitmentConfs[txid] + require.True( + t, ok, + "ConfirmationEvent must populate the cache", + ) + require.Equal(t, int32(105), cached.BlockHeight) + require.Contains( + t, h.actor.rounds, + RoundKeyStr( + roundID.KeyString(), + ), + "FSM must remain tracked before finality", + ) + require.Contains(t, h.actor.commitmentTxIndex, txid) + }) + + t.Run("reorg_before_finality_keeps_state", + func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + require.NoError(t, h.start()) + + roundID := testRoundID("test-round-reorg") + packet := h.setupRoundInInputSigSentState(roundID) + txid := packet.UnsignedTx.TxHash() + keyStr := RoundKeyStr(roundID.KeyString()) + h.actor.commitmentTxIndex[txid] = keyStr + + require.True( + t, h.receive(&ConfirmationEvent{ + Txid: txid, + BlockHeight: 200, + Confirmations: 1, + }).IsOk(), + ) + require.True( + t, h.receive( + &CommitmentReorgedEvent{Txid: txid}, + ).IsOk(), + ) + + // Reorg before finality: no rollback work to do + // because nothing user-visible was committed. The + // round stays tracked; the cache stays populated + // (a follow-up re-confirmation will overwrite it). + require.Contains(t, h.actor.rounds, keyStr) + require.Contains(t, h.actor.commitmentTxIndex, txid) + require.Contains( + t, h.actor.pendingCommitmentConfs, txid, + ) + }) + + t.Run("reconfirmation_overwrites_cache_with_canonical_height", + func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + require.NoError(t, h.start()) + + roundID := testRoundID("test-round-reconf") + packet := h.setupRoundInInputSigSentState(roundID) + txid := packet.UnsignedTx.TxHash() + h.actor.commitmentTxIndex[txid] = RoundKeyStr( + roundID.KeyString(), + ) + + require.True( + t, h.receive(&ConfirmationEvent{ + Txid: txid, + BlockHeight: 300, + Confirmations: 1, + }).IsOk(), + ) + require.True( + t, h.receive( + &CommitmentReorgedEvent{Txid: txid}, + ).IsOk(), + ) + require.True( + t, h.receive(&ConfirmationEvent{ + Txid: txid, + BlockHeight: 301, + Confirmations: 1, + }).IsOk(), + ) + + cached := h.actor.pendingCommitmentConfs[txid] + require.NotNil(t, cached) + require.Equal( + t, int32(301), cached.BlockHeight, "second "+ + "confirmation must overwrite the "+ + "cache with the canonical-chain "+ + "height; finality will replay the "+ + "latest entry, not a stale "+ + "pre-reorg observation", + ) + }) + + t.Run("finality_without_prior_conf_is_a_no_op", + func(t *testing.T) { + t.Parallel() + + h := newActorTestHarness(t) + h.setupMockRoundStoreForStart() + require.NoError(t, h.start()) + + roundID := testRoundID("test-round-bare-final") + packet := h.setupRoundInInputSigSentState(roundID) + txid := packet.UnsignedTx.TxHash() + keyStr := RoundKeyStr(roundID.KeyString()) + h.actor.commitmentTxIndex[txid] = keyStr + + // Defensive path: chainsource should not synthesize + // Done without a prior positive event (the depth + // synthesizer is gated on confirmHeight != 0), but + // the handler must not crash if it ever does. + require.True( + t, h.receive( + &CommitmentFinalizedEvent{Txid: txid}, + ).IsOk(), + ) + + require.Contains( + t, h.actor.rounds, keyStr, "Finalized "+ + "without cached conf must not "+ + "trigger an FSM transition", + ) + }) +} + // TestActorReceiveUnknownMessageType ensures that the actor rejects // unrecognized message types with an appropriate error rather than silently // ignoring them. diff --git a/round/batch_canonicality_test.go b/round/batch_canonicality_test.go new file mode 100644 index 000000000..bfa651946 --- /dev/null +++ b/round/batch_canonicality_test.go @@ -0,0 +1,132 @@ +package round + +import ( + "testing" + "time" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// bcRef aliases the canonicality manager tell-ref to keep the test helper +// signatures within the line limit. +type bcRef = actor.TellOnlyRef[batchcanon.ManagerMsg] + +// bcTestOutpoint builds a deterministic outpoint from a single seed byte. +func bcTestOutpoint(seed byte) wire.OutPoint { + var h chainhash.Hash + h[0] = seed + + return wire.OutPoint{Hash: h, Index: uint32(seed)} +} + +// newBatchCanonActor builds a minimal RoundClientActor wired with the given +// canonicality ref option. Only the fields registerBatchCanonicality touches +// are populated. +func newBatchCanonActor(ref fn.Option[bcRef]) *RoundClientActor { + return &RoundClientActor{ + cfg: &RoundClientConfig{ + BatchCanonicality: ref, + }, + log: btclog.Disabled, + } +} + +// TestRegisterBatchCanonicalityEmitsRequest verifies the round actor forwards a +// RegisterBatchRequest carrying the batch txid, consumed inputs (boarding + +// forfeited), dependent VTXO outpoints, confirmation pkScript and CSV delta +// when a canonicality manager ref is wired. +func TestRegisterBatchCanonicalityEmitsRequest(t *testing.T) { + t.Parallel() + + ref := actor.NewChannelTellOnlyRef[batchcanon.ManagerMsg]( + "batchcanon-test", 2, + ) + a := newBatchCanonActor( + fn.Some[bcRef](ref), + ) + + var commitment chainhash.Hash + commitment[0] = 0xaa + board := bcTestOutpoint(1) + forfeit := bcTestOutpoint(2) + vtxoOut := bcTestOutpoint(3) + pkScript := []byte{0x51, 0x20, 0x01} + consumed := []batchcanon.ConsumedInput{ + { + Outpoint: board, + PkScript: []byte{ + 0x51, + 0x20, + 0x0b, + }, + }, + { + Outpoint: forfeit, + PkScript: []byte{ + 0x51, + 0x20, + 0x0f, + }, + }, + } + + a.registerBatchCanonicality(t.Context(), &VTXOCreatedNotification{ + VTXOs: []*ClientVTXO{{Outpoint: vtxoOut}}, + CommitmentTxID: commitment, + ConsumedInputs: consumed, + 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, consumed, req.ConsumedInputs) + require.Equal(t, []wire.OutPoint{vtxoOut}, req.DependentVTXOs) + require.Equal(t, pkScript, req.ConfirmationPkScript) + require.Equal(t, int32(144), req.CSVExpiryDelta) +} + +// TestRegisterBatchCanonicalityNoopWhenUnwired verifies registration is a +// no-op when no manager ref is configured (the gate stays dormant), preserving +// pre-C6 behavior. +func TestRegisterBatchCanonicalityNoopWhenUnwired(t *testing.T) { + t.Parallel() + + a := newBatchCanonActor( + fn.None[bcRef](), + ) + + // Must not panic and must not attempt any delivery. + a.registerBatchCanonicality(t.Context(), &VTXOCreatedNotification{ + VTXOs: []*ClientVTXO{{Outpoint: bcTestOutpoint(3)}}, + }) +} + +// TestRegisterBatchCanonicalitySkipsEmptyBatch verifies nothing is emitted when +// the round produced no owned VTXOs and consumed no client inputs (nothing for +// the gate to govern). +func TestRegisterBatchCanonicalitySkipsEmptyBatch(t *testing.T) { + t.Parallel() + + ref := actor.NewChannelTellOnlyRef[batchcanon.ManagerMsg]( + "batchcanon-empty", 1, + ) + a := newBatchCanonActor( + fn.Some[bcRef](ref), + ) + + a.registerBatchCanonicality(t.Context(), &VTXOCreatedNotification{}) + + _, ok := ref.AwaitMessage(100 * time.Millisecond) + require.False(t, ok, "no registration expected for an empty batch") +} diff --git a/round/outbox_messages.go b/round/outbox_messages.go index f5c4db62b..1f83c732b 100644 --- a/round/outbox_messages.go +++ b/round/outbox_messages.go @@ -12,6 +12,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/lib/arkscript" "github.com/lightninglabs/wavelength/lib/tree" "github.com/lightninglabs/wavelength/lib/types" @@ -795,6 +796,34 @@ type VTXOCreatedNotification struct { // CommitmentTxID is the txid of the confirmed commitment transaction. CommitmentTxID chainhash.Hash + // ConsumedInputs are the inputs the commitment tx spends that this + // client contributed: boarding inputs plus forfeited VTXOs, each + // carrying the pkScript of the spent output. The round actor forwards + // them to the BatchCanonicalityManager so a reorg-out or double-spend + // of a consumed input invalidates the round-born VTXOs (darepo#454, + // F1/F3/F6). The pkScript is required to register the spend watch (lnd + // filters spend notifications by output script). + ConsumedInputs []batchcanon.ConsumedInput + + // ForfeitedVTXOs are the VTXOs (from prior rounds) this round forfeits. + // The round actor forwards them to the BatchCanonicalityManager as + // reverse-dependency edges so that if THIS round's commitment is later + // invalidated (its forfeit reversed by a reorg/conflict), the forfeited + // VTXOs are restored to a spendable state (darepo#454, F6). + ForfeitedVTXOs []wire.OutPoint + + // ConfirmationPkScript is the commitment-tx batch output script the + // canonicality confirmation watch keys on. Confirmation detection is by + // txid; the script only matters for script-filtering light-client + // backends (e.g. Neutrino, Esplora). + ConfirmationPkScript []byte + + // CSVExpiryDelta is the batch's CSV-relative expiry in blocks (the + // round's SweepDelay). The canonicality manager derives the effective + // absolute expiry as confirmation height plus this delta, so it is + // recomputed cleanly across reorgs rather than stored absolute. + CSVExpiryDelta int32 + // BatchExpiry is the absolute block height when the batch expires. BatchExpiry int32 diff --git a/round/transitions.go b/round/transitions.go index 6b5935def..8bc96e60c 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -19,6 +19,7 @@ import ( "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/ledger" "github.com/lightninglabs/wavelength/lib/arkscript" "github.com/lightninglabs/wavelength/lib/tree" @@ -751,7 +752,7 @@ func (s *PendingRoundAssembly) processEvent(ctx context.Context, // Arm the registration (admission) timeout so a server that // never returns a RoundJoined watermark cannot park us in // IntentSentState forever. On expiry the FSM fails the round - // and releases any forfeit-reserved inputs (wavelength#653). + // and releases any forfeit-reserved inputs (darepo-client#653). // A non-positive timeout disables the safety net. if env.RegistrationTimeout > 0 { outbox = append(outbox, &StartTimeoutReq{ @@ -877,7 +878,7 @@ func (s *IntentSentState) processEvent(ctx context.Context, event ClientEvent, // admission window. Fail the round as recoverable (the client // may retry) and release any forfeit-reserved inputs back to // LiveState so they are not stranded in pending-forfeit - // (wavelength#653). Releasing is safe here: at this phase no + // (darepo-client#653). Releasing is safe here: at this phase no // forfeit signatures have been produced or sent to the server, // so there is nothing to double-spend. const reason = "round admission timed out" @@ -2147,7 +2148,7 @@ func (s *CommitmentTxReceivedState) processEvent(ctx context.Context, // proves internal self-consistency; without this binding a // self-consistent tree rooted at the wrong commitment output // would be co-signed, after which the client's new VTXOs are - // unrecoverable (wavelength#680). + // unrecoverable (darepo-client#680). if err := validateVTXOTreeBinding( s.CommitmentTx.UnsignedTx, s.VTXOTreePaths, ); err != nil { @@ -2281,7 +2282,7 @@ func (s *CommitmentTxReceivedState) processEvent(ctx context.Context, // exists independently of the commitment tx; signing the // forfeit over it would make the old VTXO claimable even if // the replacement round never commits, breaking round - // atomicity (wavelength#681). + // atomicity (darepo-client#681). // //nolint:contextcheck // Connector-tree reconstruction is pure, // deterministic CPU work (the lib/tree materializer ignores its @@ -3652,7 +3653,7 @@ const ( // Binding the connector to the commitment tx is what preserves round // atomicity: a connector leaf is only ever spendable once the commitment tx // confirms, so the old VTXO cannot be forfeited unless the replacement round -// also commits (wavelength#681). +// also commits (darepo-client#681). func validateConnectorAncestry(commitmentTx *wire.MsgTx, operatorKey *btcec.PublicKey, mappings map[wire.OutPoint]*ConnectorLeafInfo) error { @@ -3858,7 +3859,7 @@ func connectorLeafOutput(leaf *tree.Node) (*wire.TxOut, error) { // real batch output, so the client's new VTXOs (whose outpoints are derived // from the tree root) are unrecoverable. This is the VTXO-tree counterpart to // validateConnectorAncestry; together they restore round atomicity -// (wavelength#680, companion to #681). +// (darepo-client#680, companion to #681). // // For each (outputIdx, tree) pair it asserts that the tree's BatchOutpoint // names this commitment tx, that outputIdx agrees with BatchOutpoint.Index, @@ -4307,6 +4308,30 @@ func buildClientVTXOs(ctx context.Context, checker OwnedScriptChecker, return vtxos, nil } +// commitmentInputScripts maps each commitment-tx input outpoint to the +// pkScript of the output it spends, read from the PSBT's per-input witness +// UTXO. It is used to stamp the consumed-input pkScripts the batch-canonicality +// manager needs to register reorg-aware spend watches. Returns nil for a nil +// packet; an input whose witness UTXO is absent is simply omitted (its watch is +// skipped downstream rather than failing the whole batch registration). +func commitmentInputScripts(pkt *psbt.Packet) map[wire.OutPoint][]byte { + if pkt == nil { + return nil + } + + scripts := make(map[wire.OutPoint][]byte, len(pkt.UnsignedTx.TxIn)) + for i, txIn := range pkt.UnsignedTx.TxIn { + if i >= len(pkt.Inputs) { + break + } + if wu := pkt.Inputs[i].WitnessUtxo; wu != nil { + scripts[txIn.PreviousOutPoint] = wu.PkScript + } + } + + return scripts +} + // ProcessEvent for InputSigSentState. func (s *InputSigSentState) ProcessEvent(ctx context.Context, event ClientEvent, env *ClientEnvironment) (*ClientStateTransition, error) { @@ -4414,18 +4439,69 @@ 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). + // Each consumed input carries the pkScript of the spent output, + // sourced from the commitment PSBT's witness UTXOs. The spend + // watch the manager arms needs it (lnd filters spend + // notifications by output script). + inputScripts := commitmentInputScripts(s.CommitmentTx) + consumedInputs := make( + []batchcanon.ConsumedInput, 0, + len(s.Intents.Boarding)+len(s.ForfeitedVTXOs), + ) + for i := range s.Intents.Boarding { + op := s.Intents.Boarding[i].Outpoint + consumedInputs = append( + consumedInputs, batchcanon.ConsumedInput{ + Outpoint: op, + PkScript: inputScripts[op], + }, + ) + } + for _, op := range s.ForfeitedVTXOs { + consumedInputs = append( + consumedInputs, batchcanon.ConsumedInput{ + Outpoint: op, + PkScript: inputScripts[op], + }, + ) + } + + // 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, + ForfeitedVTXOs: s.ForfeitedVTXOs, + ConfirmationPkScript: confPkScript, + CSVExpiryDelta: sweepDelay, + BatchExpiry: batchExpiry, + CreatedHeight: evt.BlockHeight, + OperatorFeeSat: operatorFee, + OperatorFeeType: operatorFeeType, }) } outbox = append(outbox, &RoundCompletedNotification{ diff --git a/sample-waved.conf b/sample-waved.conf index a4f154227..430bcd660 100644 --- a/sample-waved.conf +++ b/sample-waved.conf @@ -17,6 +17,14 @@ # Bitcoin network: mainnet, testnet, testnet4, regtest, simnet, or signet. # network=mainnet +# Blocks past a commitment/lineage transaction's first confirmation at which +# it is treated as final and its reorg-aware chain watches are released. +# Deferring irreversible effects until this depth lets a reorg before it be +# recovered from. 0 (the default) selects a network-aware value at load: the +# deeper testnet default on testnet, else the conventional six blocks. Set a +# positive value to override. +# reorgsafetydepth=0 + # Logging verbosity. A single level sets the global level; a comma-separated # list can set per-subsystem levels, for example ROND=debug,OORC=trace,info. # debuglevel=info diff --git a/systest/batch_canonicality_ancestor_test.go b/systest/batch_canonicality_ancestor_test.go new file mode 100644 index 000000000..7e1517c2e --- /dev/null +++ b/systest/batch_canonicality_ancestor_test.go @@ -0,0 +1,326 @@ +//go:build systest + +package systest + +import ( + "context" + "crypto/sha256" + "testing" + + btcaddr "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/db" + "github.com/lightninglabs/wavelength/lib/arkscript" + "github.com/lightninglabs/wavelength/lib/tree" + "github.com/lightninglabs/wavelength/lib/types" + "github.com/lightninglabs/wavelength/lndbackend" + "github.com/lightninglabs/wavelength/vtxo" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// TestBatchCanonicalityGateBlocksReorgedAncestor proves the LIVE coin-selection +// gate governs the FULL multi-parent lineage, not just a VTXO's direct +// commitment: a VTXO whose ANCESTOR batch (a cross-commitment parent, distinct +// from its direct commitment) is reorged off the canonical chain is excluded +// from coin selection, then admitted again once the ancestor reconfirms. This +// is the F4 acceptance scenario (OOR ancestor reorged then reconfirmed) at the +// vtxo.Manager seam. +// +// It proves the lineage-depth dimension that F2/F3 do not: those use a +// single-commitment VTXO, so they exercise only the direct commitment. Here the +// VTXO carries a separate ancestor in its Ancestry. The gate +// (gateUnavailableLineage) reloads the full descriptor via GetVTXO -- which +// hydrates the ancestry side table -- so lineageCommitmentTxids yields BOTH the +// direct commitment and the ancestor, and CombineAvailability takes the worst +// state across them. A reorged-out ancestor must therefore block the VTXO even +// while its direct commitment stays confirmed. +// +// The ancestor is isolated from the direct commitment by confirming them in +// different blocks (direct first, ancestor second) and reorging ONLY the +// ancestor's (later) block with an empty replacement branch. The direct +// commitment's earlier block is untouched, so the contrast is unambiguous: the +// only batch that changes state is the ancestor. +func TestBatchCanonicalityGateBlocksReorgedAncestor(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + sqlDB := db.NewTestDB(t) + clk := clock.NewDefaultClock() + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), btclog.Disabled, + ) + vtxoStore := dbStore.NewVTXOStore(clk) + canonStore := dbStore.NewBatchCanonicalityStore(clk) + + // Real batchcanon.Manager + vtxo.Manager (gate on the same store), + // mirroring waved. + bcMgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSource, + Log: fn.Some(h.SubLogger("BCAN")), + }) + bcRef := actor.RegisterWithSystem( + h.ActorSystem(), + "batch-canonicality", batchcanon.ManagerServiceKey, bcMgr, + ) + bcMgr.SetSelfRef(bcRef) + + vtxoWallet := lndbackend.NewClientWallet( + h.Harness.LND.Signer, h.Harness.LND.WalletKit, + ) + vtxoMgr := vtxo.NewManager(&vtxo.ManagerConfig{ + Store: vtxoStore, + Wallet: vtxoWallet, + ChainSource: chainSource, + ActorSystem: h.ActorSystem(), + ChainParams: h.ChainParams(), + BatchCanonicality: canonStore, + Log: fn.Some(h.SubLogger(vtxo.Subsystem)), + }) + const vtxoMgrName = "systest-vtxo-manager-f4-ancestor" + vtxoKey := actor.NewServiceKey[vtxo.ManagerMsg, vtxo.ManagerResp]( + vtxoMgrName, + ) + vtxoRef := actor.RegisterWithSystem( + h.ActorSystem(), vtxoMgrName, vtxoKey, vtxoMgr, + ) + require.NoError(t, vtxoMgr.Start(ctx, vtxoRef)) + + // Confirm the DIRECT commitment first, in its own earlier block, so + // reorging the ancestor's later block leaves it untouched. Each batch + // is faucet -> register (live watch) -> mine, one block apart, so they + // land in distinct blocks (fauceting both up front would confirm them + // in the same block and defeat the isolation). + directTxid, directScript := faucetSyntheticBatch(t, h, "direct") + registerBatch(ctx, t, bcRef, *directTxid, directScript) + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *directTxid) + + // Now the ANCESTOR, in the next block. + ancestorTxid, ancestorScript := faucetSyntheticBatch(t, h, "ancestor") + registerBatch(ctx, t, bcRef, *ancestorTxid, ancestorScript) + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *ancestorTxid) + + // Seed a live VTXO whose direct commitment is directTxid and whose + // ancestry carries ancestorTxid as a cross-commitment parent. + const vtxoAmount = btcutil.Amount(50_000) + outpoint := seedLiveVTXOWithAncestor( + t, vtxoStore, t.Name(), *directTxid, *ancestorTxid, vtxoAmount, + ) + + // ---------------------------------------------------------------- + // Beat 1: reorg ONLY the ancestor off-chain -> the VTXO must be + // EXCLUDED even though its direct commitment stays confirmed. + // ---------------------------------------------------------------- + reorg := h.Harness.ReorgExcludingMempool(1, 2) + require.Len(t, reorg.Connected, 2) + + awaitBatchState( + ctx, t, bcRef, *ancestorTxid, batchcanon.StateReorgedOut, + ) + // The direct commitment must remain Provisional throughout, so the + // block can only be attributed to the ancestor. + require.Equal( + t, batchcanon.StateProvisional, + batchState(ctx, t, bcRef, *directTxid), + "direct commitment must stay confirmed while the ancestor "+ + "is reorged out", + ) + + blockedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.False( + t, blockedResp.IsOk(), + "coin selection must fail while the VTXO's ANCESTOR batch "+ + "is reorged out, even though its direct commitment "+ + "is still confirmed", + ) + t.Logf("ancestor ReorgedOut: coin selection excluded %s", outpoint) + + // ---------------------------------------------------------------- + // Beat 2: reconfirm the ancestor -> the VTXO must be ADMITTED again. + // ---------------------------------------------------------------- + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *ancestorTxid) + + admittedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.True( + t, admittedResp.IsOk(), + "coin selection must succeed once the ancestor reconfirms", + ) + resp, err := admittedResp.Unpack() + require.NoError(t, err) + selected, ok := resp.(*vtxo.SelectAndReserveSpendResponse) + require.True(t, ok, "unexpected select response type %T", resp) + + selectedOutpoints := make( + []wire.OutPoint, 0, len(selected.SelectedVTXOs), + ) + for _, s := range selected.SelectedVTXOs { + selectedOutpoints = append(selectedOutpoints, s.Outpoint) + } + require.Contains( + t, selectedOutpoints, outpoint, + "the VTXO must be selected once its ancestor reconfirms", + ) + t.Logf( + "ancestor reconfirmed Provisional: coin selection admitted %s", + outpoint, + ) +} + +// faucetSyntheticBatch faucets a real tx to a deterministic synthetic P2WPKH +// script derived from the test name + a label, returning the tx's txid and +// pkScript. The tx stands in for a batch (commitment) tx the canonicality +// manager can track and reorg. +func faucetSyntheticBatch(t *testing.T, h *SysTestHarness, + label string) (*chainhash.Hash, []byte) { + + t.Helper() + + pubKeyHash := sha256.Sum256([]byte(t.Name() + "-" + label)) + addr, err := btcaddr.NewAddressWitnessPubKeyHash( + pubKeyHash[:20], &chaincfg.RegressionNetParams, + ) + require.NoError(t, err, "build synthetic P2WPKH address (%s)", label) + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err, "derive pkScript (%s)", label) + + amount := btcutil.Amount(btcutil.SatoshiPerBitcoin / 100) + txidStr := h.Harness.Faucet(addr.String(), amount) + txid, err := chainhash.NewHashFromStr(txidStr) + require.NoError(t, err, "parse faucet txid (%s)", label) + + return txid, pkScript +} + +// registerBatch registers a batch (no consumed inputs) with the manager and +// waits for the synchronous response. +func registerBatch(ctx context.Context, t *testing.T, + bcRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid chainhash.Hash, pkScript []byte) { + + t.Helper() + + resp := bcRef.Ask(ctx, &batchcanon.RegisterBatchRequest{ + BatchTxID: txid, + ConfirmationPkScript: pkScript, + CSVExpiryDelta: f2VTXOCSVDelay, + }).Await(ctx) + require.True(t, resp.IsOk(), "register batch %s", txid) +} + +// batchState reads a batch's current canonicality state via the manager. +func batchState(ctx context.Context, t *testing.T, + bcRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid chainhash.Hash) batchcanon.State { + + t.Helper() + + resp, err := bcRef.Ask( + ctx, &batchcanon.GetBatchStateRequest{BatchTxID: txid}, + ).Await(ctx).Unpack() + require.NoError(t, err) + got, ok := resp.(*batchcanon.GetBatchStateResponse) + require.True(t, ok, "unexpected get-state response type %T", resp) + require.True(t, got.Found, "batch %s not found", txid) + + return got.Record.State +} + +// seedLiveVTXOWithAncestor persists a single live VTXO whose direct commitment +// is directTxid and whose ancestry carries ancestorTxid as a distinct +// cross-commitment parent, returning its outpoint. The owner/operator keys and +// tapscript are real so the descriptor is well-formed; the ancestry tree +// fragment is a minimal placeholder (the gate reads only the commitment txids). +func seedLiveVTXOWithAncestor(t *testing.T, vtxoStore *db.VTXOPersistenceStore, + name string, directTxid, ancestorTxid chainhash.Hash, + amount btcutil.Amount) wire.OutPoint { + + t.Helper() + + clientPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "client key") + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "operator key") + operatorKey := operatorPriv.PubKey() + + descriptor, err := tree.NewVTXODescriptor( + amount, clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo descriptor") + + tapScript, err := arkscript.VTXOTapScript( + clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo tapscript") + + outpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte(name + "-seeded-vtxo")), + Index: 0, + } + + // Minimal ancestry tree fragment; only the CommitmentTxID is consulted + // by the canonicality gate. + ancestorTree := &tree.Tree{ + BatchOutpoint: outpoint, + Root: &tree.Node{ + Input: outpoint, + Outputs: []*wire.TxOut{}, + CoSigners: []*btcec.PublicKey{}, + Children: make(map[uint32]*tree.Node), + }, + } + + err = vtxoStore.SaveVTXO(t.Context(), &vtxo.Descriptor{ + Outpoint: outpoint, + Amount: amount, + PolicyTemplate: descriptor.PolicyTemplate, + PkScript: descriptor.PkScript, + ClientKey: keychain.KeyDescriptor{ + PubKey: clientPriv.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: types.VTXOOwnerKeyFamily, + Index: 7, + }, + }, + OperatorKey: operatorKey, + TapScript: tapScript, + Ancestry: []types.Ancestry{{ + TreePath: ancestorTree, + CommitmentTxID: ancestorTxid, + TreeDepth: 0, + }}, + RoundID: chainhash. + HashH([]byte(name + "-round")). + String(), + CommitmentTxID: directTxid, + BatchExpiry: 500000, + RelativeExpiry: f2VTXOCSVDelay, + CreatedHeight: 1, + Status: vtxo.VTXOStatusLive, + }) + require.NoError(t, err, "save live vtxo with ancestor") + + return outpoint +} diff --git a/systest/batch_canonicality_conflict_test.go b/systest/batch_canonicality_conflict_test.go new file mode 100644 index 000000000..8495604ce --- /dev/null +++ b/systest/batch_canonicality_conflict_test.go @@ -0,0 +1,226 @@ +//go:build systest + +package systest + +import ( + "crypto/sha256" + "testing" + + btcaddr "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/db" + "github.com/lightninglabs/wavelength/lndbackend" + "github.com/lightninglabs/wavelength/vtxo" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// TestBatchCanonicalityGateBlocksConflictedVTXO proves the LIVE coin-selection +// reorg-safety gate handles the INPUT-CONFLICT path: a VTXO whose batch has one +// of its consumed inputs double-spent by a competing transaction is excluded +// from coin selection, then admitted again once the conflicting spend is +// reorged away. This is the F3 acceptance scenario (batch input conflicted) at +// the vtxo.Manager seam, driven by a real bitcoind double-spend + reorg. +// +// It exercises a DIFFERENT code path from the F2 reorg test +// (TestBatchCanonicalityGateBlocksReorgedVTXO): F2 trips the conf-watch +// (LimboReorg) by reorging the batch tx itself off-chain, whereas this test +// trips the per-input SPEND watch (LimboConflict). The batchcanon.Manager flags +// a batch ConflictProvisional the moment one of its registered consumed inputs +// is spent by any tx other than the batch tx (handleInputSpent: +// spendingTxid != w.txid), and clears the conflict when that spend is reorged +// out (handleInputSpendReorged). Both layers are governed by the same gate +// (batchcanon.LineageBlocked treats LimboConflict as blocked), so coin +// selection must refuse the VTXO while the conflict stands and resume once it +// clears. +// +// The wiring mirrors production exactly as the F2 test does: real chainsource, +// real batchcanon.Manager arming reorg-aware conf + spend watches, real +// vtxo.Manager whose gate reads the same durable store. +// +// The flow: +// +// 1. Faucet the batch (commitment) tx and pick an independent spendable +// outpoint O. Register the batch with O as its consumed input and seed a +// live VTXO anchored on the batch. Confirm -> Provisional. +// 2. Double-spend O with a competing tx and confirm it. The spend watch fires +// with spendingTxid != batchTxid -> ConflictProvisional. Coin selection +// must FAIL (the VTXO is gated out). +// 3. Reorg the conflicting spend off-chain (empty replacement branch, so it is +// not re-mined). The spend watch reports the spend reorged out -> the +// conflict clears -> Provisional. Coin selection must SUCCEED again. +func TestBatchCanonicalityGateBlocksConflictedVTXO(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + sqlDB := db.NewTestDB(t) + clk := clock.NewDefaultClock() + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), btclog.Disabled, + ) + vtxoStore := dbStore.NewVTXOStore(clk) + canonStore := dbStore.NewBatchCanonicalityStore(clk) + + // Faucet the batch (commitment) tx to a synthetic P2WPKH script. + pubKeyHash := sha256.Sum256([]byte(t.Name())) + addr, err := btcaddr.NewAddressWitnessPubKeyHash( + pubKeyHash[:20], &chaincfg.RegressionNetParams, + ) + require.NoError(t, err, "build synthetic P2WPKH address") + batchPkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err, "derive pkScript for synthetic address") + + batchAmount := btcutil.Amount(btcutil.SatoshiPerBitcoin / 100) + batchTxidStr := h.Harness.Faucet(addr.String(), batchAmount) + batchTxid, err := chainhash.NewHashFromStr(batchTxidStr) + require.NoError(t, err, "parse faucet txid") + + // Pick an independent confirmed wallet outpoint to register as the + // batch's consumed input and later double-spend. listunspent excludes + // the faucet tx's own (mempool-spent) input and its unconfirmed + // outputs, so this outpoint is unrelated to the batch tx -- which is + // fine: the manager flags a conflict on whatever consumed inputs it is + // told to watch, by comparing the spending txid to the batch txid. + conflictInput, conflictValueBTC, conflictScript := + h.Harness.FirstSpendableOutpoint() + + // Seed a live VTXO anchored on the batch tx, before the manager starts. + const vtxoAmount = btcutil.Amount(50_000) + outpoint := seedLiveVTXOForBatch( + t, vtxoStore, t.Name(), *batchTxid, vtxoAmount, + ) + + // Real batchcanon.Manager over the durable store + real chainsource. + bcMgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSource, + Log: fn.Some(h.SubLogger("BCAN")), + }) + bcRef := actor.RegisterWithSystem( + h.ActorSystem(), + "batch-canonicality", batchcanon.ManagerServiceKey, bcMgr, + ) + bcMgr.SetSelfRef(bcRef) + + // Real vtxo.Manager with the coin-selection gate on the same store. + vtxoWallet := lndbackend.NewClientWallet( + h.Harness.LND.Signer, h.Harness.LND.WalletKit, + ) + vtxoMgr := vtxo.NewManager(&vtxo.ManagerConfig{ + Store: vtxoStore, + Wallet: vtxoWallet, + ChainSource: chainSource, + ActorSystem: h.ActorSystem(), + ChainParams: h.ChainParams(), + BatchCanonicality: canonStore, + Log: fn.Some(h.SubLogger(vtxo.Subsystem)), + }) + const vtxoMgrName = "systest-vtxo-manager-f3-conflict" + vtxoKey := actor.NewServiceKey[vtxo.ManagerMsg, vtxo.ManagerResp]( + vtxoMgrName, + ) + vtxoRef := actor.RegisterWithSystem( + h.ActorSystem(), vtxoMgrName, vtxoKey, vtxoMgr, + ) + require.NoError(t, vtxoMgr.Start(ctx, vtxoRef)) + + // Register the batch with the conflict input as its consumed input, so + // the manager arms a reorg-aware spend watch on it. + regResp := bcRef.Ask(ctx, &batchcanon.RegisterBatchRequest{ + BatchTxID: *batchTxid, + ConfirmationPkScript: batchPkScript, + CSVExpiryDelta: f2VTXOCSVDelay, + ConsumedInputs: []batchcanon.ConsumedInput{{ + Outpoint: conflictInput, + PkScript: conflictScript, + }}, + }).Await(ctx) + require.True(t, regResp.IsOk(), "register batch with manager") + + // Confirm the batch tx -> Provisional. + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *batchTxid) + + // ---------------------------------------------------------------- + // Beat 1: double-spend the consumed input -> ConflictProvisional -> + // the VTXO must be EXCLUDED from coin selection. + // ---------------------------------------------------------------- + conflictTxid := h.Harness.SpendOutpoint(conflictInput, conflictValueBTC) + require.NotEqual( + t, batchTxidStr, conflictTxid, + "the conflicting spend must be a different tx from the batch", + ) + h.Harness.Generate(1) + + awaitBatchState( + ctx, t, bcRef, *batchTxid, batchcanon.StateConflictProvisional, + ) + + blockedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.False( + t, blockedResp.IsOk(), + "coin selection must fail while the VTXO's batch input is "+ + "conflicted: the only candidate is gated out", + ) + t.Logf( + "batch ConflictProvisional: coin selection excluded %s", + outpoint, + ) + + // ---------------------------------------------------------------- + // Beat 2: reorg the conflicting spend off-chain (empty replacement + // branch, so it is not re-mined) -> the spend watch reports it reorged + // out -> the conflict clears -> Provisional -> the VTXO is ADMITTED. + // ---------------------------------------------------------------- + reorg := h.Harness.ReorgExcludingMempool(1, 2) + require.Len(t, reorg.Connected, 2) + t.Logf( + "reorg (empty replacement): disconnected=%d connected=%d "+ + "fork_height=%d", len(reorg.Disconnected), + len(reorg.Connected), reorg.ForkPoint.Height, + ) + + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *batchTxid) + + admittedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.True( + t, admittedResp.IsOk(), + "coin selection must succeed once the conflicting spend is "+ + "reorged away", + ) + resp, err := admittedResp.Unpack() + require.NoError(t, err) + selected, ok := resp.(*vtxo.SelectAndReserveSpendResponse) + require.True(t, ok, "unexpected select response type %T", resp) + + selectedOutpoints := make( + []wire.OutPoint, 0, len(selected.SelectedVTXOs), + ) + for _, s := range selected.SelectedVTXOs { + selectedOutpoints = append(selectedOutpoints, s.Outpoint) + } + require.Contains( + t, selectedOutpoints, outpoint, + "the de-conflicted VTXO must be selected", + ) + t.Logf( + "conflict cleared, batch Provisional: coin selection "+ + "admitted %s", outpoint, + ) +} diff --git a/systest/batch_canonicality_forfeit_restore_test.go b/systest/batch_canonicality_forfeit_restore_test.go new file mode 100644 index 000000000..1400c97a3 --- /dev/null +++ b/systest/batch_canonicality_forfeit_restore_test.go @@ -0,0 +1,316 @@ +//go:build systest + +package systest + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/chainsource" + "github.com/lightninglabs/wavelength/db" + "github.com/lightninglabs/wavelength/lib/arkscript" + "github.com/lightninglabs/wavelength/lib/tree" + "github.com/lightninglabs/wavelength/lib/types" + "github.com/lightninglabs/wavelength/lndbackend" + "github.com/lightninglabs/wavelength/vtxo" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// TestBatchCanonicalityRestoresForfeitedVTXO is the F6 acceptance scenario -- +// the strongest proof that business state tracks chain canonicality rather than +// the reverse. A VTXO forfeited into a round-2 commitment is restored to a +// spendable state when that commitment is invalidated (its forfeit reversed by +// a finalized conflict), driven end to end through real bitcoind + LND. +// +// It exercises the reverse-dependency restore wired across two managers: +// +// batchcanon.Manager (records the consumer-batch edge, detects the +// finalized conflict that invalidates the consumer batch) +// -> RestoreConsumedVTXO callback +// -> vtxo.Manager.RestoreForfeitedVTXORequest +// -> re-materialize the forfeited VTXO as Live from its descriptor +// +// The round-1 VTXO is seeded directly in the FORFEITED state (as the FSM leaves +// it once a round consuming it confirms; its actor was reaped). The round-2 +// commitment is a faucet tx registered with that VTXO as a forfeited VTXO and +// with an independent consumed input we can double-spend. Double-spending the +// input and maturing the conflict past the reorg-safety depth drives the +// consumer batch to ConflictFinalized, which fires the restore. The VTXO must +// return to Live and become selectable again. +func TestBatchCanonicalityRestoresForfeitedVTXO(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + sqlDB := db.NewTestDB(t) + clk := clock.NewDefaultClock() + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), btclog.Disabled, + ) + vtxoStore := dbStore.NewVTXOStore(clk) + canonStore := dbStore.NewBatchCanonicalityStore(clk) + + // Seed the round-1 VTXO in the FORFEITED state (its actor reaped), as + // the FSM leaves it once the round-2 commitment that consumes it + // confirms. + const vtxoAmount = btcutil.Amount(50_000) + forfeitedOutpoint := seedForfeitedVTXO( + t, vtxoStore, t.Name(), vtxoAmount, + ) + + // The round-2 commitment batch + an independent consumed input we can + // double-spend to invalidate it. + batchTxid, batchScript := faucetSyntheticBatch(t, h, "round2") + conflictInput, conflictValueBTC, conflictScript := + h.Harness.FirstSpendableOutpoint() + + // Real vtxo.Manager (with the restore handler) first, so the + // batchcanon manager's restore callback can target it. + vtxoWallet := lndbackend.NewClientWallet( + h.Harness.LND.Signer, h.Harness.LND.WalletKit, + ) + vtxoMgr := vtxo.NewManager(&vtxo.ManagerConfig{ + Store: vtxoStore, + Wallet: vtxoWallet, + ChainSource: chainSource, + ActorSystem: h.ActorSystem(), + ChainParams: h.ChainParams(), + BatchCanonicality: canonStore, + Log: fn.Some(h.SubLogger(vtxo.Subsystem)), + }) + const vtxoMgrName = "systest-vtxo-manager-f6-restore" + vtxoKey := actor.NewServiceKey[vtxo.ManagerMsg, vtxo.ManagerResp]( + vtxoMgrName, + ) + vtxoRef := actor.RegisterWithSystem( + h.ActorSystem(), vtxoMgrName, vtxoKey, vtxoMgr, + ) + require.NoError(t, vtxoMgr.Start(ctx, vtxoRef)) + + // Real batchcanon.Manager with the restore callback wired to the VTXO + // manager -- mirroring waved.restoreForfeitedVTXO. + bcMgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSource, + Log: fn.Some(h.SubLogger("BCAN")), + RestoreConsumedVTXO: func(ctx context.Context, + op wire.OutPoint) error { + + _, err := vtxoRef.Ask( + ctx, &vtxo.RestoreForfeitedVTXORequest{ + Outpoint: op, + }, + ).Await(ctx).Unpack() + + return err + }, + }) + bcRef := actor.RegisterWithSystem( + h.ActorSystem(), + "batch-canonicality", batchcanon.ManagerServiceKey, bcMgr, + ) + bcMgr.SetSelfRef(bcRef) + + // Register the round-2 commitment batch: it forfeits the round-1 VTXO + // and consumes the input we will double-spend. + regResp := bcRef.Ask(ctx, &batchcanon.RegisterBatchRequest{ + BatchTxID: *batchTxid, + ConfirmationPkScript: batchScript, + CSVExpiryDelta: f2VTXOCSVDelay, + ConsumedInputs: []batchcanon.ConsumedInput{{ + Outpoint: conflictInput, + PkScript: conflictScript, + }}, + ForfeitedVTXOs: []wire.OutPoint{forfeitedOutpoint}, + }).Await(ctx) + require.True(t, regResp.IsOk(), "register round-2 batch") + + // Confirm the round-2 commitment so the forfeit looks complete. + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *batchTxid) + + // Precondition: the round-1 VTXO is forfeited and therefore not + // spendable. + require.Equal( + t, vtxo.VTXOStatusForfeited, + vtxoStatus(ctx, t, vtxoStore, forfeitedOutpoint), + "precondition: the round-1 VTXO must be forfeited", + ) + + // ---------------------------------------------------------------- + // Invalidate the round-2 commitment: double-spend its consumed input + // and mature the conflict past the reorg-safety depth, driving the + // batch to ConflictFinalized. + // ---------------------------------------------------------------- + conflictTxid := h.Harness.SpendOutpoint(conflictInput, conflictValueBTC) + require.NotEqual(t, batchTxid.String(), conflictTxid) + h.Harness.Generate(1) + awaitBatchState( + ctx, t, bcRef, *batchTxid, batchcanon.StateConflictProvisional, + ) + + // Mature the conflicting spend past the finality depth so it + // finalizes. DefaultFinalityDepth + a margin guarantees the spend Done + // event synthesizes and the batch reaches ConflictFinalized. + h.Harness.Generate(int(chainsource.DefaultFinalityDepth) + 2) + awaitBatchState( + ctx, t, bcRef, *batchTxid, batchcanon.StateConflictFinalized, + ) + + // ---------------------------------------------------------------- + // The forfeit is reversed: the round-1 VTXO must be restored to Live + // and become selectable again. + // ---------------------------------------------------------------- + awaitVTXOStatus( + ctx, t, vtxoStore, forfeitedOutpoint, vtxo.VTXOStatusLive, + ) + t.Logf( + "round-2 ConflictFinalized: forfeited VTXO %s restored to live", + forfeitedOutpoint, + ) + + resp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.True( + t, resp.IsOk(), + "the restored VTXO must be selectable for spending again", + ) + unpacked, err := resp.Unpack() + require.NoError(t, err) + selected, ok := unpacked.(*vtxo.SelectAndReserveSpendResponse) + require.True(t, ok, "unexpected select response type %T", unpacked) + + selectedOutpoints := make( + []wire.OutPoint, 0, len(selected.SelectedVTXOs), + ) + for _, s := range selected.SelectedVTXOs { + selectedOutpoints = append(selectedOutpoints, s.Outpoint) + } + require.Contains( + t, selectedOutpoints, forfeitedOutpoint, + "the restored VTXO must be among the selected coins", + ) +} + +// vtxoStatus reads a VTXO's persisted status. +func vtxoStatus(ctx context.Context, t *testing.T, + store *db.VTXOPersistenceStore, op wire.OutPoint) vtxo.VTXOStatus { + + t.Helper() + + desc, err := store.GetVTXO(ctx, op) + require.NoError(t, err) + require.NotNil(t, desc) + + return desc.Status +} + +// awaitVTXOStatus polls until a VTXO reaches the wanted persisted status, +// failing on timeout. The restore is asynchronous (the canonicality manager +// fires a callback that asks the VTXO manager), so a retry is required. +func awaitVTXOStatus(ctx context.Context, t *testing.T, + store *db.VTXOPersistenceStore, op wire.OutPoint, + want vtxo.VTXOStatus) { + + t.Helper() + + require.Eventuallyf( + t, func() bool { + desc, err := store.GetVTXO(ctx, op) + if err != nil || desc == nil { + return false + } + + return desc.Status == want + }, reorgSystestEventTimeout, batchCanonPollInterval, + "vtxo %s never reached status %v", op, want, + ) +} + +// seedForfeitedVTXO persists a single VTXO in the FORFEITED state, returning +// its outpoint. It mirrors the descriptor a round leaves behind once the +// commitment that forfeits the VTXO confirms (status Forfeited, no live actor). +func seedForfeitedVTXO(t *testing.T, vtxoStore *db.VTXOPersistenceStore, + name string, amount btcutil.Amount) wire.OutPoint { + + t.Helper() + + clientPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "client key") + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "operator key") + operatorKey := operatorPriv.PubKey() + + descriptor, err := tree.NewVTXODescriptor( + amount, clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo descriptor") + + tapScript, err := arkscript.VTXOTapScript( + clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo tapscript") + + // A distinct synthetic commitment txid for the round-1 batch. It is not + // registered with the canonicality manager, so its lineage is unseen + // (permissive) and the restored VTXO is selectable. + commitmentTxid := chainhash.HashH([]byte(name + "-round1-commitment")) + outpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte(name + "-forfeited-vtxo")), + Index: 0, + } + + err = vtxoStore.SaveVTXO(t.Context(), &vtxo.Descriptor{ + Outpoint: outpoint, + Amount: amount, + PolicyTemplate: descriptor.PolicyTemplate, + PkScript: descriptor.PkScript, + ClientKey: keychain.KeyDescriptor{ + PubKey: clientPriv.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: types.VTXOOwnerKeyFamily, + Index: 7, + }, + }, + OperatorKey: operatorKey, + TapScript: tapScript, + RoundID: chainhash. + HashH([]byte(name + "-round1")). + String(), + CommitmentTxID: commitmentTxid, + BatchExpiry: 500000, + RelativeExpiry: f2VTXOCSVDelay, + CreatedHeight: 1, + Status: vtxo.VTXOStatusForfeited, + }) + require.NoError(t, err, "save forfeited vtxo") + + // SaveVTXO always persists a freshly-created VTXO as Live, so flip it + // to Forfeited explicitly. This must happen before the VTXO manager + // starts so the VTXO is excluded from live recovery (no actor spawned), + // exactly as a reaped forfeit leaves it. + require.NoError( + t, + vtxoStore.UpdateVTXOStatus( + t.Context(), outpoint, vtxo.VTXOStatusForfeited, + ), + "mark seeded vtxo forfeited", + ) + + return outpoint +} diff --git a/systest/batch_canonicality_gate_test.go b/systest/batch_canonicality_gate_test.go new file mode 100644 index 000000000..4c7dcefe6 --- /dev/null +++ b/systest/batch_canonicality_gate_test.go @@ -0,0 +1,325 @@ +//go:build systest + +package systest + +import ( + "context" + "crypto/sha256" + "testing" + + btcaddr "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/db" + "github.com/lightninglabs/wavelength/lib/arkscript" + "github.com/lightninglabs/wavelength/lib/tree" + "github.com/lightninglabs/wavelength/lib/types" + "github.com/lightninglabs/wavelength/lndbackend" + "github.com/lightninglabs/wavelength/round" + "github.com/lightninglabs/wavelength/vtxo" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// f2VTXOCSVDelay is the relative-expiry CSV delay stamped on the synthetic +// test VTXO. The value is arbitrary for this test (expiry is never exercised); +// it just has to be a valid non-zero delay for descriptor/tapscript +// construction. +const f2VTXOCSVDelay = 144 + +// TestBatchCanonicalityGateBlocksReorgedVTXO proves the LIVE coin-selection +// reorg-safety gate does its job end to end: a VTXO whose batch (commitment +// tx) is reorged off the canonical chain is excluded from coin selection, +// then admitted again once the batch reconfirms. This is the F2 acceptance +// scenario (batch reorged out then reconfirmed) at the vtxo.Manager seam, +// driven by a real bitcoind reorg. +// +// The wiring mirrors production: a real chainsource actor over the harness +// LND, a real batchcanon.Manager arming reorg-aware watches, and a real +// vtxo.Manager whose ManagerConfig.BatchCanonicality points at the SAME +// durable store the manager writes -- exactly how waved.initBatchCanonicality +// connects them. Only the VTXO is seeded directly (as seedLiveVTXO does for the +// directed-send systest) rather than produced by a live round; the round +// production path is covered by TestSendVTXOEndToEnd. +// +// The batch (commitment) tx is a real faucet transaction so its txid is a live +// on-chain tx the canonicality conf-watch can track and reorg. The seeded +// VTXO's CommitmentTxID is set to that txid, so the gate (which reloads the +// full descriptor via GetVTXO and reads its direct commitment txid) governs +// the VTXO by that batch. +// +// To make the "batch off-chain" window deterministic, the reorg mines its +// replacement branch with EMPTY blocks (ReorgExcludingMempool), so the batch +// tx is NOT auto-re-confirmed and the canonicality record holds ReorgedOut +// stably. A plain Reorg would re-mine the tx from the mempool on the first +// replacement block, collapsing the window before coin selection could observe +// it. A subsequent normal block re-confirms the stranded tx. +// +// The proof is a contrast on a single VTXO with a single selection target, +// where the ONLY thing that changes between the two beats is the batch's chain +// canonicality: +// +// 1. Batch ReorgedOut -> SelectAndReserveSpendRequest fails (gated out). +// 2. Batch reconfirmed -> SelectAndReserveSpendRequest succeeds (admitted). +func TestBatchCanonicalityGateBlocksReorgedVTXO(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + sqlDB := db.NewTestDB(t) + clk := clock.NewDefaultClock() + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), btclog.Disabled, + ) + vtxoStore := dbStore.NewVTXOStore(clk) + canonStore := dbStore.NewBatchCanonicalityStore(clk) + + // Faucet a real tx whose txid is the batch (commitment) tx. We faucet + // to a synthetic P2WPKH script we never spend; we only need a known + // txid + pkScript to anchor the VTXO lineage and arm the conf watch. + pubKeyHash := sha256.Sum256([]byte(t.Name())) + addr, err := btcaddr.NewAddressWitnessPubKeyHash( + pubKeyHash[:20], &chaincfg.RegressionNetParams, + ) + require.NoError(t, err, "build synthetic P2WPKH address") + batchPkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err, "derive pkScript for synthetic address") + + batchAmount := btcutil.Amount(btcutil.SatoshiPerBitcoin / 100) + batchTxidStr := h.Harness.Faucet(addr.String(), batchAmount) + batchTxid, err := chainhash.NewHashFromStr(batchTxidStr) + require.NoError(t, err, "parse faucet txid") + + // Seed a live VTXO anchored on the batch tx, BEFORE the manager starts + // so it is recovered into a resident actor. Its outpoint is synthetic + // (a VTXO leaf is not the batch tx itself); only its CommitmentTxID + // matters to the gate. + const vtxoAmount = btcutil.Amount(50_000) + outpoint := seedLiveVTXOForBatch( + t, vtxoStore, t.Name(), *batchTxid, vtxoAmount, + ) + + // Real batchcanon.Manager over the durable store, wired to the real + // chainsource so it arms reorg-aware watches. + bcMgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSource, + Log: fn.Some(h.SubLogger("BCAN")), + }) + bcRef := actor.RegisterWithSystem( + h.ActorSystem(), + "batch-canonicality", batchcanon.ManagerServiceKey, bcMgr, + ) + bcMgr.SetSelfRef(bcRef) + + // Real vtxo.Manager with the coin-selection gate pointed at the SAME + // canonicality store, mirroring waved's wiring. + vtxoWallet := lndbackend.NewClientWallet( + h.Harness.LND.Signer, h.Harness.LND.WalletKit, + ) + vtxoMgr := vtxo.NewManager(&vtxo.ManagerConfig{ + Store: vtxoStore, + Wallet: vtxoWallet, + ChainSource: chainSource, + ActorSystem: h.ActorSystem(), + ChainParams: h.ChainParams(), + BatchCanonicality: canonStore, + Log: fn.Some(h.SubLogger(vtxo.Subsystem)), + }) + const vtxoMgrName = "systest-vtxo-manager-f2-gate" + vtxoKey := actor.NewServiceKey[vtxo.ManagerMsg, vtxo.ManagerResp]( + vtxoMgrName, + ) + vtxoRef := actor.RegisterWithSystem( + h.ActorSystem(), vtxoMgrName, vtxoKey, vtxoMgr, + ) + require.NoError(t, vtxoMgr.Start(ctx, vtxoRef)) + + // Register the batch so the manager arms a reorg-aware conf watch on + // the faucet tx. + regResp := bcRef.Ask(ctx, &batchcanon.RegisterBatchRequest{ + BatchTxID: *batchTxid, + ConfirmationPkScript: batchPkScript, + CSVExpiryDelta: f2VTXOCSVDelay, + }).Await(ctx) + require.True(t, regResp.IsOk(), "register batch with manager") + + // Confirm the batch tx -> Provisional. (The gate is not asserted here; + // the reconfirm beat below proves Provisional is selectable.) + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *batchTxid) + + // ---------------------------------------------------------------- + // Beat 1: reorg the batch off-chain (stable ReorgedOut) -> the VTXO + // must be EXCLUDED from coin selection. The replacement branch is + // mined empty so the batch tx is not auto-re-confirmed. + // ---------------------------------------------------------------- + reorg := h.Harness.ReorgExcludingMempool(1, 2) + require.Len(t, reorg.Connected, 2) + t.Logf( + "reorg (empty replacement): disconnected=%d connected=%d "+ + "fork_height=%d", len(reorg.Disconnected), + len(reorg.Connected), reorg.ForkPoint.Height, + ) + + awaitBatchState(ctx, t, bcRef, *batchTxid, batchcanon.StateReorgedOut) + + blockedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.False( + t, blockedResp.IsOk(), + "coin selection must fail while the VTXO's batch is "+ + "reorged out: the only candidate is gated out, "+ + "leaving no liquidity", + ) + t.Logf( + "batch ReorgedOut: coin selection correctly excluded %s", + outpoint, + ) + + // ---------------------------------------------------------------- + // Beat 2: reconfirm the batch -> Provisional -> the VTXO must be + // ADMITTED again. Mine a normal block so the stranded mempool tx is + // re-included. + // ---------------------------------------------------------------- + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *batchTxid) + + admittedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.True( + t, admittedResp.IsOk(), + "coin selection must succeed once the VTXO's batch reconfirms", + ) + resp, err := admittedResp.Unpack() + require.NoError(t, err) + selected, ok := resp.(*vtxo.SelectAndReserveSpendResponse) + require.True(t, ok, "unexpected select response type %T", resp) + + selectedOutpoints := make( + []wire.OutPoint, 0, len(selected.SelectedVTXOs), + ) + for _, s := range selected.SelectedVTXOs { + selectedOutpoints = append(selectedOutpoints, s.Outpoint) + } + require.Contains( + t, selectedOutpoints, outpoint, + "the reconfirmed VTXO must be selected", + ) + t.Logf( + "batch reconfirmed Provisional: coin selection admitted %s", + outpoint, + ) +} + +// awaitBatchProvisionalAtNoBlock polls the manager until the batch is +// Provisional, without asserting a specific confirmation block (the block hash +// changes across the reorg). It reuses the shared awaitBatchRecord poller. +func awaitBatchProvisionalAtNoBlock(ctx context.Context, t *testing.T, + mgrRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid chainhash.Hash) *batchcanon.Record { + + t.Helper() + + return awaitBatchRecord( + ctx, t, mgrRef, txid, + func(rec *batchcanon.Record) bool { + return rec.State == batchcanon.StateProvisional + }, + "Provisional", + ) +} + +// awaitBatchState polls the manager until the batch reaches the wanted state. +func awaitBatchState(ctx context.Context, t *testing.T, + mgrRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid chainhash.Hash, want batchcanon.State) *batchcanon.Record { + + t.Helper() + + return awaitBatchRecord( + ctx, t, mgrRef, txid, + func(rec *batchcanon.Record) bool { + return rec.State == want + }, + "state %v", want, + ) +} + +// seedLiveVTXOForBatch persists a single live VTXO whose lineage is anchored on +// batchTxid, returning its outpoint. It is a focused analogue of the directed- +// send systest's seedLiveVTXO: it writes straight to the provided VTXO store +// (SaveVTXO auto-inserts the backing round row) instead of a daemon DB dir, and +// stamps CommitmentTxID = batchTxid so the canonicality gate governs it by that +// batch. The owner/operator keys and tapscript are real so the descriptor is +// well-formed, but they are never used to sign in this test. +func seedLiveVTXOForBatch(t *testing.T, vtxoStore *db.VTXOPersistenceStore, + name string, batchTxid chainhash.Hash, + amount btcutil.Amount) wire.OutPoint { + + t.Helper() + + clientPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "client key") + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "operator key") + operatorKey := operatorPriv.PubKey() + + roundID, err := round.NewRoundID() + require.NoError(t, err, "round id") + + descriptor, err := tree.NewVTXODescriptor( + amount, clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo descriptor") + + tapScript, err := arkscript.VTXOTapScript( + clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo tapscript") + + outpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte(name + "-seeded-vtxo")), + Index: 0, + } + + err = vtxoStore.SaveVTXO(t.Context(), &vtxo.Descriptor{ + Outpoint: outpoint, + Amount: amount, + PolicyTemplate: descriptor.PolicyTemplate, + PkScript: descriptor.PkScript, + ClientKey: keychain.KeyDescriptor{ + PubKey: clientPriv.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: types.VTXOOwnerKeyFamily, + Index: 7, + }, + }, + OperatorKey: operatorKey, + TapScript: tapScript, + RoundID: roundID.String(), + CommitmentTxID: batchTxid, + BatchExpiry: 500000, + RelativeExpiry: f2VTXOCSVDelay, + CreatedHeight: 1, + Status: vtxo.VTXOStatusLive, + }) + require.NoError(t, err, "save live vtxo") + + return outpoint +} diff --git a/systest/batch_canonicality_multiroot_test.go b/systest/batch_canonicality_multiroot_test.go new file mode 100644 index 000000000..db8576a5e --- /dev/null +++ b/systest/batch_canonicality_multiroot_test.go @@ -0,0 +1,280 @@ +//go:build systest + +package systest + +import ( + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/db" + "github.com/lightninglabs/wavelength/lib/arkscript" + "github.com/lightninglabs/wavelength/lib/tree" + "github.com/lightninglabs/wavelength/lib/types" + "github.com/lightninglabs/wavelength/lndbackend" + "github.com/lightninglabs/wavelength/vtxo" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// TestBatchCanonicalityGateBlocksPartialRootReorg proves the F7 acceptance +// scenario: the coin-selection gate combines availability across a VTXO's +// ENTIRE multi-root ancestry, so reorging out any STRICT SUBSET of its roots +// (a "partial-root" reorg) excludes the VTXO even while every other root stays +// confirmed. Once the reorged root reconfirms, the VTXO is admitted again. +// +// This is the N>2 generalization of F4 +// (TestBatchCanonicalityGateBlocksReorgedAncestor). F4 has a single ancestor, +// so its gate combines availability over exactly {direct commitment, one +// ancestor} — a 2-element set where "worst-of-N" is indistinguishable from a +// simple pairwise min. A VTXO minted from a merge/OOR that draws inputs from +// several distinct commitment batches carries MULTIPLE cross-commitment roots; +// the gate must reduce over all of them and take the worst. Here the VTXO +// carries two independent ancestors (rootA, rootB) plus its direct commitment, +// and we reorg ONLY rootB. If the gate stopped at the first canonical root, or +// short-circuited on the direct commitment, the reorged-out rootB would slip +// through and the VTXO would be wrongly spendable against a lineage that is no +// longer fully on-chain. +// +// The roots are isolated into distinct blocks (direct, then rootA, then rootB, +// one block apart) so reorging only the tip block cleanly targets rootB and +// leaves the direct commitment and rootA untouched — making the contrast +// unambiguous: the sole batch that changes state is rootB. +func TestBatchCanonicalityGateBlocksPartialRootReorg(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + sqlDB := db.NewTestDB(t) + clk := clock.NewDefaultClock() + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), btclog.Disabled, + ) + vtxoStore := dbStore.NewVTXOStore(clk) + canonStore := dbStore.NewBatchCanonicalityStore(clk) + + // Real batchcanon.Manager + vtxo.Manager (gate on the same store), + // mirroring waved. + bcMgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSource, + Log: fn.Some(h.SubLogger("BCAN")), + }) + bcRef := actor.RegisterWithSystem( + h.ActorSystem(), + "batch-canonicality", batchcanon.ManagerServiceKey, bcMgr, + ) + bcMgr.SetSelfRef(bcRef) + + vtxoWallet := lndbackend.NewClientWallet( + h.Harness.LND.Signer, h.Harness.LND.WalletKit, + ) + vtxoMgr := vtxo.NewManager(&vtxo.ManagerConfig{ + Store: vtxoStore, + Wallet: vtxoWallet, + ChainSource: chainSource, + ActorSystem: h.ActorSystem(), + ChainParams: h.ChainParams(), + BatchCanonicality: canonStore, + Log: fn.Some(h.SubLogger(vtxo.Subsystem)), + }) + const vtxoMgrName = "systest-vtxo-manager-f7-multiroot" + vtxoKey := actor.NewServiceKey[vtxo.ManagerMsg, vtxo.ManagerResp]( + vtxoMgrName, + ) + vtxoRef := actor.RegisterWithSystem( + h.ActorSystem(), vtxoMgrName, vtxoKey, vtxoMgr, + ) + require.NoError(t, vtxoMgr.Start(ctx, vtxoRef)) + + // Confirm the DIRECT commitment first, then each root, one block apart, + // so the roots land in distinct blocks and reorging only the tip block + // targets rootB alone (fauceting them together would confirm them in + // the same block and defeat the isolation). + directTxid, directScript := faucetSyntheticBatch(t, h, "direct") + registerBatch(ctx, t, bcRef, *directTxid, directScript) + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *directTxid) + + rootATxid, rootAScript := faucetSyntheticBatch(t, h, "rootA") + registerBatch(ctx, t, bcRef, *rootATxid, rootAScript) + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *rootATxid) + + rootBTxid, rootBScript := faucetSyntheticBatch(t, h, "rootB") + registerBatch(ctx, t, bcRef, *rootBTxid, rootBScript) + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *rootBTxid) + + // Seed a live VTXO whose direct commitment is directTxid and whose + // ancestry carries BOTH rootA and rootB as distinct cross-commitment + // parents. + const vtxoAmount = btcutil.Amount(50_000) + outpoint := seedLiveVTXOWithAncestors( + t, vtxoStore, t.Name(), *directTxid, + []chainhash.Hash{*rootATxid, *rootBTxid}, vtxoAmount, + ) + + // ---------------------------------------------------------------- + // Beat 1: reorg ONLY rootB off-chain (a partial-root reorg) -> the + // VTXO must be EXCLUDED even though its direct commitment and rootA + // both stay confirmed. + // ---------------------------------------------------------------- + reorg := h.Harness.ReorgExcludingMempool(1, 2) + require.Len(t, reorg.Connected, 2) + + awaitBatchState(ctx, t, bcRef, *rootBTxid, batchcanon.StateReorgedOut) + + // The direct commitment and rootA must remain Provisional throughout, + // so the reorged block can only be attributed to rootB. + require.Equal( + t, batchcanon.StateProvisional, + batchState(ctx, t, bcRef, *directTxid), + "direct commitment must stay confirmed while rootB is "+ + "reorged out", + ) + require.Equal( + t, batchcanon.StateProvisional, + batchState(ctx, t, bcRef, *rootATxid), + "rootA must stay confirmed while rootB is reorged out", + ) + + blockedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.False( + t, blockedResp.IsOk(), + "coin selection must fail while ANY root (rootB) is "+ + "reorged out, even though the direct commitment "+ + "and rootA are still confirmed", + ) + t.Logf("partial-root ReorgedOut: coin selection excluded %s", outpoint) + + // ---------------------------------------------------------------- + // Beat 2: reconfirm rootB -> the whole lineage is canonical again, so + // the VTXO must be ADMITTED. + // ---------------------------------------------------------------- + h.Harness.Generate(1) + awaitBatchProvisionalAtNoBlock(ctx, t, bcRef, *rootBTxid) + + admittedResp := vtxoRef.Ask(ctx, &vtxo.SelectAndReserveSpendRequest{ + TargetAmount: vtxoAmount, + }).Await(ctx) + require.True( + t, admittedResp.IsOk(), + "coin selection must succeed once every root is canonical "+ + "again", + ) + resp, err := admittedResp.Unpack() + require.NoError(t, err) + selected, ok := resp.(*vtxo.SelectAndReserveSpendResponse) + require.True(t, ok, "unexpected select response type %T", resp) + + selectedOutpoints := make( + []wire.OutPoint, 0, len(selected.SelectedVTXOs), + ) + for _, s := range selected.SelectedVTXOs { + selectedOutpoints = append(selectedOutpoints, s.Outpoint) + } + require.Contains( + t, selectedOutpoints, outpoint, "the VTXO must be selected "+ + "once its full multi-root lineage reconfirms", + ) + t.Logf( + "all roots reconfirmed Provisional: coin selection admitted %s", + outpoint, + ) +} + +// seedLiveVTXOWithAncestors persists a single live VTXO whose direct commitment +// is directTxid and whose ancestry carries EACH of ancestorTxids as a distinct +// cross-commitment parent, returning its outpoint. It is the multi-root +// generalization of seedLiveVTXOWithAncestor. The owner/operator keys and +// tapscript are real so the descriptor is well-formed; each ancestry tree +// fragment is a minimal placeholder (the gate reads only the commitment txids). +func seedLiveVTXOWithAncestors(t *testing.T, vtxoStore *db.VTXOPersistenceStore, + name string, directTxid chainhash.Hash, ancestorTxids []chainhash.Hash, + amount btcutil.Amount) wire.OutPoint { + + t.Helper() + + clientPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "client key") + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err, "operator key") + operatorKey := operatorPriv.PubKey() + + descriptor, err := tree.NewVTXODescriptor( + amount, clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo descriptor") + + tapScript, err := arkscript.VTXOTapScript( + clientPriv.PubKey(), operatorKey, f2VTXOCSVDelay, + ) + require.NoError(t, err, "vtxo tapscript") + + outpoint := wire.OutPoint{ + Hash: chainhash.HashH([]byte(name + "-seeded-vtxo")), + Index: 0, + } + + // One minimal ancestry entry per root; only the CommitmentTxID of each + // is consulted by the canonicality gate. + ancestry := make([]types.Ancestry, 0, len(ancestorTxids)) + for _, ancestorTxid := range ancestorTxids { + ancestorTree := &tree.Tree{ + BatchOutpoint: outpoint, + Root: &tree.Node{ + Input: outpoint, + Outputs: []*wire.TxOut{}, + CoSigners: []*btcec.PublicKey{}, + Children: make(map[uint32]*tree.Node), + }, + } + ancestry = append(ancestry, types.Ancestry{ + TreePath: ancestorTree, + CommitmentTxID: ancestorTxid, + TreeDepth: 0, + }) + } + + err = vtxoStore.SaveVTXO(t.Context(), &vtxo.Descriptor{ + Outpoint: outpoint, + Amount: amount, + PolicyTemplate: descriptor.PolicyTemplate, + PkScript: descriptor.PkScript, + ClientKey: keychain.KeyDescriptor{ + PubKey: clientPriv.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: types.VTXOOwnerKeyFamily, + Index: 7, + }, + }, + OperatorKey: operatorKey, + TapScript: tapScript, + Ancestry: ancestry, + RoundID: chainhash. + HashH([]byte(name + "-round")). + String(), + CommitmentTxID: directTxid, + BatchExpiry: 500000, + RelativeExpiry: f2VTXOCSVDelay, + CreatedHeight: 1, + Status: vtxo.VTXOStatusLive, + }) + require.NoError(t, err, "save live vtxo with ancestors") + + return outpoint +} diff --git a/systest/batch_canonicality_reorg_test.go b/systest/batch_canonicality_reorg_test.go new file mode 100644 index 000000000..1aa3876d8 --- /dev/null +++ b/systest/batch_canonicality_reorg_test.go @@ -0,0 +1,272 @@ +//go:build systest + +package systest + +import ( + "context" + "crypto/sha256" + "testing" + "time" + + btcaddr "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/db" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// batchCanonPollInterval is how often the systest re-reads the manager's +// persisted batch record while waiting for an expected canonicality state. +// It is shorter than reorgSystestEventTimeout so several reads land inside a +// single event-propagation window; 250ms keeps the Ask traffic on the +// manager mailbox light without lengthening the test materially. +const batchCanonPollInterval = 250 * time.Millisecond + +// TestBatchCanonicalityReorgRoundTrip drives a real bitcoind reorg through the +// full batch-canonicality pipeline and proves the manager re-anchors a batch's +// interpreted canonicality to the replacement chain: +// +// bitcoind invalidate/mine +// -> lnd chainntnfs (in-process) +// -> lndclient gRPC (WithReOrgChan) +// -> chainbackends.LNDBackend (multi-shot forwarder) +// -> chainsource.ConfActor (reorg-aware mode) +// -> batchcanon.Manager (conf/reorg interpretation) +// -> db.BatchCanonicalityPersistenceStore (durable record) +// +// The batchcanon unit tests (batchcanon/manager_test.go) already prove the +// StateProvisional -> StateReorgedOut -> StateProvisional transition against a +// mock conf actor, including the transient reorged-out beat. They cannot prove +// that lndclient.WithReOrgChan actually fires the reorg signal over the real +// gRPC transport, nor that the durable store survives the round-trip. This +// test is the systest-level oracle for that. +// +// The oracle is re-anchoring: a batch confirmed at block X must, after the +// reorg, end up Provisional again but confirmed at a DIFFERENT block Y that +// belongs to the replacement branch. The only way the manager's persisted +// confirmation block can move from X to Y is the full reorg -> re-confirmation +// round-trip (a non-reorg-aware watch would stay pinned to X forever). The +// intermediate reorged-out state is transient — bitcoind preserves the tx in +// its mempool across the invalidate so it re-confirms on the first new block — +// so it is not asserted here (the unit tests own that beat); the block-hash +// move is the end-to-end proof. +// +// The flow is: +// +// 1. Faucet to a synthetic P2WPKH pkScript (no spendable key needed) so we +// know the batch txid + confirmation pkScript up front. +// 2. Register the batch with the manager BEFORE mining, exercising the +// live-detection conf watch rather than the historical-backfill path. +// 3. Mine one block. Assert StateProvisional anchored to the mined block +// (one conf is < DefaultFinalityDepth, so not yet Finalized). +// 4. Drive a 1-block reorg replaced by a longer 2-block branch. Assert the +// batch becomes Provisional again, re-anchored to a NEW block on the +// replacement branch. +func TestBatchCanonicalityReorgRoundTrip(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + // Spawn a real chainsource actor over the harness's LND, then build + // the batch-canonicality manager on top of a real durable store. + chainSource := h.NewChainSourceActor() + + sqlDB := db.NewTestDB(t) + dbStore := db.NewStore( + sqlDB.DB, sqlDB.Queries, sqlDB.Backend(), btclog.Disabled, + ) + canonStore := dbStore.NewBatchCanonicalityStore( + clock.NewDefaultClock(), + ) + + mgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSource, + Log: fn.Some(h.SubLogger("BCAN")), + }) + mgrRef := actor.RegisterWithSystem( + h.ActorSystem(), + "batch-canonicality", batchcanon.ManagerServiceKey, mgr, + ) + mgr.SetSelfRef(mgrRef) + + // Build a synthetic P2WPKH address from a deterministic per-test + // pubkey hash. We never spend it; we only need a known pkScript to + // faucet to and register a confirmation watch on. + pubKeyHash := sha256.Sum256([]byte(t.Name())) + addr, err := btcaddr.NewAddressWitnessPubKeyHash( + pubKeyHash[:20], &chaincfg.RegressionNetParams, + ) + require.NoError(t, err, "build synthetic P2WPKH address") + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err, "derive pkScript for synthetic address") + + // Faucet first so we have the txid before registering the watch. The + // watch keys on (txid, pkScript), so the ordering between mempool + // entry and registration is fine; what must NOT happen is mining the + // block before registering, which would dispatch historical conf + // state and race two delivery paths. + amount := btcutil.Amount(btcutil.SatoshiPerBitcoin / 100) + txidStr := h.Harness.Faucet(addr.String(), amount) + txid, err := chainhash.NewHashFromStr(txidStr) + require.NoError(t, err, "parse faucet txid") + + // Register the batch. CSVExpiryDelta is a representative non-zero + // value; this test does not exercise expiry. No consumed inputs or + // dependent VTXOs are needed to observe the conf/reorg lifecycle. + const csvExpiryDelta = 144 + regResp := mgrRef.Ask(ctx, &batchcanon.RegisterBatchRequest{ + BatchTxID: *txid, + ConfirmationPkScript: pkScript, + CSVExpiryDelta: csvExpiryDelta, + }).Await(ctx) + require.True(t, regResp.IsOk(), "register batch with manager") + + // 1. Mine the block that confirms the faucet tx and assert the batch + // becomes Provisional, anchored to the mined block. + originalBlocks := h.Harness.Generate(1) + require.Len(t, originalBlocks, 1) + originalHash, err := chainhash.NewHashFromStr(originalBlocks[0].Hash) + require.NoError(t, err, "parse original block hash") + + rec := awaitBatchProvisionalAt(ctx, t, mgrRef, *txid, *originalHash) + t.Logf( + "batch %s Provisional at height %d block %s", txid, + rec.ConfirmationHeight.UnwrapOr(0), originalHash, + ) + + // 2. Drive a reorg: invalidate the confirmation block and mine a + // strictly longer (2-block) replacement branch. bitcoind preserves + // the tx in its mempool across the invalidate, so it re-confirms in + // the new chain. The manager's conf watch fires its reorg and then a + // fresh confirmation, re-anchoring the record to the new branch. + reorg := h.Harness.Reorg(1, 2) + require.Equal( + t, originalBlocks[0].Hash, reorg.Disconnected[0].Hash, + "the reorg should have disconnected the confirmation block", + ) + require.Len(t, reorg.Connected, 2) + t.Logf( + "reorg: disconnected=%d connected=%d fork_height=%d", + len(reorg.Disconnected), len(reorg.Connected), + reorg.ForkPoint.Height, + ) + + // 3. Assert the manager re-anchored the batch to a NEW block on the + // replacement branch, proving the reorg round-trip propagated all the + // way to the durable canonicality record. + reanchored := awaitBatchReanchored( + ctx, t, mgrRef, *txid, *originalHash, + ) + newHash := reanchored.ConfirmationBlock.UnwrapOr(chainhash.Hash{}) + + replacementHashes := make(map[string]struct{}, len(reorg.Connected)) + for _, blk := range reorg.Connected { + replacementHashes[blk.Hash] = struct{}{} + } + require.Contains( + t, replacementHashes, newHash.String(), + "re-confirmation block must belong to the replacement branch", + ) + t.Logf( + "batch %s re-anchored Provisional at height %d block %s", txid, + reanchored.ConfirmationHeight.UnwrapOr(0), newHash, + ) +} + +// awaitBatchProvisionalAt polls the manager's persisted record until the batch +// is Provisional and anchored to the wanted confirmation block, failing on +// timeout. The returned record is the one observed in that state. +func awaitBatchProvisionalAt(ctx context.Context, t *testing.T, + mgrRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid, wantBlock chainhash.Hash) *batchcanon.Record { + + t.Helper() + + return awaitBatchRecord( + ctx, t, mgrRef, txid, + func(rec *batchcanon.Record) bool { + return rec.State == batchcanon.StateProvisional && + rec.ConfirmationBlock.UnwrapOr( + chainhash.Hash{}, + ) == wantBlock + }, + "Provisional anchored at %s", wantBlock, + ) +} + +// awaitBatchReanchored polls the manager's persisted record until the batch is +// Provisional and anchored to a confirmation block DIFFERENT from oldBlock, +// failing on timeout. This is the re-anchoring oracle: it can only succeed if +// the reorg disconnected the original confirmation and a fresh confirmation +// re-anchored the record to the replacement chain. +func awaitBatchReanchored(ctx context.Context, t *testing.T, + mgrRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid, oldBlock chainhash.Hash) *batchcanon.Record { + + t.Helper() + + return awaitBatchRecord( + ctx, t, mgrRef, txid, + func(rec *batchcanon.Record) bool { + if rec.State != batchcanon.StateProvisional || + rec.ConfirmationBlock.IsNone() { + return false + } + block := rec.ConfirmationBlock.UnwrapOr( + chainhash.Hash{}, + ) + + return block != oldBlock + }, + "Provisional re-anchored off %s", oldBlock, + ) +} + +// awaitBatchRecord polls the manager's persisted record for a batch until pred +// holds, failing the test on timeout. Because the conf/reorg events propagate +// asynchronously over the real gRPC transport, we retry rather than read once. +// The returned record is the one that satisfied pred. +func awaitBatchRecord(ctx context.Context, t *testing.T, + mgrRef actor.ActorRef[batchcanon.ManagerMsg, batchcanon.ManagerResp], + txid chainhash.Hash, pred func(*batchcanon.Record) bool, + wantDesc string, wantArgs ...any) *batchcanon.Record { + + t.Helper() + + var matched *batchcanon.Record + require.Eventuallyf( + t, func() bool { + resp, err := mgrRef.Ask( + ctx, &batchcanon.GetBatchStateRequest{ + BatchTxID: txid, + }, + ).Await(ctx).Unpack() + if err != nil { + return false + } + got, ok := resp.(*batchcanon.GetBatchStateResponse) + if !ok || !got.Found { + return false + } + if !pred(got.Record) { + return false + } + matched = got.Record + + return true + }, reorgSystestEventTimeout, batchCanonPollInterval, + "batch %s never reached state: "+wantDesc, + append([]any{txid}, wantArgs...)..., + ) + + return matched +} diff --git a/systest/reorg_test.go b/systest/reorg_test.go new file mode 100644 index 000000000..b4ada12e6 --- /dev/null +++ b/systest/reorg_test.go @@ -0,0 +1,244 @@ +//go:build systest + +package systest + +import ( + "crypto/sha256" + "testing" + "time" + + btcaddr "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/chainsource" + "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// reorgSystestEventTimeout is the per-step deadline used by the +// chainsource reorg systests. The chain-notification pipeline runs +// over gRPC to a real lnd instance, so the timeout is generous enough +// to absorb container startup variance and notifier wake-up latency. +const reorgSystestEventTimeout = 30 * time.Second + +// TestChainSourceConfReorgRoundTrip drives a real bitcoind reorg +// through the full chainsource pipeline: +// +// lnd chainntnfs (in-process) +// -> lndclient gRPC (WithReOrgChan) +// -> chainbackends.LndClientChainNotifier (bridge) +// -> chainbackends.LNDBackend (multi-shot forwarder) +// -> chainsource.ConfActor (reorg-aware mode) +// -> test actor refs +// +// The flow is: +// +// 1. Register a reorg-aware confirmation watch on a synthetic P2WPKH +// pkScript whose txid we know once we faucet to it. +// 2. Faucet + mine one block. Assert ConfirmationEvent arrives with +// the expected (txid, blockHeight, blockHash). +// 3. Drive a 1-block reorg via the harness helper, which invalidates +// the confirmation block and mines a strictly longer (2-block) +// replacement branch. Bitcoind preserves the original tx in its +// mempool, so the transaction re-confirms in the new chain at a +// different block hash (and potentially the same height). +// 4. Assert ConfReorgedEvent arrives. +// 5. Assert a fresh ConfirmationEvent arrives with the new chain's +// block hash (NOT the original one), demonstrating that lnd's +// chainntnfs dispatched a re-confirmation after the reorg and +// that every layer above it propagated the multi-shot signal +// correctly. +// +// This is the systest-level oracle for "the reorg-aware pipeline +// actually works over the real gRPC transport". The unit tests in +// chainsource/reorg_test.go and chainbackends/lnd_reorg_test.go prove +// the same lifecycle against mocks, but they cannot prove that +// lndclient.WithReOrgChan fires when wired to real lnd. Only this +// test does. +func TestChainSourceConfReorgRoundTrip(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + // Spawn a real chainsource actor over the harness's LND. + chainSource := h.NewChainSourceActor() + + // Build a synthetic P2WPKH address from a deterministic per-test + // pubkey hash. The address does not need a controllable key (we + // never spend it in this test); we just need a known pkScript so + // we can register a confirmation watch. + pubKeyHash := sha256.Sum256([]byte(t.Name())) + addr, err := btcaddr.NewAddressWitnessPubKeyHash( + pubKeyHash[:20], &chaincfg.RegressionNetParams, + ) + require.NoError(t, err, "build synthetic P2WPKH address") + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err, "derive pkScript for synthetic address") + + // Wire test refs for each event variant. The Reorged ref also + // has to be set for NotifyDone-or-NotifyReorged admission to flip + // the sub-actor into multi-shot mode; we leave Done unwired + // because the lndclient transport does not synthesize a Done + // signal so it would never fire over a real lnd run anyway. + confRef := actor.NewChannelTellOnlyRef[chainsource.ConfirmationEvent]( + "systest-conf", 8, + ) + reorgRef := actor.NewChannelTellOnlyRef[chainsource.ConfReorgedEvent]( + "systest-conf-reorged", 8, + ) + + // Register the watch BEFORE the tx hits the mempool so we + // exercise the live-detection path rather than the + // historical-backfill path. + heightHint := h.Harness.BlockCount() + amount := btcutil.Amount(btcutil.SatoshiPerBitcoin / 100) + + // We need the txid up front, which means faucet first, then + // register, then mine. The watch is on the txid + pkScript pair + // so the ordering between mempool entry and registration is OK; + // what must NOT happen is that we mine the block before + // registering, because lnd's notifier would then dispatch + // historical confirmation state and our test would be racing two + // delivery paths. + txidStr := h.Harness.Faucet(addr.String(), amount) + txid, err := chainhash.NewHashFromStr(txidStr) + require.NoError(t, err, "parse faucet txid") + + confNotify := actor.TellOnlyRef[chainsource.ConfirmationEvent]( + confRef, + ) + reorgNotify := actor.TellOnlyRef[chainsource.ConfReorgedEvent]( + reorgRef, + ) + + regResp := chainSource.Ask(ctx, &chainsource.RegisterConfRequest{ + CallerID: "test-reorg-conf-" + txidStr, + Txid: txid, + PkScript: pkScript, + TargetConfs: 1, + HeightHint: heightHint, + NotifyActor: fn.Some(confNotify), + NotifyReorged: fn.Some(reorgNotify), + }).Await(ctx) + require.True(t, regResp.IsOk(), "register reorg-aware conf watch") + resp, err := regResp.Unpack() + require.NoError(t, err) + _, ok := resp.(*chainsource.RegisterConfResponse) + require.True(t, ok, "unexpected register response type") + + // 1. Mine the block that confirms the faucet tx. + originalBlocks := h.Harness.Generate(1) + require.Len(t, originalBlocks, 1) + originalBlock := originalBlocks[0] + + originalHash, err := chainhash.NewHashFromStr(originalBlock.Hash) + require.NoError(t, err, "parse original block hash") + + // 2. Assert the first ConfirmationEvent. + firstConf := awaitConfEvent(t, confRef) + require.Equal(t, *txid, firstConf.Txid, "first conf txid mismatch") + require.Equal( + t, int32(originalBlock.Height), firstConf.BlockHeight, + "first conf block height should match the mined block", + ) + require.Equal( + t, *originalHash, firstConf.BlockHash, + "first conf block hash should match the mined block", + ) + t.Logf( + "first ConfirmationEvent: txid=%s height=%d hash=%s", + firstConf.Txid, firstConf.BlockHeight, firstConf.BlockHash, + ) + + // 3. Drive a reorg: invalidate the conf block, mine a strictly + // longer replacement branch. The harness Reorg helper waits for + // lnd's chain sync to catch up before returning. + reorg := h.Harness.Reorg(1, 2) + require.Equal( + t, originalBlock.Hash, reorg.Disconnected[0].Hash, + "the reorg should have disconnected the conf block", + ) + require.Len(t, reorg.Connected, 2) + t.Logf( + "reorg: disconnected=%d connected=%d fork=%d", + len(reorg.Disconnected), len(reorg.Connected), + reorg.ForkPoint.Height, + ) + + // 4. Assert the ConfReorgedEvent. Lnd's chainntnfs notifier + // dispatches NegativeConf for the disconnected confirmation + // asynchronously after processing the disconnect; the gRPC + // transport adds a further hop, so this can take longer than + // the initial confirmation event. + reorgEvt := awaitReorgEvent(t, reorgRef) + require.Equal(t, *txid, reorgEvt.Txid, "reorg event txid mismatch") + t.Logf("ConfReorgedEvent: txid=%s", reorgEvt.Txid) + + // 5. Assert a fresh ConfirmationEvent for the same tx, now in + // the replacement chain. Bitcoind keeps the tx in mempool across + // the invalidate, so generatetoaddress picks it back up on the + // first new block. The new block hash MUST differ from the + // original; the height may or may not match depending on where + // the tx landed in the replacement branch. + secondConf := awaitConfEvent(t, confRef) + require.Equal(t, *txid, secondConf.Txid, "re-conf txid mismatch") + require.NotEqual( + t, firstConf.BlockHash, secondConf.BlockHash, + "re-confirmation must arrive in a new block", + ) + t.Logf( + "second ConfirmationEvent: txid=%s height=%d hash=%s", + secondConf.Txid, secondConf.BlockHeight, secondConf.BlockHash, + ) + + // Sanity: the new block hash must be one of the harness-reported + // connected blocks. chainhash.Hash.String() renders the + // canonical big-endian hex form that bitcoind's RPCs emit, so + // the strings can be compared directly. + connectedHashes := make(map[string]struct{}, len(reorg.Connected)) + for _, blk := range reorg.Connected { + connectedHashes[blk.Hash] = struct{}{} + } + require.Contains( + t, connectedHashes, secondConf.BlockHash.String(), + "re-confirmation block must belong to the replacement branch", + ) +} + +// awaitConfEvent reads a single ConfirmationEvent from the test ref +// with a generous deadline, failing the test on timeout. +func awaitConfEvent(t *testing.T, + ref *actor.ChannelTellOnlyRef[chainsource.ConfirmationEvent], +) chainsource.ConfirmationEvent { + + t.Helper() + + evt, ok := ref.AwaitMessage(reorgSystestEventTimeout) + require.True( + t, ok, "timeout waiting for ConfirmationEvent (%s)", + reorgSystestEventTimeout, + ) + + return evt +} + +// awaitReorgEvent reads a single ConfReorgedEvent from the test ref +// with a generous deadline, failing the test on timeout. +func awaitReorgEvent(t *testing.T, + ref *actor.ChannelTellOnlyRef[chainsource.ConfReorgedEvent], +) chainsource.ConfReorgedEvent { + + t.Helper() + + evt, ok := ref.AwaitMessage(reorgSystestEventTimeout) + require.True( + t, ok, "timeout waiting for ConfReorgedEvent (%s)", + reorgSystestEventTimeout, + ) + + return evt +} diff --git a/systest/systest.go b/systest/systest.go index be5e58e37..b485b3801 100644 --- a/systest/systest.go +++ b/systest/systest.go @@ -212,6 +212,13 @@ func (h *SysTestHarness) NewChainSourceActor() actor.ActorRef[ chainsource.ChainSourceConfig{ Backend: backend, System: h.actorSystem, + // Mirror the production wiring so systests that + // register reorg-aware conf/spend watches do not + // leak per-watch sub-actors past test teardown: + // the lndclient backend never writes the upstream + // Done channel, so without height-based synthesis + // reorg-aware watches would stay open forever. + FinalityDepth: chainsource.DefaultFinalityDepth, }.WithLogger( h.SubLogger(chainsource.Subsystem), ), diff --git a/systest/txconfirm_reorg_test.go b/systest/txconfirm_reorg_test.go new file mode 100644 index 000000000..ad376e18e --- /dev/null +++ b/systest/txconfirm_reorg_test.go @@ -0,0 +1,221 @@ +//go:build systest + +package systest + +import ( + "context" + "crypto/sha256" + "testing" + "time" + + btcaddr "github.com/btcsuite/btcd/address/v2" + "github.com/btcsuite/btcd/btcutil/v2" + "github.com/btcsuite/btcd/chaincfg/v2" + "github.com/btcsuite/btcd/txscript/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/txconfirm" + "github.com/stretchr/testify/require" +) + +// txConfirmSystestEventTimeout is the per-step deadline used by the +// txconfirm reorg systest. Generous because the chain-notification +// pipeline runs over gRPC to a real lnd instance plus this test waits +// for txconfirm's tracked-tx FSM to forward each event. +const txConfirmSystestEventTimeout = 30 * time.Second + +// recordingNotificationRef captures every Notification delivered to a +// txconfirm subscriber so the test can assert on the order and shape +// of the lifecycle without racing concurrent delivery. +type recordingNotificationRef struct { + id string + msgs chan txconfirm.Notification +} + +func newRecordingNotificationRef(id string) *recordingNotificationRef { + return &recordingNotificationRef{ + id: id, + msgs: make(chan txconfirm.Notification, 16), + } +} + +// ID returns the subscriber identifier. +func (r *recordingNotificationRef) ID() string { + return r.id +} + +// Tell records the inbound notification on the channel for the test to +// consume. +func (r *recordingNotificationRef) Tell(_ context.Context, + msg txconfirm.Notification) error { + + r.msgs <- msg + + return nil +} + +// await pulls the next notification or fails the test on timeout. +func (r *recordingNotificationRef) await(t *testing.T) txconfirm.Notification { + t.Helper() + + select { + case msg := <-r.msgs: + return msg + + case <-time.After(txConfirmSystestEventTimeout): + t.Fatalf("timeout waiting for txconfirm notification (%s)", + txConfirmSystestEventTimeout) + + return nil + } +} + +// TestTxConfirmReorgRoundTrip drives a real bitcoind reorg through +// the full txconfirm pipeline: +// +// lnd chainntnfs (in-process) +// -> lndclient gRPC (WithReOrgChan) +// -> chainbackends.LndClientChainNotifier (bridge) +// -> chainbackends.LNDBackend (multi-shot forwarder) +// -> chainsource.ConfActor (reorg-aware mode + finality synth) +// -> txconfirm.TxBroadcasterActor (tracked-tx FSM) +// -> recording subscriber +// +// This is the systest-level oracle for the layer that unroll consumes. +// The chainsource-level systest (TestChainSourceConfReorgRoundTrip) +// proves the chain-event plumbing; this one proves the tracked-tx FSM +// transitions Confirmed -> AwaitingConfirmation -> Confirmed correctly +// on real reorgs and that subscribers see TxConfirmed -> TxReorged -> +// TxConfirmed in order, with TxFinalized arriving once the +// height-based safety depth is reached. +// +// Full daemon-level end-to-end coverage (the VTXOUnrollActor walking +// a real proof through a real wallet under a real reorg) belongs in +// itest; the unroll FSM's reducer behavior on these events is already +// covered by unit tests in unroll/reorg_safety_test.go. +func TestTxConfirmReorgRoundTrip(t *testing.T) { + ParallelN(t) + + h := NewSysTestHarness(t) + ctx := h.Context() + + chainSource := h.NewChainSourceActor() + + // Spawn a txconfirm actor over the real chainsource. Wallet is + // nil because the faucet tx is non-anchor and we never trigger + // CPFP fee-input selection in this test. + txconfBehavior := txconfirm.NewTxBroadcasterActor(txconfirm.Config{ + ChainSource: chainSource, + }) + txconfInstance := actor.NewActor(actor.ActorConfig[ + txconfirm.Msg, txconfirm.Resp, + ]{ + ID: "txconfirm-systest", + Behavior: txconfBehavior, + MailboxSize: 64, + }) + txconfBehavior.SetSelfRef(txconfInstance.TellRef()) + txconfInstance.Start() + t.Cleanup(txconfInstance.Stop) + + // Synthetic watched address: deterministic P2WPKH derived from + // the test name. We faucet to it so we have a known txid and + // pkScript for txconfirm to track. The address is never spent. + pubKeyHash := sha256.Sum256([]byte(t.Name())) + addr, err := btcaddr.NewAddressWitnessPubKeyHash( + pubKeyHash[:20], &chaincfg.RegressionNetParams, + ) + require.NoError(t, err, "build synthetic P2WPKH address") + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(t, err, "derive pkScript for synthetic address") + + heightHint := h.Harness.BlockCount() + + // txconfirm's CPFP broadcaster enforces v3/TRUC at the version + // gate, and the standard bitcoind faucet returns v2 txs. Build a + // v3 tx that pays our synthetic address from bitcoind's wallet, + // sign it, but do NOT broadcast — txconfirm will handle the + // first broadcast on EnsureConfirmedReq. + signedTx := h.Harness.SignedV3Tx( + pkScript, btcutil.Amount(btcutil.SatoshiPerBitcoin/100), + ) + txidVal := signedTx.TxHash() + txid := &txidVal + t.Logf("constructed v3 tx: txid=%s", txid) + + subscriber := newRecordingNotificationRef("txconfirm-sub") + var subRef actor.TellOnlyRef[txconfirm.Notification] = subscriber + + // Register the EnsureConfirmedReq BEFORE mining so we exercise + // the live-detection path and the tracked-tx FSM walks the full + // Broadcasting -> AwaitingConfirmation -> Confirmed transitions. + ensureResp, err := txconfInstance.Ref().Ask( + ctx, &txconfirm.EnsureConfirmedReq{ + Tx: signedTx, + ConfirmationPkScript: pkScript, + Label: "systest-reorg", + HeightHint: heightHint, + TargetConfs: 1, + Subscriber: subRef, + }, + ).Await(ctx).Unpack() + require.NoError(t, err, "EnsureConfirmedReq failed") + require.IsType(t, &txconfirm.EnsureConfirmedResp{}, ensureResp) + + // 1. Mine the block that confirms the faucet tx. + originalBlocks := h.Harness.Generate(1) + require.Len(t, originalBlocks, 1) + originalBlock := originalBlocks[0] + + // 2. Expect TxConfirmed. + first := subscriber.await(t) + firstConfirmed, ok := first.(*txconfirm.TxConfirmed) + require.True( + t, ok, "first notification must be TxConfirmed, got %T", first, + ) + require.Equal(t, *txid, firstConfirmed.Txid) + require.Equal( + t, int32(originalBlock.Height), firstConfirmed.BlockHeight, + "first conf block height should match the mined block", + ) + t.Logf( + "first TxConfirmed: txid=%s height=%d", firstConfirmed.Txid, + firstConfirmed.BlockHeight, + ) + + // 3. Reorg the conf block out, mine a strictly longer + // replacement branch, wait for lnd to chain-sync. + reorg := h.Harness.Reorg(1, 2) + require.Equal( + t, originalBlock.Hash, reorg.Disconnected[0].Hash, + "the reorg should have disconnected the conf block", + ) + require.Len(t, reorg.Connected, 2) + t.Logf( + "reorg: disconnected=%d connected=%d fork=%d", + len(reorg.Disconnected), len(reorg.Connected), + reorg.ForkPoint.Height, + ) + + // 4. Expect TxReorged. + second := subscriber.await(t) + reorgedMsg, ok := second.(*txconfirm.TxReorged) + require.True( + t, ok, "second notification must be TxReorged, got %T", second, + ) + require.Equal(t, *txid, reorgedMsg.Txid) + t.Logf("TxReorged: txid=%s", reorgedMsg.Txid) + + // 5. Expect a fresh TxConfirmed on the replacement chain. The + // faucet tx stays in mempool across the invalidate so it + // re-confirms in the first new block. + third := subscriber.await(t) + secondConfirmed, ok := third.(*txconfirm.TxConfirmed) + require.True( + t, ok, "third notification must be TxConfirmed, got %T", third, + ) + require.Equal(t, *txid, secondConfirmed.Txid) + t.Logf( + "second TxConfirmed: txid=%s height=%d", secondConfirmed.Txid, + secondConfirmed.BlockHeight, + ) +} diff --git a/txconfirm/actor.go b/txconfirm/actor.go index 976f4b315..deef2b7aa 100644 --- a/txconfirm/actor.go +++ b/txconfirm/actor.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "sync/atomic" "time" "github.com/btcsuite/btcd/chainhash/v2" @@ -52,6 +53,14 @@ var ( // waiting longer only risks blocking unrelated confirmation work behind // a durable subscriber's DB writer. terminalNotifyTimeout = time.Second + + // reversibleNotifyTimeout bounds how long a fire-and-forget reversible + // notification goroutine (TxConfirmed, TxReorged) is willing to wait + // on a slow subscriber's mailbox before logging the drop and returning. + // Reversible deliveries are best-effort: the next state transition on + // the same tracked tx supersedes the missed event, so we trade a stale + // notification for keeping txconfirm's actor loop unblocked. + reversibleNotifyTimeout = time.Second ) // ErrEnsureParamsMismatch is returned by EnsureConfirmedReq when a second @@ -185,6 +194,24 @@ type TxBroadcasterActor struct { blockSubscriptionActive bool } +// trackedSubscriber is one attached subscriber to a tracked tx. Every +// subscriber receives the full reorg-aware lifecycle (TxConfirmed, +// TxReorged, re-TxConfirmed, TxFinalized, TxFailed) and remains +// attached until TxFinalized or TxFailed is acknowledged. +// +// pendingConfirmed reports whether the INITIAL TxConfirmed delivery +// is still owed to this subscriber. It is true at admission, flipped +// false the first time notifyOneConfirmed lands successfully, and +// drives retryConfirmedRedelivery's per-tick retry loop so the +// at-least-once initial-TxConfirmed contract holds even when the +// subscriber's mailbox is briefly slow. Re-confirmations after a +// reorg are delivered best-effort because the eventual TxFinalized +// reliably carries the final height/numConfs. +type trackedSubscriber struct { + Ref actor.TellOnlyRef[Notification] + pendingConfirmed bool +} + // trackedTx stores the actor-owned handle for one tracked txid. // // The struct is the actor's single source of truth about a tracked @@ -195,7 +222,7 @@ type trackedTx struct { data trackedTxData fsm *trackedTxStateMachine - subscribers map[string]actor.TellOnlyRef[Notification] + subscribers map[string]trackedSubscriber // escalateLog rate-limits the operator-facing escalation that fires // once a tx has failed to reach any mempool repeatedly. It @@ -219,6 +246,17 @@ type trackedTx struct { // subsequent interval-paced bumps fall back to the estimator. Zero // means "no override pending". pendingTargetFeeRate int64 + + // sealed is set true the moment terminal delivery (TxFinalized / + // TxFailed) begins for this entry. Reversible deliveries + // (TxReorged / re-TxConfirmed) run on detached fire-and-forget + // goroutines, so a reversible spawned just before finality could + // otherwise Tell its subscriber after the terminal notification and + // resurrect reorg-recovery bookkeeping the consumer already dropped. + // Each reversible goroutine checks this flag immediately before its + // Tell and skips if the entry has sealed. It is atomic because it is + // read off the actor goroutine; only the actor goroutine writes it. + sealed atomic.Bool } // confirmationObservedMsg routes a chainsource confirmation callback back into @@ -239,14 +277,53 @@ func (m *confirmationObservedMsg) MessageType() string { // set. func (m *confirmationObservedMsg) txConfirmMsgSealed() {} -// terminalNotifyResultMsg returns the result of a terminal notification that -// outlived the actor-path wait budget. +// confirmationReorgedMsg routes a chainsource ConfReorgedEvent back into +// the actor mailbox. +type confirmationReorgedMsg struct { + actor.BaseMessage + txid chainhash.Hash +} + +// MessageType returns the stable message type identifier. +func (m *confirmationReorgedMsg) MessageType() string { + return "confirmationReorgedMsg" +} + +// txConfirmMsgSealed seals confirmationReorgedMsg into the package message +// set. +func (m *confirmationReorgedMsg) txConfirmMsgSealed() {} + +// confirmationDoneMsg routes a chainsource ConfDoneEvent back into the +// actor mailbox. +type confirmationDoneMsg struct { + actor.BaseMessage + txid chainhash.Hash +} + +// MessageType returns the stable message type identifier. +func (m *confirmationDoneMsg) MessageType() string { + return "confirmationDoneMsg" +} + +// txConfirmMsgSealed seals confirmationDoneMsg into the package message +// set. +func (m *confirmationDoneMsg) txConfirmMsgSealed() {} + +// terminalNotifyResultMsg returns the result of a terminal-shape +// notification that outlived the actor-path wait budget. kind carries the +// original delivery kind ("confirmed" / "finalized" / "failed") so +// handleTerminalNotifyResult can distinguish a mid-lifecycle +// initial-TxConfirmed redelivery (where the subscriber must stay attached +// to receive later TxReorged / TxFinalized) from a truly terminal +// notification (where the subscriber should be removed and the entry can +// evict once empty). type terminalNotifyResultMsg struct { actor.BaseMessage txid chainhash.Hash subscriberID string inflightKey string + kind string err error } @@ -354,6 +431,22 @@ func (a *TxBroadcasterActor) Receive(ctx context.Context, State: TxStateConfirmed, }) + case *confirmationReorgedMsg: + a.handleConfirmationReorged(ctx, req) + + return fn.Ok[Resp](&EnsureConfirmedResp{ + Txid: req.txid, + State: TxStateAwaitingConfirmation, + }) + + case *confirmationDoneMsg: + a.handleConfirmationDone(ctx, req) + + return fn.Ok[Resp](&EnsureConfirmedResp{ + Txid: req.txid, + State: TxStateFinalized, + }) + case *blockEpochObservedMsg: a.handleBlockObserved(ctx, req) @@ -401,7 +494,23 @@ func (a *TxBroadcasterActor) OnStop(ctx context.Context) error { continue } - if state == TxStateConfirmed || state == TxStateFailed { + if isTerminalTxState(state) { + // A terminal entry can still hold a registered conf + // watch: a Failed entry never received Done, and a + // Finalized entry whose Done-driven release raced + // OnStop may not have torn it down yet. Release it so + // the chainsource sub-actor does not leak for the + // daemon's lifetime. confWatchRegistered guards the + // common case where Done already released the watch. + if entry.confWatchRegistered { + if err := a.unregisterConfWatch( + ctx, entry, + ); err != nil && firstErr == nil { + + firstErr = err + } + } + if entry.fsm != nil { entry.fsm.Stop() } @@ -476,7 +585,10 @@ func (a *TxBroadcasterActor) handleEnsure(ctx context.Context, } return a.attachExistingSubscriber( - ctx, existing, req.Subscriber, + ctx, existing, trackedSubscriber{ + Ref: req.Subscriber, + pendingConfirmed: true, + }, ), nil } @@ -700,7 +812,7 @@ func (a *TxBroadcasterActor) handleCancel(ctx context.Context, return nil, err } - if state == TxStateConfirmed || state == TxStateFailed { + if isTerminalTxState(state) { a.evictTerminal(ctx, entry) return resp, nil @@ -920,8 +1032,13 @@ func (a *TxBroadcasterActor) handleBumpNow(ctx context.Context, }, nil } -// handleConfirmationObserved marks a tracked txid as confirmed and fans the -// result out to all subscribers. +// handleConfirmationObserved advances a tracked txid into the reversible +// Confirmed state and fans TxConfirmed out to all subscribers. The +// confirmation watch is intentionally kept alive: a subsequent reorg +// would arrive on the same registration, and the entry must stay +// observable so the chainsource sub-actor's reorg/done channels reach +// this layer. The terminal Finalized state is reached separately when +// the backend emits a Done signal. func (a *TxBroadcasterActor) handleConfirmationObserved(ctx context.Context, msg *confirmationObservedMsg) { @@ -942,7 +1059,9 @@ func (a *TxBroadcasterActor) handleConfirmationObserved(ctx context.Context, return } - if state == TxStateConfirmed || state == TxStateFailed { + // Terminal entries (Failed / Finalized) are sticky; if delivery to + // any subscriber was deferred, retry it and evict on success. + if isTerminalTxState(state) { if a.retryTerminalNotifications(ctx, entry) { a.evictTerminal(ctx, entry) } @@ -950,6 +1069,17 @@ func (a *TxBroadcasterActor) handleConfirmationObserved(ctx context.Context, return } + // An already-Confirmed entry receiving another Confirmed event + // without an intervening Reorged is unexpected on a well-behaved + // backend (the chainsource sub-actor only re-fires Confirmed after + // a reorg), but we tolerate it by re-delivering TxConfirmed rather + // than failing the FSM. + if state == TxStateConfirmed { + a.notifyConfirmed(ctx, entry, msg.blockHeight, msg.numConfs) + + return + } + if err := a.advanceTrackedTxFSM(ctx, entry, &trackedTxConfirmed{ BlockHeight: msg.blockHeight, }); err != nil { @@ -960,13 +1090,153 @@ func (a *TxBroadcasterActor) handleConfirmationObserved(ctx context.Context, return } + a.notifyConfirmed(ctx, entry, msg.blockHeight, msg.numConfs) +} + +// handleConfirmationReorged moves a Confirmed tracked txid back into +// AwaitingConfirmation and fans TxReorged out to all subscribers. The +// confirmation watch stays alive on the chainsource side, so a +// subsequent re-confirmation arrives on the same registration and drives +// another handleConfirmationObserved. +func (a *TxBroadcasterActor) handleConfirmationReorged(ctx context.Context, + msg *confirmationReorgedMsg) { + + entry, ok := a.tracked[msg.txid] + if !ok { + return + } + + state, err := entry.currentTxState() + if err != nil { + a.log.WarnS(ctx, "Failed to read tracked tx state", + err, "txid", entry.data.Txid) + + return + } + + // Only Confirmed entries can be reorged. A reorg ping in any other + // state is benign and dropped: it may be a late notification for an + // entry the actor has already evicted or terminally failed. + if state != TxStateConfirmed { + return + } + + if err := a.advanceTrackedTxFSM( + ctx, entry, &trackedTxReorged{}, + ); err != nil { + + a.log.WarnS(ctx, "Failed to apply reorg to tracked tx", + err, "txid", entry.data.Txid) + + return + } + + a.notifyReorged(ctx, entry) +} + +// handleConfirmationDone moves a Confirmed tracked txid into the terminal +// Finalized state, fans TxFinalized out to all subscribers, releases the +// confirmation watch, and evicts the entry. +func (a *TxBroadcasterActor) handleConfirmationDone(ctx context.Context, + msg *confirmationDoneMsg) { + + entry, ok := a.tracked[msg.txid] + if !ok { + return + } + + state, err := entry.currentTxState() + if err != nil { + a.log.WarnS(ctx, "Failed to read tracked tx state", + err, "txid", entry.data.Txid) + + return + } + + // Only Confirmed entries can be finalized. A Done ping for an entry + // that is not Confirmed has three possible origins: + // + // - Idempotent re-delivery for a tx that is already Finalized: a + // benign no-op. + // + // - Late arrival while the entry is in AwaitingConfirmation + // after a reorg: structurally anomalous for the current + // backends — chainntnfs (in-process lnd) only writes Done + // after the tx has matured past the safety depth, and the + // lndclient adapter never writes the channel at all and + // relies on height-based synthesis (which is gated on + // confirmHeight, reset to 0 on reorg). A future backend that + // DID fire Done during the reorg gap would land here, the + // chainsource sub-actor would already have exited (Done is + // one-shot), and this txid would stop receiving new events. + // The watch is unrecoverable without a fresh RegisterConf + // from this layer. + // + // We log + drop rather than failing the entry: the realistic + // backends do not produce this state, and failing on a hypothetical + // edge case would break callers that rely on the FSM staying live + // for re-confirmation after a reorg. If the log starts firing in + // the wild, the right follow-up is to re-register the conf watch + // from here (see registerConfWatch) rather than relax the guard. + if state != TxStateConfirmed { + a.log.WarnS(ctx, "Dropping confirmation Done for non-Confirmed "+ + "entry; chainsource watch is gone, entry will not "+ + "receive further reorg/finality events", + fmt.Errorf("state %s", state), + "txid", entry.data.Txid) + + return + } + + // Snapshot the confirmation height BEFORE advancing the FSM so we + // can attach it to the outgoing TxFinalized event; the new state + // preserves it but reading from the FSM state directly avoids a + // second state-lookup roundtrip. + fsmState, err := entry.currentFSMState() + if err != nil { + a.log.WarnS(ctx, "Failed to read tracked tx FSM state", + err, "txid", entry.data.Txid) + + return + } + confirmHeight, _ := trackedTxConfirmHeight(fsmState) + + if err := a.advanceTrackedTxFSM( + ctx, entry, &trackedTxFinalized{}, + ); err != nil { + + a.log.WarnS(ctx, "Failed to finalize tracked tx", + err, "txid", entry.data.Txid) + + return + } + + // Only release the conf watch and evict once every subscriber has + // acknowledged the terminal TxFinalized notification. Failed + // deliveries leave the entry in place so retryTerminalNotifications + // can resend on a later actor tick. + if !a.notifyFinalized(ctx, entry, confirmHeight) { + return + } + if err := a.unregisterConfWatch(ctx, entry); err != nil { a.log.WarnS(ctx, "Failed to unregister confirmation watch", err, "txid", entry.data.Txid) } - if a.notifyConfirmed(ctx, entry, msg.blockHeight, msg.numConfs) { - a.evictTerminal(ctx, entry) + a.evictTerminal(ctx, entry) +} + +// isTerminalTxState reports whether a public TxState value represents a +// terminal lifecycle stage that the actor will not advance further on +// its own. +func isTerminalTxState(state TxState) bool { + switch state { + case TxStateFinalized, TxStateFailed: + return true + + default: + return false } } @@ -1003,7 +1273,7 @@ func (a *TxBroadcasterActor) handleBlockObserved(ctx context.Context, continue } - if state == TxStateConfirmed || state == TxStateFailed { + if isTerminalTxState(state) { if a.retryTerminalNotifications(ctx, entry) { a.evictTerminal(ctx, entry) } @@ -1057,13 +1327,13 @@ func (a *TxBroadcasterActor) handleBlockObserved(ctx context.Context, // txid or immediately replays a terminal result. func (a *TxBroadcasterActor) attachExistingSubscriber( ctx context.Context, entry *trackedTx, - subscriber actor.TellOnlyRef[Notification], + subscriber trackedSubscriber, ) *EnsureConfirmedResp { state, err := entry.currentFSMState() if err != nil { a.notifyOneFailed( - ctx, subscriber, entry.data.Txid, + ctx, subscriber.Ref, entry.data.Txid, fmt.Sprintf("tracked tx state: %v", err), ) @@ -1073,27 +1343,57 @@ func (a *TxBroadcasterActor) attachExistingSubscriber( } } + subID := subscriber.Ref.ID() switch state := state.(type) { case *trackedTxStateConfirmed: confirmHeight, _ := trackedTxConfirmHeight(state) - if !a.notifyOneConfirmed( - ctx, subscriber, entry.data.Txid, confirmHeight, - entry.data.TargetConfs, + txConfirmed := &TxConfirmed{ + Txid: entry.data.Txid, + BlockHeight: confirmHeight, + NumConfs: entry.data.TargetConfs, + } + + // Reliable replay of the at-least-once TxConfirmed contract. + // The subscriber is retained in the map regardless of + // delivery outcome so it also receives any later TxReorged + // or TxFinalized on this entry, and on timeout the per-tick + // retryTerminalNotifications path re-attempts delivery via + // the still-true pendingConfirmed flag until it lands. + if a.notifyOneConfirmed( + ctx, subscriber.Ref, entry.data.Txid, txConfirmed, + ) { + + subscriber.pendingConfirmed = false + } + entry.subscribers[subID] = subscriber + + case *trackedTxStateFinalized: + // Finalized is terminal. Deliver TxFinalized reliably and + // retain on timeout so the per-tick retry path can finish + // the handoff. Note that a late-attaching subscriber has + // not yet received TxConfirmed; TxFinalized carries the + // authoritative confirmation height so consumers that need + // it (sweep-finality gates etc.) can recover without an + // out-of-band lookup. + if !a.notifyOneFinalized( + ctx, subscriber.Ref, entry.data.Txid, + state.ConfirmHeight, entry.data.TargetConfs, ) { - entry.subscribers[subscriber.ID()] = subscriber + entry.subscribers[subID] = subscriber } case *trackedTxStateFailed: reason, _ := trackedTxFailureReason(state) - if !a.notifyOneFailed(ctx, subscriber, entry.data.Txid, - reason) { + if !a.notifyOneFailed( + ctx, subscriber.Ref, entry.data.Txid, reason, + ) { - entry.subscribers[subscriber.ID()] = subscriber + entry.subscribers[subID] = subscriber } default: - entry.subscribers[subscriber.ID()] = subscriber + entry.subscribers[subID] = subscriber } return a.ensureResp(entry, false) @@ -1150,8 +1450,11 @@ func (a *TxBroadcasterActor) newTrackedTx(ctx context.Context, return &trackedTx{ data: data, fsm: fsm, - subscribers: map[string]actor.TellOnlyRef[Notification]{ - req.Subscriber.ID(): req.Subscriber, + subscribers: map[string]trackedSubscriber{ + req.Subscriber.ID(): { + Ref: req.Subscriber, + pendingConfirmed: true, + }, }, escalateLog: rate.Sometimes{ First: 1, @@ -1289,6 +1592,9 @@ func (a *TxBroadcasterActor) ensureBlockSubscription( } // registerConfWatch registers a confirmation watch for one tracked txid. +// The watch is registered in reorg-aware mode so the tracked entry can +// observe a confirmation being reorged out and a confirmation maturing +// past the backend's reorg-safety depth. func (a *TxBroadcasterActor) registerConfWatch(ctx context.Context, entry *trackedTx) error { @@ -1303,6 +1609,18 @@ func (a *TxBroadcasterActor) registerConfWatch(ctx context.Context, } }, ) + reorgRef := chainsource.MapConfReorgedEvent( + a.selfRef, + func(event chainsource.ConfReorgedEvent) Msg { + return &confirmationReorgedMsg{txid: event.Txid} + }, + ) + doneRef := chainsource.MapConfDoneEvent( + a.selfRef, + func(event chainsource.ConfDoneEvent) Msg { + return &confirmationDoneMsg{txid: event.Txid} + }, + ) _, err := a.cfg.ChainSource.Ask( ctx, &chainsource.RegisterConfRequest{ @@ -1311,9 +1629,11 @@ func (a *TxBroadcasterActor) registerConfWatch(ctx context.Context, PkScript: append( []byte(nil), entry.data.ConfirmationPkScript..., ), - TargetConfs: entry.data.TargetConfs, - HeightHint: entry.data.HeightHint, - NotifyActor: fn.Some(notifyRef), + TargetConfs: entry.data.TargetConfs, + HeightHint: entry.data.HeightHint, + NotifyActor: fn.Some(notifyRef), + NotifyReorged: fn.Some(reorgRef), + NotifyDone: fn.Some(doneRef), }, ).Await(ctx).Unpack() if err != nil { @@ -1570,11 +1890,18 @@ func (a *TxBroadcasterActor) retryTerminalNotifications(ctx context.Context, switch state := state.(type) { case *trackedTxStateConfirmed: - confirmHeight, _ := trackedTxConfirmHeight(state) + // Retry deferred TxConfirmed deliveries to legacy + // subscribers whose first attempt timed out. Reorg-aware + // subscribers receive TxConfirmed fire-and-forget and have + // no retry tracking; they are skipped here. Returning false + // keeps the entry alive — the caller never evicts on + // Confirmed, only on terminal states. + a.retryConfirmedRedelivery(ctx, entry, state.ConfirmHeight) - return a.notifyConfirmed( - ctx, entry, confirmHeight, entry.data.TargetConfs, - ) + return false + + case *trackedTxStateFinalized: + return a.notifyFinalized(ctx, entry, state.ConfirmHeight) case *trackedTxStateFailed: reason, _ := trackedTxFailureReason(state) @@ -1586,8 +1913,57 @@ func (a *TxBroadcasterActor) retryTerminalNotifications(ctx context.Context, } } -// handleTerminalNotifyResult applies the result of a terminal subscriber -// notification that continued after txconfirm returned to its actor mailbox. +// retryConfirmedRedelivery retries TxConfirmed delivery to every +// subscriber whose initial delivery timed out. The at-least-once +// initial-TxConfirmed contract means a missed delivery must be +// landed before the lifecycle moves on, so this helper runs every +// actor tick from retryTerminalNotifications until every +// pendingConfirmed subscriber has been notified. On successful +// delivery the subscriber's pendingConfirmed flag is cleared and +// the subscriber stays in the map so it continues to receive any +// later TxReorged / TxFinalized on this entry — eviction is +// reserved for the truly terminal Finalized / Failed states. +func (a *TxBroadcasterActor) retryConfirmedRedelivery(ctx context.Context, + entry *trackedTx, confirmHeight int32) { + + for id, subscriber := range entry.subscribers { + if !subscriber.pendingConfirmed { + continue + } + + txConfirmed := &TxConfirmed{ + Txid: entry.data.Txid, + BlockHeight: confirmHeight, + NumConfs: entry.data.TargetConfs, + } + if a.notifyOneConfirmed( + ctx, subscriber.Ref, entry.data.Txid, txConfirmed, + ) { + + subscriber.pendingConfirmed = false + entry.subscribers[id] = subscriber + } + } +} + +// handleTerminalNotifyResult applies the result of a terminal-shape +// subscriber notification that continued after txconfirm returned to its +// actor mailbox. +// +// The kind field distinguishes mid-lifecycle from truly terminal +// deliveries: +// +// - kind == "confirmed": the late-landing delivery is the INITIAL +// TxConfirmed for this subscriber. Clear pendingConfirmed and KEEP +// the subscriber attached to the entry so later TxReorged / +// TxFinalized still reach it. Removing the subscriber here would +// silently drop the entire reorg-aware tail of its lifecycle — +// exactly the kind of failure the unified reliable-delivery path is +// supposed to prevent. +// +// - kind == "finalized" or "failed": the lifecycle is genuinely over +// for this subscriber. Remove it, and evict the entry once every +// subscriber has been notified. func (a *TxBroadcasterActor) handleTerminalNotifyResult(ctx context.Context, msg *terminalNotifyResultMsg) { @@ -1596,7 +1972,8 @@ func (a *TxBroadcasterActor) handleTerminalNotifyResult(ctx context.Context, if msg.err != nil { a.log.WarnS(ctx, "Terminal notification failed after "+ "actor-path timeout", msg.err, "txid", msg.txid, - "subscriber_id", msg.subscriberID) + "subscriber_id", msg.subscriberID, + "notification_kind", msg.kind) return } @@ -1606,6 +1983,40 @@ func (a *TxBroadcasterActor) handleTerminalNotifyResult(ctx context.Context, return } + if msg.kind == "confirmed" { + subscriber, ok := entry.subscribers[msg.subscriberID] + if !ok { + return + } + subscriber.pendingConfirmed = false + entry.subscribers[msg.subscriberID] = subscriber + + // If the tx reorged out of its confirmation while this + // subscriber's initial TxConfirmed was still parked on the + // async notify path, notifyReorged skipped it: a TxReorged must + // never precede the TxConfirmed it reverses. Now that the + // initial delivery has completed and pendingConfirmed is + // cleared, deliver the catch-up TxReorged so the subscriber is + // not left believing a reorged-out tx is confirmed. A tx that + // reorged and then re-confirmed during the window is back in + // Confirmed, so the delivered TxConfirmed is already accurate + // and no catch-up is owed; terminal states own their own + // notifications. + state, err := entry.currentTxState() + if err == nil && state != TxStateConfirmed && + !isTerminalTxState(state) { + + a.notifyReversibleAsync( + ctx, &entry.sealed, subscriber.Ref, + entry.data.Txid, "TxReorged", &TxReorged{ + Txid: entry.data.Txid, + }, + ) + } + + return + } + delete(entry.subscribers, msg.subscriberID) if len(entry.subscribers) != 0 { return @@ -1619,21 +2030,205 @@ func (a *TxBroadcasterActor) handleTerminalNotifyResult(ctx context.Context, return } - if state == TxStateConfirmed || state == TxStateFailed { + if isTerminalTxState(state) { a.evictTerminal(ctx, entry) } } -// notifyConfirmed fans a confirmation result out to all current subscribers. -// It returns true only after every subscriber accepted the terminal -// notification. Failed deliveries are left in the subscriber map so a later -// actor tick can retry instead of permanently losing the confirmation. +// notifyConfirmed fans a TxConfirmed notification out to every current +// subscriber. The initial TxConfirmed (subscriber.pendingConfirmed == +// true) goes through the reliable terminal-delivery path: on success +// pendingConfirmed flips false and the subscriber is retained so it +// keeps receiving the reorg-aware lifecycle (TxReorged / TxFinalized); +// on timeout pendingConfirmed stays true and retryConfirmedRedelivery +// re-attempts on the next actor tick, so the at-least-once initial- +// TxConfirmed guarantee holds even for slow durable subscribers. +// +// Re-confirmations (pendingConfirmed == false, i.e. this subscriber +// has already received the initial TxConfirmed and the chain reorged +// then re-confirmed) are best-effort. The subscriber already knows +// the tx confirmed at least once; the eventual TxFinalized carries +// the authoritative height / numConfs so a missed re-confirmation is +// not load-bearing. +// +// Subscribers are never deleted from the map by this function — that +// happens only on TxFinalized / TxFailed acknowledgment, which is +// when the reorg-aware lifecycle reaches a truly terminal state. func (a *TxBroadcasterActor) notifyConfirmed(ctx context.Context, - entry *trackedTx, blockHeight int32, numConfs uint32) bool { + entry *trackedTx, blockHeight int32, numConfs uint32) { + + for id, subscriber := range entry.subscribers { + txConfirmed := &TxConfirmed{ + Txid: entry.data.Txid, + BlockHeight: blockHeight, + NumConfs: numConfs, + } + + if !subscriber.pendingConfirmed { + a.notifyReversibleAsync( + ctx, &entry.sealed, subscriber.Ref, + entry.data.Txid, "TxConfirmed", txConfirmed, + ) + + continue + } + + if a.notifyOneConfirmed( + ctx, subscriber.Ref, entry.data.Txid, txConfirmed, + ) { + + subscriber.pendingConfirmed = false + entry.subscribers[id] = subscriber + } + } +} + +// notifyOneConfirmed delivers one TxConfirmed notification to a legacy +// (non-opt-in) subscriber via the reliable terminal-delivery path so a +// slow durable subscriber cannot block the actor while still +// preserving the pre-reorg-aware contract of guaranteed-at-least-once +// TxConfirmed delivery. +func (a *TxBroadcasterActor) notifyOneConfirmed(ctx context.Context, + subscriber actor.TellOnlyRef[Notification], txid chainhash.Hash, + notification *TxConfirmed) bool { + + return a.notifyOneTerminal( + ctx, subscriber, txid, "confirmed", + func(notifyCtx context.Context) error { + return subscriber.Tell(notifyCtx, notification) + }, + ) +} + +// notifyReorged fans a TxReorged notification out to every retained +// subscriber whose initial TxConfirmed has already landed. Delivery is +// fire-and-forget: a slow subscriber that misses the reorg is recovered +// by the next lifecycle event (re-TxConfirmed, TxFinalized, or TxFailed) +// since the actor stays attached until one of those terminal events lands. +// +// Subscribers still owed their initial TxConfirmed (pendingConfirmed) are +// skipped: that delivery is deferred to the reliable retry path while a +// reorg's TxReorged goes out fire-and-forget, so notifying them here would +// race the two and could surface TxReorged before — or without — the +// initial TxConfirmed, violating the at-least-once contract and leaving a +// consumer believing a reorged-out tx is confirmed. They observe the live +// state on their eventual initial delivery instead. +func (a *TxBroadcasterActor) notifyReorged(ctx context.Context, + entry *trackedTx) { + + for _, subscriber := range entry.subscribers { + if subscriber.pendingConfirmed { + continue + } + + a.notifyReversibleAsync( + ctx, &entry.sealed, subscriber.Ref, entry.data.Txid, + "TxReorged", + &TxReorged{ + Txid: entry.data.Txid, + }, + ) + } +} + +// notifyReversibleAsync delivers one reversible (non-terminal) +// Notification to a subscriber on a fresh goroutine so txconfirm's +// actor loop does not block on a slow durable subscriber's mailbox. +// Failures are logged but not retried: the next event in the tracked +// tx's lifecycle (re-TxConfirmed after a TxReorged, TxFinalized, or +// eventual TxFailed) carries the live state and supersedes any +// dropped reversible delivery. +// +// The delivery context is detached from the actor transaction (so the +// txconfirm goroutine's DB tx is not held open behind a subscriber +// roundtrip) and from ctx cancellation (so a per-message context +// expiring mid-flight does not race the actor releasing its mailbox). +// Each goroutine is bounded by reversibleNotifyTimeout so a stuck +// subscriber does not leak unbounded goroutines on every block. +func (a *TxBroadcasterActor) notifyReversibleAsync(ctx context.Context, + sealed *atomic.Bool, subscriber actor.TellOnlyRef[Notification], + txid chainhash.Hash, kind string, notification Notification) { + + notifyCtx := actor.WithoutTx(context.WithoutCancel(ctx)) + notifyCtx, cancel := context.WithTimeout( + notifyCtx, reversibleNotifyTimeout, + ) + + subscriberID := subscriber.ID() + go func() { + defer cancel() + + // Drop the reversible delivery if the entry has sealed (a + // terminal TxFinalized/TxFailed has begun). Checked here, just + // before the Tell, so a reversible spawned before finality + // cannot trail the terminal notification into the subscriber's + // mailbox and resurrect bookkeeping it already released. + if sealed != nil && sealed.Load() { + return + } + + if err := subscriber.Tell(notifyCtx, notification); err != nil { + a.log.WarnS(notifyCtx, + "Failed to deliver reversible notification", + err, "txid", txid, + "subscriber_id", subscriberID, + "notification_kind", kind) + } + }() +} + +// notifyFinalized fans a TxFinalized notification out to every +// retained subscriber. The map may still contain subscribers whose +// initial reliable TxConfirmed delivery timed out +// (pendingConfirmed=true) — those still need TxConfirmed before they +// see TxFinalized, otherwise the at-least-once TxConfirmed contract +// is violated. For each pending subscriber we make one TxConfirmed +// attempt, flip pendingConfirmed on success, and either way leave +// them attached for the next finalize retry (the per-tick retry path +// will keep cycling through this function until both deliveries +// land). +// +// On successful TxFinalized delivery the subscriber is removed and +// the caller may evict the tracked entry once every subscriber has +// acknowledged. Failed deliveries are left in the subscriber map for +// retry from the next actor tick. +// +// confirmHeight is the height observed at finalization time, replayed +// onto TxFinalized so consumers that dropped the fire-and-forget +// re-TxConfirmed can recover the authoritative confirmation height +// without an out-of-band lookup. +func (a *TxBroadcasterActor) notifyFinalized(ctx context.Context, + entry *trackedTx, confirmHeight int32) bool { + + // Seal the entry so any in-flight reversible delivery skips its Tell + // rather than trailing this terminal notification into a subscriber's + // mailbox. Idempotent across the per-tick finalize retries. + entry.sealed.Store(true) for id, subscriber := range entry.subscribers { - ok := a.notifyOneConfirmed( - ctx, subscriber, entry.data.Txid, blockHeight, numConfs, + if subscriber.pendingConfirmed { + if a.notifyOneConfirmed( + ctx, subscriber.Ref, entry.data.Txid, + &TxConfirmed{ + Txid: entry.data.Txid, + BlockHeight: confirmHeight, + NumConfs: entry.data.TargetConfs, + }, + ) { + + subscriber.pendingConfirmed = false + entry.subscribers[id] = subscriber + } else { + // Hold until the next tick — we must not + // deliver TxFinalized before the documented + // initial TxConfirmed has landed. + continue + } + } + + ok := a.notifyOneFinalized( + ctx, subscriber.Ref, entry.data.Txid, confirmHeight, + entry.data.TargetConfs, ) if !ok { continue @@ -1652,9 +2247,14 @@ func (a *TxBroadcasterActor) notifyConfirmed(ctx context.Context, func (a *TxBroadcasterActor) notifyFailed(ctx context.Context, entry *trackedTx, reason string) bool { + // Seal the entry so any in-flight reversible delivery skips its Tell + // rather than trailing this terminal failure into a subscriber's + // mailbox. Idempotent across the per-tick retries. + entry.sealed.Store(true) + for id, subscriber := range entry.subscribers { ok := a.notifyOneFailed( - ctx, subscriber, entry.data.Txid, reason, + ctx, subscriber.Ref, entry.data.Txid, reason, ) if !ok { continue @@ -1666,15 +2266,20 @@ func (a *TxBroadcasterActor) notifyFailed(ctx context.Context, entry *trackedTx, return len(entry.subscribers) == 0 } -// notifyOneConfirmed delivers one confirmation notification. -func (a *TxBroadcasterActor) notifyOneConfirmed(ctx context.Context, +// notifyOneFinalized delivers one TxFinalized notification through the +// terminal-delivery path so a slow subscriber cannot block the actor +// loop. blockHeight and numConfs are replayed onto the notification +// from the last observed confirmation so opt-in subscribers that +// dropped the (fire-and-forget) TxConfirmed event can still recover +// the authoritative confirmation height. +func (a *TxBroadcasterActor) notifyOneFinalized(ctx context.Context, subscriber actor.TellOnlyRef[Notification], txid chainhash.Hash, blockHeight int32, numConfs uint32) bool { return a.notifyOneTerminal( - ctx, subscriber, txid, "confirmed", + ctx, subscriber, txid, "finalized", func(notifyCtx context.Context) error { - return subscriber.Tell(notifyCtx, &TxConfirmed{ + return subscriber.Tell(notifyCtx, &TxFinalized{ Txid: txid, BlockHeight: blockHeight, NumConfs: numConfs, @@ -1735,7 +2340,7 @@ func (a *TxBroadcasterActor) notifyOneTerminal(ctx context.Context, a.terminalNotifyInflight[inflightKey] = struct{}{} //nolint:contextcheck // async result outlives ctx a.completeTerminalNotifyAsync( - inflightKey, txid, subscriberID, errChan, cancel, + inflightKey, txid, subscriberID, kind, errChan, cancel, ) a.log.DebugS(ctx, "Terminal tx notification deferred", @@ -1748,10 +2353,13 @@ func (a *TxBroadcasterActor) notifyOneTerminal(ctx context.Context, } } -// completeTerminalNotifyAsync reports a timed-out terminal delivery back to the -// txconfirm actor once the underlying Tell returns. +// completeTerminalNotifyAsync reports a timed-out terminal-shape delivery +// back to the txconfirm actor once the underlying Tell returns. kind +// records the notification variant ("confirmed" / "finalized" / "failed") +// so the actor-side handler can apply the correct post-delivery state +// transition. func (a *TxBroadcasterActor) completeTerminalNotifyAsync(inflightKey string, - txid chainhash.Hash, subscriberID string, errChan <-chan error, + txid chainhash.Hash, subscriberID, kind string, errChan <-chan error, cancel context.CancelFunc) { if a.selfRef == nil { @@ -1768,6 +2376,7 @@ func (a *TxBroadcasterActor) completeTerminalNotifyAsync(inflightKey string, txid: txid, subscriberID: subscriberID, inflightKey: inflightKey, + kind: kind, err: err, } bgCtx := context.Background() diff --git a/txconfirm/actor_test.go b/txconfirm/actor_test.go index f42055627..25b9856fe 100644 --- a/txconfirm/actor_test.go +++ b/txconfirm/actor_test.go @@ -29,6 +29,11 @@ import ( // testTimeout is the default timeout used by txconfirm actor tests. const testTimeout = time.Second +// confReorgedRef / confDoneRef are the reorg / done notification ref +// types used in the fake chain-source ref. +type confReorgedRef = actor.TellOnlyRef[chainsource.ConfReorgedEvent] +type confDoneRef = actor.TellOnlyRef[chainsource.ConfDoneEvent] + // confNotifyRef is the confirmation-event notification ref type used in // the fake chainsource test double. type confNotifyRef = actor.TellOnlyRef[chainsource.ConfirmationEvent] @@ -54,6 +59,8 @@ type fakeChainSourceRef struct { blockNotify actor.TellOnlyRef[chainsource.BlockEpoch] confNotify map[chainhash.Hash]confNotifyRef + confReorged map[chainhash.Hash]confReorgedRef + confDone map[chainhash.Hash]confDoneRef confConfs map[chainhash.Hash]uint32 alreadyConfirmed map[chainhash.Hash]chainsource.ConfirmationEvent @@ -148,6 +155,93 @@ func (b *blockingNotifyRef) attemptsCount() int { return b.attempts } +// deferringNotifyRef blocks Tell until released, then completes +// successfully (returns nil regardless of ctx state). Used to drive the +// async-completion path of notifyOneTerminal: Tell exceeds the actor-path +// budget, so the caller observes a timeout and parks the result on the +// completeTerminalNotifyAsync goroutine; the test then releases the Tell +// to simulate the underlying mailbox eventually accepting the +// notification, producing a terminalNotifyResultMsg{err: nil} that the +// actor mailbox processes via handleTerminalNotifyResult. +type deferringNotifyRef struct { + id string + + started chan struct{} + release chan struct{} + once sync.Once + + mu sync.Mutex + attempts int + msgs []Notification +} + +// newDeferringNotifyRef creates a subscriber that blocks on the first Tell +// until release is closed. +func newDeferringNotifyRef(id string) *deferringNotifyRef { + return &deferringNotifyRef{ + id: id, + started: make(chan struct{}), + release: make(chan struct{}), + } +} + +// ID returns the fake subscriber ID. +func (d *deferringNotifyRef) ID() string { + return d.id +} + +// Tell blocks until release is closed, records the notification, and +// returns nil. ctx cancellation is intentionally ignored so the test can +// drive the "Tell eventually succeeded" path even after the actor-side +// notifyCtx timed out. +func (d *deferringNotifyRef) Tell(_ context.Context, n Notification) error { + d.mu.Lock() + d.attempts++ + d.mu.Unlock() + + d.once.Do(func() { + close(d.started) + }) + + <-d.release + + d.mu.Lock() + d.msgs = append(d.msgs, n) + d.mu.Unlock() + + return nil +} + +// waitStarted blocks until the first Tell has begun, so the test can +// release after the actor-side timeout has fired. +func (d *deferringNotifyRef) waitStarted(t *testing.T) { + t.Helper() + + select { + case <-d.started: + case <-time.After(testTimeout): + t.Fatal("deferringNotifyRef Tell never started") + } +} + +// releaseTell unblocks every parked Tell call so the deferred deliveries +// complete and the test observes the async-completion path. +func (d *deferringNotifyRef) releaseTell() { + close(d.release) +} + +// snapshotMessages returns a defensive copy of every notification that +// has landed in the subscriber's mailbox so far. +func (d *deferringNotifyRef) snapshotMessages() []Notification { + d.mu.Lock() + defer d.mu.Unlock() + + out := make([]Notification, len(d.msgs)) + copy(out, d.msgs) + + return out +} + // ID returns the fake subscriber ID. func (r *contextInspectNotifyRef) ID() string { return r.id @@ -232,10 +326,12 @@ func (r *retryNotifyRef) awaitMessage(timeout time.Duration) (Notification, // newFakeChainSourceRef creates a new controllable chainsource test double. func newFakeChainSourceRef(bestHeight int32) *fakeChainSourceRef { return &fakeChainSourceRef{ - bestHeight: bestHeight, - feeRate: 5, - confNotify: make(map[chainhash.Hash]confNotifyRef), - confConfs: make(map[chainhash.Hash]uint32), + bestHeight: bestHeight, + feeRate: 5, + confNotify: make(map[chainhash.Hash]confNotifyRef), + confReorged: make(map[chainhash.Hash]confReorgedRef), + confDone: make(map[chainhash.Hash]confDoneRef), + confConfs: make(map[chainhash.Hash]uint32), alreadyConfirmed: make( map[chainhash.Hash]chainsource.ConfirmationEvent, ), @@ -344,6 +440,12 @@ func (f *fakeChainSourceRef) handleAsk(_ context.Context, if req.Txid != nil && req.NotifyActor.IsSome() { f.confNotify[*req.Txid] = req.NotifyActor.UnwrapOr(nil) f.confConfs[*req.Txid] = req.TargetConfs + req.NotifyReorged.WhenSome(func(r confReorgedRef) { + f.confReorged[*req.Txid] = r + }) + req.NotifyDone.WhenSome(func(r confDoneRef) { + f.confDone[*req.Txid] = r + }) if event, ok := f.alreadyConfirmed[*req.Txid]; ok { notifyRef := req.NotifyActor.UnwrapOr(nil) //nolint:contextcheck // fake backend @@ -358,6 +460,8 @@ func (f *fakeChainSourceRef) handleAsk(_ context.Context, if req.Txid != nil { delete(f.confNotify, *req.Txid) delete(f.confConfs, *req.Txid) + delete(f.confReorged, *req.Txid) + delete(f.confDone, *req.Txid) } return &chainsource.UnregisterConfResponse{}, nil @@ -417,6 +521,34 @@ func (f *fakeChainSourceRef) emitConfirmation(t *testing.T, txid chainhash.Hash, require.NoError(t, err) } +// emitConfReorged delivers a reorg event for one tracked txid. +func (f *fakeChainSourceRef) emitConfReorged(t *testing.T, + txid chainhash.Hash) { + + t.Helper() + + f.mu.Lock() + ref := f.confReorged[txid] + f.mu.Unlock() + + require.NotNil(t, ref) + err := ref.Tell(t.Context(), chainsource.ConfReorgedEvent{Txid: txid}) + require.NoError(t, err) +} + +// emitConfDone delivers a finality event for one tracked txid. +func (f *fakeChainSourceRef) emitConfDone(t *testing.T, txid chainhash.Hash) { + t.Helper() + + f.mu.Lock() + ref := f.confDone[txid] + f.mu.Unlock() + + require.NotNil(t, ref) + err := ref.Tell(t.Context(), chainsource.ConfDoneEvent{Txid: txid}) + require.NoError(t, err) +} + // emitBlock delivers a new block epoch to the shared block subscriber. func (f *fakeChainSourceRef) emitBlock(t *testing.T, height int32) { t.Helper() @@ -815,15 +947,27 @@ func TestEnsureConfirmedDedupesTwoSubscribers(t *testing.T) { require.IsType(t, &TxConfirmed{}, confirmedA) require.IsType(t, &TxConfirmed{}, confirmedB) + + // TxConfirmed alone does not release the chainsource conf watch in + // the reorg-aware model: the watch stays alive until the backend + // finalizes the confirmation. Driving Done evicts the tracked + // entry and unregisters. + chain.emitConfDone(t, tx.TxHash()) + mustAwaitNotification(t, subA) + mustAwaitNotification(t, subB) mustEventually(t, func() bool { return chain.unregisterConfCount() == 1 }) } -// TestConfirmationDeliveryRetriesAfterTellFailure verifies that a transient -// subscriber delivery failure does not permanently drop a terminal -// confirmation notification. -func TestConfirmationDeliveryRetriesAfterTellFailure(t *testing.T) { +// TestLifecycleDeliveryRetriesAfterTellFailure verifies that a transient +// subscriber delivery failure does not permanently drop a lifecycle +// notification. Both the initial TxConfirmed and the terminal +// TxFinalized are reliable: if the first Tell attempt fails, the +// per-tick retry path keeps re-attempting until delivery lands, and +// the entry only evicts after every retained subscriber has +// acknowledged both events. +func TestLifecycleDeliveryRetriesAfterTellFailure(t *testing.T) { chain := newFakeChainSourceRef(100) ref, _ := newTestActor(t, Config{ ChainSource: chain, @@ -831,6 +975,9 @@ func TestConfirmationDeliveryRetriesAfterTellFailure(t *testing.T) { tx := makeTestTx(false) txid := tx.TxHash() + // Fail the first Tell attempt: TxConfirmed delivery has to be + // retried before pendingConfirmed clears, after which TxFinalized + // can be attempted. sub := newRetryNotifyRef("sub-retry", 1) resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ @@ -844,31 +991,40 @@ func TestConfirmationDeliveryRetriesAfterTellFailure(t *testing.T) { return sub.attemptsCount() == 1 }) + // First TxConfirmed attempt failed; the entry stays alive with + // pendingConfirmed=true and no notification has reached the + // subscriber mailbox yet. msg, ok := sub.awaitMessage(100 * time.Millisecond) - require.False(t, ok, "unexpected notification: %v", msg) - mustEventually(t, func() bool { - return chain.unregisterConfCount() == 1 - }) - - chain.emitBlock(t, 102) - msg, ok = sub.awaitMessage(testTimeout) - require.True(t, ok, "expected retried notification") - - confirmed, ok := msg.(*TxConfirmed) - require.True(t, ok) + require.False(t, ok, "unexpected early notification: %v", msg) + require.Equal(t, 0, chain.unregisterConfCount()) + + // Finalization fires. notifyFinalized retries TxConfirmed first + // (the at-least-once initial-confirmation contract must land + // before TxFinalized is allowed through). The second Tell + // succeeds, so TxConfirmed lands and TxFinalized follows in the + // same notifyFinalized pass. + chain.emitConfDone(t, txid) + + first, ok := sub.awaitMessage(testTimeout) + require.True(t, ok, "expected retried TxConfirmed") + confirmed, ok := first.(*TxConfirmed) + require.True( + t, ok, "first notification must be TxConfirmed, got %T", first, + ) require.Equal(t, txid, confirmed.Txid) - require.Equal(t, int32(101), confirmed.BlockHeight) - require.Equal(t, uint32(1), confirmed.NumConfs) - require.Equal(t, 2, sub.attemptsCount()) - freshSub := actor.NewChannelTellOnlyRef[Notification]("sub-fresh", 4) - replayResp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ - Tx: tx, - Subscriber: freshSub, + second, ok := sub.awaitMessage(testTimeout) + require.True(t, ok, "expected TxFinalized") + finalized, ok := second.(*TxFinalized) + require.True( + t, ok, "second notification must be TxFinalized, got %T", + second, + ) + require.Equal(t, txid, finalized.Txid) + + mustEventually(t, func() bool { + return chain.unregisterConfCount() == 1 }) - require.True(t, replayResp.Created) - require.Equal(t, 2, chain.registerConfCount()) - require.Equal(t, 2, chain.broadcastCallCount()) } // TestTerminalNotificationsDoNotInheritCallerContext verifies that terminal @@ -884,17 +1040,17 @@ func TestTerminalNotificationsDoNotInheritCallerContext(t *testing.T) { cancel() txid := chainhash.Hash{1} - confirmedSub := &contextInspectNotifyRef{id: "confirmed-sub"} - ok := behavior.notifyOneConfirmed( - ctx, confirmedSub, txid, 101, 1, + finalizedSub := &contextInspectNotifyRef{id: "finalized-sub"} + ok := behavior.notifyOneFinalized( + ctx, finalizedSub, txid, 101, 1, ) require.True(t, ok) - hasTx, ctxErr, msgs := confirmedSub.snapshot() + hasTx, ctxErr, msgs := finalizedSub.snapshot() require.False(t, hasTx) require.NoError(t, ctxErr) require.Len(t, msgs, 1) - require.IsType(t, &TxConfirmed{}, msgs[0]) + require.IsType(t, &TxFinalized{}, msgs[0]) failedSub := &contextInspectNotifyRef{id: "failed-sub"} ok = behavior.notifyOneFailed(ctx, failedSub, txid, "boom") @@ -937,14 +1093,14 @@ func TestTerminalNotificationTimeoutDoesNotBlockActor(t *testing.T) { }() start := time.Now() - ok := behavior.notifyOneConfirmed( + ok := behavior.notifyOneFinalized( context.Background(), sub, txid, 101, 1, ) require.False(t, ok) require.Less(t, time.Since(start), testTimeout) require.True(t, <-started) - key := terminalNotifyKey(txid, sub.ID(), "confirmed") + key := terminalNotifyKey(txid, sub.ID(), "finalized") _, inflight := behavior.terminalNotifyInflight[key] require.True(t, inflight) require.Equal(t, 1, sub.attemptsCount()) @@ -1051,6 +1207,9 @@ func TestEnsureConfirmedAlreadyConfirmedUsesSuccessPath(t *testing.T) { subA := actor.NewChannelTellOnlyRef[Notification]("sub-a", 4) subB := actor.NewChannelTellOnlyRef[Notification]("sub-b", 4) + // Opt in to reorg-aware notifications so the tracked entry stays + // alive past TxConfirmed and the second EnsureConfirmedReq can + // attach to the existing tracking state. resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ Tx: tx, Subscriber: subA, @@ -1062,21 +1221,21 @@ func TestEnsureConfirmedAlreadyConfirmedUsesSuccessPath(t *testing.T) { require.True(t, ok) require.Equal(t, int32(99), confirmed.BlockHeight) - // Once subA's TxConfirmed has been delivered, terminal eviction drops - // the tracked entry. A subsequent EnsureConfirmedReq for the same - // txid therefore starts fresh tracking rather than replaying cached - // state. Chainsource immediately re-fires the confirmation for the - // already-confirmed tx, so subB still receives TxConfirmed. + // TxConfirmed is no longer terminal: the tracked entry remains alive + // (still subscribed to chainsource reorg/done) until a Done event + // fires. A subsequent EnsureConfirmedReq therefore attaches to the + // existing tracking state and replays TxConfirmed without + // re-broadcasting or re-registering with chainsource. replayResp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ Tx: tx, Subscriber: subB, }) - require.True(t, replayResp.Created) + require.False(t, replayResp.Created) replayed := mustAwaitNotification(t, subB) require.IsType(t, &TxConfirmed{}, replayed) - require.Equal(t, 2, chain.broadcastCallCount()) - require.Equal(t, 2, chain.registerConfCount()) + require.Equal(t, 1, chain.broadcastCallCount()) + require.Equal(t, 1, chain.registerConfCount()) } // TestEnsureConfirmedBroadcastFailureNotifiesFailure verifies that terminal @@ -1462,6 +1621,11 @@ func TestUnregisterConfMatchesRegisterServiceKey(t *testing.T) { confirmed := mustAwaitNotification(t, sub) require.IsType(t, &TxConfirmed{}, confirmed) + // The conf watch is only released once chainsource fires Done. + chain.emitConfDone(t, tx.TxHash()) + finalized := mustAwaitNotification(t, sub) + require.IsType(t, &TxFinalized{}, finalized) + mustEventually(t, func() bool { return chain.unregisterConfCount() == 1 }) @@ -1531,7 +1695,17 @@ func TestTerminalEntriesEvictedAfterConfirmation(t *testing.T) { require.IsType(t, &TxConfirmed{}, confirmed) } - // Every confirmation should have produced exactly one unregister. + // Finalization drives terminal eviction: TxConfirmed alone is now + // reversible and the tracked entry stays alive until the backend + // fires Done. + for i := 0; i < numTxs; i++ { + chain.emitConfDone(t, txids[i]) + + finalized := mustAwaitNotification(t, subs[i]) + require.IsType(t, &TxFinalized{}, finalized) + } + + // Every finalized entry should have produced exactly one unregister. mustEventually(t, func() bool { return chain.unregisterConfCount() == numTxs }) @@ -1907,3 +2081,81 @@ func TestEnsureConfirmedFailsPermanentBroadcastError(t *testing.T) { failed := mustAwaitNotification(t, sub) require.IsType(t, &TxFailed{}, failed) } + +// TestInitialConfirmedAsyncDeliveryRetainsSubscriber regression-tests an +// initial TxConfirmed delivery whose Tell exceeds terminalNotifyTimeout +// before landing successfully via the async-completion goroutine. The +// subscriber must remain attached to the entry afterwards so the +// reorg-aware tail of its lifecycle (TxReorged / TxFinalized) still +// reaches it; the previous handleTerminalNotifyResult deleted the +// subscriber on any successful async terminal completion, silently +// dropping every subsequent reorg event for slow mailboxes. +func TestInitialConfirmedAsyncDeliveryRetainsSubscriber(t *testing.T) { + // Shorten the actor-path budget so the test reliably exercises the + // async-completion code path within a normal test runtime. + oldTimeout := terminalNotifyTimeout + terminalNotifyTimeout = 20 * time.Millisecond + t.Cleanup(func() { + terminalNotifyTimeout = oldTimeout + }) + + chain := newFakeChainSourceRef(100) + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + txid := tx.TxHash() + sub := newDeferringNotifyRef("sub-async-confirmed") + + resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + require.True(t, resp.Created) + + // Fire the confirmation. notifyOneConfirmed -> notifyOneTerminal + // will park on sub.Tell, blow through terminalNotifyTimeout, and + // hand the in-flight Tell off to completeTerminalNotifyAsync. + chain.emitConfirmation(t, txid, 101) + sub.waitStarted(t) + + // Wait long enough that the actor-side notifyCtx is guaranteed to + // have timed out and the async-completion goroutine is in flight. + // Without this delay the Tell could land synchronously and the + // async path the regression targets would not be exercised. + time.Sleep(10 * terminalNotifyTimeout) + + // Release the parked Tell so the async goroutine reports back to + // the txconfirm actor with a successful terminalNotifyResultMsg. + sub.releaseTell() + + // Wait for TxConfirmed to actually land in the subscriber mailbox. + require.Eventually(t, func() bool { + for _, m := range sub.snapshotMessages() { + if _, ok := m.(*TxConfirmed); ok { + return true + } + } + + return false + }, testTimeout, 10*time.Millisecond, "TxConfirmed never delivered") + + // Drive a reorg. If the subscriber was wrongly evicted on the + // async-confirmed completion, notifyReorged has nobody to fan to + // and the TxReorged below never arrives. + chain.emitConfReorged(t, txid) + + require.Eventually(t, func() bool { + for _, m := range sub.snapshotMessages() { + if _, ok := m.(*TxReorged); ok { + return true + } + } + + return false + }, testTimeout, 10*time.Millisecond, + "TxReorged never reached subscriber whose initial TxConfirmed "+ + "completed via the async path — handleTerminalNotify"+ + "Result probably evicted it on completion") +} diff --git a/txconfirm/broadcaster_test.go b/txconfirm/broadcaster_test.go index 3485aa042..0d48bc21c 100644 --- a/txconfirm/broadcaster_test.go +++ b/txconfirm/broadcaster_test.go @@ -361,7 +361,7 @@ func newTrackedTxForState(t *testing.T, state trackedTxState) *trackedTx { return &trackedTx{ data: data, fsm: &fsm, - subscribers: make(map[string]actor.TellOnlyRef[Notification]), + subscribers: make(map[string]trackedSubscriber), } } @@ -1619,11 +1619,13 @@ func TestActorValidationAndCleanup(t *testing.T) { }, } entry := newTrackedTxForState(t, awaitConf) - entry.subscribers["fail"] = &failingNotifyRef{} + entry.subscribers["fail"] = trackedSubscriber{ + Ref: &failingNotifyRef{}, + } behavior.tracked[entry.data.Txid] = entry - behavior.notifyOneConfirmed( - t.Context(), &failingNotifyRef{}, entry.data.Txid, 1, 1, + behavior.notifyOneFinalized( + t.Context(), &failingNotifyRef{}, entry.data.Txid, 0, 0, ) behavior.notifyOneFailed( t.Context(), &failingNotifyRef{}, entry.data.Txid, diff --git a/txconfirm/fsm_types.go b/txconfirm/fsm_types.go index fbc1882d8..7f4edac17 100644 --- a/txconfirm/fsm_types.go +++ b/txconfirm/fsm_types.go @@ -168,6 +168,21 @@ type trackedTxFailed struct { // trackedTxEventSealed marks trackedTxFailed as a tracked-tx event. func (e *trackedTxFailed) trackedTxEventSealed() {} +// trackedTxReorged records that a previously delivered confirmation was +// reorged out of the canonical chain. Only valid from +// trackedTxStateConfirmed. +type trackedTxReorged struct{} + +// trackedTxEventSealed marks trackedTxReorged as a tracked-tx event. +func (e *trackedTxReorged) trackedTxEventSealed() {} + +// trackedTxFinalized records that a confirmation is past the backend's +// reorg-safety depth. Only valid from trackedTxStateConfirmed. +type trackedTxFinalized struct{} + +// trackedTxEventSealed marks trackedTxFinalized as a tracked-tx event. +func (e *trackedTxFinalized) trackedTxEventSealed() {} + // trackedTxErrorReporter reports tracked-tx FSM errors through the package // logger. type trackedTxErrorReporter struct { @@ -225,6 +240,9 @@ func txStateFromTrackedState(state trackedTxState) TxState { case *trackedTxStateConfirmed: return TxStateConfirmed + case *trackedTxStateFinalized: + return TxStateFinalized + case *trackedTxStateFailed: return TxStateFailed @@ -279,6 +297,9 @@ func trackedTxLastBroadcastHeight(state trackedTxState) fn.Option[int32] { case *trackedTxStateConfirmed: return s.LastBroadcastHeight + case *trackedTxStateFinalized: + return s.LastBroadcastHeight + case *trackedTxStateFailed: return s.LastBroadcastHeight @@ -302,12 +323,16 @@ func trackedTxBroadcastFailures(state trackedTxState) int { // trackedTxConfirmHeight returns the state's confirmation height if the // transaction has already confirmed. func trackedTxConfirmHeight(state trackedTxState) (int32, bool) { - confirmed, ok := state.(*trackedTxStateConfirmed) - if !ok { + switch s := state.(type) { + case *trackedTxStateConfirmed: + return s.ConfirmHeight, true + + case *trackedTxStateFinalized: + return s.ConfirmHeight, true + + default: return 0, false } - - return confirmed.ConfirmHeight, true } // trackedTxFailureReason returns the state's terminal failure reason when diff --git a/txconfirm/funded_anchor_test.go b/txconfirm/funded_anchor_test.go index 3dd375fb5..8a6f2c2c7 100644 --- a/txconfirm/funded_anchor_test.go +++ b/txconfirm/funded_anchor_test.go @@ -575,9 +575,12 @@ func TestBumpNowParentFeeSufficient(t *testing.T) { } // TestBumpNowUntrackedAndTerminal covers the remaining no-op guards: an -// untracked txid, and a transaction that confirmed and was evicted from -// tracking (a confirmed entry whose terminal notification is delivered is -// dropped from the map, so a late bump lands in the untracked branch). +// untracked txid, and a transaction that has reached its confirmation target. +// Under the reorg-aware lifecycle a confirmed entry is NOT evicted at its +// confirmation — it stays tracked in the reversible Confirmed state until the +// backend signals finality (Done), so it can observe a later reorg — so a bump +// of a confirmed-but-not-final tx lands in the "already confirmed" no-op +// branch rather than the untracked branch. func TestBumpNowUntrackedAndTerminal(t *testing.T) { t.Parallel() @@ -608,9 +611,10 @@ func TestBumpNowUntrackedAndTerminal(t *testing.T) { Subscriber: sub, }) - // Drain the confirmation callback the fake chain delivered to the - // self ref so the entry reaches its terminal state and, with its - // notification delivered, is evicted from tracking. + // Drain the confirmation callback the fake chain delivered to the self + // ref so the entry advances into the reversible Confirmed state and its + // TxConfirmed notification is delivered. It is NOT evicted: the entry + // stays tracked until finality so a reorg can still be observed. selfRef, ok := behavior.selfRef.(*actor.ChannelTellOnlyRef[Msg]) require.True(t, ok) for { @@ -622,11 +626,13 @@ func TestBumpNowUntrackedAndTerminal(t *testing.T) { } require.NotNil(t, mustAwaitNotification(t, sub)) - // A bump after eviction reports the untracked no-op: there is no - // entry left to bump, which is the correct answer for a confirmed tx. + // A bump of the confirmed (but not-yet-final) entry is the confirmed + // no-op: it is still tracked, so the handler reports "already + // confirmed" rather than the untracked branch. Either way a confirmed + // tx cannot be fee-bumped. bumpResp = mustReceiveBump(t, behavior, &BumpNowReq{ Txid: tx.TxHash(), }) require.False(t, bumpResp.Bumped) - require.Contains(t, bumpResp.Reason, "not tracked") + require.Contains(t, bumpResp.Reason, "already confirmed") } diff --git a/txconfirm/messages.go b/txconfirm/messages.go index 031bc6748..b4285d5b0 100644 --- a/txconfirm/messages.go +++ b/txconfirm/messages.go @@ -58,9 +58,15 @@ const ( TxStateFeeBumping // TxStateConfirmed indicates the tracked transaction reached its target - // confirmation count. + // confirmation count on the canonical chain. This state is reversible: + // a reorg moves the tracked entry back to TxStateAwaitingConfirmation, + // and finality moves it to TxStateFinalized. TxStateConfirmed + // TxStateFinalized indicates the tracked transaction confirmed and is + // past the backend's reorg-safety depth. Terminal. + TxStateFinalized + // TxStateFailed indicates the actor encountered a terminal // failure while // trying to confirm the transaction. @@ -85,6 +91,9 @@ func (s TxState) String() string { case TxStateConfirmed: return "confirmed" + case TxStateFinalized: + return "finalized" + case TxStateFailed: return "failed" @@ -126,8 +135,17 @@ type EnsureConfirmedReq struct { // parents and for callers that do not fee-bump. ParentFee btcutil.Amount - // Subscriber receives TxConfirmed or TxFailed notifications for this - // request. + // Subscriber receives the full reorg-aware notification lifecycle + // for this request: TxConfirmed when the tx reaches the + // confirmation target, TxReorged if a previously delivered + // TxConfirmed is reorged out, re-TxConfirmed on the new canonical + // chain, TxFinalized once the confirmation is past the backend's + // reorg-safety depth, and TxFailed on terminal failure. + // TxConfirmed and TxFailed deliveries are reliable (retry on + // timeout from the per-tick retry path); TxReorged is best-effort + // because the next lifecycle event (re-TxConfirmed / TxFinalized / + // TxFailed) re-establishes state. TxFinalized is reliable so the + // caller can free reorg-recovery bookkeeping deterministically. Subscriber actor.TellOnlyRef[Notification] } @@ -306,6 +324,65 @@ func (m *TxConfirmed) MessageType() string { // set. func (m *TxConfirmed) txConfirmNotificationSealed() {} +// TxReorged notifies a subscriber that a previously delivered TxConfirmed +// has been reorged out of the canonical chain. After receiving this event +// a consumer should consider the prior confirmation no longer valid; if +// the transaction re-confirms on the new canonical chain a fresh +// TxConfirmed will follow on the same subscription. +type TxReorged struct { + actor.BaseMessage + + // Txid identifies the reorged transaction. + Txid chainhash.Hash +} + +// MessageType returns the stable message type identifier. +func (m *TxReorged) MessageType() string { + return "TxReorged" +} + +// txConfirmNotificationSealed seals TxReorged into the package notification +// set. +func (m *TxReorged) txConfirmNotificationSealed() {} + +// TxFinalized notifies a subscriber that the tracked transaction is past +// the backend's reorg-safety depth and will receive no further events. +// Subscribers may use this signal to drop any reorg-recovery bookkeeping +// they were holding for the registration. Not all backends synthesize +// this event (the lndclient transport does not), so consumers must treat +// its absence as a normal operating condition rather than an error. +// +// BlockHeight and NumConfs replay the authoritative confirmation +// numbers carried by the last TxConfirmed before finalization. Because +// reversible TxConfirmed deliveries are fire-and-forget for opt-in +// subscribers and may be dropped on a momentarily-full mailbox, the +// finalization event must carry enough information for height-dependent +// consumers to recover without out-of-band lookups. +type TxFinalized struct { + actor.BaseMessage + + // Txid identifies the finalized transaction. + Txid chainhash.Hash + + // BlockHeight is the height of the block carrying the latest + // observed confirmation (post-any-reorg) at finalization time. + BlockHeight int32 + + // NumConfs is the confirmation count that triggered the + // finalization event — typically the EnsureConfirmedReq's + // TargetConfs. + NumConfs uint32 +} + +// MessageType returns the stable message type identifier. +func (m *TxFinalized) MessageType() string { + return "TxFinalized" +} + +// txConfirmNotificationSealed seals TxFinalized into the package +// notification set. +func (m *TxFinalized) txConfirmNotificationSealed() {} + // TxFailed notifies a subscriber that the actor encountered a terminal // failure while trying to confirm the tracked transaction. type TxFailed struct { diff --git a/txconfirm/reorg_test.go b/txconfirm/reorg_test.go new file mode 100644 index 000000000..ef6c4d512 --- /dev/null +++ b/txconfirm/reorg_test.go @@ -0,0 +1,147 @@ +package txconfirm + +import ( + "testing" + + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/stretchr/testify/require" +) + +// TestEnsureConfirmedReorgLifecycle drives the full reorg-aware lifecycle +// (Confirmed -> Reorged -> Confirmed -> Finalized) through the +// TxBroadcasterActor and asserts that: +// +// - Each chainsource event reaches the subscriber as a matching public +// notification, in order. +// - The conf watch is held open across the reorg-out / re-confirm +// bounce (no unregister fires before Done). +// - Finalization releases the conf watch and evicts the tracked entry. +func TestEnsureConfirmedReorgLifecycle(t *testing.T) { + chain := newFakeChainSourceRef(100) + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + txid := tx.TxHash() + sub := actor.NewChannelTellOnlyRef[Notification]("sub", 16) + + resp := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + require.True(t, resp.Created) + require.Equal(t, TxStateAwaitingConfirmation, resp.State) + require.Equal(t, 1, chain.registerConfCount()) + + // 1. First confirmation on the canonical chain. + chain.emitConfirmation(t, txid, 101) + first := mustAwaitNotification(t, sub) + confirmed, ok := first.(*TxConfirmed) + require.True(t, ok, "first event must be TxConfirmed") + require.Equal(t, txid, confirmed.Txid) + require.Equal(t, int32(101), confirmed.BlockHeight) + + // The conf watch must NOT have been released yet: the entry is + // still in the reversible Confirmed state. + require.Equal(t, 0, chain.unregisterConfCount()) + + // 2. Reorg evicts that confirmation. + chain.emitConfReorged(t, txid) + second := mustAwaitNotification(t, sub) + reorged, ok := second.(*TxReorged) + require.True(t, ok, "second event must be TxReorged") + require.Equal(t, txid, reorged.Txid) + require.Equal(t, 0, chain.unregisterConfCount()) + + // 3. Transaction re-confirms on the new tip. + chain.emitConfirmation(t, txid, 102) + third := mustAwaitNotification(t, sub) + reConfirmed, ok := third.(*TxConfirmed) + require.True(t, ok, "third event must be TxConfirmed") + require.Equal(t, int32(102), reConfirmed.BlockHeight) + require.Equal(t, 0, chain.unregisterConfCount()) + + // 4. Finality. After TxFinalized the entry evicts. + chain.emitConfDone(t, txid) + fourth := mustAwaitNotification(t, sub) + finalized, ok := fourth.(*TxFinalized) + require.True(t, ok, "fourth event must be TxFinalized") + require.Equal(t, txid, finalized.Txid) + + mustEventually(t, func() bool { + return chain.unregisterConfCount() == 1 + }) + + // Cancel for the now-evicted entry must observe an empty map: Removed + // is false because there is nothing to remove. + cancelResp := mustCancel(t, ref.Ref(), &CancelInterestReq{ + Txid: txid, + SubscriberID: sub.ID(), + }) + require.False(t, cancelResp.Removed) +} + +// TestEnsureConfirmedDoneDuringReorgGapDropped pins the documented +// edge-case semantics: if the chainsource backend fires Done while the +// tracked entry is in AwaitingConfirmation (post-reorg, pre-re-confirm), +// txconfirm drops the Done rather than advancing to Finalized. The +// realistic backends (chainntnfs, lndclient) do not fire Done during a +// reorg gap, but this test pins the guard so a future backend change +// cannot silently land the entry in Finalized off a non-Confirmed +// state, and so an accidental relaxation of the guard fails loudly. +func TestEnsureConfirmedDoneDuringReorgGapDropped(t *testing.T) { + chain := newFakeChainSourceRef(100) + ref, _ := newTestActor(t, Config{ + ChainSource: chain, + }) + + tx := makeTestTx(false) + txid := tx.TxHash() + sub := actor.NewChannelTellOnlyRef[Notification]("sub", 16) + + mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + + // Confirm, then reorg out — entry is now in AwaitingConfirmation. + chain.emitConfirmation(t, txid, 101) + first := mustAwaitNotification(t, sub) + _, ok := first.(*TxConfirmed) + require.True(t, ok, "first event must be TxConfirmed") + + chain.emitConfReorged(t, txid) + second := mustAwaitNotification(t, sub) + _, ok = second.(*TxReorged) + require.True(t, ok, "second event must be TxReorged") + + // Fire Done out-of-band, before any re-confirmation. txconfirm + // must NOT advance the entry to Finalized, must NOT deliver + // TxFinalized to the subscriber, and must NOT unregister the + // conf watch. + chain.emitConfDone(t, txid) + + // Re-issuing EnsureConfirmedReq for the same (txid, params) is a + // no-op attach that flushes the mailbox: by the time the response + // returns, the queued confirmationDoneMsg has been processed (or + // in this case, logged and dropped). The reported state must + // remain AwaitingConfirmation. + probe := mustEnsure(t, ref.Ref(), &EnsureConfirmedReq{ + Tx: tx, + Subscriber: sub, + }) + require.False(t, probe.Created) + require.Equal( + t, TxStateAwaitingConfirmation, probe.State, + "Done during reorg gap must not promote entry to Finalized", + ) + + // No TxFinalized notification should have been delivered. + mustHaveNoNotification(t, sub) + + require.Equal( + t, 0, chain.unregisterConfCount(), + "dropped Done must not release the conf watch", + ) +} diff --git a/txconfirm/states.go b/txconfirm/states.go index 3555c5ce7..1b173fdad 100644 --- a/txconfirm/states.go +++ b/txconfirm/states.go @@ -249,7 +249,10 @@ func (s *trackedTxStateFeeBumping) ProcessEvent(_ context.Context, } } -// trackedTxStateConfirmed is the terminal confirmed state. +// trackedTxStateConfirmed is the reorg-reversible confirmed state. A +// transaction in this state has reached its target confirmation count on +// the canonical chain; a subsequent reorg moves it back to +// AwaitingConfirmation, and finality moves it to Finalized. type trackedTxStateConfirmed struct { trackedTxData trackedTxProgress @@ -263,19 +266,74 @@ func (s *trackedTxStateConfirmed) String() string { return "Confirmed" } -// IsTerminal returns true because confirmed is terminal. +// IsTerminal returns false because the confirmation is reversible until +// the backend reports finality via trackedTxFinalized. func (s *trackedTxStateConfirmed) IsTerminal() bool { - return true + return false } // trackedTxStateSealed marks trackedTxStateConfirmed as a tracked-tx state. func (s *trackedTxStateConfirmed) trackedTxStateSealed() {} -// ProcessEvent rejects unexpected events in the terminal confirmed state. +// ProcessEvent applies one event to the confirmed state. A reorg moves +// the FSM back to AwaitingConfirmation; finality moves it to the terminal +// Finalized state. func (s *trackedTxStateConfirmed) ProcessEvent(_ context.Context, event trackedTxEvent, _ *trackedTxEnvironment) ( *trackedTxStateTransition, error) { + switch event.(type) { + case *trackedTxReorged: + return &trackedTxStateTransition{ + NextState: &trackedTxStateAwaitingConfirmation{ + trackedTxData: s.trackedTxData, + trackedTxProgress: s.trackedTxProgress, + }, + }, nil + + case *trackedTxFinalized: + return &trackedTxStateTransition{ + NextState: &trackedTxStateFinalized{ + trackedTxData: s.trackedTxData, + trackedTxProgress: s.trackedTxProgress, + ConfirmHeight: s.ConfirmHeight, + }, + }, nil + + default: + return nil, fmt.Errorf("unexpected event %T in %s", event, s) + } +} + +// trackedTxStateFinalized is the terminal "confirmed past reorg safety +// depth" state. No further events are accepted. +type trackedTxStateFinalized struct { + trackedTxData + trackedTxProgress + + // ConfirmHeight is the block height where the tx confirmed before + // being finalized. + ConfirmHeight int32 +} + +// String returns a human-readable representation of the finalized state. +func (s *trackedTxStateFinalized) String() string { + return "Finalized" +} + +// IsTerminal returns true because finalized is terminal. +func (s *trackedTxStateFinalized) IsTerminal() bool { + return true +} + +// trackedTxStateSealed marks trackedTxStateFinalized as a tracked-tx state. +func (s *trackedTxStateFinalized) trackedTxStateSealed() {} + +// ProcessEvent rejects unexpected events in the terminal finalized state. +func (s *trackedTxStateFinalized) ProcessEvent(_ context.Context, + event trackedTxEvent, _ *trackedTxEnvironment) ( + *trackedTxStateTransition, error) { + return nil, fmt.Errorf("unexpected event %T in %s", event, s) } diff --git a/unroll/registry.go b/unroll/registry.go index ebbf8ab5c..c56737512 100644 --- a/unroll/registry.go +++ b/unroll/registry.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/chainsource" "github.com/lightninglabs/wavelength/ledger" "github.com/lightninglabs/wavelength/lib/actormsg" @@ -74,7 +75,7 @@ type RegistryRecord struct { // on-chain footprint, so the target VTXO is safe to roll back to // live. It is persisted (as a distinct DB status) so boot-time // reconciliation can recover a VTXO whose recovery notification was - // lost before the manager applied it (wavelength#602). + // lost before the manager applied it (darepo-client#602). RecoverableFailure bool } @@ -119,6 +120,17 @@ type RegistryConfig struct { // VTXOStore loads target descriptors for child actors. VTXOStore vtxo.VTXOStore + // BatchCanonicality, when set, gates fresh unroll admission on the + // target VTXO's source-lineage canonicality (darepo#454): an unroll is + // refused only when a batch in the VTXO's lineage is permanently + // invalidated (conflict-finalized), since the exit tree can then never + // confirm. Transient reorgs are admitted and the unroll self-reconciles + // its anchors (#410). Nil disables the gate (the default until the + // batch producers register batches); it is permissive otherwise (unseen + // / unregistered lineage does not block), and only fresh admissions are + // gated — an already-running unroll is never interrupted. + BatchCanonicality batchcanon.Store + // TxConfirmRef is the shared tx-confirmation actor. TxConfirmRef actor.ActorRef[txconfirm.Msg, txconfirm.Resp] @@ -165,7 +177,7 @@ type RegistryConfig struct { // to live, and a completed exit asks it to retire the VTXO to spent. // This is the feedback edge that keeps VTXO lifecycle gated on the // unroll job's terminal on-chain outcome rather than the user's intent - // to exit (wavelength#602). When None, terminal outcomes are not + // to exit (darepo-client#602). When None, terminal outcomes are not // forwarded (used by tests that don't exercise the manager). VTXOExitObserver fn.Option[actor.TellOnlyRef[vtxo.ManagerMsg]] } @@ -406,6 +418,105 @@ func (r *registryBehavior) OnStop(context.Context) error { return nil } +// ErrSourceLineageUnavailable is returned by EnsureUnroll when the target +// VTXO's source lineage is permanently off the canonical chain (a batch in its +// lineage had a consumed input double-spent past finality), so its exit tree +// can never confirm. This is terminal — there is nothing to retry. +var ErrSourceLineageUnavailable = errors.New("vtxo source lineage " + + "unavailable for unroll") + +// refuseIfSourceLineageInvalidated returns ErrSourceLineageUnavailable (wrapped +// with the target outpoint) when the target VTXO's source lineage is +// permanently invalidated, so a fresh unroll must not be admitted. It is a +// no-op when the gate is dormant or the lineage is still recoverable. +func (r *registryBehavior) refuseIfSourceLineageInvalidated(ctx context.Context, + outpoint wire.OutPoint) error { + + if r.sourceLineageInvalidated(ctx, outpoint) { + return fmt.Errorf("%w: %s", ErrSourceLineageUnavailable, + outpoint) + } + + return nil +} + +// sourceLineageInvalidated reports whether the target VTXO's source lineage is +// PERMANENTLY invalidated (a batch in its lineage is conflict-finalized), in +// which case the exit tree can never confirm and a fresh unroll is pointless. +// +// It deliberately blocks ONLY the terminal Invalidated verdict, not the +// transient LimboReorg / LimboConflict states: a reorged-out batch (no input +// conflict) is expected to re-confirm on its own, and a not-yet-final conflict +// may still resolve in the batch's favor. Blocking those would risk dropping a +// needed critical-expiry / fraud-triggered exit during exactly the window it +// matters — and the critical-expiry safety net reaches this gate via a +// fire-and-forget Tell, so a refusal cannot be observed or retried. An +// already-admitted unroll tolerates a transiently-absent parent by reconciling +// its own chain anchors (#410), so a fresh safety exit should be admitted for +// the same transient condition rather than refused. +// +// It is a no-op (returns false) when no canonicality store is wired, and it is +// fail-permissive: any descriptor-load or canonicality lookup error logs and +// returns false rather than blocking an exit, mirroring the gate's permissive +// "unseen / unregistered lineage does not block" stance. +func (r *registryBehavior) sourceLineageInvalidated(ctx context.Context, + outpoint wire.OutPoint) bool { + + if r.cfg.BatchCanonicality == nil { + return false + } + + desc, err := r.cfg.VTXOStore.GetVTXO(ctx, outpoint) + if err != nil { + r.log.DebugS(ctx, "Unroll lineage gate: vtxo load failed, "+ + "permitting admission", err, + slog.String("outpoint", outpoint.String())) + + return false + } + + avail, err := batchcanon.LineageAvailability( + ctx, r.cfg.BatchCanonicality, + unrollLineageCommitmentTxids(desc)..., + ) + if err != nil { + r.log.DebugS(ctx, "Unroll lineage gate: availability lookup "+ + "failed, permitting admission", err, + slog.String("outpoint", outpoint.String())) + + return false + } + + return avail == batchcanon.Invalidated +} + +// unrollLineageCommitmentTxids returns the deduped commitment txids in a VTXO's +// lineage: its direct commitment tx plus every distinct ancestor commitment tx +// (zero-skipped). Mirrors the vtxo package's selection-gate helper for the +// unroll admission gate. +func unrollLineageCommitmentTxids(desc *vtxo.Descriptor) []chainhash.Hash { + seen := make(map[chainhash.Hash]struct{}, len(desc.Ancestry)+1) + txids := make([]chainhash.Hash, 0, len(desc.Ancestry)+1) + + add := func(txid chainhash.Hash) { + if txid == (chainhash.Hash{}) { + return + } + if _, ok := seen[txid]; ok { + return + } + seen[txid] = struct{}{} + txids = append(txids, txid) + } + + add(desc.CommitmentTxID) + for i := range desc.Ancestry { + add(desc.Ancestry[i].CommitmentTxID) + } + + return txids +} + // handleEnsure is the admission gate for new unroll jobs. It runs a // four-stage check to decide whether the caller is re-asking for an // already-tracked target or requesting a brand-new unroll, spawns and @@ -460,7 +571,7 @@ func (r *registryBehavior) handleEnsure(ctx context.Context, // // A recoverable failure is the exception: the prior exit failed // cleanly with no on-chain footprint and the VTXO was rolled back to - // live (wavelength#602), so a fresh exit must be admittable rather + // live (darepo-client#602), so a fresh exit must be admittable rather // than deduped against the dead attempt. Fall through to the spawn // path, whose UpsertRecord overwrites the stale recoverable row. if record, ok := r.pending[req.Outpoint]; ok && @@ -486,7 +597,7 @@ func (r *registryBehavior) handleEnsure(ctx context.Context, // stable identity and do not clobber the recorded sweep // txid or failure reason. A *recoverable* terminal // failure is the exception: the VTXO was rolled back to - // live (wavelength#602), so it falls through to a + // live (darepo-client#602), so it falls through to a // fresh spawn (handled below). // // 2. Non-terminal record — the actor was admitted in a @@ -554,6 +665,15 @@ func (r *registryBehavior) handleEnsure(ctx context.Context, } } + // Refuse a fresh unroll only when the target's source lineage is + // permanently invalidated (see sourceLineageInvalidated); transient + // reorgs are admitted and self-reconcile. + if err := r.refuseIfSourceLineageInvalidated( + ctx, req.Outpoint, + ); err != nil { + return fn.Err[RegistryResp](err) + } + height, err := r.queryBestHeight(ctx) if err != nil { return fn.Err[RegistryResp](fmt.Errorf("best height: %w", err)) @@ -762,7 +882,7 @@ func (r *registryBehavior) handleChildAdmissionResult(ctx context.Context, // the child broadcasts anything, so the VTXO has no on-chain footprint and is // recoverable to live. The record is persisted as a recoverable failure and // the VTXO manager is notified so the VTXO is rolled back rather than -// stranded in unilateral exit (wavelength#602). +// stranded in unilateral exit (darepo-client#602). func (r *registryBehavior) failAdmittedChild(ctx context.Context, target wire.OutPoint, child *VTXOUnrollActor, err error) { @@ -1000,7 +1120,7 @@ func (r *registryBehavior) handleTerminated(ctx context.Context, // outlives r.pending: a completed async persist can evict the cached // record before this terminal handoff. Prefer the message's kind so a // recovery-only target is still held in exit rather than relived as a - // live coin (wavelength#602). We only feed it to the manager, not + // live coin (darepo-client#602). We only feed it to the manager, not // the persisted record: the message has no policy ref, so stamping its // kind onto the record would drop the store's (kind, ref) identity. policyKind := req.ExitPolicyKind @@ -1010,7 +1130,7 @@ func (r *registryBehavior) handleTerminated(ctx context.Context, // Forward the terminal outcome to the VTXO manager so the VTXO's // lifecycle tracks the unroll job's terminal on-chain result rather - // than the user's intent to exit (wavelength#602). The handoff must + // than the user's intent to exit (darepo-client#602). The handoff must // survive caller-context cancellation, so detach the context. The exit // policy rides along so the manager can hold a recovery-only target in // exit rather than relive it as a live coin. diff --git a/unroll/source_lineage_gate_test.go b/unroll/source_lineage_gate_test.go new file mode 100644 index 000000000..18db03350 --- /dev/null +++ b/unroll/source_lineage_gate_test.go @@ -0,0 +1,246 @@ +package unroll + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/lightninglabs/wavelength/vtxo" + "github.com/stretchr/testify/require" +) + +// stubBatchCanon is a minimal batchcanon.Store for the unroll source-lineage +// gate tests: it answers GetBatch from a txid->state map and returns zero +// values for the methods the gate never calls. +type stubBatchCanon struct { + states map[chainhash.Hash]batchcanon.State +} + +func (s *stubBatchCanon) GetBatch(_ context.Context, txid chainhash.Hash) ( + *batchcanon.Record, error) { + + st, ok := s.states[txid] + if !ok { + return nil, batchcanon.ErrBatchNotFound + } + + return &batchcanon.Record{BatchTxID: txid, State: st}, nil +} + +func (s *stubBatchCanon) UpsertBatch(context.Context, + *batchcanon.Record) error { + + return nil +} + +func (s *stubBatchCanon) ListBatchesByState(context.Context, batchcanon.State) ( + []*batchcanon.Record, error) { + + return nil, nil +} + +func (s *stubBatchCanon) UpdateBatchState(context.Context, chainhash.Hash, + batchcanon.State) error { + + return nil +} + +func (s *stubBatchCanon) RecordConfirmation(context.Context, chainhash.Hash, + int32, chainhash.Hash) error { + + return nil +} + +func (s *stubBatchCanon) RecordInputConflict(context.Context, chainhash.Hash, + wire.OutPoint, bool, bool) error { + + return nil +} + +func (s *stubBatchCanon) ClearConfirmation(context.Context, + chainhash.Hash) error { + + return nil +} + +func (s *stubBatchCanon) FindBatchesConsumingOutpoint(context.Context, + wire.OutPoint) ([]chainhash.Hash, error) { + + return nil, nil +} + +func (s *stubBatchCanon) AddProvisionalConsumer(context.Context, wire.OutPoint, + chainhash.Hash) error { + + return nil +} + +func (s *stubBatchCanon) ListProvisionalConsumersForBatch(context.Context, + chainhash.Hash) ([]wire.OutPoint, error) { + + return nil, nil +} + +func (s *stubBatchCanon) DeleteProvisionalConsumersForBatch(context.Context, + chainhash.Hash) error { + + return nil +} + +var _ batchcanon.Store = (*stubBatchCanon)(nil) + +// gateTarget builds a target outpoint + a descriptor whose lineage is the +// supplied commitment txids (first is the direct commitment, rest are +// cross-commitment ancestors). +func gateTarget(direct chainhash.Hash, + ancestors ...chainhash.Hash) (wire.OutPoint, *vtxo.Descriptor) { + + op := wire.OutPoint{Hash: chainhash.Hash{0xfe}, Index: 0} + desc := &vtxo.Descriptor{Outpoint: op, CommitmentTxID: direct} + for _, a := range ancestors { + desc.Ancestry = append(desc.Ancestry, vtxo.Ancestry{ + CommitmentTxID: a, + }) + } + + return op, desc +} + +func newGateBehavior(store batchcanon.Store, + desc *vtxo.Descriptor) *registryBehavior { + + return ®istryBehavior{ + cfg: RegistryConfig{ + BatchCanonicality: store, + VTXOStore: &mockVTXOStore{ + desc: desc, + }, + }, + } +} + +// TestSourceLineageBlockedOnInvalidatedAncestor verifies a fresh unroll is +// refused when any batch in the target's lineage is permanently invalidated +// (conflict-finalized), even if its direct commitment is canonical. +func TestSourceLineageBlockedOnInvalidatedAncestor(t *testing.T) { + t.Parallel() + + direct := chainhash.Hash{0xaa} + ancestor := chainhash.Hash{0xbb} + op, desc := gateTarget(direct, ancestor) + + b := newGateBehavior(&stubBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + direct: batchcanon.StateProvisional, + ancestor: batchcanon.StateConflictFinalized, + }, + }, desc) + + require.True(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestSourceLineagePermitsTransientReorg verifies a reorged-out (but not +// conflict-finalized) ancestor does NOT block a fresh unroll: the reorg is +// expected to self-heal, and blocking could drop a needed safety exit that +// cannot be retried. +func TestSourceLineagePermitsTransientReorg(t *testing.T) { + t.Parallel() + + direct := chainhash.Hash{0xaa} + ancestor := chainhash.Hash{0xbb} + op, desc := gateTarget(direct, ancestor) + + b := newGateBehavior(&stubBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + direct: batchcanon.StateProvisional, + ancestor: batchcanon.StateReorgedOut, + }, + }, desc) + + require.False(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestSourceLineageNotBlockedWhenCanonical verifies a fresh unroll is admitted +// when the whole lineage is canonical. +func TestSourceLineageNotBlockedWhenCanonical(t *testing.T) { + t.Parallel() + + direct := chainhash.Hash{0xaa} + ancestor := chainhash.Hash{0xbb} + op, desc := gateTarget(direct, ancestor) + + b := newGateBehavior(&stubBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + direct: batchcanon.StateFinalized, + ancestor: batchcanon.StateProvisional, + }, + }, desc) + + require.False(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestSourceLineagePermissiveWhenUnregistered verifies the gate does not block +// when the lineage batches are not registered (unseen), preserving the +// permissive default. +func TestSourceLineagePermissiveWhenUnregistered(t *testing.T) { + t.Parallel() + + op, desc := gateTarget(chainhash.Hash{0xaa}, chainhash.Hash{0xbb}) + + b := newGateBehavior(&stubBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{}, + }, desc) + + require.False(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestSourceLineagePermissiveWhenVTXOLoadFails verifies the gate fails +// permissive (admits) when the target descriptor cannot be loaded, rather than +// blocking an exit on a transient store error. +func TestSourceLineagePermissiveWhenVTXOLoadFails(t *testing.T) { + t.Parallel() + + op, _ := gateTarget(chainhash.Hash{0xaa}) + + b := ®istryBehavior{ + cfg: RegistryConfig{ + BatchCanonicality: &stubBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{}, + }, + VTXOStore: &mockVTXOStore{ + err: errors.New("boom"), + }, + }, + log: btclog.Disabled, + } + + require.False(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestSourceLineageGateDormantWhenNoStore verifies the gate is a no-op when no +// canonicality store is wired. +func TestSourceLineageGateDormantWhenNoStore(t *testing.T) { + t.Parallel() + + op, _ := gateTarget(chainhash.Hash{0xaa}) + + b := ®istryBehavior{cfg: RegistryConfig{}} + + require.False(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestErrSourceLineageUnavailableIsSentinel guards that the wrapped form +// handleEnsure returns is matchable via errors.Is, so RPC/chain-resolver +// callers can classify a lineage-refused unroll. +func TestErrSourceLineageUnavailableIsSentinel(t *testing.T) { + t.Parallel() + + wrapped := fmt.Errorf("%w: %s", ErrSourceLineageUnavailable, + "some-outpoint") + require.ErrorIs(t, wrapped, ErrSourceLineageUnavailable) +} diff --git a/vtxo/manager.go b/vtxo/manager.go index 1e76b4ad3..c0e27f0a4 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -12,10 +12,12 @@ 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" "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/build" "github.com/lightninglabs/wavelength/chainsource" "github.com/lightninglabs/wavelength/coinselect" @@ -140,6 +142,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 @@ -598,6 +610,9 @@ func (m *Manager) Receive(ctx context.Context, case *ReleaseForfeitRequest: return m.handleReleaseForfeit(ctx, req) + case *RestoreForfeitedVTXORequest: + return m.handleRestoreForfeitedVTXO(ctx, req) + case *ActivateCustomForfeitInputsRequest: return m.handleActivateCustomForfeitInputs(ctx, req) @@ -1037,6 +1052,98 @@ func (m *Manager) recoverExitedVTXO(ctx context.Context, return fn.Ok[ManagerResp](&ExitOutcomeResp{}) } +// handleRestoreForfeitedVTXO rolls a forfeited VTXO back to LiveState because +// the batch that consumed it via forfeit has been invalidated (its forfeit +// reversed by a finalized reorg / conflict). The forfeit transition reaped the +// VTXO actor and persisted VTXOStatusForfeited, so this re-materializes a live +// actor from the persisted descriptor, mirroring recoverExitedVTXO. It is +// idempotent: a VTXO that is not currently forfeited is left untouched. +func (m *Manager) handleRestoreForfeitedVTXO(ctx context.Context, + req *RestoreForfeitedVTXORequest) fn.Result[ManagerResp] { + + descriptor, err := m.cfg.Store.GetVTXO(ctx, req.Outpoint) + if err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("load vtxo for forfeit restore: %w", err), + ) + } + if descriptor == nil { + m.logger(ctx).WarnS(ctx, "No descriptor to restore forfeited "+ + "VTXO", nil, + slog.String("outpoint", req.Outpoint.String())) + + return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{}) + } + + // Idempotency guard: only relive a VTXO that is actually forfeited. A + // re-delivered restore must not clobber a VTXO that already moved on or + // was never forfeited. + if descriptor.Status != VTXOStatusForfeited { + m.logger(ctx).DebugS(ctx, "Skipping forfeit restore for "+ + "non-forfeited VTXO", + slog.String("outpoint", req.Outpoint.String()), + slog.String("status", descriptor.Status.String())) + + return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{}) + } + + // The forfeit transition reaps the actor, so none should be resident. + // If one somehow is, do not spawn a duplicate. A resident actor whose + // descriptor is still marked Forfeited means a prior restore spawned + // the actor but its status write did not land (the spawn happens + // before the status flip below, precisely so a failed write is + // re-driven on the next attempt). Complete that missed flip here so + // the coin is not left Forfeited in the DB, which would drop it on the + // next restart -- a permanent loss of funds. + if _, ok := m.actors[req.Outpoint]; ok { + m.logger(ctx).DebugS(ctx, "Forfeited VTXO already has a live "+ + "actor; reconciling DB status to live", + slog.String("outpoint", req.Outpoint.String())) + + if err := m.cfg.Store.UpdateVTXOStatus( + ctx, req.Outpoint, VTXOStatusLive, + ); err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("unable to reconcile restored "+ + "vtxo status: %w", err), + ) + } + + return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{ + Restored: true, + }) + } + + // Spawn the live actor BEFORE persisting the status flip (same ordering + // rationale as recoverExitedVTXO: the coin must be monitored the moment + // its status becomes recoverable, and a failed status write is + // re-driven by the persisted Forfeited status on the next restore + // attempt). + descriptor.Status = VTXOStatusLive + + ref, err := m.spawnVTXOActor(ctx, descriptor) + if err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("respawn restored vtxo actor: %w", err), + ) + } + m.actors[req.Outpoint] = ref + + if err := m.cfg.Store.UpdateVTXOStatus( + ctx, req.Outpoint, VTXOStatusLive, + ); err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("restore forfeited vtxo status: %w", err), + ) + } + + m.logger(ctx).InfoS(ctx, "Restored forfeited VTXO to live after "+ + "batch invalidation", + slog.String("outpoint", req.Outpoint.String())) + + return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{Restored: true}) +} + // confirmExitedVTXO retires a VTXO to the terminal SpentState after its // unilateral exit confirmed on-chain. When the actor is alive it drives the // ExitConfirmedEvent through the FSM (which emits the terminated @@ -1260,6 +1367,83 @@ 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 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) { + + 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, + lineageCommitmentTxids(desc)..., + ) + if err != nil { + return nil, fmt.Errorf("lineage gate %s: %w", + c.Outpoint, err) + } + if blocked { + m.logger(ctx).DebugS(ctx, "Excluding VTXO with "+ + "unavailable batch lineage from selection", + slog.String("outpoint", c.Outpoint.String()), + slog.String("availability", avail.String())) + + continue + } + + kept = append(kept, c) + } + + return kept, nil +} + func (m *Manager) selectAndReserveVTXOs(ctx context.Context, p reserveParams) ( []SelectedVTXO, btcutil.Amount, error) { @@ -1305,6 +1489,15 @@ func (m *Manager) selectAndReserveVTXOs(ctx context.Context, p reserveParams) ( }) } + // Drop any candidate whose batch lineage is in limbo or invalidated, so + // a VTXO whose batch reorged out or was conflict-invalidated is never + // selected while its lineage is off the canonical chain. No-op when no + // canonicality store is configured. + candidates, err = m.gateUnavailableLineage(ctx, candidates) + if err != nil { + return nil, 0, err + } + // Run largest-first selection through the shared selector. Map its // typed outcomes back onto the manager's liquidity diagnostics: a // dust-change rejection is reported verbatim, while any shortfall @@ -1924,6 +2117,26 @@ func (m *Manager) handleReserveForfeit(ctx context.Context, ErrVTXOLiquidityLocked, op), ) } + + // Refuse to forfeit a VTXO whose batch lineage is in limbo + // (reorged out) or invalidated: forfeiting commits the VTXO + // into a round, and a VTXO that is not on the canonical chain + // must not be spent. The coin-selection gate + // (gateUnavailableLineage) already excludes such VTXOs, but the + // wallet's explicit-outpoint paths (refresh / leave / sweep-all + // / replay) reserve by name and bypass selection, so the same + // gate is enforced here (darepo#454). + blocked, avail, err := m.forfeitLineageBlocked(ctx, op) + if err != nil { + return fn.Err[ManagerResp](err) + } + if blocked { + return fn.Err[ManagerResp]( + fmt.Errorf("%w: outpoint %s batch lineage "+ + "unavailable (%s)", + ErrVTXOLiquidityLocked, op, avail), + ) + } } // Reserve each VTXO. Track successes for rollback on failure. @@ -1955,6 +2168,32 @@ 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, 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) { + + 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, lineageCommitmentTxids(desc)..., + ) +} + // rollbackForfeit sends ForfeitReleasedEvent to previously reserved VTXOs. // Best-effort: errors are logged but do not propagate. func (m *Manager) rollbackForfeit(ctx context.Context, diff --git a/vtxo/manager_forfeit_gate_test.go b/vtxo/manager_forfeit_gate_test.go new file mode 100644 index 000000000..c2852fb2a --- /dev/null +++ b/vtxo/manager_forfeit_gate_test.go @@ -0,0 +1,70 @@ +package vtxo + +import ( + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestForfeitLineageBlockedOnLimbo verifies the explicit-outpoint forfeit gate +// refuses a VTXO whose batch reorged out, matching the coin-selection gate so +// the wallet's reserve-by-name paths (refresh/leave/sweep/replay) cannot +// forfeit a VTXO that is off the canonical chain. +func TestForfeitLineageBlockedOnLimbo(t *testing.T) { + t.Parallel() + + v := makeDescriptor(t, 50_000, 0) + v.CommitmentTxID = chainhash.Hash{0xaa} + + mgr, store := newTestManager(t, []*Descriptor{v}) + mgr.cfg.BatchCanonicality = &fakeBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + v.CommitmentTxID: batchcanon.StateReorgedOut, + }, + } + store.On("GetVTXO", mock.Anything, v.Outpoint).Return(v, nil) + + blocked, avail, err := mgr.forfeitLineageBlocked( + t.Context(), v.Outpoint, + ) + require.NoError(t, err) + require.True(t, blocked) + require.Equal(t, batchcanon.LimboReorg, avail) +} + +// TestForfeitLineageNotBlockedWhenCanonical verifies a canonical VTXO is +// admissible for forfeit. +func TestForfeitLineageNotBlockedWhenCanonical(t *testing.T) { + t.Parallel() + + v := makeDescriptor(t, 50_000, 0) + v.CommitmentTxID = chainhash.Hash{0xaa} + + mgr, store := newTestManager(t, []*Descriptor{v}) + mgr.cfg.BatchCanonicality = &fakeBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + v.CommitmentTxID: batchcanon.StateProvisional, + }, + } + store.On("GetVTXO", mock.Anything, v.Outpoint).Return(v, nil) + + blocked, _, err := mgr.forfeitLineageBlocked(t.Context(), v.Outpoint) + require.NoError(t, err) + require.False(t, blocked) +} + +// TestForfeitLineageGateDormantWhenNoStore verifies the forfeit gate is a no-op +// when no canonicality store is wired. +func TestForfeitLineageGateDormantWhenNoStore(t *testing.T) { + t.Parallel() + + v := makeDescriptor(t, 50_000, 0) + mgr, _ := newTestManager(t, []*Descriptor{v}) + + blocked, _, err := mgr.forfeitLineageBlocked(t.Context(), v.Outpoint) + require.NoError(t, err) + require.False(t, blocked) +} diff --git a/vtxo/manager_lineage_gate_test.go b/vtxo/manager_lineage_gate_test.go new file mode 100644 index 000000000..4c7e6f992 --- /dev/null +++ b/vtxo/manager_lineage_gate_test.go @@ -0,0 +1,243 @@ +package vtxo + +import ( + "context" + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// fakeBatchCanon is a minimal batchcanon.Store for the lineage-gate tests: it +// maps batch txids to canonicality states and answers GetBatch from that map. +// The other Store methods are unused by the gate and return zero values. +type fakeBatchCanon struct { + states map[chainhash.Hash]batchcanon.State +} + +func (f *fakeBatchCanon) GetBatch(_ context.Context, txid chainhash.Hash) ( + *batchcanon.Record, error) { + + st, ok := f.states[txid] + if !ok { + return nil, batchcanon.ErrBatchNotFound + } + + return &batchcanon.Record{BatchTxID: txid, State: st}, nil +} + +func (f *fakeBatchCanon) UpsertBatch(context.Context, + *batchcanon.Record) error { + + return nil +} + +func (f *fakeBatchCanon) ListBatchesByState(context.Context, batchcanon.State) ( + []*batchcanon.Record, error) { + + return nil, nil +} + +func (f *fakeBatchCanon) UpdateBatchState(context.Context, chainhash.Hash, + batchcanon.State) error { + + return nil +} + +func (f *fakeBatchCanon) RecordConfirmation(context.Context, chainhash.Hash, + int32, chainhash.Hash) error { + + return nil +} + +func (f *fakeBatchCanon) RecordInputConflict(context.Context, chainhash.Hash, + wire.OutPoint, bool, bool) 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) +} diff --git a/vtxo/manager_multiparent_gate_test.go b/vtxo/manager_multiparent_gate_test.go new file mode 100644 index 000000000..b53ae9477 --- /dev/null +++ b/vtxo/manager_multiparent_gate_test.go @@ -0,0 +1,115 @@ +package vtxo + +import ( + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/lightninglabs/wavelength/batchcanon" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestLineageCommitmentTxids verifies the helper collects the direct +// commitment txid plus every distinct ancestor commitment txid, dedupes, and +// skips the zero hash. +func TestLineageCommitmentTxids(t *testing.T) { + t.Parallel() + + direct := chainhash.Hash{0x01} + parentA := chainhash.Hash{0x02} + parentB := chainhash.Hash{0x03} + + desc := &Descriptor{ + CommitmentTxID: direct, + Ancestry: []Ancestry{ + { + CommitmentTxID: parentA, + }, + { + CommitmentTxID: parentB, + }, + // Duplicate of the direct txid: must be deduped. + { + CommitmentTxID: direct, + }, + // Zero hash: must be skipped. + { + CommitmentTxID: chainhash.Hash{}, + }, + }, + } + + got := lineageCommitmentTxids(desc) + require.Equal( + t, []chainhash.Hash{direct, parentA, parentB}, got, + ) +} + +// TestLineageCommitmentTxidsDirectOnly verifies a VTXO with no ancestry (e.g. +// an incoming VTXO materialized without its commitment tree) still yields its +// direct commitment txid so the gate governs it. +func TestLineageCommitmentTxidsDirectOnly(t *testing.T) { + t.Parallel() + + desc := &Descriptor{CommitmentTxID: chainhash.Hash{0x09}} + require.Equal( + t, []chainhash.Hash{{0x09}}, lineageCommitmentTxids(desc), + ) +} + +// TestSelectExcludesMultiParentLimboLineage verifies that a cross-commitment +// OOR VTXO is gated out when ANY of its ancestor batches is in limbo, even +// though its direct commitment batch is canonical. This is the multi-parent +// extension: the worst parent dominates. +func TestSelectExcludesMultiParentLimboLineage(t *testing.T) { + t.Parallel() + + good := makeDescriptor(t, 40000, 0) + multi := makeDescriptor(t, 50000, 1) + + good.CommitmentTxID = chainhash.Hash{0xaa} + + // multi descends from two batches: its direct commitment (canonical) + // and a cross-commitment ancestor that reorged out. + directBatch := chainhash.Hash{0xbb} + ancestorBatch := chainhash.Hash{0xcc} + multi.CommitmentTxID = directBatch + multi.Ancestry = []Ancestry{ + { + CommitmentTxID: directBatch, + }, + { + CommitmentTxID: ancestorBatch, + }, + } + + mgr, store := newTestManager(t, []*Descriptor{good, multi}) + mgr.cfg.BatchCanonicality = &fakeBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + good.CommitmentTxID: batchcanon.StateProvisional, + directBatch: batchcanon.StateProvisional, + ancestorBatch: batchcanon.StateReorgedOut, + }, + } + + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusLive, + ).Return([]*Descriptor{good, multi}, nil) + store.On("GetVTXO", mock.Anything, good.Outpoint).Return(good, nil) + store.On("GetVTXO", mock.Anything, multi.Outpoint).Return(multi, nil) + + result := mgr.Receive(t.Context(), &SelectAndReserveSpendRequest{ + TargetAmount: 40000, + }) + resp, err := result.Unpack() + require.NoError(t, err) + + spendResp, ok := resp.(*SelectAndReserveSpendResponse) + require.True(t, ok) + + // The 50000 multi-parent candidate is gated out for its reorged-out + // ANCESTOR batch despite its direct batch being canonical, so selection + // falls to the 40000 candidate. + require.Len(t, spendResp.SelectedVTXOs, 1) + require.Equal(t, good.Outpoint, spendResp.SelectedVTXOs[0].Outpoint) +} diff --git a/vtxo/messages.go b/vtxo/messages.go index 984353c1f..4c6a4e1b2 100644 --- a/vtxo/messages.go +++ b/vtxo/messages.go @@ -289,6 +289,12 @@ type ReleaseForfeitRequest = actormsg.ReleaseForfeitRequest // ReleaseForfeitResponse is an alias for the canonical type in actormsg. type ReleaseForfeitResponse = actormsg.ReleaseForfeitResponse +// RestoreForfeitedVTXORequest is an alias for the canonical type in actormsg. +type RestoreForfeitedVTXORequest = actormsg.RestoreForfeitedVTXORequest + +// RestoreForfeitedVTXOResponse is an alias for the canonical type in actormsg. +type RestoreForfeitedVTXOResponse = actormsg.RestoreForfeitedVTXOResponse + // CustomForfeitInput is an alias for the canonical type in actormsg. type CustomForfeitInput = actormsg.CustomForfeitInput diff --git a/wallet/boarding_sweep_actor.go b/wallet/boarding_sweep_actor.go index 6759d789e..58cf3b223 100644 --- a/wallet/boarding_sweep_actor.go +++ b/wallet/boarding_sweep_actor.go @@ -233,29 +233,154 @@ func (m BoardingSweepSpendNotification) MessageType() string { func (m BoardingSweepSpendNotification) walletMsgSealed() {} -// BoardingSweepTxNotification is a Tell carrying a txconfirm terminal -// notification (confirmation or failure) for a tracked sweep tx, -// re-wrapped from txconfirm.TxConfirmed / txconfirm.TxFailed via +// BoardingSweepTxStatus identifies which point in the txconfirm +// reorg-aware lifecycle drove this notification. Splitting the status +// out (instead of a single `Confirmed bool`) makes it impossible for +// the handler to confuse a reorg-out with a terminal failure: txconfirm +// fan-outs a TxReorged whenever a previously delivered TxConfirmed is +// rolled back on chain, and we must NOT treat that as a sweep failure. +type BoardingSweepTxStatus int + +const ( + // BoardingSweepTxStatusUnknown is the zero value; receiving it + // indicates a programmer error (MapNotification missed a kind). + BoardingSweepTxStatusUnknown BoardingSweepTxStatus = iota + + // BoardingSweepTxStatusConfirmed reports that the sweep + // transaction has been observed on the canonical chain at + // BlockHeight with NumConfs confirmations. The observation is + // provisional until BoardingSweepTxStatusFinalized arrives — a + // reorg can roll it back via BoardingSweepTxStatusReorged. + BoardingSweepTxStatusConfirmed + + // BoardingSweepTxStatusReorged reports that a previously + // delivered TxConfirmed for this sweep was reorged out. The + // handler must NOT call MarkBoardingSweepFailed; instead it + // leaves the spend watches and pending state armed so the next + // TxConfirmed on the new canonical chain (or an eventual + // TxFailed) drives the terminal decision. + BoardingSweepTxStatusReorged + + // BoardingSweepTxStatusFinalized reports that the sweep + // confirmation is past the chainsource backend's reorg-safety + // depth and is no longer reversible. The handler can release + // any reorg-recovery bookkeeping for this sweep. + BoardingSweepTxStatusFinalized + + // BoardingSweepTxStatusFailed reports a terminal failure from + // txconfirm (broadcast rejected, retry budget exhausted, etc.). + // The handler marks the sweep failed in the store and cleans up + // pending state. + BoardingSweepTxStatusFailed +) + +// classifyTxconfirmNotificationForBoardingSweep maps one event from +// the txconfirm reorg-aware lifecycle into the wallet-domain +// BoardingSweepTxNotification shape consumed by +// handleSweepTxNotification. Pulled out of submitSweepConfirmer so the +// systest (TestBoardingSweepReorgRoundTrip) can reuse the exact same +// classifier the production wiring uses; if the production wiring +// drifts from the systest classifier, the test stops validating +// production behavior. +func classifyTxconfirmNotificationForBoardingSweep( + n txconfirm.Notification) BoardingSweepTxNotification { + + switch ev := n.(type) { + case *txconfirm.TxConfirmed: + return BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusConfirmed, + + Txid: ev.Txid, + BlockHeight: ev.BlockHeight, + NumConfs: ev.NumConfs, + } + + case *txconfirm.TxReorged: + // Reorg-out: do NOT mark the sweep as failed in the + // handler. Leave spend watches and pending state armed so + // the next TxConfirmed on the new canonical chain (or an + // eventual TxFailed) drives the terminal decision. + return BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusReorged, + Txid: ev.Txid, + } + + case *txconfirm.TxFinalized: + // Reorg-safety horizon reached. The sweep observation is + // no longer reversible; the handler may release reorg- + // recovery bookkeeping. Audit/ledger emission already + // fired on the original Confirmed (idempotent on replay). + return BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusFinalized, + + Txid: ev.Txid, + BlockHeight: ev.BlockHeight, + NumConfs: ev.NumConfs, + } + + case *txconfirm.TxFailed: + return BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusFailed, + Txid: ev.Txid, + Reason: ev.Reason, + } + } + + return BoardingSweepTxNotification{} +} + +// NewBoardingSweepTxconfirmSubscriber wires the production boarding- +// sweep classifier onto a txconfirm.MapNotification anchored at +// selfRef. The returned ref can be set as the Subscriber field on a +// txconfirm.EnsureConfirmedReq; every TxConfirmed / TxReorged / +// TxFinalized / TxFailed delivered for that sweep will be classified +// into a BoardingSweepTxNotification and Tell'd to selfRef as a +// WalletMsg, which the wallet actor's Receive arm dispatches to +// handleSweepTxNotification. +// +// Exported so the systest can construct the exact same subscriber +// chain that submitSweepConfirmer uses in production. +func NewBoardingSweepTxconfirmSubscriber( + selfRef actor.TellOnlyRef[WalletMsg], +) actor.TellOnlyRef[txconfirm.Notification] { + + walletNotif := actor.NewMapInputRef[ + BoardingSweepTxNotification, WalletMsg, + ]( + selfRef, + func(n BoardingSweepTxNotification) WalletMsg { + return n + }, + ) + + return txconfirm.MapNotification( + walletNotif, classifyTxconfirmNotificationForBoardingSweep, + ) +} + +// BoardingSweepTxNotification is a Tell carrying one event from the +// txconfirm reorg-aware lifecycle for a tracked sweep tx, re-wrapped +// from txconfirm.{TxConfirmed,TxReorged,TxFinalized,TxFailed} via // txconfirm.MapNotification. type BoardingSweepTxNotification struct { actor.BaseMessage - // Confirmed is true when the underlying txconfirm.TxConfirmed event - // fired; false when it was txconfirm.TxFailed. - Confirmed bool + // Status identifies which lifecycle event this notification + // carries. See BoardingSweepTxStatus. + Status BoardingSweepTxStatus // Txid identifies the tracked sweep transaction. Txid chainhash.Hash // BlockHeight is the height at which the sweep confirmed when - // Confirmed=true; zero otherwise. + // Status=Confirmed or Finalized; zero otherwise. BlockHeight int32 - // NumConfs is the confirmation count when Confirmed=true; zero - // otherwise. + // NumConfs is the confirmation count when Status=Confirmed or + // Finalized; zero otherwise. NumConfs uint32 - // Reason is the human-readable failure reason when Confirmed=false. + // Reason is the human-readable failure reason when Status=Failed. Reason string } @@ -430,6 +555,16 @@ func (a *Ark) loadSweepCandidates(ctx context.Context, return intents, nil } +// isTerminalSuccessSweepStatus reports whether a persisted boarding-sweep +// status represents a resolved sweep whose accounting is already booked and +// must not be rolled back to failed: confirmed (our sweep landed) or +// external_resolved (the input was spent by another path). A spurious +// TxFailed for such a sweep is ignored. +func isTerminalSuccessSweepStatus(status string) bool { + return status == BoardingSweepStatusConfirmed || + status == BoardingSweepStatusExternalResolved +} + // defaultBoardingSweepStatuses are the boarding-intent statuses considered // candidates for an aggregate timeout sweep when no outpoint set is // supplied. @@ -868,37 +1003,11 @@ func (a *Ark) cancelSweepSpendWatches(ctx context.Context, func (a *Ark) submitSweepConfirmer(ctx context.Context, tx *wire.MsgTx, pkScript []byte, heightHint uint32) error { - walletNotif := actor.NewMapInputRef[ - BoardingSweepTxNotification, WalletMsg, - ]( - a.selfRef, - func(n BoardingSweepTxNotification) WalletMsg { - return n - }, - ) - - subscriber := txconfirm.MapNotification(walletNotif, - func(n txconfirm.Notification) BoardingSweepTxNotification { - switch ev := n.(type) { - case *txconfirm.TxConfirmed: - return BoardingSweepTxNotification{ - Confirmed: true, - Txid: ev.Txid, - BlockHeight: ev.BlockHeight, - NumConfs: ev.NumConfs, - } - - case *txconfirm.TxFailed: - return BoardingSweepTxNotification{ - Confirmed: false, - Txid: ev.Txid, - Reason: ev.Reason, - } - } + if a.actorSystem == nil { + return fmt.Errorf("actor system unavailable") + } - return BoardingSweepTxNotification{} - }, - ) + subscriber := NewBoardingSweepTxconfirmSubscriber(a.selfRef) return a.submitSweepToConfirm( ctx, tx, pkScript, heightHint, boardingSweepBroadcastLabel, @@ -1061,8 +1170,8 @@ func (a *Ark) handleSweepTxNotification(ctx context.Context, return fn.Ok[WalletResp](&BoardingSweepNotificationAck{}) } - switch { - case notif.Confirmed: + switch notif.Status { + case BoardingSweepTxStatusConfirmed: a.logger(ctx).DebugS( ctx, "Boarding sweep confirmation observed by broadcaster", @@ -1074,7 +1183,74 @@ func (a *Ark) handleSweepTxNotification(ctx context.Context, a.emitSweepConfirmedLedger(ctx, notif) - default: + case BoardingSweepTxStatusReorged: + // A previously delivered TxConfirmed for this sweep was + // reorged out. Do NOT mark the sweep as failed and do NOT + // tear down the pending entry: txconfirm keeps the watch + // alive, so a subsequent TxConfirmed on the new canonical + // chain will re-run reconcileSweepInputsOnConfirm with the + // new height. MarkBoardingSweepInputSpent is idempotent on + // (outpoint, txid), and the ledger emissions use txid-keyed + // idempotency, so the second confirmation will not produce + // duplicate audit/balance entries. + // + // Best-effort backstop only: the per-input chainsource + // spend watch fires handleSweepSpendNotification if some + // other party spends an input. That spend-watch path is + // not itself reorg-symmetric — MapSpendEvent collapses the + // chainsource SpendEvent lifecycle to a single + // BoardingSweepSpendNotification without surfacing + // Reorged / Done — so a reorged-out external spender will + // leave the input row marked external_spent until manual + // reconciliation. Closing that gap is tracked separately. + a.logger(ctx).WarnS( + ctx, + "Boarding sweep confirmation reorged out; waiting "+ + "for re-confirmation or external spend", + nil, + slog.String("txid", notif.Txid.String()), + ) + + case BoardingSweepTxStatusFinalized: + // Confirmation is past the backend's reorg-safety depth. + // No further reversible events will fire for this sweep — + // reorg-recovery bookkeeping can drop. Audit/ledger + // emission already ran on Confirmed and is idempotent on + // replay, so we do not re-emit here. + a.logger(ctx).DebugS( + ctx, + "Boarding sweep confirmation finalized", + nil, + slog.String("txid", notif.Txid.String()), + slog.Int("block_height", int(notif.BlockHeight)), + ) + + case BoardingSweepTxStatusFailed: + // Defensive guard against a failure arriving for a sweep that + // already confirmed. txconfirm's contract is that TxFailed + // never follows a real TxConfirmed, but the confirmed sweep's + // ledger legs (fee + per-input + destination) are irreversible + // and txid-keyed, so rolling the intents back to failed here + // would diverge the store from the ledger. If the persisted + // record shows the sweep already reached a terminal-success + // status, ignore the failure rather than undo a booked sweep. + if rec, ok := a.lookupSweepRecord(ctx, notif.Txid); ok && + isTerminalSuccessSweepStatus(rec.Status) { + + a.logger(ctx).WarnS( + ctx, + "Ignoring boarding sweep failure for an "+ + "already-resolved sweep", + errors.New(notif.Reason), + slog.String("txid", notif.Txid.String()), + slog.String("status", rec.Status), + ) + + return fn.Ok[WalletResp]( + &BoardingSweepNotificationAck{}, + ) + } + a.logger(ctx).WarnS( ctx, "Boarding sweep broadcaster reported failure", @@ -1104,6 +1280,14 @@ func (a *Ark) handleSweepTxNotification(ctx context.Context, if pending != nil { a.cancelSweepSpendWatches(ctx, pending) } + + default: + a.logger(ctx).WarnS( + ctx, + "Boarding sweep tx notification with unknown status", + fmt.Errorf("status=%d", notif.Status), + slog.String("txid", notif.Txid.String()), + ) } return fn.Ok[WalletResp](&BoardingSweepNotificationAck{}) @@ -1116,6 +1300,18 @@ func (a *Ark) handleSweepTxNotification(ctx context.Context, // the spending txid is the sweep's own txid. MarkBoardingSweepInputSpent // is idempotent, so inputs already resolved via the spend-notification path // are left untouched. +// +// Under the reorg-aware lifecycle a second TxConfirmed can land after a +// TxReorged / re-confirmation cycle. The store's status guard rejects +// the redundant transition with sql.ErrNoRows; that signals "input row +// already advanced past pending/published" and is treated as a benign +// no-op, mirroring how handleSweepSpendNotification classifies the +// same error. Other errors still log at warn because they indicate a +// real persistence problem. Note: the input row's confirmed_height +// stays pinned to the FIRST Confirmed observation — a deliberate +// first-observation-wins audit policy that survives reorg-reconfirm +// at a different height; the reorg-safety horizon is reported +// separately via the Finalized lifecycle event. func (a *Ark) reconcileSweepInputsOnConfirm(ctx context.Context, notif BoardingSweepTxNotification) { @@ -1128,7 +1324,17 @@ func (a *Ark) reconcileSweepInputsOnConfirm(ctx context.Context, _, err := a.sweepStore.MarkBoardingSweepInputSpent( ctx, op, notif.Txid, notif.BlockHeight, ) - if err != nil { + switch { + case err == nil: + // Success. + + case errors.Is(err, sql.ErrNoRows): + // Row already past pending/published — likely + // resolved via handleSweepSpendNotification or a + // previous Confirmed in a reorg-reconfirm cycle. + // Idempotent no-op. + + default: a.logger(ctx).WarnS( ctx, "Failed to mark sweep input spent on confirm", diff --git a/wallet/boarding_sweep_actor_test.go b/wallet/boarding_sweep_actor_test.go index 1a1f190e2..29493ba8d 100644 --- a/wallet/boarding_sweep_actor_test.go +++ b/wallet/boarding_sweep_actor_test.go @@ -599,7 +599,7 @@ func TestSweepTxNotificationConfirmedEmitsLedger(t *testing.T) { result := a.handleSweepTxNotification( t.Context(), BoardingSweepTxNotification{ - Confirmed: true, + Status: BoardingSweepTxStatusConfirmed, Txid: swept, BlockHeight: 800_650, NumConfs: 1, @@ -719,7 +719,7 @@ func TestSweepTxNotificationConfirmedExternalDestSkipsCreated(t *testing.T) { result := a.handleSweepTxNotification( t.Context(), BoardingSweepTxNotification{ - Confirmed: true, + Status: BoardingSweepTxStatusConfirmed, Txid: swept, BlockHeight: 800_700, }, @@ -854,12 +854,13 @@ func TestSweepLedgerClearingNetsToZero(t *testing.T) { ), ) + notif := BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusConfirmed, + Txid: swept, + BlockHeight: 800_800, + } result := a.handleSweepTxNotification( - t.Context(), BoardingSweepTxNotification{ - Confirmed: true, - Txid: swept, - BlockHeight: 800_800, - }, + t.Context(), notif, ) require.True(t, result.IsOk()) @@ -934,7 +935,7 @@ func TestSweepTxNotificationMissingTxSkipsLegs(t *testing.T) { result := a.handleSweepTxNotification( t.Context(), BoardingSweepTxNotification{ - Confirmed: true, + Status: BoardingSweepTxStatusConfirmed, Txid: swept, BlockHeight: 800_900, }, @@ -962,6 +963,12 @@ func TestSweepTxNotificationFailedMarksFailed(t *testing.T) { mock.Anything, ).Return(nil) + // The Failed arm looks the sweep up first to ignore failures for an + // already-resolved sweep; absent record means the failure proceeds. + store.On( + "GetBoardingSweep", mock.Anything, failedTxid, + ).Return(nil, nil) + a := newSweepTestArk(t, store, nil, 0, 0) a.pendingSweeps[failedTxid] = &pendingSweepState{ txid: failedTxid, @@ -970,9 +977,9 @@ func TestSweepTxNotificationFailedMarksFailed(t *testing.T) { result := a.handleSweepTxNotification( t.Context(), BoardingSweepTxNotification{ - Confirmed: false, - Txid: failedTxid, - Reason: "test failure", + Status: BoardingSweepTxStatusFailed, + Txid: failedTxid, + Reason: "test failure", }, ) require.True(t, result.IsOk()) @@ -980,3 +987,245 @@ func TestSweepTxNotificationFailedMarksFailed(t *testing.T) { store.AssertExpectations(t) } + +// TestSweepTxNotificationReorgedDoesNotMarkFailed verifies that a +// TxReorged event from txconfirm — which arrives whenever a previously +// observed confirmation is rolled back on chain — must NOT be treated +// as a sweep failure. The handler should leave pendingSweeps and the +// persistent sweep record intact so that the next TxConfirmed on the +// new canonical chain (or, in the worst case, a chainsource spend +// notification for some other spender of the inputs) drives the +// terminal decision. +func TestSweepTxNotificationReorgedDoesNotMarkFailed(t *testing.T) { + t.Parallel() + + reorgedTxid := chainhash.Hash{0xa1} + store := &MockBoardingSweepStore{} + // CRITICAL: MarkBoardingSweepFailed must NOT be called on reorg. + // testify/mock will fail the test if any unexpected method is + // invoked, so we simply do not register MarkBoardingSweepFailed + // here and rely on AssertExpectations to verify the negative. + + a := newSweepTestArk(t, store, nil, 0, 0) + pending := &pendingSweepState{ + txid: reorgedTxid, + inputs: map[wire.OutPoint]string{}, + } + a.pendingSweeps[reorgedTxid] = pending + + result := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusReorged, + Txid: reorgedTxid, + }, + ) + require.True(t, result.IsOk()) + + // Pending state must remain intact: a re-confirmation on the new + // canonical chain has to find the same entry to drive its + // reconcileSweepInputsOnConfirm pass. + require.Same( + t, pending, a.pendingSweeps[reorgedTxid], + "reorg must not evict the pending sweep entry", + ) + + // Mock did not register MarkBoardingSweepFailed; AssertExpectations + // passes vacuously, but any call would have failed the mock. + store.AssertExpectations(t) +} + +// TestSweepTxNotificationFinalizedIsBenign verifies that a TxFinalized +// event (the chainsource reorg-safety horizon is reached) does not +// mark the sweep failed, does not re-emit ledger entries, and does +// not tear down pending state. Pending state remains because the +// terminal release path is the same as the legacy confirmation path — +// post-finalization cleanup is a no-op since reconcileSweepInputsOnConfirm +// already ran on the original TxConfirmed. +func TestSweepTxNotificationFinalizedIsBenign(t *testing.T) { + t.Parallel() + + finalizedTxid := chainhash.Hash{0xa2} + store := &MockBoardingSweepStore{} + // No expectations registered — finalized must not call any + // store mutation method. + + a := newSweepTestArk(t, store, nil, 0, 0) + pending := &pendingSweepState{ + txid: finalizedTxid, + inputs: map[wire.OutPoint]string{}, + } + a.pendingSweeps[finalizedTxid] = pending + + result := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusFinalized, + Txid: finalizedTxid, + BlockHeight: 800_750, + NumConfs: 6, + }, + ) + require.True(t, result.IsOk()) + + // Pending state should still be present; finalized is informational. + require.Same(t, pending, a.pendingSweeps[finalizedTxid]) + + store.AssertExpectations(t) +} + +// TestSweepTxNotificationReorgedAfterPendingCleared verifies the +// Reorged handler arm survives a missing pendingSweeps entry (which +// can happen when every per-input spend notification has already +// resolved and the entry was cleaned up by handleSweepSpendNotification +// before the tx-level reorg notification arrives). +func TestSweepTxNotificationReorgedAfterPendingCleared(t *testing.T) { + t.Parallel() + + reorgedTxid := chainhash.Hash{0xa3} + store := &MockBoardingSweepStore{} + // No store expectations — reorg with no pending entry must touch + // nothing. + + a := newSweepTestArk(t, store, nil, 0, 0) + // Note: pendingSweeps is intentionally empty. + + result := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusReorged, + Txid: reorgedTxid, + }, + ) + require.True(t, result.IsOk()) + require.Empty(t, a.pendingSweeps) + + store.AssertExpectations(t) +} + +// TestSweepTxNotificationFailedAfterReorgedStillTerminates verifies +// the Reorged arm does NOT suppress a subsequent terminal Failed +// notification: a sequence of Reorged then Failed must still call +// MarkBoardingSweepFailed and drop the pending entry, otherwise a +// reorg followed by a hard broadcast failure would be silently +// stranded. +func TestSweepTxNotificationFailedAfterReorgedStillTerminates(t *testing.T) { + t.Parallel() + + txid := chainhash.Hash{0xa4} + store := &MockBoardingSweepStore{} + store.On( + "MarkBoardingSweepFailed", mock.Anything, txid, + mock.Anything, + ).Return(nil) + + // The Failed arm now looks the sweep up first to ignore a spurious + // failure for an already-resolved sweep. Here the record is absent + // (not terminal-success), so the failure path proceeds as before. + store.On( + "GetBoardingSweep", mock.Anything, txid, + ).Return(nil, nil) + + a := newSweepTestArk(t, store, nil, 0, 0) + pending := &pendingSweepState{ + txid: txid, + inputs: map[wire.OutPoint]string{}, + } + a.pendingSweeps[txid] = pending + + // Step 1: Reorged — pending should remain, store should not be + // touched. + reorgResult := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusReorged, + Txid: txid, + }, + ) + require.True(t, reorgResult.IsOk()) + require.Same(t, pending, a.pendingSweeps[txid]) + + // Step 2: Failed — terminal path must still fire even though we + // passed through Reorged first. + failResult := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusFailed, + Txid: txid, + Reason: "post-reorg broadcast failure", + }, + ) + require.True(t, failResult.IsOk()) + require.Empty( + t, a.pendingSweeps, + "Failed after Reorged must still tear down pendingSweeps", + ) + + store.AssertExpectations(t) +} + +// TestSweepTxNotificationFailedIgnoredForConfirmedSweep verifies the +// defensive guard on the Failed arm: a spurious Failed notification for a +// sweep whose persisted record already shows a terminal-success status +// (confirmed) must NOT roll the sweep back to failed, because the +// confirmed sweep's ledger legs are irreversible and txid-keyed. +func TestSweepTxNotificationFailedIgnoredForConfirmedSweep(t *testing.T) { + t.Parallel() + + txid := chainhash.Hash{0xa6} + store := &MockBoardingSweepStore{} + + // The record is already confirmed, so the guard must short-circuit + // before MarkBoardingSweepFailed; no expectation is set for it, so a + // call would fail the test. + store.On( + "GetBoardingSweep", mock.Anything, txid, + ).Return(&BoardingSweepRecord{ + Status: BoardingSweepStatusConfirmed, + }, nil) + + a := newSweepTestArk(t, store, nil, 0, 0) + pending := &pendingSweepState{ + txid: txid, + inputs: map[wire.OutPoint]string{}, + } + a.pendingSweeps[txid] = pending + + failResult := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusFailed, + Txid: txid, + Reason: "spurious failure after confirmation", + }, + ) + require.True(t, failResult.IsOk()) + + store.AssertNotCalled(t, "MarkBoardingSweepFailed") + store.AssertExpectations(t) +} + +// TestSweepTxNotificationUnknownStatusIsBenign verifies the default +// arm of handleSweepTxNotification handles an unrecognised status +// without touching the store or pendingSweeps. This guards against a +// future txconfirm lifecycle event being added without a matching +// MapNotification arm. +func TestSweepTxNotificationUnknownStatusIsBenign(t *testing.T) { + t.Parallel() + + txid := chainhash.Hash{0xa5} + store := &MockBoardingSweepStore{} + // No expectations — unknown must touch nothing. + + a := newSweepTestArk(t, store, nil, 0, 0) + pending := &pendingSweepState{ + txid: txid, + inputs: map[wire.OutPoint]string{}, + } + a.pendingSweeps[txid] = pending + + result := a.handleSweepTxNotification( + t.Context(), BoardingSweepTxNotification{ + Status: BoardingSweepTxStatusUnknown, + Txid: txid, + }, + ) + require.True(t, result.IsOk()) + require.Same(t, pending, a.pendingSweeps[txid]) + + store.AssertExpectations(t) +} diff --git a/waved/config.go b/waved/config.go index cc83eefa6..8486e0408 100644 --- a/waved/config.go +++ b/waved/config.go @@ -184,6 +184,24 @@ const ( // config knob. DefaultMaxOperatorFeeSat int64 = 1_000_000 + // DefaultReorgSafetyDepth is the reorg-safety / finality depth on + // networks with conventional reorg behavior (mainnet, regtest, simnet, + // signet): the confirmation depth at which a batch (commitment) tx is + // treated as final and its reorg-aware chain watches (confirmation + + // consumed-input spend) are released. It bounds the deepest reorg the + // daemon actively detects and recovers from. Six matches the wider + // Lightning stack's finality threshold. + DefaultReorgSafetyDepth uint32 = 6 + + // DefaultTestnetReorgSafetyDepth is the deeper default used on testnet, + // whose 20-minute minimum-difficulty rule routinely produces reorgs far + // past the conventional six blocks (occasionally many tens to hundreds + // of blocks). 100 covers the deep reorgs observed on testnet3 with + // margin while keeping watch lifetimes bounded; operators on an + // unusually volatile network can raise it via the `reorgsafetydepth` + // knob. + DefaultTestnetReorgSafetyDepth uint32 = 100 + // RPCTransportGRPC selects native gRPC for daemon-owned outbound RPCs. RPCTransportGRPC = "grpc" @@ -355,6 +373,17 @@ type Config struct { // below any reasonable mainnet abuse threshold. MaxOperatorFeeSat int64 `mapstructure:"maxoperatorfeesat"` + // ReorgSafetyDepth is the confirmation depth at which a batch + // (commitment) tx is treated as final and its reorg-aware chain watches + // (confirmation + consumed-input spend) are released. It bounds the + // deepest reorg the daemon actively detects and recovers from across + // the batch-canonicality gate and the unilateral-exit txconfirm + // watches. Zero selects a network-aware default via + // ResolveReorgSafetyDepth (DefaultReorgSafetyDepth, or the deeper + // DefaultTestnetReorgSafetyDepth on testnet). Raise it on networks + // prone to deep reorgs. + ReorgSafetyDepth uint32 `mapstructure:"reorgsafetydepth"` + // OOR configures off-band receive/send actor behavior. OOR *OORConfig `mapstructure:"oor"` @@ -1491,6 +1520,25 @@ func (c *Config) NetworkDir() string { return filepath.Join(c.DataDir, "data", c.Network) } +// ResolveReorgSafetyDepth returns the configured reorg-safety / finality +// depth, or a network-aware default when unset (zero). Testnet gets the +// deeper DefaultTestnetReorgSafetyDepth because its minimum-difficulty rule +// produces reorgs far past the conventional six blocks; every other network +// uses DefaultReorgSafetyDepth. The result is always positive, so chain +// watches always finalize (a zero depth would disable Done synthesis and leak +// watch state). +func (c *Config) ResolveReorgSafetyDepth() uint32 { + if c.ReorgSafetyDepth > 0 { + return c.ReorgSafetyDepth + } + + if c.Network == "testnet" { + return DefaultTestnetReorgSafetyDepth + } + + return DefaultReorgSafetyDepth +} + // LogDir returns the network-scoped log directory. Path fields are // normalized by Validate via expandPaths, so callers must validate // before reaching this helper. diff --git a/waved/config_reorg_safety_depth_test.go b/waved/config_reorg_safety_depth_test.go new file mode 100644 index 000000000..c0eb9b65d --- /dev/null +++ b/waved/config_reorg_safety_depth_test.go @@ -0,0 +1,73 @@ +package waved + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestResolveReorgSafetyDepth asserts the network-aware default and the +// explicit override for the reorg-safety / finality depth. An explicit +// positive value always wins; otherwise testnet gets the deeper default +// (its minimum-difficulty rule produces deep reorgs) and every other network +// gets the conventional six-block default. The resolved value is always +// positive so chain watches finalize rather than leak. +func TestResolveReorgSafetyDepth(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + network string + configured uint32 + want uint32 + }{ + { + name: "mainnet default", + network: "mainnet", + want: DefaultReorgSafetyDepth, + }, + { + name: "regtest default", + network: "regtest", + want: DefaultReorgSafetyDepth, + }, + { + name: "signet default", + network: "signet", + want: DefaultReorgSafetyDepth, + }, + { + name: "testnet deeper default", + network: "testnet", + want: DefaultTestnetReorgSafetyDepth, + }, + { + name: "explicit override wins on testnet", + network: "testnet", + configured: 30, + want: 30, + }, + { + name: "explicit override wins on mainnet", + network: "mainnet", + configured: 50, + want: 50, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + c := &Config{ + Network: tc.network, + ReorgSafetyDepth: tc.configured, + } + got := c.ResolveReorgSafetyDepth() + require.Equal(t, tc.want, got) + require.Positive( + t, got, "resolved depth must be positive", + ) + }) + } +} diff --git a/waved/server.go b/waved/server.go index 724edb25e..f482bdb79 100644 --- a/waved/server.go +++ b/waved/server.go @@ -29,6 +29,7 @@ import ( "github.com/lightninglabs/lndclient" "github.com/lightninglabs/wavelength/arkrpc" "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/batchcanon" "github.com/lightninglabs/wavelength/btcwbackend" "github.com/lightninglabs/wavelength/build" "github.com/lightninglabs/wavelength/chainbackends" @@ -386,6 +387,19 @@ type Server struct { // subsystem is initialized. lazyChainResolver *vtxo.LazyChainResolver + // batchCanonStore is the durable batch-canonicality store backing the + // VTXO coin-selection and unroll-admission reorg-safety gates. They + // read batch lineage canonicality lazily at admission time, so a nil + // store (e.g. before the wallet-dependent actors start) is a no-op + // gate rather than a hard dependency. + batchCanonStore batchcanon.Store + + // batchCanonRef is the BatchCanonicalityManager actor ref. The round + // and OOR producers Tell it a RegisterBatchRequest as their VTXOs are + // born so each lineage batch gets reorg-aware conf/spend watches. + // None until startWalletDependentActors registers the manager. + batchCanonRef fn.Option[actor.TellOnlyRef[batchcanon.ManagerMsg]] + serverConn *grpc.ClientConn arkClient arkrpc.ArkServiceClient mailboxClient mailboxpb.MailboxServiceClient @@ -2082,6 +2096,16 @@ func (s *Server) registerChainSourceActor( chainsource.ChainSourceConfig{ Backend: s.chainBackend, System: s.actorSystem, + // Enable height-based Done synthesis at the + // operator-configured reorg-safety depth (network-aware + // default; deeper on testnet). Without this, conf/spend + // sub-actors driven through lndclient (whose Done + // channel is allocated-but-never-written) would never + // receive a finality signal, and reorg-aware consumers + // like txconfirm and the batch-canonicality manager + // would leak watch state forever. The depth also bounds + // the deepest reorg those consumers detect. + FinalityDepth: s.cfg.ResolveReorgSafetyDepth(), }, ) @@ -2286,6 +2310,18 @@ func (s *Server) startWalletDependentActors(ctx context.Context, } s.walletRef = fn.Some(walletRef) + // ------------------------------------------------------- + // 9b. Build the batch-canonicality store and manager. The + // store backs the VTXO and unroll reorg-safety gates; + // the manager ref lets the round and OOR producers + // register batches as their VTXOs are born. Built before + // the VTXO manager so its config can carry the store + // directly without a post-Start mutation. + // ------------------------------------------------------- + if err := s.initBatchCanonicality(ctx, chainSourceRef); err != nil { + return err + } + // ------------------------------------------------------- // 10. Start the VTXO manager before the round actor so // the manager ref can be passed directly in the round @@ -4062,6 +4098,7 @@ func (s *Server) initRoundActor(ctx context.Context, OwnedScriptChecker: scriptChecker, OwnedScriptRegistrar: scriptRegistrar, LedgerSink: fn.Some(ledger.NewSink(s.actorSystem)), + BatchCanonicality: s.batchCanonRef, MetricsSink: s.metricsSink, ForfeitCollectionTimeout: s.cfg. ForfeitCollectionTimeout, @@ -4111,6 +4148,135 @@ func (s *Server) dropCustomForfeitSigningContexts(_ context.Context, // initVTXOManager creates, registers, and starts the VTXO manager actor. // The manager recovers persisted VTXOs on startup and spawns one VTXO actor // per live descriptor. +// initBatchCanonicality builds the durable batch-canonicality store, backfills +// canonicality records from the VTXOs already in the DB, and starts the +// BatchCanonicalityManager actor that arms reorg-aware confirmation and spend +// watches on every tracked batch. The store and manager ref are stashed on the +// Server so the VTXO, round, unroll, and OOR subsystems can consult and feed +// the reorg-safety gate. It must run after the chain source is registered: it +// needs the current best height to anchor backfilled records and the chain +// source ref to arm watches. +func (s *Server) initBatchCanonicality(ctx context.Context, + chainSourceRef actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]) error { + + dbStore := db.NewStore( + s.db.DB, s.db.Queries, s.db.Backend(), + s.subLogger(db.Subsystem), + ) + canonStore := dbStore.NewBatchCanonicalityStore(s.clk) + + // Seed canonicality records for batches anchoring VTXOs already in the + // DB so historical lineage is gated too, not just batches born after + // this start. Backfill anchors each record relative to the live tip, + // so a record's confirmation depth is correct on the first reconcile. + bestHeight, err := s.batchCanonBestHeight(ctx, chainSourceRef) + if err != nil { + return fmt.Errorf("unable to read best height for batch "+ + "canonicality backfill: %w", err) + } + created, err := canonStore.BackfillFromVTXOs( + ctx, bestHeight, s.cfg.ResolveReorgSafetyDepth(), + ) + if err != nil { + return fmt.Errorf("unable to backfill batch canonicality: %w", + err) + } + + mgr := batchcanon.NewManager(batchcanon.ManagerConfig{ + Store: canonStore, + ChainSource: chainSourceRef, + Log: fn.Some(s.subLogger("BCAN")), + RestoreConsumedVTXO: s.restoreForfeitedVTXO, + }) + mgrRef := actor.RegisterWithSystem( + s.actorSystem, "batch-canonicality", + batchcanon.ManagerServiceKey, mgr, + ) + mgr.SetSelfRef(mgrRef) + + // Reconcile re-arms watches for every persisted record (the freshly + // backfilled ones plus any that survived a restart) so a reorg that + // lands while the daemon is down is still detected on the next start. + if err := mgr.Reconcile(ctx); err != nil { + s.actorSystem.StopAndRemoveActor("batch-canonicality") + + return fmt.Errorf("unable to reconcile batch canonicality: %w", + err) + } + + s.batchCanonStore = canonStore + s.batchCanonRef = fn.Some[actor.TellOnlyRef[batchcanon.ManagerMsg]]( + mgrRef, + ) + + s.log.InfoS(ctx, "Batch canonicality manager registered and started", + slog.Int("backfilled_records", created), + ) + + return nil +} + +// batchCanonBestHeight asks the chain source for the current best block height, +// used to anchor batch-canonicality records during backfill. +func (s *Server) batchCanonBestHeight(ctx context.Context, + chainSourceRef actor.ActorRef[ + chainsource.ChainSourceMsg, chainsource.ChainSourceResp, + ]) (int32, error) { + + resp, err := chainSourceRef.Ask( + ctx, &chainsource.BestHeightRequest{}, + ).Await(ctx).Unpack() + if err != nil { + return 0, err + } + + heightResp, ok := resp.(*chainsource.BestHeightResponse) + if !ok { + return 0, fmt.Errorf("unexpected best-height response type %T", + resp) + } + + return heightResp.Height, nil +} + +// restoreForfeitedVTXOTimeout bounds the cross-actor ask the batch-canonicality +// manager issues to the VTXO manager to roll a forfeited VTXO back to live. It +// runs on the canonicality manager's actor turn, so the bound stops a slow or +// wedged VTXO manager from stalling canonicality processing. 30s is generous +// for an in-process spawn + single status write while still being a hard cap. +const restoreForfeitedVTXOTimeout = 30 * time.Second + +// restoreForfeitedVTXO rolls a forfeited VTXO back to a spendable state when +// the batch that consumed it via forfeit has been invalidated. It is wired as +// the batch-canonicality manager's RestoreConsumedVTXO callback. The VTXO +// manager ref is resolved lazily from s.vtxoMgrRef: this callback only fires on +// a chain event, long after the VTXO manager registers (step 10), so the ref is +// always populated by the time it runs. +func (s *Server) restoreForfeitedVTXO(ctx context.Context, + outpoint wire.OutPoint) error { + + if !s.vtxoMgrRef.IsSome() { + return fmt.Errorf("vtxo manager not ready to restore "+ + "forfeited vtxo %s", outpoint) + } + + // Detach from the canonicality manager's turn context (this runs on its + // actor goroutine) and bound the ask so a slow VTXO manager cannot + // stall canonicality processing indefinitely. + askCtx, cancel := context.WithTimeout( + context.WithoutCancel(ctx), restoreForfeitedVTXOTimeout, + ) + defer cancel() + + _, err := s.vtxoMgrRef.UnsafeFromSome().Ask( + askCtx, &vtxo.RestoreForfeitedVTXORequest{Outpoint: outpoint}, + ).Await(askCtx).Unpack() + + return err +} + func (s *Server) initVTXOManager(ctx context.Context, chainSourceRef actor.ActorRef[ chainsource.ChainSourceMsg, chainsource.ChainSourceResp, @@ -4156,6 +4322,7 @@ func (s *Server) initVTXOManager(ctx context.Context, RoundActor: roundActor, LedgerSink: fn.Some(ledgerSink), ChainResolver: chainResolver, + BatchCanonicality: s.batchCanonStore, RefreshFeeQuoter: s.autoRefreshFeeQuoter(), FetchOperatorKey: s.fetchCurrentOperatorPubKey, ForfeitParticipantSigner: s.forfeitSignatures.sign, @@ -4366,19 +4533,20 @@ func (s *Server) initOORActor(ctx context.Context, s.oorSessionStore = registryStore s.oorRegistry, err = oor.NewOORRegistryActor(oor.OORRegistryConfig{ - Log: fn.Some(s.subLogger(oor.Subsystem)), - Signer: oorSigner, - IncomingHandler: outboxHandler, - RegistryStore: registryStore, - DeliveryStore: s.deliveryStore, - ServerConn: s.runtime.TellRef(), - VTXOManager: vtxoManagerRef, - SpendCompleter: s.oorCompleteSpend, - SpendReleaser: s.oorReleaseSpend, - ReservationStore: reservationStore, - PackageStore: packageStore, - VTXOStore: vtxoStore, - LedgerSink: fn.Some(ledger.NewSink(s.actorSystem)), + Log: fn.Some(s.subLogger(oor.Subsystem)), + Signer: oorSigner, + IncomingHandler: outboxHandler, + RegistryStore: registryStore, + DeliveryStore: s.deliveryStore, + ServerConn: s.runtime.TellRef(), + VTXOManager: vtxoManagerRef, + SpendCompleter: s.oorCompleteSpend, + SpendReleaser: s.oorReleaseSpend, + ReservationStore: reservationStore, + PackageStore: packageStore, + VTXOStore: vtxoStore, + BatchCanonicality: s.batchCanonRef, + LedgerSink: fn.Some(ledger.NewSink(s.actorSystem)), IncomingVTXOObserver: func(ctx context.Context, descs []*vtxo.Descriptor) error { @@ -5325,12 +5493,13 @@ func (s *Server) initUnrollSubsystem(ctx context.Context, Store: &unroll.DBRegistryStore{ UEStore: ueStore, }, - DeliveryStore: s.deliveryStore, - ProofAssembler: proofAssembler, - VTXOStore: vtxoStore, - TxConfirmRef: unrollTxConfirmRef, - ChainSource: chainSourceRef, - Wallet: unrollWallet, + DeliveryStore: s.deliveryStore, + ProofAssembler: proofAssembler, + VTXOStore: vtxoStore, + TxConfirmRef: unrollTxConfirmRef, + ChainSource: chainSourceRef, + Wallet: unrollWallet, + BatchCanonicality: s.batchCanonStore, LedgerSink: fn.Some( ledger.NewSink(s.actorSystem), ),