diff --git a/batchcanon/manager.go b/batchcanon/manager.go index 281d30e25..e605e3441 100644 --- a/batchcanon/manager.go +++ b/batchcanon/manager.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" @@ -78,6 +79,16 @@ type ManagerConfig struct { chainsource.ChainSourceMsg, chainsource.ChainSourceResp, ] + // RestoreConsumedVTXO, when set, is invoked for each VTXO outpoint that + // was provisionally forfeited into a batch that has now been + // invalidated (a finalized conflict reversed its forfeit). The daemon + // wires this to the VTXO manager to roll the VTXO back to a spendable + // state. Nil leaves the reverse-dependency edges in place without + // acting -- the default until the FSM restore path is wired -- so the + // data model stays consistent and a later run can still act on the + // persisted edges. + RestoreConsumedVTXO func(ctx context.Context, vtxo wire.OutPoint) error + // Log is an optional logger. Log fn.Option[btclog.Logger] } @@ -88,11 +99,11 @@ type ManagerConfig struct { // persists the result. It is an actor behavior: chainsource events arrive as // internal messages re-wrapped onto the manager's own mailbox. // -// Light-client backends (neutrino, Esplora) filter spend notifications by the -// prevout pkScript, which the manager does not yet carry per consumed input; -// spend watches are registered by outpoint, which is sufficient for the -// full-node (LND) path. Threading per-input pkScripts is a follow-up for when -// the producers (round, OOR) — which hold those scripts — wire in. +// Spend watches on consumed inputs are registered with the prevout pkScript +// carried on each RegisterBatchRequest.ConsumedInput, which lnd's spend +// notifier requires (it filters by output script). An input with no pkScript +// is skipped rather than failing the whole batch registration, so the +// confirmation watch and the remaining inputs still arm. type Manager struct { cfg ManagerConfig log btclog.Logger @@ -197,6 +208,22 @@ func (m *Manager) handleRegisterBatch(ctx context.Context, ) } + // Record a reverse-dependency edge for every VTXO this batch forfeits, + // so the VTXO can be restored if this batch is later invalidated. The + // edges are persisted and idempotent on (consumedVTXO, consumerBatch). + for _, forfeited := range req.ForfeitedVTXOs { + err := m.cfg.Store.AddProvisionalConsumer( + ctx, forfeited, req.BatchTxID, + ) + if err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("record provisional consumer %s "+ + "-> %s: %w", forfeited, req.BatchTxID, + err), + ) + } + } + w := &batchWatch{ txid: req.BatchTxID, pkScript: req.ConfirmationPkScript, @@ -204,7 +231,7 @@ func (m *Manager) handleRegisterBatch(ctx context.Context, inputs: make(map[wire.OutPoint]*inputWatch), } for _, in := range req.ConsumedInputs { - w.inputs[in] = &inputWatch{} + w.inputs[in.Outpoint] = &inputWatch{} } // Record the watch only AFTER arming succeeds. If we recorded it first @@ -255,7 +282,7 @@ func (m *Manager) mergeDependents(ctx context.Context, w *batchWatch, // armWatches registers the reorg-aware confirmation watch on the batch tx and // a reorg-aware spend watch on each consumed input. func (m *Manager) armWatches(ctx context.Context, w *batchWatch, - inputs []wire.OutPoint) error { + inputs []ConsumedInput) error { heightHint := m.bestHeightHint(ctx) @@ -309,9 +336,27 @@ func (m *Manager) armWatches(ctx context.Context, w *batchWatch, } for i := range inputs { - op := inputs[i] + // A consumed input with no pkScript cannot be watched: lnd's + // spend notifier filters by output script. Rather than fail the + // whole batch registration (which would also drop the working + // confirmation watch), skip just this input's spend watch. + // Conf- based reorg tracking still works; only conflict + // detection on this one input is degraded. Empty scripts should + // only occur on legacy/backfilled inputs that predate script + // tracking. + if len(inputs[i].PkScript) == 0 { + m.logger(ctx).InfoS(ctx, "Skipping spend watch for "+ + "consumed input with no pkScript", + slog.String("batch", w.txid.String()), + slog.String( + "outpoint", inputs[i].Outpoint.String(), + )) + + continue + } + if err := m.armSpendWatch( - ctx, w.txid, op, heightHint, + ctx, w.txid, inputs[i], heightHint, ); err != nil { return err } @@ -320,13 +365,18 @@ func (m *Manager) armWatches(ctx context.Context, w *batchWatch, return nil } -// armSpendWatch registers one reorg-aware spend watch on a consumed input. +// armSpendWatch registers one reorg-aware spend watch on a consumed input. The +// input's pkScript is forwarded to the spend notifier, which filters by output +// script; without it lnd rejects the registration ("an output script must be +// provided") and the conflict-detection watch never arms. func (m *Manager) armSpendWatch(ctx context.Context, txid chainhash.Hash, - op wire.OutPoint, heightHint uint32) error { + in ConsumedInput, heightHint uint32) error { + op := in.Outpoint spendReq := &chainsource.RegisterSpendRequest{ CallerID: spendCallerID(txid, op), Outpoint: &op, + PkScript: in.PkScript, HeightHint: heightHint, NotifyActor: fn.Some( chainsource.MapSpendEvent( @@ -586,6 +636,94 @@ func (m *Manager) deriveAndPersist(ctx context.Context, w *batchWatch) { return } w.persisted = next + + m.handleConsumerLifecycle(ctx, w.txid, next) +} + +// handleConsumerLifecycle reacts to a batch's canonicality transition for the +// VTXOs it provisionally forfeits (its reverse-dependency edges): +// +// - StateConflictFinalized: the batch is permanently off the canonical chain +// (a conflicting spend matured past the reorg-safety depth), so its forfeit +// is reversed -- restore every consumed VTXO, then drop the edges. +// - StateFinalized: the batch is itself canonical and final, so the forfeit +// is now safe and the restore window closes -- drop the edges without +// restoring. +// +// Transient states (provisional / reorged-out / conflict-provisional) are left +// untouched: a reorged-out batch may still reconfirm, so its forfeit must not +// be reversed until the invalidation is final. +func (m *Manager) handleConsumerLifecycle(ctx context.Context, + txid chainhash.Hash, next State) { + + switch next { + case StateConflictFinalized: + m.restoreProvisionalConsumers(ctx, txid) + + case StateFinalized: + err := m.cfg.Store.DeleteProvisionalConsumersForBatch(ctx, txid) + if err != nil { + m.logger(ctx).WarnS(ctx, "Failed to clear provisional "+ + "consumer edges for finalized batch", err, + slog.String("batch", txid.String())) + } + + case StateUnseen, StateProvisional, StateReorgedOut, + StateConflictProvisional: + + // Transient/non-final states: the forfeit's fate is not yet + // decided, so the reverse-dependency edges are left untouched. + // A reorged-out batch may still reconfirm, so its forfeit must + // not be reversed until the invalidation (or finalization) is + // final. + } +} + +// restoreProvisionalConsumers restores every VTXO the given (now invalidated) +// batch provisionally forfeited, then drops the edges so the restore fires at +// most once. A nil RestoreConsumedVTXO callback leaves the edges in place (the +// data model stays consistent for a later wired run). +func (m *Manager) restoreProvisionalConsumers(ctx context.Context, + txid chainhash.Hash) { + + consumed, err := m.cfg.Store.ListProvisionalConsumersForBatch(ctx, txid) + if err != nil { + m.logger(ctx).WarnS(ctx, "Failed to list provisional "+ + "consumers for invalidated batch", err, + slog.String("batch", txid.String())) + + return + } + if len(consumed) == 0 { + return + } + + if m.cfg.RestoreConsumedVTXO == nil { + + // No restore path wired: leave the edges so a future run with a + // callback can still act on them. + return + } + + for _, op := range consumed { + if err := m.cfg.RestoreConsumedVTXO(ctx, op); err != nil { + m.logger(ctx).WarnS(ctx, "Failed to restore forfeited "+ + "VTXO after batch invalidation", err, + slog.String("batch", txid.String()), + slog.String("vtxo", op.String())) + + // Keep the edges so the restore can be retried; do not + // drop them on partial failure. + return + } + } + + err = m.cfg.Store.DeleteProvisionalConsumersForBatch(ctx, txid) + if err != nil { + m.logger(ctx).WarnS(ctx, "Failed to clear provisional "+ + "consumer edges after restore", err, + slog.String("batch", txid.String())) + } } // releaseSpendWatches unregisters the per-input spend watches for a batch, @@ -667,7 +805,7 @@ func (m *Manager) reconcileOne(ctx context.Context, record *Record) { } for _, in := range record.ConsumedInputs { - w.inputs[in] = &inputWatch{} + w.inputs[in.Outpoint] = &inputWatch{} } m.watches[record.BatchTxID] = w diff --git a/batchcanon/manager_conflict_shared_test.go b/batchcanon/manager_conflict_shared_test.go index 2a2828635..90fa19aef 100644 --- a/batchcanon/manager_conflict_shared_test.go +++ b/batchcanon/manager_conflict_shared_test.go @@ -3,7 +3,6 @@ package batchcanon import ( "testing" - "github.com/btcsuite/btcd/wire/v2" "github.com/stretchr/testify/require" ) @@ -24,12 +23,12 @@ func TestManagerSharedInputSpendClassifiesPerBatch(t *testing.T) { h.registerBatch(t, &RegisterBatchRequest{ BatchTxID: txA, ConfirmationPkScript: []byte{0x51, 0x20, 0x01}, - ConsumedInputs: []wire.OutPoint{shared}, + ConsumedInputs: []ConsumedInput{ci(shared)}, }) h.registerBatch(t, &RegisterBatchRequest{ BatchTxID: txB, ConfirmationPkScript: []byte{0x51, 0x20, 0x02}, - ConsumedInputs: []wire.OutPoint{shared}, + ConsumedInputs: []ConsumedInput{ci(shared)}, }) // Batch A wins the input and confirms; B never confirms. diff --git a/batchcanon/manager_provisional_consumer_test.go b/batchcanon/manager_provisional_consumer_test.go new file mode 100644 index 000000000..17a9bde01 --- /dev/null +++ b/batchcanon/manager_provisional_consumer_test.go @@ -0,0 +1,132 @@ +package batchcanon + +import ( + "context" + "sync" + "testing" + + "github.com/btcsuite/btcd/wire/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", + ) +} diff --git a/batchcanon/manager_test.go b/batchcanon/manager_test.go index 0b5c1b819..e046f1cee 100644 --- a/batchcanon/manager_test.go +++ b/batchcanon/manager_test.go @@ -34,9 +34,16 @@ func newFakeStore() *fakeStore { } } +// ci builds a ConsumedInput from an outpoint with a placeholder non-empty +// pkScript. The mock chainsource ignores the script's value, but it must be +// non-empty so the manager arms the spend watch rather than skipping it. +func ci(op wire.OutPoint) ConsumedInput { + return ConsumedInput{Outpoint: op, PkScript: []byte{0x51}} +} + func cloneRecord(r *Record) *Record { cp := *r - cp.ConsumedInputs = append([]wire.OutPoint(nil), r.ConsumedInputs...) + cp.ConsumedInputs = append([]ConsumedInput(nil), r.ConsumedInputs...) cp.DependentVTXOs = append([]wire.OutPoint(nil), r.DependentVTXOs...) cp.ConfirmationPkScript = append( []byte(nil), r.ConfirmationPkScript..., @@ -127,7 +134,7 @@ func (s *fakeStore) FindBatchesConsumingOutpoint(_ context.Context, var out []chainhash.Hash for txid, r := range s.records { for _, in := range r.ConsumedInputs { - if in == op { + if in.Outpoint == op { out = append(out, txid) } } @@ -339,6 +346,16 @@ type managerHarness struct { } func newManagerHarness(t *testing.T, bestHeight int32) *managerHarness { + return newManagerHarnessWithRestore(t, bestHeight, nil) +} + +// newManagerHarnessWithRestore is newManagerHarness with a RestoreConsumedVTXO +// callback wired into the manager config, for the reverse-dependency +// (provisional-forfeit restore) tests. +func newManagerHarnessWithRestore(t *testing.T, bestHeight int32, + restore func(ctx context.Context, vtxo wire.OutPoint) error, +) *managerHarness { + t.Helper() mock := newMockChainSource(bestHeight) @@ -354,8 +371,9 @@ func newManagerHarness(t *testing.T, bestHeight int32) *managerHarness { store := newFakeStore() mgr := NewManager(ManagerConfig{ - Store: store, - ChainSource: mockActor.Ref(), + Store: store, + ChainSource: mockActor.Ref(), + RestoreConsumedVTXO: restore, }) mgrActor := actor.NewActor(actor.ActorConfig[ManagerMsg, ManagerResp]{ ID: "batch-canonicality", @@ -588,7 +606,7 @@ func TestManagerInputConflict(t *testing.T) { h.registerBatch(t, &RegisterBatchRequest{ BatchTxID: txid, CSVExpiryDelta: 50, - ConsumedInputs: []wire.OutPoint{input}, + ConsumedInputs: []ConsumedInput{ci(input)}, }) h.fireConfirmed(t, txid, 101, testBatchTxid(0x33)) @@ -621,7 +639,7 @@ func TestManagerConflictClearsOnSpendReorg(t *testing.T) { h.registerBatch(t, &RegisterBatchRequest{ BatchTxID: txid, CSVExpiryDelta: 50, - ConsumedInputs: []wire.OutPoint{input}, + ConsumedInputs: []ConsumedInput{ci(input)}, }) h.fireConfirmed(t, txid, 101, testBatchTxid(0x43)) @@ -648,7 +666,7 @@ func TestManagerBatchSelfSpendNotConflict(t *testing.T) { h.registerBatch(t, &RegisterBatchRequest{ BatchTxID: txid, CSVExpiryDelta: 50, - ConsumedInputs: []wire.OutPoint{input}, + ConsumedInputs: []ConsumedInput{ci(input)}, }) h.fireConfirmed(t, txid, 101, testBatchTxid(0x53)) @@ -673,7 +691,7 @@ func TestManagerConflictDominatesReorg(t *testing.T) { h.registerBatch(t, &RegisterBatchRequest{ BatchTxID: txid, CSVExpiryDelta: 50, - ConsumedInputs: []wire.OutPoint{input}, + ConsumedInputs: []ConsumedInput{ci(input)}, }) h.fireConfirmed(t, txid, 101, testBatchTxid(0x63)) h.fireConfReorged(t, txid) @@ -699,7 +717,7 @@ func TestManagerFinalizeReleasesSpendWatches(t *testing.T) { h.registerBatch(t, &RegisterBatchRequest{ BatchTxID: txid, CSVExpiryDelta: 50, - ConsumedInputs: []wire.OutPoint{input}, + ConsumedInputs: []ConsumedInput{ci(input)}, }) h.fireConfirmed(t, txid, 101, testBatchTxid(0x73)) h.fireConfDone(t, txid) @@ -762,7 +780,7 @@ func TestManagerReconcileReArmsWatches(t *testing.T) { State: StateProvisional, ConfirmationHeight: fn.Some[int32](90), CSVExpiryDelta: 50, - ConsumedInputs: []wire.OutPoint{input}, + ConsumedInputs: []ConsumedInput{ci(input)}, }, ), ) diff --git a/batchcanon/messages.go b/batchcanon/messages.go index 91213e86c..ffb645762 100644 --- a/batchcanon/messages.go +++ b/batchcanon/messages.go @@ -41,12 +41,23 @@ type RegisterBatchRequest struct { // CSVExpiryDelta is the batch's CSV-relative expiry timeout in blocks. CSVExpiryDelta int32 - // ConsumedInputs are the outpoints the batch tx spends. Each gets a - // reorg-aware spend watch so a conflicting double-spend is detected. - ConsumedInputs []wire.OutPoint + // ConsumedInputs are the inputs the batch tx spends, each carrying the + // pkScript of the spent output. Each gets a reorg-aware spend watch so + // a conflicting double-spend is detected; the pkScript is required to + // register that watch (lnd's spend notifier filters by output script). + ConsumedInputs []ConsumedInput // DependentVTXOs are the VTXO outpoints anchored by this batch. DependentVTXOs []wire.OutPoint + + // ForfeitedVTXOs are VTXOs from prior batches that this batch consumes + // via forfeit. The manager records a reverse-dependency edge for each + // so the VTXO is restored if this batch is later invalidated (its + // forfeit reversed by a finalized conflict). This is distinct from + // ConsumedInputs, which are the batch tx's own inputs watched for a + // conflicting spend; a forfeited VTXO is a tree leaf, not necessarily a + // direct input of this batch tx. + ForfeitedVTXOs []wire.OutPoint } // MessageType returns the message type identifier. diff --git a/batchcanon/record.go b/batchcanon/record.go index bd891eeb3..c63697c12 100644 --- a/batchcanon/record.go +++ b/batchcanon/record.go @@ -49,15 +49,32 @@ type Record struct { // PolicyState. PolicyState PolicyState - // ConsumedInputs are the outpoints this batch tx spends. They are - // tracked so the manager can watch each one for a conflicting spend. - ConsumedInputs []wire.OutPoint + // ConsumedInputs are the inputs this batch tx spends. They are tracked + // so the manager can watch each one for a conflicting spend. + ConsumedInputs []ConsumedInput // DependentVTXOs are the VTXO outpoints anchored by this batch. Their // derived availability follows this batch's canonicality. DependentVTXOs []wire.OutPoint } +// ConsumedInput is one input a batch (commitment) tx spends, paired with the +// pkScript of the output being spent. The pkScript is required to register the +// reorg-aware spend watch: lnd's spend notifier filters by the output's script, +// so a bare outpoint is rejected ("an output script must be provided"). It is +// persisted alongside the outpoint so the watch can be re-armed after a +// restart. +type ConsumedInput struct { + // Outpoint is the spent output. + Outpoint wire.OutPoint + + // PkScript is the scriptPubKey of the spent output, used to register + // the spend watch. May be empty only for legacy/backfilled rows that + // predate script tracking; such inputs cannot be watched on + // light-client backends. + PkScript []byte +} + // EffectiveExpiry derives the absolute expiry height from the current // confirmation observation: ConfirmationHeight + CSVExpiryDelta. It returns // None when the batch is not currently confirmed. diff --git a/chainsource/finality.go b/chainsource/finality.go index 5bdc97ac6..2b8561b1f 100644 --- a/chainsource/finality.go +++ b/chainsource/finality.go @@ -21,16 +21,6 @@ var finalityBlockSubscriptionBackoffs = []time.Duration{ 2 * time.Second, } -// finalityBlockSubscriptionAttemptTimeout bounds each individual -// RegisterBlocks attempt. Without it a single hung RegisterBlocks call -// (e.g. a wedged lndclient gRPC stream) would block the conf/spend -// monitoring goroutine indefinitely — stalling Confirmed/Reorged/Done -// delivery on that watch — since the retry schedule only bounds the gaps -// between attempts, not the attempts themselves. 10s mirrors the per-call -// registration timeout used in conf_actor.go's handleRegisterConf so the -// whole file behaves consistently under a slow backend. -const finalityBlockSubscriptionAttemptTimeout = 10 * time.Second - // registerBlocksForFinality registers a block-epoch subscription used // to synthesize a Done signal at FinalityDepth past an observed // confirmation or spend. The call is retried with a short bounded @@ -39,11 +29,22 @@ const finalityBlockSubscriptionAttemptTimeout = 10 * time.Second // lndclient over gRPC); a one-shot RegisterBlocks attempt that // briefly hiccups would leak the per-watch sub-actor indefinitely. // -// The retries run in the calling sub-actor's monitoring goroutine, so -// brief blocking here is safe: more confirmation/spend events on this -// specific watch are not expected during the retry window (we already -// consumed the one that triggered the arm), and ctx cancellation -// breaks out promptly. +// The retries run in a dedicated arming goroutine (not the sub-actor's +// select loop), so brief blocking here is safe: more confirmation/spend +// events on this specific watch are not expected during the retry window +// (we already consumed the one that triggered the arm), and ctx +// cancellation breaks out promptly. +// +// The passed ctx MUST be the sub-actor's long-lived context, and it is +// handed to RegisterBlocks unwrapped: for in-process backends the +// block-epoch forwarder goroutine is tied to the ctx it receives, so +// bounding each attempt with a cancellable child ctx (and cancelling it +// once the call returns) would tear the subscription down the instant it +// was armed — starving finality synthesis of the very epochs it needs. +// A hung RegisterBlocks can therefore stall this arming goroutine, but +// that is contained: it is off the select loop (fix moved arming there +// precisely so a slow backend cannot wedge Confirmed/Reorged/Done +// delivery), and a genuinely wedged backend is a lost watch regardless. // // Returns the registration on success, or a non-nil error after // retries are exhausted. Callers should log the error at warn level @@ -54,14 +55,7 @@ func registerBlocksForFinality(ctx context.Context, backend ChainBackend, var lastErr error for attempt, backoff := range finalityBlockSubscriptionBackoffs { - // Bound each attempt so a hung RegisterBlocks cannot wedge the - // monitoring goroutine; the retry schedule only bounds the gaps - // between attempts, not a single stuck call. - attemptCtx, cancel := context.WithTimeout( - ctx, finalityBlockSubscriptionAttemptTimeout, - ) - reg, err := backend.RegisterBlocks(attemptCtx) - cancel() + reg, err := backend.RegisterBlocks(ctx) if err == nil { return reg, nil } diff --git a/cmd/darepod/main.go b/cmd/darepod/main.go index 04d386319..7054a11b3 100644 --- a/cmd/darepod/main.go +++ b/cmd/darepod/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) // EagerRoundJoin makes the wallet actor drive round-joining // without a follow-up Board / LeaveVTXOs RPC. The default is @@ -344,6 +337,32 @@ func registerArkServerFlags(f *pflag.FlagSet, cfg *darepod.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 *darepod.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 *darepod.Config) { f.String( diff --git a/darepod/AGENTS.md b/darepod/AGENTS.md index 57f51ff42..4f6b88bb9 100644 --- a/darepod/AGENTS.md +++ b/darepod/AGENTS.md @@ -202,6 +202,17 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/darep hands it to `lazyChainResolver.Set`. - `unrollMaxFeeRate` — `cfg.Unroll.MaxFeeRateSatPerVByte` if positive, else zero (each downstream uses its own default). +- `initBatchCanonicality` — wires the reorg-safety gate live during + `startWalletDependentActors` (step 9b, AFTER the wallet but BEFORE the + VTXO manager). Builds the durable `db.BatchCanonicalityStore`, + backfills records from existing VTXOs anchored to the current best + height (via `batchCanonBestHeight` → chainsource `BestHeightRequest`), + registers + `SetSelfRef`s the `batchcanon.Manager` under + `"batch-canonicality"`, and calls `Reconcile` so a reorg that landed + while the daemon was down is re-detected on boot. Stashes the store on + `s.batchCanonStore` (fed to the VTXO + unroll gates) and the manager + ref on `s.batchCanonRef` (fed to the round + OOR batch-registration + paths). Uses `s.clk`, never `clock.NewDefaultClock()`. ### Test Hooks (NOT for production) @@ -324,6 +335,15 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/darep `initUnrollSubsystem` later calls `lazyChainResolver.Set(...)`. Any code that also needs this seam must run AFTER `initUnrollSubsystem` or it will see an unset target. +- **Batch-canonicality ordering**: `initBatchCanonicality` runs in + `startWalletDependentActors` AFTER the wallet but BEFORE the VTXO + manager, so `s.batchCanonStore` / `s.batchCanonRef` are populated when + the VTXO, round, unroll, and OOR configs are assembled. All four gate + fields default to nil/`None` (a permissive no-op) when unset, so the + reorg-safety gate is dormant unless this wiring runs — which it always + does in the daemon, but tests that build those configs directly can + leave it off. Manager registration needs the chain source ref, so this + must stay after the chain source is registered. - `initUnrollSubsystem` creates its own `dbStore` + `vtxoStore` to decouple the unroll store lifecycle from the VTXO manager's; the persisted `s.ueStore` is reused by the `GetUnrollStatus` fallback diff --git a/darepod/CLAUDE.md b/darepod/CLAUDE.md index 57f51ff42..4f6b88bb9 100644 --- a/darepod/CLAUDE.md +++ b/darepod/CLAUDE.md @@ -202,6 +202,17 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/darep hands it to `lazyChainResolver.Set`. - `unrollMaxFeeRate` — `cfg.Unroll.MaxFeeRateSatPerVByte` if positive, else zero (each downstream uses its own default). +- `initBatchCanonicality` — wires the reorg-safety gate live during + `startWalletDependentActors` (step 9b, AFTER the wallet but BEFORE the + VTXO manager). Builds the durable `db.BatchCanonicalityStore`, + backfills records from existing VTXOs anchored to the current best + height (via `batchCanonBestHeight` → chainsource `BestHeightRequest`), + registers + `SetSelfRef`s the `batchcanon.Manager` under + `"batch-canonicality"`, and calls `Reconcile` so a reorg that landed + while the daemon was down is re-detected on boot. Stashes the store on + `s.batchCanonStore` (fed to the VTXO + unroll gates) and the manager + ref on `s.batchCanonRef` (fed to the round + OOR batch-registration + paths). Uses `s.clk`, never `clock.NewDefaultClock()`. ### Test Hooks (NOT for production) @@ -324,6 +335,15 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/darep `initUnrollSubsystem` later calls `lazyChainResolver.Set(...)`. Any code that also needs this seam must run AFTER `initUnrollSubsystem` or it will see an unset target. +- **Batch-canonicality ordering**: `initBatchCanonicality` runs in + `startWalletDependentActors` AFTER the wallet but BEFORE the VTXO + manager, so `s.batchCanonStore` / `s.batchCanonRef` are populated when + the VTXO, round, unroll, and OOR configs are assembled. All four gate + fields default to nil/`None` (a permissive no-op) when unset, so the + reorg-safety gate is dormant unless this wiring runs — which it always + does in the daemon, but tests that build those configs directly can + leave it off. Manager registration needs the chain source ref, so this + must stay after the chain source is registered. - `initUnrollSubsystem` creates its own `dbStore` + `vtxoStore` to decouple the unroll store lifecycle from the VTXO manager's; the persisted `s.ueStore` is reused by the `GetUnrollStatus` fallback diff --git a/darepod/config.go b/darepod/config.go index 1304759eb..577e53d9c 100644 --- a/darepod/config.go +++ b/darepod/config.go @@ -106,6 +106,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" @@ -272,6 +290,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"` @@ -1247,6 +1276,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/darepod/config_reorg_safety_depth_test.go b/darepod/config_reorg_safety_depth_test.go new file mode 100644 index 000000000..5f4f4d164 --- /dev/null +++ b/darepod/config_reorg_safety_depth_test.go @@ -0,0 +1,73 @@ +package darepod + +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/darepod/server.go b/darepod/server.go index c7f5a7e2b..5a7ed0ba2 100644 --- a/darepod/server.go +++ b/darepod/server.go @@ -28,6 +28,7 @@ import ( "github.com/btcsuite/btcwallet/wtxmgr" "github.com/lightninglabs/darepo-client/arkrpc" "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/batchcanon" "github.com/lightninglabs/darepo-client/btcwbackend" "github.com/lightninglabs/darepo-client/build" "github.com/lightninglabs/darepo-client/chainbackends" @@ -391,6 +392,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 @@ -2085,13 +2099,16 @@ func (s *Server) registerChainSourceActor( chainsource.ChainSourceConfig{ Backend: s.chainBackend, System: s.actorSystem, - // Enable height-based Done synthesis at the default - // safety depth. Without this, conf/spend sub-actors - // driven through lndclient (whose Done channel is - // allocated-but-never-written) would never receive a - // finality signal, and reorg-aware consumers like - // txconfirm would leak watch state forever. - FinalityDepth: chainsource.DefaultFinalityDepth, + // Enable height-based Done synthesis at the + // operator-configured reorg-safety depth (network-aware + // default; deeper on testnet). Without this, conf/spend + // sub-actors driven through lndclient (whose Done + // channel is allocated-but-never-written) would never + // receive a finality signal, and reorg-aware consumers + // like txconfirm and the batch-canonicality manager + // would leak watch state forever. The depth also bounds + // the deepest reorg those consumers detect. + FinalityDepth: s.cfg.ResolveReorgSafetyDepth(), }, ) @@ -2296,6 +2313,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 @@ -4047,6 +4076,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, @@ -4096,6 +4126,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, @@ -4141,6 +4300,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, @@ -4345,19 +4505,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 { @@ -5303,12 +5464,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), ), diff --git a/db/batch_canonicality_store.go b/db/batch_canonicality_store.go index 94819cc6c..852f7692e 100644 --- a/db/batch_canonicality_store.go +++ b/db/batch_canonicality_store.go @@ -141,9 +141,10 @@ func (s *BatchCanonicalityPersistenceStore) UpsertBatch(ctx context.Context, for _, in := range record.ConsumedInputs { err := q.InsertBatchConsumedInput( ctx, sqlc.InsertBatchConsumedInputParams{ - BatchTxid: txid[:], - InputHash: in.Hash[:], - InputIndex: int32(in.Index), + BatchTxid: txid[:], + InputHash: in.Outpoint.Hash[:], + InputIndex: int32(in.Outpoint.Index), + InputPkScript: in.PkScript, }, ) if err != nil { @@ -561,15 +562,18 @@ func (s *BatchCanonicalityPersistenceStore) hydrateRecord(ctx context.Context, if err != nil { return nil, err } - inputs := make([]wire.OutPoint, 0, len(inputRows)) + inputs := make([]batchcanon.ConsumedInput, 0, len(inputRows)) for _, in := range inputRows { hash, err := chainhash.NewHash(in.InputHash) if err != nil { return nil, err } - inputs = append(inputs, wire.OutPoint{ - Hash: *hash, - Index: uint32(in.InputIndex), + inputs = append(inputs, batchcanon.ConsumedInput{ + Outpoint: wire.OutPoint{ + Hash: *hash, + Index: uint32(in.InputIndex), + }, + PkScript: in.InputPkScript, }) } diff --git a/db/batch_canonicality_store_test.go b/db/batch_canonicality_store_test.go index a128cd9e1..a5b5a5f55 100644 --- a/db/batch_canonicality_store_test.go +++ b/db/batch_canonicality_store_test.go @@ -42,6 +42,20 @@ func outpoint(b byte, index uint32) wire.OutPoint { return wire.OutPoint{Hash: chainhash.Hash{b}, Index: index} } +// consumedInput builds a batchcanon.ConsumedInput from an outpoint with a +// deterministic non-empty pkScript so persistence round-trips can assert the +// script is stored and reloaded alongside the outpoint. +func consumedInput(op wire.OutPoint) batchcanon.ConsumedInput { + return batchcanon.ConsumedInput{ + Outpoint: op, + PkScript: []byte{ + 0x51, + 0x20, + op.Hash[0], + }, + } +} + // TestBatchCanonicalityUpsertRoundTrip verifies a record survives an upsert // and read with all of its fields, consumed inputs, and dependent VTXOs. func TestBatchCanonicalityUpsertRoundTrip(t *testing.T) { @@ -58,8 +72,9 @@ func TestBatchCanonicalityUpsertRoundTrip(t *testing.T) { ConfirmationBlock: fn.Some(chainhash.Hash{0xbb}), CSVExpiryDelta: 144, PolicyState: batchcanon.PolicyStateDefault, - ConsumedInputs: []wire.OutPoint{ - outpoint(0x01, 0), outpoint(0x02, 3), + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(outpoint(0x01, 0)), + consumedInput(outpoint(0x02, 3)), }, DependentVTXOs: []wire.OutPoint{ outpoint(0x03, 1), @@ -109,8 +124,8 @@ func TestBatchCanonicalityUpsertReplacesEdges(t *testing.T) { BatchTxID: txid, State: batchcanon.StateUnseen, CSVExpiryDelta: 10, - ConsumedInputs: []wire.OutPoint{ - outpoint(0x01, 0), + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(outpoint(0x01, 0)), }, DependentVTXOs: []wire.OutPoint{ outpoint(0x02, 0), @@ -127,8 +142,8 @@ func TestBatchCanonicalityUpsertReplacesEdges(t *testing.T) { BatchTxID: txid, State: batchcanon.StateProvisional, CSVExpiryDelta: 10, - ConsumedInputs: []wire.OutPoint{ - outpoint(0x09, 2), + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(outpoint(0x09, 2)), }, DependentVTXOs: nil, }, @@ -137,7 +152,10 @@ func TestBatchCanonicalityUpsertReplacesEdges(t *testing.T) { got, err := store.GetBatch(ctx, txid) require.NoError(t, err) - require.Equal(t, []wire.OutPoint{outpoint(0x09, 2)}, got.ConsumedInputs) + require.Equal( + t, []batchcanon.ConsumedInput{consumedInput(outpoint(0x09, 2))}, + got.ConsumedInputs, + ) require.Empty(t, got.DependentVTXOs) } @@ -297,7 +315,9 @@ func TestBatchCanonicalityFindByConsumedOutpoint(t *testing.T) { BatchTxID: batchA, State: batchcanon.StateProvisional, CSVExpiryDelta: 1, - ConsumedInputs: []wire.OutPoint{shared}, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(shared), + }, }, ), ) @@ -308,7 +328,9 @@ func TestBatchCanonicalityFindByConsumedOutpoint(t *testing.T) { BatchTxID: batchB, State: batchcanon.StateProvisional, CSVExpiryDelta: 1, - ConsumedInputs: []wire.OutPoint{shared}, + ConsumedInputs: []batchcanon.ConsumedInput{ + consumedInput(shared), + }, }, ), ) diff --git a/db/sqlc/batch_canonicality.sql.go b/db/sqlc/batch_canonicality.sql.go index 7729a8049..9ceb9b4bc 100644 --- a/db/sqlc/batch_canonicality.sql.go +++ b/db/sqlc/batch_canonicality.sql.go @@ -125,20 +125,29 @@ func (q *Queries) GetBatchCanonicality(ctx context.Context, batchTxid []byte) (B } const InsertBatchConsumedInput = `-- name: InsertBatchConsumedInput :exec -INSERT INTO batch_consumed_inputs (batch_txid, input_hash, input_index) -VALUES ($1, $2, $3) +INSERT INTO batch_consumed_inputs ( + batch_txid, input_hash, input_index, input_pk_script +) +VALUES ($1, $2, $3, $4) ON CONFLICT (batch_txid, input_hash, input_index) DO NOTHING ` type InsertBatchConsumedInputParams struct { - BatchTxid []byte - InputHash []byte - InputIndex int32 + BatchTxid []byte + InputHash []byte + InputIndex int32 + InputPkScript []byte } -// InsertBatchConsumedInput records one outpoint consumed by a batch. +// InsertBatchConsumedInput records one input consumed by a batch, together +// with the pkScript of the spent output (needed to register the spend watch). func (q *Queries) InsertBatchConsumedInput(ctx context.Context, arg InsertBatchConsumedInputParams) error { - _, err := q.db.ExecContext(ctx, InsertBatchConsumedInput, arg.BatchTxid, arg.InputHash, arg.InputIndex) + _, err := q.db.ExecContext(ctx, InsertBatchConsumedInput, + arg.BatchTxid, + arg.InputHash, + arg.InputIndex, + arg.InputPkScript, + ) return err } @@ -233,17 +242,19 @@ func (q *Queries) ListBatchCanonicalityByState(ctx context.Context, state int32) } const ListBatchConsumedInputs = `-- name: ListBatchConsumedInputs :many -SELECT input_hash, input_index +SELECT input_hash, input_index, input_pk_script FROM batch_consumed_inputs WHERE batch_txid = $1 ` type ListBatchConsumedInputsRow struct { - InputHash []byte - InputIndex int32 + InputHash []byte + InputIndex int32 + InputPkScript []byte } -// ListBatchConsumedInputs returns the outpoints a batch consumes. +// ListBatchConsumedInputs returns the inputs a batch consumes, with the +// pkScript of each spent output. func (q *Queries) ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) ([]ListBatchConsumedInputsRow, error) { rows, err := q.db.QueryContext(ctx, ListBatchConsumedInputs, batchTxid) if err != nil { @@ -253,7 +264,7 @@ func (q *Queries) ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) var items []ListBatchConsumedInputsRow for rows.Next() { var i ListBatchConsumedInputsRow - if err := rows.Scan(&i.InputHash, &i.InputIndex); err != nil { + if err := rows.Scan(&i.InputHash, &i.InputIndex, &i.InputPkScript); err != nil { return nil, err } items = append(items, i) diff --git a/db/sqlc/migrations/000011_batch_canonicality.up.sql b/db/sqlc/migrations/000011_batch_canonicality.up.sql index 498632459..3a3a81d9b 100644 --- a/db/sqlc/migrations/000011_batch_canonicality.up.sql +++ b/db/sqlc/migrations/000011_batch_canonicality.up.sql @@ -64,6 +64,14 @@ CREATE TABLE IF NOT EXISTS batch_consumed_inputs ( input_hash BLOB NOT NULL CHECK (length(input_hash) = 32), input_index INTEGER NOT NULL CHECK (input_index >= 0), + -- input_pk_script is the scriptPubKey of the spent output. It is + -- required to register the reorg-aware spend watch: lnd's spend + -- notifier filters by output script, so a bare outpoint is rejected + -- ("an output script must be provided"). Persisting it lets restart + -- reconciliation re-arm every watch. NULL only on legacy rows that + -- predate script tracking. + input_pk_script BLOB, + PRIMARY KEY (batch_txid, input_hash, input_index), FOREIGN KEY (batch_txid) REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE diff --git a/db/sqlc/models.go b/db/sqlc/models.go index 251dd87cc..62545fa82 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -74,9 +74,10 @@ type BatchCanonicality struct { } type BatchConsumedInput struct { - BatchTxid []byte - InputHash []byte - InputIndex int32 + BatchTxid []byte + InputHash []byte + InputIndex int32 + InputPkScript []byte } type BatchDependentVtxo struct { diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 8efbbacd4..de0bfa7ad 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -115,7 +115,8 @@ type Querier interface { // GetVTXOReplacement retrieves the replacement VTXO outpoint for a forfeited // VTXO. Returns NULL if not forfeited or no replacement recorded. GetVTXOReplacement(ctx context.Context, arg GetVTXOReplacementParams) (GetVTXOReplacementRow, error) - // InsertBatchConsumedInput records one outpoint consumed by a batch. + // InsertBatchConsumedInput records one input consumed by a batch, together + // with the pkScript of the spent output (needed to register the spend watch). InsertBatchConsumedInput(ctx context.Context, arg InsertBatchConsumedInputParams) error // InsertBatchDependentVTXO records one VTXO outpoint anchored by a batch. InsertBatchDependentVTXO(ctx context.Context, arg InsertBatchDependentVTXOParams) error @@ -189,7 +190,8 @@ type Querier interface { // ListBatchCanonicalityByState returns every batch currently in the given // state. ListBatchCanonicalityByState(ctx context.Context, state int32) ([]BatchCanonicality, error) - // ListBatchConsumedInputs returns the outpoints a batch consumes. + // ListBatchConsumedInputs returns the inputs a batch consumes, with the + // pkScript of each spent output. ListBatchConsumedInputs(ctx context.Context, batchTxid []byte) ([]ListBatchConsumedInputsRow, error) // ListBatchDependentVTXOs returns the VTXO outpoints a batch anchors. ListBatchDependentVTXOs(ctx context.Context, batchTxid []byte) ([]ListBatchDependentVTXOsRow, error) diff --git a/db/sqlc/queries/batch_canonicality.sql b/db/sqlc/queries/batch_canonicality.sql index 8be642c5a..2449163c4 100644 --- a/db/sqlc/queries/batch_canonicality.sql +++ b/db/sqlc/queries/batch_canonicality.sql @@ -66,9 +66,12 @@ SET confirmation_height = NULL, confirmation_block_hash = NULL, updated_at = $2 WHERE batch_txid = $1; -- name: InsertBatchConsumedInput :exec --- InsertBatchConsumedInput records one outpoint consumed by a batch. -INSERT INTO batch_consumed_inputs (batch_txid, input_hash, input_index) -VALUES ($1, $2, $3) +-- InsertBatchConsumedInput records one input consumed by a batch, together +-- with the pkScript of the spent output (needed to register the spend watch). +INSERT INTO batch_consumed_inputs ( + batch_txid, input_hash, input_index, input_pk_script +) +VALUES ($1, $2, $3, $4) ON CONFLICT (batch_txid, input_hash, input_index) DO NOTHING; -- name: DeleteBatchConsumedInputs :exec @@ -77,8 +80,9 @@ ON CONFLICT (batch_txid, input_hash, input_index) DO NOTHING; DELETE FROM batch_consumed_inputs WHERE batch_txid = $1; -- name: ListBatchConsumedInputs :many --- ListBatchConsumedInputs returns the outpoints a batch consumes. -SELECT input_hash, input_index +-- ListBatchConsumedInputs returns the inputs a batch consumes, with the +-- pkScript of each spent output. +SELECT input_hash, input_index, input_pk_script FROM batch_consumed_inputs WHERE batch_txid = $1; diff --git a/db/sqlc/schemas/generated_schema.sql b/db/sqlc/schemas/generated_schema.sql index acfaffa4f..66208ca93 100644 --- a/db/sqlc/schemas/generated_schema.sql +++ b/db/sqlc/schemas/generated_schema.sql @@ -146,6 +146,14 @@ CREATE TABLE batch_consumed_inputs ( input_hash BLOB NOT NULL CHECK (length(input_hash) = 32), input_index INTEGER NOT NULL CHECK (input_index >= 0), + -- input_pk_script is the scriptPubKey of the spent output. It is + -- required to register the reorg-aware spend watch: lnd's spend + -- notifier filters by output script, so a bare outpoint is rejected + -- ("an output script must be provided"). Persisting it lets restart + -- reconciliation re-arm every watch. NULL only on legacy rows that + -- predate script tracking. + input_pk_script BLOB, + PRIMARY KEY (batch_txid, input_hash, input_index), FOREIGN KEY (batch_txid) REFERENCES batch_canonicality(batch_txid) ON DELETE CASCADE diff --git a/harness/harness.go b/harness/harness.go index 12c5f4258..393ed6e13 100644 --- a/harness/harness.go +++ b/harness/harness.go @@ -1629,10 +1629,47 @@ func (h *Harness) ReorgDepth(depth int) ReorgResult { // Reorg invalidates the current tip's last depth blocks, mines newBlocks on // top of the fork point, and waits for the primary LND node to resync. The -// harness must be fully started before calling Reorg. +// harness must be fully started before calling Reorg. The replacement branch +// is mined with generatetoaddress, which sweeps the mempool, so a transaction +// from the disconnected branch re-confirms on the first replacement block. Use +// ReorgExcludingMempool when the disconnected transaction must stay off-chain. func (h *Harness) Reorg(depth, newBlocks int) ReorgResult { h.T.Helper() + return h.reorgWith(depth, newBlocks, "", h.Generate) +} + +// ReorgExcludingMempool performs a reorg like Reorg, but mines the replacement +// branch with EMPTY blocks (via the generateblock RPC) so transactions from the +// disconnected branch are NOT automatically re-confirmed on the new branch. +// This lets a caller observe the post-reorg "transaction off-chain" window +// deterministically -- e.g. a confirmation watch staying in its reorged-out +// state, or a canonicality record holding ReorgedOut -- instead of the tx +// silently re-confirming on the first replacement block as it would under +// Reorg's generatetoaddress (which pulls the mempool). The stranded tx stays in +// the mempool; mine a normal block afterwards with Generate to re-confirm it. +// +// newBlocks must be > depth so the replacement branch strictly outweighs the +// disconnected one and becomes active. +func (h *Harness) ReorgExcludingMempool(depth, newBlocks int) ReorgResult { + h.T.Helper() + + return h.reorgWith( + depth, newBlocks, " (empty replacement)", h.generateEmptyBlocks, + ) +} + +// reorgWith is the shared reorg driver behind Reorg and ReorgExcludingMempool. +// It invalidates the last depth blocks, waits for bitcoind to roll the active +// chain back to the fork point, mines the replacement branch via the supplied +// generate function, asserts the new branch became active, and waits for the +// primary LND node to resync. logSuffix is appended to the progress log line so +// the variant is identifiable. +func (h *Harness) reorgWith(depth, newBlocks int, logSuffix string, + generate func(int) []BlockHeader) ReorgResult { + + h.T.Helper() + require.Positive(h.T, depth, "reorg depth must be positive") require.Greater( h.T, newBlocks, depth, @@ -1657,13 +1694,14 @@ func (h *Harness) Reorg(depth, newBlocks int) ReorgResult { invalidateHash := disconnected[0].Hash h.Logf( - "Reorging depth=%d from old_tip=%s fork_point=%s "+ - "invalidate=%s new_blocks=%d", depth, oldTip.Hash, - forkPoint.Hash, invalidateHash, newBlocks, + "Reorging%s depth=%d from old_tip=%s fork_point=%s "+ + "invalidate=%s new_blocks=%d", logSuffix, depth, + oldTip.Hash, forkPoint.Hash, invalidateHash, newBlocks, ) _, err := h.bitcoinRPCCall("invalidateblock", invalidateHash) require.NoError(h.T, err, "invalidateblock %s", invalidateHash) + // forkHeight is validated non-negative above. expectedForkHeight := uint32(forkHeight) require.Eventually( @@ -1673,7 +1711,7 @@ func (h *Harness) Reorg(depth, newBlocks int) ReorgResult { "bitcoind did not roll back to fork height %d", forkHeight, ) - connected := h.Generate(newBlocks) + connected := generate(newBlocks) newTip := h.BestBlockHeader() require.Equal( h.T, connected[len(connected)-1].Hash, newTip.Hash, @@ -1694,6 +1732,39 @@ func (h *Harness) Reorg(depth, newBlocks int) ReorgResult { } } +// generateEmptyBlocks mines the given number of blocks that contain only their +// coinbase, using the generateblock RPC with an empty transaction list so the +// mempool is NOT swept into them. Returns the new block headers in height +// order. Used by ReorgExcludingMempool to build a replacement branch that does +// not re-confirm the disconnected branch's transactions. +func (h *Harness) generateEmptyBlocks(blocks int) []BlockHeader { + h.T.Helper() + + addr := h.bitcoindGetNewAddress() + + headers := make([]BlockHeader, 0, blocks) + for range blocks { + // generateblock mines a single block containing only the + // listed transactions (plus coinbase); an empty list yields an + // empty block that ignores the mempool entirely. + res, err := h.bitcoinRPCCall( + "generateblock", addr, []string{}, + ) + require.NoError(h.T, err, "generateblock rpc failed") + + var out struct { + Hash string `json:"hash"` + } + require.NoError( + h.T, json.Unmarshal(res, &out), + "generateblock unmarshal failed", + ) + headers = append(headers, h.BlockHeader(out.Hash)) + } + + return headers +} + // ReconsiderBlock asks bitcoind to reconsider a previously invalidated block. func (h *Harness) ReconsiderBlock(hash string) { h.T.Helper() @@ -1827,7 +1898,7 @@ func (h *Harness) SignedV3Tx(destPkScript []byte, // Find a confirmed wallet UTXO with enough value to cover the // destination + change + a generous fee. - utxoTxid, utxoVout, utxoValueBTC := h.bitcoindFirstSpendableUTXO() + utxoTxid, utxoVout, utxoValueBTC, _ := h.bitcoindFirstSpendableUTXO() utxoValue := btcutil.Amount(utxoValueBTC * btcutil.SatoshiPerBitcoin) feeSat := btcutil.Amount(2_000) // ~5 sat/vB on a ~400 vB tx. @@ -1918,7 +1989,9 @@ func (h *Harness) SignedV3Tx(destPkScript []byte, // bitcoindFirstSpendableUTXO picks the first confirmed wallet UTXO via // `listunspent` and returns its txid, vout, and amount in BTC. -func (h *Harness) bitcoindFirstSpendableUTXO() (string, uint32, float64) { +func (h *Harness) bitcoindFirstSpendableUTXO() (string, uint32, float64, + string) { + h.T.Helper() // Restrict to confirmed and spendable; bitcoind defaults are @@ -1927,9 +2000,10 @@ func (h *Harness) bitcoindFirstSpendableUTXO() (string, uint32, float64) { require.NoError(h.T, err, "listunspent rpc failed") var utxos []struct { - Txid string `json:"txid"` - Vout uint32 `json:"vout"` - Amount float64 `json:"amount"` + Txid string `json:"txid"` + Vout uint32 `json:"vout"` + Amount float64 `json:"amount"` + ScriptPubKey string `json:"scriptPubKey"` } require.NoError( h.T, json.Unmarshal(res, &utxos), @@ -1939,7 +2013,107 @@ func (h *Harness) bitcoindFirstSpendableUTXO() (string, uint32, float64) { first := utxos[0] - return first.Txid, first.Vout, first.Amount + return first.Txid, first.Vout, first.Amount, first.ScriptPubKey +} + +// FirstSpendableOutpoint returns a confirmed, spendable wallet outpoint along +// with its value in BTC and the pkScript of the output, WITHOUT spending it. It +// is used to obtain an outpoint a test can register as a batch's consumed input +// (the pkScript is needed to arm the spend watch) and later double-spend (via +// SpendOutpoint) to exercise conflict detection. +func (h *Harness) FirstSpendableOutpoint() (wire.OutPoint, float64, []byte) { + h.T.Helper() + + h.bitcoindEnsureWallet() + + txid, vout, valueBTC, scriptHex := h.bitcoindFirstSpendableUTXO() + hash, err := chainhash.NewHashFromStr(txid) + require.NoError(h.T, err, "parse spendable utxo txid") + + pkScript, err := hex.DecodeString(scriptHex) + require.NoError(h.T, err, "decode spendable utxo pkScript") + + return wire.OutPoint{Hash: *hash, Index: vout}, valueBTC, pkScript +} + +// SpendOutpoint builds, signs, and broadcasts a transaction that spends the +// given wallet-owned outpoint (worth valueBTC) to a fresh wallet address, +// returning the spending txid. The output value is valueBTC minus a flat fee. +// The transaction is broadcast to the mempool but NOT mined -- the caller mines +// it -- so a test can register watches before the spend confirms. Used to +// create a controlled double-spend of a batch's registered consumed input. +func (h *Harness) SpendOutpoint(op wire.OutPoint, valueBTC float64) string { + h.T.Helper() + + h.bitcoindEnsureWallet() + + value := btcutil.Amount(valueBTC * btcutil.SatoshiPerBitcoin) + feeSat := btcutil.Amount(2_000) // ~5 sat/vB on a ~400 vB tx. + require.Greater( + h.T, value, feeSat, "outpoint value too small to cover fee", + ) + + destAddrRes, err := h.bitcoinRPCCall("getnewaddress") + require.NoError(h.T, err, "getnewaddress for spend dest failed") + var destAddrStr string + require.NoError( + h.T, json.Unmarshal(destAddrRes, &destAddrStr), + "getnewaddress unmarshal failed", + ) + destAddr, err := btcaddr.DecodeAddress( + destAddrStr, &chaincfg.RegressionNetParams, + ) + require.NoError(h.T, err, "decode spend dest address failed") + destPkScript, err := txscript.PayToAddrScript(destAddr) + require.NoError(h.T, err, "derive spend dest pkScript failed") + + tx := wire.NewMsgTx(2) + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: op, + Sequence: wire.MaxTxInSequenceNum, + }) + tx.AddTxOut(&wire.TxOut{ + Value: int64(value - feeSat), + PkScript: destPkScript, + }) + + var buf bytes.Buffer + require.NoError(h.T, tx.Serialize(&buf), "serialize spend tx failed") + + signRes, err := h.bitcoinRPCCall( + "signrawtransactionwithwallet", + hex.EncodeToString( + buf.Bytes(), + ), + ) + require.NoError(h.T, err, "signrawtransactionwithwallet failed") + + var signResult struct { + Hex string `json:"hex"` + Complete bool `json:"complete"` + } + require.NoError( + h.T, json.Unmarshal(signRes, &signResult), + "signrawtransactionwithwallet unmarshal failed", + ) + require.True( + h.T, signResult.Complete, "spend tx signing incomplete: %s", + signResult.Hex, + ) + + sendRes, err := h.bitcoinRPCCall( + "sendrawtransaction", signResult.Hex, + ) + require.NoError(h.T, err, "sendrawtransaction failed") + var txid string + require.NoError( + h.T, json.Unmarshal(sendRes, &txid), + "sendrawtransaction unmarshal failed", + ) + + h.Logf("Spent outpoint %s in tx %s", op, txid) + + return txid } // Faucet funds a test address by sending the specified amount from bitcoind's diff --git a/lib/actormsg/vtxo_admission.go b/lib/actormsg/vtxo_admission.go index 1bf5944c8..2f70a9d5b 100644 --- a/lib/actormsg/vtxo_admission.go +++ b/lib/actormsg/vtxo_admission.go @@ -176,6 +176,38 @@ type ReleaseForfeitResponse struct { // VTXOManagerResp implements the VTXOManagerResp marker interface. func (r *ReleaseForfeitResponse) VTXOManagerResp() {} +// RestoreForfeitedVTXORequest asks the VTXO manager to roll a forfeited VTXO +// back to a spendable (Live) state because the batch that consumed it via +// forfeit has been invalidated (its forfeit reversed by a finalized reorg / +// conflict). The manager re-materializes the VTXO from its persisted +// descriptor, mirroring unilateral-exit recovery. It is idempotent: a VTXO +// that is not currently forfeited is left untouched. +type RestoreForfeitedVTXORequest struct { + actor.BaseMessage + + // Outpoint identifies the forfeited VTXO to restore. + Outpoint wire.OutPoint +} + +// VTXOManagerMsg implements the VTXOManagerMsg marker interface. +func (m *RestoreForfeitedVTXORequest) VTXOManagerMsg() {} + +// MessageType returns the message type for logging. +func (m *RestoreForfeitedVTXORequest) MessageType() string { + return "RestoreForfeitedVTXORequest" +} + +// RestoreForfeitedVTXOResponse confirms the restore request was processed. +// Restored reports whether the VTXO was actually rolled back to Live (false +// when it was not in a forfeited state, i.e. the request was a no-op). +type RestoreForfeitedVTXOResponse struct { + // Restored is true when the VTXO was rolled back to Live. + Restored bool +} + +// VTXOManagerResp implements the VTXOManagerResp marker interface. +func (r *RestoreForfeitedVTXOResponse) VTXOManagerResp() {} + // CustomForfeitInput describes a caller-supplied VTXO that is not part of the // wallet's live coin set but still needs a local VTXO actor to sign the exact // round forfeit transaction once connector details are known. diff --git a/oor/registry.go b/oor/registry.go index 2e6119578..90707fc5d 100644 --- a/oor/registry.go +++ b/oor/registry.go @@ -10,6 +10,7 @@ import ( "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/batchcanon" "github.com/lightninglabs/darepo-client/build" clientdb "github.com/lightninglabs/darepo-client/db" "github.com/lightninglabs/darepo-client/ledger" @@ -85,6 +86,11 @@ type OORRegistryConfig struct { TimeoutActor actor.TellOnlyRef[timeout.Msg] CallbackRef actor.TellOnlyRef[*timeout.ExpiredMsg] ActorSystem actor.SystemContext + + // BatchCanonicality, when set, is forwarded to every spawned session + // actor so a received VTXO's lineage batches get registered with the + // reorg-safety gate as they materialize. None disables registration. + BatchCanonicality fn.Option[actor.TellOnlyRef[batchcanon.ManagerMsg]] } // OORRegistryActor is the thin coordinator over per-session OOR actors. It @@ -1479,6 +1485,7 @@ func (r *oorRegistryBehavior) childConfig(sessionID SessionID, Limits: normalizeReceiveLimits(r.cfg.Limits), TimeoutActor: r.cfg.TimeoutActor, CallbackRef: r.cfg.CallbackRef, + BatchCanonicality: r.cfg.BatchCanonicality, Registry: r.selfRef, } } diff --git a/round/actor.go b/round/actor.go index 7dacd8e38..f327a44aa 100644 --- a/round/actor.go +++ b/round/actor.go @@ -533,6 +533,7 @@ func (a *RoundClientActor) registerBatchCanonicality(ctx context.Context, ConfirmationPkScript: n.ConfirmationPkScript, CSVExpiryDelta: n.CSVExpiryDelta, ConsumedInputs: n.ConsumedInputs, + ForfeitedVTXOs: n.ForfeitedVTXOs, DependentVTXOs: dependents, } diff --git a/round/batch_canonicality_test.go b/round/batch_canonicality_test.go index 521cc6cd9..d16677d0a 100644 --- a/round/batch_canonicality_test.go +++ b/round/batch_canonicality_test.go @@ -57,11 +57,29 @@ func TestRegisterBatchCanonicalityEmitsRequest(t *testing.T) { forfeit := bcTestOutpoint(2) vtxoOut := bcTestOutpoint(3) pkScript := []byte{0x51, 0x20, 0x01} + consumed := []batchcanon.ConsumedInput{ + { + Outpoint: board, + PkScript: []byte{ + 0x51, + 0x20, + 0x0b, + }, + }, + { + Outpoint: forfeit, + PkScript: []byte{ + 0x51, + 0x20, + 0x0f, + }, + }, + } a.registerBatchCanonicality(t.Context(), &VTXOCreatedNotification{ VTXOs: []*ClientVTXO{{Outpoint: vtxoOut}}, CommitmentTxID: commitment, - ConsumedInputs: []wire.OutPoint{board, forfeit}, + ConsumedInputs: consumed, ConfirmationPkScript: pkScript, CSVExpiryDelta: 144, }) @@ -72,7 +90,7 @@ func TestRegisterBatchCanonicalityEmitsRequest(t *testing.T) { req, ok := msg.(*batchcanon.RegisterBatchRequest) require.True(t, ok) require.Equal(t, commitment, req.BatchTxID) - require.Equal(t, []wire.OutPoint{board, forfeit}, req.ConsumedInputs) + require.Equal(t, consumed, req.ConsumedInputs) require.Equal(t, []wire.OutPoint{vtxoOut}, req.DependentVTXOs) require.Equal(t, pkScript, req.ConfirmationPkScript) require.Equal(t, int32(144), req.CSVExpiryDelta) diff --git a/round/outbox_messages.go b/round/outbox_messages.go index 7da27f219..b2b16ed46 100644 --- a/round/outbox_messages.go +++ b/round/outbox_messages.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/batchcanon" "github.com/lightninglabs/darepo-client/lib/arkscript" "github.com/lightninglabs/darepo-client/lib/tree" "github.com/lightninglabs/darepo-client/lib/types" @@ -795,12 +796,21 @@ type VTXOCreatedNotification struct { // CommitmentTxID is the txid of the confirmed commitment transaction. CommitmentTxID chainhash.Hash - // ConsumedInputs are the outpoints the commitment tx spends that this - // client contributed: boarding input outpoints plus forfeited VTXO - // outpoints. The round actor forwards them to the - // BatchCanonicalityManager so a reorg-out or double-spend of a consumed - // input invalidates the round-born VTXOs (darepo#454, F1/F3/F6). - ConsumedInputs []wire.OutPoint + // ConsumedInputs are the inputs the commitment tx spends that this + // client contributed: boarding inputs plus forfeited VTXOs, each + // carrying the pkScript of the spent output. The round actor forwards + // them to the BatchCanonicalityManager so a reorg-out or double-spend + // of a consumed input invalidates the round-born VTXOs (darepo#454, + // F1/F3/F6). The pkScript is required to register the spend watch (lnd + // filters spend notifications by output script). + ConsumedInputs []batchcanon.ConsumedInput + + // ForfeitedVTXOs are the VTXOs (from prior rounds) this round forfeits. + // The round actor forwards them to the BatchCanonicalityManager as + // reverse-dependency edges so that if THIS round's commitment is later + // invalidated (its forfeit reversed by a reorg/conflict), the forfeited + // VTXOs are restored to a spendable state (darepo#454, F6). + ForfeitedVTXOs []wire.OutPoint // ConfirmationPkScript is the commitment-tx batch output script the // canonicality confirmation watch keys on. Confirmation detection is by diff --git a/round/transitions.go b/round/transitions.go index e64897b56..878d9fe69 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -18,6 +18,7 @@ import ( "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/batchcanon" "github.com/lightninglabs/darepo-client/ledger" "github.com/lightninglabs/darepo-client/lib/arkscript" "github.com/lightninglabs/darepo-client/lib/tree" @@ -4095,6 +4096,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) { @@ -4206,16 +4231,32 @@ func (s *InputSigSentState) ProcessEvent(ctx context.Context, event ClientEvent, // round actor can register the batch's reorg-safety lineage. A // double-spend or reorg-out of any of these invalidates the // round-born VTXOs anchored by this batch (darepo#454). + // Each consumed input carries the pkScript of the spent output, + // sourced from the commitment PSBT's witness UTXOs. The spend + // watch the manager arms needs it (lnd filters spend + // notifications by output script). + inputScripts := commitmentInputScripts(s.CommitmentTx) consumedInputs := make( - []wire.OutPoint, 0, + []batchcanon.ConsumedInput, 0, len(s.Intents.Boarding)+len(s.ForfeitedVTXOs), ) for i := range s.Intents.Boarding { + op := s.Intents.Boarding[i].Outpoint consumedInputs = append( - consumedInputs, s.Intents.Boarding[i].Outpoint, + consumedInputs, batchcanon.ConsumedInput{ + Outpoint: op, + PkScript: inputScripts[op], + }, + ) + } + for _, op := range s.ForfeitedVTXOs { + consumedInputs = append( + consumedInputs, batchcanon.ConsumedInput{ + Outpoint: op, + PkScript: inputScripts[op], + }, ) } - consumedInputs = append(consumedInputs, s.ForfeitedVTXOs...) // The batch confirmation watch keys on the commitment tx's // batch output. Detection is by txid; the script is what @@ -4241,6 +4282,7 @@ func (s *InputSigSentState) ProcessEvent(ctx context.Context, event ClientEvent, RoundID: s.RoundID.String(), CommitmentTxID: evt.TxID, ConsumedInputs: consumedInputs, + ForfeitedVTXOs: s.ForfeitedVTXOs, ConfirmationPkScript: confPkScript, CSVExpiryDelta: sweepDelay, BatchExpiry: batchExpiry, diff --git a/systest/batch_canonicality_ancestor_test.go b/systest/batch_canonicality_ancestor_test.go new file mode 100644 index 000000000..81a0e77b7 --- /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/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/batchcanon" + "github.com/lightninglabs/darepo-client/db" + "github.com/lightninglabs/darepo-client/lib/arkscript" + "github.com/lightninglabs/darepo-client/lib/tree" + "github.com/lightninglabs/darepo-client/lib/types" + "github.com/lightninglabs/darepo-client/lndbackend" + "github.com/lightninglabs/darepo-client/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 darepod. + 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..08e6f09f2 --- /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/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/batchcanon" + "github.com/lightninglabs/darepo-client/db" + "github.com/lightninglabs/darepo-client/lndbackend" + "github.com/lightninglabs/darepo-client/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..506b36d53 --- /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/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/batchcanon" + "github.com/lightninglabs/darepo-client/chainsource" + "github.com/lightninglabs/darepo-client/db" + "github.com/lightninglabs/darepo-client/lib/arkscript" + "github.com/lightninglabs/darepo-client/lib/tree" + "github.com/lightninglabs/darepo-client/lib/types" + "github.com/lightninglabs/darepo-client/lndbackend" + "github.com/lightninglabs/darepo-client/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 darepod.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..e39b0864a --- /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/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/batchcanon" + "github.com/lightninglabs/darepo-client/db" + "github.com/lightninglabs/darepo-client/lib/arkscript" + "github.com/lightninglabs/darepo-client/lib/tree" + "github.com/lightninglabs/darepo-client/lib/types" + "github.com/lightninglabs/darepo-client/lndbackend" + "github.com/lightninglabs/darepo-client/round" + "github.com/lightninglabs/darepo-client/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 darepod.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 darepod'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..8f0617d6c --- /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/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/batchcanon" + "github.com/lightninglabs/darepo-client/db" + "github.com/lightninglabs/darepo-client/lib/arkscript" + "github.com/lightninglabs/darepo-client/lib/tree" + "github.com/lightninglabs/darepo-client/lib/types" + "github.com/lightninglabs/darepo-client/lndbackend" + "github.com/lightninglabs/darepo-client/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 darepod. + 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..9aaa630d3 --- /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/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/batchcanon" + "github.com/lightninglabs/darepo-client/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/unroll/registry.go b/unroll/registry.go index 881ab30c8..d9236142a 100644 --- a/unroll/registry.go +++ b/unroll/registry.go @@ -11,6 +11,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/batchcanon" "github.com/lightninglabs/darepo-client/chainsource" "github.com/lightninglabs/darepo-client/ledger" "github.com/lightninglabs/darepo-client/txconfirm" @@ -111,6 +112,17 @@ type RegistryConfig struct { // VTXOStore loads target descriptors for child actors. VTXOStore vtxo.VTXOStore + // BatchCanonicality, when set, gates fresh unroll admission on the + // target VTXO's source-lineage canonicality (darepo#454): an unroll is + // refused only when a batch in the VTXO's lineage is permanently + // invalidated (conflict-finalized), since the exit tree can then never + // confirm. Transient reorgs are admitted and the unroll self-reconciles + // its anchors (#410). Nil disables the gate (the default until the + // batch producers register batches); it is permissive otherwise (unseen + // / unregistered lineage does not block), and only fresh admissions are + // gated — an already-running unroll is never interrupted. + BatchCanonicality batchcanon.Store + // TxConfirmRef is the shared tx-confirmation actor. TxConfirmRef actor.ActorRef[txconfirm.Msg, txconfirm.Resp] @@ -398,6 +410,105 @@ func (r *registryBehavior) OnStop(context.Context) error { return nil } +// ErrSourceLineageUnavailable is returned by EnsureUnroll when the target +// VTXO's source lineage is permanently off the canonical chain (a batch in its +// lineage had a consumed input double-spent past finality), so its exit tree +// can never confirm. This is terminal — there is nothing to retry. +var ErrSourceLineageUnavailable = errors.New("vtxo source lineage " + + "unavailable for unroll") + +// refuseIfSourceLineageInvalidated returns ErrSourceLineageUnavailable (wrapped +// with the target outpoint) when the target VTXO's source lineage is +// permanently invalidated, so a fresh unroll must not be admitted. It is a +// no-op when the gate is dormant or the lineage is still recoverable. +func (r *registryBehavior) refuseIfSourceLineageInvalidated(ctx context.Context, + outpoint wire.OutPoint) error { + + if r.sourceLineageInvalidated(ctx, outpoint) { + return fmt.Errorf("%w: %s", ErrSourceLineageUnavailable, + outpoint) + } + + return nil +} + +// sourceLineageInvalidated reports whether the target VTXO's source lineage is +// PERMANENTLY invalidated (a batch in its lineage is conflict-finalized), in +// which case the exit tree can never confirm and a fresh unroll is pointless. +// +// It deliberately blocks ONLY the terminal Invalidated verdict, not the +// transient LimboReorg / LimboConflict states: a reorged-out batch (no input +// conflict) is expected to re-confirm on its own, and a not-yet-final conflict +// may still resolve in the batch's favor. Blocking those would risk dropping a +// needed critical-expiry / fraud-triggered exit during exactly the window it +// matters — and the critical-expiry safety net reaches this gate via a +// fire-and-forget Tell, so a refusal cannot be observed or retried. An +// already-admitted unroll tolerates a transiently-absent parent by reconciling +// its own chain anchors (#410), so a fresh safety exit should be admitted for +// the same transient condition rather than refused. +// +// It is a no-op (returns false) when no canonicality store is wired, and it is +// fail-permissive: any descriptor-load or canonicality lookup error logs and +// returns false rather than blocking an exit, mirroring the gate's permissive +// "unseen / unregistered lineage does not block" stance. +func (r *registryBehavior) sourceLineageInvalidated(ctx context.Context, + outpoint wire.OutPoint) bool { + + if r.cfg.BatchCanonicality == nil { + return false + } + + desc, err := r.cfg.VTXOStore.GetVTXO(ctx, outpoint) + if err != nil { + r.log.DebugS(ctx, "Unroll lineage gate: vtxo load failed, "+ + "permitting admission", err, + slog.String("outpoint", outpoint.String())) + + return false + } + + avail, err := batchcanon.LineageAvailability( + ctx, r.cfg.BatchCanonicality, + unrollLineageCommitmentTxids(desc)..., + ) + if err != nil { + r.log.DebugS(ctx, "Unroll lineage gate: availability lookup "+ + "failed, permitting admission", err, + slog.String("outpoint", outpoint.String())) + + return false + } + + return avail == batchcanon.Invalidated +} + +// unrollLineageCommitmentTxids returns the deduped commitment txids in a VTXO's +// lineage: its direct commitment tx plus every distinct ancestor commitment tx +// (zero-skipped). Mirrors the vtxo package's selection-gate helper for the +// unroll admission gate. +func unrollLineageCommitmentTxids(desc *vtxo.Descriptor) []chainhash.Hash { + seen := make(map[chainhash.Hash]struct{}, len(desc.Ancestry)+1) + txids := make([]chainhash.Hash, 0, len(desc.Ancestry)+1) + + add := func(txid chainhash.Hash) { + if txid == (chainhash.Hash{}) { + return + } + if _, ok := seen[txid]; ok { + return + } + seen[txid] = struct{}{} + txids = append(txids, txid) + } + + add(desc.CommitmentTxID) + for i := range desc.Ancestry { + add(desc.Ancestry[i].CommitmentTxID) + } + + return txids +} + // handleEnsure is the admission gate for new unroll jobs. It runs a // four-stage check to decide whether the caller is re-asking for an // already-tracked target or requesting a brand-new unroll, spawns and @@ -546,6 +657,15 @@ func (r *registryBehavior) handleEnsure(ctx context.Context, } } + // Refuse a fresh unroll only when the target's source lineage is + // permanently invalidated (see sourceLineageInvalidated); transient + // reorgs are admitted and self-reconcile. + if err := r.refuseIfSourceLineageInvalidated( + ctx, req.Outpoint, + ); err != nil { + return fn.Err[RegistryResp](err) + } + height, err := r.queryBestHeight(ctx) if err != nil { return fn.Err[RegistryResp](fmt.Errorf("best height: %w", err)) diff --git a/unroll/source_lineage_gate_test.go b/unroll/source_lineage_gate_test.go new file mode 100644 index 000000000..2ca34c951 --- /dev/null +++ b/unroll/source_lineage_gate_test.go @@ -0,0 +1,240 @@ +package unroll + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/batchcanon" + "github.com/lightninglabs/darepo-client/vtxo" + "github.com/stretchr/testify/require" +) + +// stubBatchCanon is a minimal batchcanon.Store for the unroll source-lineage +// gate tests: it answers GetBatch from a txid->state map and returns zero +// values for the methods the gate never calls. +type stubBatchCanon struct { + states map[chainhash.Hash]batchcanon.State +} + +func (s *stubBatchCanon) GetBatch(_ context.Context, txid chainhash.Hash) ( + *batchcanon.Record, error) { + + st, ok := s.states[txid] + if !ok { + return nil, batchcanon.ErrBatchNotFound + } + + return &batchcanon.Record{BatchTxID: txid, State: st}, nil +} + +func (s *stubBatchCanon) UpsertBatch(context.Context, + *batchcanon.Record) error { + + return nil +} + +func (s *stubBatchCanon) ListBatchesByState(context.Context, batchcanon.State) ( + []*batchcanon.Record, error) { + + return nil, nil +} + +func (s *stubBatchCanon) UpdateBatchState(context.Context, chainhash.Hash, + batchcanon.State) error { + + return nil +} + +func (s *stubBatchCanon) RecordConfirmation(context.Context, chainhash.Hash, + int32, chainhash.Hash) error { + + return nil +} + +func (s *stubBatchCanon) ClearConfirmation(context.Context, + chainhash.Hash) error { + + return nil +} + +func (s *stubBatchCanon) FindBatchesConsumingOutpoint(context.Context, + wire.OutPoint) ([]chainhash.Hash, error) { + + return nil, nil +} + +func (s *stubBatchCanon) AddProvisionalConsumer(context.Context, wire.OutPoint, + chainhash.Hash) error { + + return nil +} + +func (s *stubBatchCanon) ListProvisionalConsumersForBatch(context.Context, + chainhash.Hash) ([]wire.OutPoint, error) { + + return nil, nil +} + +func (s *stubBatchCanon) DeleteProvisionalConsumersForBatch(context.Context, + chainhash.Hash) error { + + return nil +} + +var _ batchcanon.Store = (*stubBatchCanon)(nil) + +// gateTarget builds a target outpoint + a descriptor whose lineage is the +// supplied commitment txids (first is the direct commitment, rest are +// cross-commitment ancestors). +func gateTarget(direct chainhash.Hash, + ancestors ...chainhash.Hash) (wire.OutPoint, *vtxo.Descriptor) { + + op := wire.OutPoint{Hash: chainhash.Hash{0xfe}, Index: 0} + desc := &vtxo.Descriptor{Outpoint: op, CommitmentTxID: direct} + for _, a := range ancestors { + desc.Ancestry = append(desc.Ancestry, vtxo.Ancestry{ + CommitmentTxID: a, + }) + } + + return op, desc +} + +func newGateBehavior(store batchcanon.Store, + desc *vtxo.Descriptor) *registryBehavior { + + return ®istryBehavior{ + cfg: RegistryConfig{ + BatchCanonicality: store, + VTXOStore: &mockVTXOStore{ + desc: desc, + }, + }, + } +} + +// TestSourceLineageBlockedOnInvalidatedAncestor verifies a fresh unroll is +// refused when any batch in the target's lineage is permanently invalidated +// (conflict-finalized), even if its direct commitment is canonical. +func TestSourceLineageBlockedOnInvalidatedAncestor(t *testing.T) { + t.Parallel() + + direct := chainhash.Hash{0xaa} + ancestor := chainhash.Hash{0xbb} + op, desc := gateTarget(direct, ancestor) + + b := newGateBehavior(&stubBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + direct: batchcanon.StateProvisional, + ancestor: batchcanon.StateConflictFinalized, + }, + }, desc) + + require.True(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestSourceLineagePermitsTransientReorg verifies a reorged-out (but not +// conflict-finalized) ancestor does NOT block a fresh unroll: the reorg is +// expected to self-heal, and blocking could drop a needed safety exit that +// cannot be retried. +func TestSourceLineagePermitsTransientReorg(t *testing.T) { + t.Parallel() + + direct := chainhash.Hash{0xaa} + ancestor := chainhash.Hash{0xbb} + op, desc := gateTarget(direct, ancestor) + + b := newGateBehavior(&stubBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + direct: batchcanon.StateProvisional, + ancestor: batchcanon.StateReorgedOut, + }, + }, desc) + + require.False(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestSourceLineageNotBlockedWhenCanonical verifies a fresh unroll is admitted +// when the whole lineage is canonical. +func TestSourceLineageNotBlockedWhenCanonical(t *testing.T) { + t.Parallel() + + direct := chainhash.Hash{0xaa} + ancestor := chainhash.Hash{0xbb} + op, desc := gateTarget(direct, ancestor) + + b := newGateBehavior(&stubBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{ + direct: batchcanon.StateFinalized, + ancestor: batchcanon.StateProvisional, + }, + }, desc) + + require.False(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestSourceLineagePermissiveWhenUnregistered verifies the gate does not block +// when the lineage batches are not registered (unseen), preserving the +// permissive default. +func TestSourceLineagePermissiveWhenUnregistered(t *testing.T) { + t.Parallel() + + op, desc := gateTarget(chainhash.Hash{0xaa}, chainhash.Hash{0xbb}) + + b := newGateBehavior(&stubBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{}, + }, desc) + + require.False(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestSourceLineagePermissiveWhenVTXOLoadFails verifies the gate fails +// permissive (admits) when the target descriptor cannot be loaded, rather than +// blocking an exit on a transient store error. +func TestSourceLineagePermissiveWhenVTXOLoadFails(t *testing.T) { + t.Parallel() + + op, _ := gateTarget(chainhash.Hash{0xaa}) + + b := ®istryBehavior{ + cfg: RegistryConfig{ + BatchCanonicality: &stubBatchCanon{ + states: map[chainhash.Hash]batchcanon.State{}, + }, + VTXOStore: &mockVTXOStore{ + err: errors.New("boom"), + }, + }, + log: btclog.Disabled, + } + + require.False(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestSourceLineageGateDormantWhenNoStore verifies the gate is a no-op when no +// canonicality store is wired. +func TestSourceLineageGateDormantWhenNoStore(t *testing.T) { + t.Parallel() + + op, _ := gateTarget(chainhash.Hash{0xaa}) + + b := ®istryBehavior{cfg: RegistryConfig{}} + + require.False(t, b.sourceLineageInvalidated(t.Context(), op)) +} + +// TestErrSourceLineageUnavailableIsSentinel guards that the wrapped form +// handleEnsure returns is matchable via errors.Is, so RPC/chain-resolver +// callers can classify a lineage-refused unroll. +func TestErrSourceLineageUnavailableIsSentinel(t *testing.T) { + t.Parallel() + + wrapped := fmt.Errorf("%w: %s", ErrSourceLineageUnavailable, + "some-outpoint") + require.ErrorIs(t, wrapped, ErrSourceLineageUnavailable) +} diff --git a/vtxo/manager.go b/vtxo/manager.go index 1d5057e66..55004f8e9 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -605,6 +605,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) @@ -937,6 +940,82 @@ func (m *Manager) recoverExitedVTXO(ctx context.Context, return fn.Ok[ManagerResp](&ExitOutcomeResp{}) } +// handleRestoreForfeitedVTXO rolls a forfeited VTXO back to LiveState because +// the batch that consumed it via forfeit has been invalidated (its forfeit +// reversed by a finalized reorg / conflict). The forfeit transition reaped the +// VTXO actor and persisted VTXOStatusForfeited, so this re-materializes a live +// actor from the persisted descriptor, mirroring recoverExitedVTXO. It is +// idempotent: a VTXO that is not currently forfeited is left untouched. +func (m *Manager) handleRestoreForfeitedVTXO(ctx context.Context, + req *RestoreForfeitedVTXORequest) fn.Result[ManagerResp] { + + descriptor, err := m.cfg.Store.GetVTXO(ctx, req.Outpoint) + if err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("load vtxo for forfeit restore: %w", err), + ) + } + if descriptor == nil { + m.logger(ctx).WarnS(ctx, "No descriptor to restore forfeited "+ + "VTXO", nil, + slog.String("outpoint", req.Outpoint.String())) + + return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{}) + } + + // Idempotency guard: only relive a VTXO that is actually forfeited. A + // re-delivered restore must not clobber a VTXO that already moved on or + // was never forfeited. + if descriptor.Status != VTXOStatusForfeited { + m.logger(ctx).DebugS(ctx, "Skipping forfeit restore for "+ + "non-forfeited VTXO", + slog.String("outpoint", req.Outpoint.String()), + slog.String("status", descriptor.Status.String())) + + return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{}) + } + + // The forfeit transition reaps the actor, so none should be resident. + // If one somehow is, do not spawn a duplicate; treat it as already + // restored. + if _, ok := m.actors[req.Outpoint]; ok { + m.logger(ctx).DebugS(ctx, "Forfeited VTXO already has a live "+ + "actor; skipping restore", + slog.String("outpoint", req.Outpoint.String())) + + return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{}) + } + + // Spawn the live actor BEFORE persisting the status flip (same ordering + // rationale as recoverExitedVTXO: the coin must be monitored the moment + // its status becomes recoverable, and a failed status write is + // re-driven by the persisted Forfeited status on the next restore + // attempt). + descriptor.Status = VTXOStatusLive + + ref, err := m.spawnVTXOActor(ctx, descriptor) + if err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("respawn restored vtxo actor: %w", err), + ) + } + m.actors[req.Outpoint] = ref + + if err := m.cfg.Store.UpdateVTXOStatus( + ctx, req.Outpoint, VTXOStatusLive, + ); err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("restore forfeited vtxo status: %w", err), + ) + } + + m.logger(ctx).InfoS(ctx, "Restored forfeited VTXO to live after "+ + "batch invalidation", + slog.String("outpoint", req.Outpoint.String())) + + return fn.Ok[ManagerResp](&RestoreForfeitedVTXOResponse{Restored: true}) +} + // confirmExitedVTXO retires a VTXO to the terminal SpentState after its // unilateral exit confirmed on-chain. When the actor is alive it drives the // ExitConfirmedEvent through the FSM (which emits the terminated diff --git a/vtxo/messages.go b/vtxo/messages.go index 4c984dca8..64a474522 100644 --- a/vtxo/messages.go +++ b/vtxo/messages.go @@ -282,6 +282,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