From 5929498e990dc4e31c77d9731c7d5b80b712534e Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 9 Jul 2026 15:27:44 -0700 Subject: [PATCH 01/11] actormsg+vtxo: thread unroll trigger and policy through ForceUnroll In this commit, we widen the ForceUnroll path so a forced unilateral exit can carry two pieces of metadata end-to-end: the trigger that started it (manual, critical expiry, fraud spend) and an optional exit-spend policy identity (a vHTLC refund names its own policy). This is the groundwork for routing fraud and vHTLC recovery through the VTXO manager's admission gate rather than letting them admit the unroll registry behind the manager's back. We add these as a string-typed UnrollTrigger enum and an fn.Option[ExitPolicy] on actormsg.ForceUnrollRequest, mirror them onto the vtxo ForceUnrollEvent and ExpiringNotification, and populate them in every FSM arm that escalates to UnilateralExitState. They ride string-typed on the vtxo/actormsg side on purpose: unroll already imports vtxo, so the real unroll.StartTrigger / unroll.ExitPolicyKind can't be referenced here without a cycle. The darepod chain-resolver bridge converts them back at the seam where both packages are in scope. We also make UnilateralExitState re-emit the ExpiringNotification on a duplicate ForceUnrollEvent instead of silently self-looping. A first admission can be lost before the registry records it (a crash between the status flip and the registry UpsertRecord), so an idempotent re-admission lets a restart re-drive the exit under the same trigger and policy. The registry dedups against a live record, so a redundant re-admit is harmless. --- lib/actormsg/vtxo_admission.go | 97 +++++++++++++++++++++++++++++++++- vtxo/events.go | 22 ++++++-- vtxo/outbox_messages.go | 13 +++++ vtxo/transitions.go | 43 ++++++++++++++- vtxo/transitions_test.go | 78 +++++++++++++++++++++++---- 5 files changed, 237 insertions(+), 16 deletions(-) diff --git a/lib/actormsg/vtxo_admission.go b/lib/actormsg/vtxo_admission.go index 1bf5944c8..b42804098 100644 --- a/lib/actormsg/vtxo_admission.go +++ b/lib/actormsg/vtxo_admission.go @@ -7,6 +7,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/darepo-client/baselib/actor" "github.com/lightninglabs/darepo-client/lib/types" + fn "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/keychain" ) @@ -335,9 +336,93 @@ type SelectAndReserveForfeitResponse struct { // VTXOManagerResp implements the VTXOManagerResp marker interface. func (r *SelectAndReserveForfeitResponse) VTXOManagerResp() {} +// UnrollTrigger names why a unilateral exit was started. It is a +// string-typed mirror of the unroll package's StartTrigger so the vtxo and +// actormsg packages can carry the trigger through the ForceUnroll path +// without importing unroll (which would form a cycle). The darepod chain +// resolver bridge converts these back into unroll.StartTrigger values at the +// seam where both packages are already in scope. The empty string is the +// default and preserves the historical critical-expiry admission. +type UnrollTrigger string + +const ( + // UnrollTriggerCriticalExpiry marks an exit driven by a VTXO + // approaching its batch expiry. It is the zero-value default so an + // unset trigger keeps admitting as critical expiry, matching the + // behavior before triggers were threaded end-to-end. + UnrollTriggerCriticalExpiry UnrollTrigger = "" + + // UnrollTriggerManual marks an operator- or subsystem-requested exit + // that follows the standard VTXO timeout sweep policy (manual RPC exit, + // vHTLC refund recovery). + UnrollTriggerManual UnrollTrigger = "manual" + + // UnrollTriggerFraudSpend marks an exit forced because a watched + // ancestor of an OOR VTXO was seen spent on-chain. It changes the + // unroll FSM's CSV handling, so it must survive the whole ForceUnroll + // path rather than being flattened to the default. + UnrollTriggerFraudSpend UnrollTrigger = "fraud_spend" +) + +// ExitPolicyKind names a durable exit-spend policy for a forced unilateral +// exit. It is a string-typed enum mirroring the unroll / vhtlcrecovery policy +// vocabulary so the vtxo and actormsg packages can carry a policy through the +// ForceUnroll path without importing unroll (a cycle: unroll already imports +// vtxo). The darepod chain resolver bridge converts it back into an +// unroll.ExitPolicyKind at the seam where both packages are in scope. +// +// The standard timeout policy is represented by a None fn.Option[ExitPolicy] +// rather than a distinct kind, so the constants below enumerate only the +// non-standard policies that actually ride the ForceUnroll path. +type ExitPolicyKind string + +const ( + // ExitPolicyVHTLCClaim identifies the vHTLC unilateral claim leaf + // spend, mirroring vhtlcrecovery.ExitPolicyKindClaim. + ExitPolicyVHTLCClaim ExitPolicyKind = "vhtlc_claim" + + // ExitPolicyVHTLCRefundWithoutReceiver identifies the vHTLC unilateral + // refund-without-receiver leaf spend, mirroring + // vhtlcrecovery.ExitPolicyKindRefundWithoutReceiver. + ExitPolicyVHTLCRefundWithoutReceiver ExitPolicyKind = "vhtlc_" + + "refund_without_receiver" +) + +// Valid reports whether the exit policy kind is one of the known non-standard +// policies that can ride the ForceUnroll path. +func (k ExitPolicyKind) Valid() bool { + switch k { + case ExitPolicyVHTLCClaim, ExitPolicyVHTLCRefundWithoutReceiver: + return true + + default: + return false + } +} + +// ExitPolicyRef is the policy-specific durable reference paired with an +// ExitPolicyKind (e.g. the vHTLC recovery job id). It is a distinct type so +// the Kind and Ref of a policy identity can't be transposed by accident. +type ExitPolicyRef string + +// ExitPolicy bundles a non-standard exit-spend policy kind with its durable +// reference. The registry admission boundary validates the pair as a single +// identity, so they travel together. A None fn.Option[ExitPolicy] selects the +// standard VTXO timeout policy. +type ExitPolicy struct { + // Kind names the durable spend policy. + Kind ExitPolicyKind + + // Ref is the policy-specific durable reference. + Ref ExitPolicyRef +} + // ForceUnrollRequest asks the VTXO manager to transition a specific VTXO // into UnilateralExitState and trigger unroll through the chain resolver -// seam. This converges manual and automatic unroll on the same code path. +// seam. This converges manual, critical-expiry, fraud, and vHTLC-recovery +// unroll on the same admission path: the manager owns the state transition +// for every trigger, so the coin is persisted UnilateralExit (out of the +// live set) before the unroll registry admits the job. type ForceUnrollRequest struct { actor.BaseMessage @@ -346,6 +431,16 @@ type ForceUnrollRequest struct { // Reason explains why the unroll was requested. Reason string + + // Trigger identifies why the unroll was requested so the chain + // resolver bridge can admit the registry job under the right + // StartTrigger. The zero value admits as critical expiry. + Trigger UnrollTrigger + + // ExitPolicy carries a non-standard exit-spend policy identity (e.g. a + // vHTLC refund policy) to persist for this target. None selects the + // standard VTXO timeout policy. + ExitPolicy fn.Option[ExitPolicy] } // VTXOManagerMsg implements VTXOManagerMsg marker interface. diff --git a/vtxo/events.go b/vtxo/events.go index 9f170401a..ca152b995 100644 --- a/vtxo/events.go +++ b/vtxo/events.go @@ -4,6 +4,7 @@ import ( "github.com/lightninglabs/darepo-client/baselib/actor" "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/round" + fn "github.com/lightningnetwork/lnd/fn/v2" ) // VTXOEvent embeds actormsg.VTXOActorMsg for all events that can be processed @@ -59,15 +60,26 @@ type ( ForfeitReleasedEvent = round.ForfeitReleasedEvent ) -// ForceUnrollEvent is sent to a VTXO actor when a manual unilateral -// exit is requested via the Unroll RPC. The VTXO actor transitions to -// UnilateralExitState and emits ExpiringNotification through the chain -// resolver seam, converging with the automatic critical-expiry path. +// ForceUnrollEvent is sent to a VTXO actor when a unilateral exit is +// requested (manual RPC, fraud spend, or vHTLC recovery). The VTXO actor +// transitions to UnilateralExitState and emits ExpiringNotification through +// the chain resolver seam, converging with the automatic critical-expiry +// path. The trigger and exit-policy identity ride along so the chain +// resolver bridge can admit the registry job under the right policy. type ForceUnrollEvent struct { actor.BaseMessage - // Reason explains why the manual unroll was requested. + // Reason explains why the unroll was requested. Reason string + + // Trigger identifies why the unroll was requested. The zero value + // admits as critical expiry. + Trigger actormsg.UnrollTrigger + + // ExitPolicy carries a non-standard exit-spend policy identity to + // persist for this target. None selects the standard VTXO timeout + // policy. + ExitPolicy fn.Option[actormsg.ExitPolicy] } // VTXOActorMsg implements actormsg.VTXOActorMsg marker interface. diff --git a/vtxo/outbox_messages.go b/vtxo/outbox_messages.go index 17c0b8de4..0cdf06c8f 100644 --- a/vtxo/outbox_messages.go +++ b/vtxo/outbox_messages.go @@ -4,8 +4,10 @@ import ( "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/darepo-client/baselib/actor" + "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/lib/arkscript" "github.com/lightninglabs/darepo-client/lib/types" + fn "github.com/lightningnetwork/lnd/fn/v2" ) // VTXOOutMsg is a sealed interface for messages emitted via the FSM outbox. @@ -81,6 +83,17 @@ type ExpiringNotification struct { // Reason explains why the VTXO is being sent to chain resolver. Reason string + + // Trigger identifies why the unilateral exit was started so the chain + // resolver bridge admits the registry job under the right + // StartTrigger. The zero value admits as critical expiry, preserving + // the auto-expiry default. + Trigger actormsg.UnrollTrigger + + // ExitPolicy carries a non-standard exit-spend policy identity (e.g. a + // vHTLC refund policy) to persist for this target. None selects the + // standard VTXO timeout policy. + ExitPolicy fn.Option[actormsg.ExitPolicy] } func (m ExpiringNotification) vtxoOutMsgSealed() {} diff --git a/vtxo/transitions.go b/vtxo/transitions.go index c68504aaa..2ef717cab 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -134,6 +134,8 @@ func (s *LiveState) handleForceUnroll(_ context.Context, VTXO: s.VTXO, BlocksRemaining: 0, Reason: reason, + Trigger: evt.Trigger, + ExitPolicy: evt.ExitPolicy, }, &VTXOStatusUpdate{ Outpoint: s.VTXO.Outpoint, @@ -670,6 +672,8 @@ func (s *PendingForfeitState) ProcessEvent(ctx context.Context, event VTXOEvent, VTXO: s.VTXO, BlocksRemaining: 0, Reason: reason, + Trigger: evt.Trigger, + ExitPolicy: evt.ExitPolicy, }, &VTXOStatusUpdate{ Outpoint: s.VTXO.Outpoint, @@ -938,6 +942,8 @@ func (s *ForfeitingState) ProcessEvent(ctx context.Context, event VTXOEvent, VTXO: s.VTXO, BlocksRemaining: 0, Reason: reason, + Trigger: evt.Trigger, + ExitPolicy: evt.ExitPolicy, }, &VTXOStatusUpdate{ Outpoint: s.VTXO.Outpoint, @@ -1143,6 +1149,8 @@ func (s *SpendingState) ProcessEvent(_ context.Context, event VTXOEvent, VTXO: s.VTXO, BlocksRemaining: 0, Reason: reason, + Trigger: evt.Trigger, + ExitPolicy: evt.ExitPolicy, }, &VTXOStatusUpdate{ Outpoint: s.VTXO.Outpoint, @@ -1206,7 +1214,40 @@ func (s *ForfeitedState) ProcessEvent(_ context.Context, _ VTXOEvent, func (s *UnilateralExitState) ProcessEvent(_ context.Context, event VTXOEvent, _ *VTXOEnvironment) (*VTXOStateTransition, error) { - switch event.(type) { + switch evt := event.(type) { + case *ForceUnrollEvent: + // A ForceUnrollEvent on an already-exiting VTXO is an + // idempotent re-admission, not a no-op: the manager drives one + // whenever an external trigger (vHTLC recovery restore, a + // repeated fraud spend) re-asks for the exit, and the registry + // record may not have been written yet (the first admission's + // ExpiringNotification is a best-effort Tell that can be lost + // to a crash before the registry's UpsertRecord). Re-emit the + // notification so the chain resolver bridge re-admits under the + // same trigger/policy; the registry dedups against a live + // record, so a redundant re-admit is a benign no-op. Stay in + // UnilateralExitState and do not re-persist the status (already + // UnilateralExit). + reason := evt.Reason + if reason == "" { + reason = s.Reason + } + + return &VTXOStateTransition{ + NextState: s, + NewEvents: fn.Some(VTXOEmittedEvent{ + Outbox: []VTXOOutMsg{ + &ExpiringNotification{ + VTXO: s.VTXO, + BlocksRemaining: 0, + Reason: reason, + Trigger: evt.Trigger, + ExitPolicy: evt.ExitPolicy, + }, + }, + }), + }, nil + case *ExitFailedEvent: // The unroll job failed without any on-chain footprint, so the // VTXO is still live from the operator's perspective. Roll back diff --git a/vtxo/transitions_test.go b/vtxo/transitions_test.go index dd32ad995..7d3b81676 100644 --- a/vtxo/transitions_test.go +++ b/vtxo/transitions_test.go @@ -10,10 +10,12 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/lib/arkscript" "github.com/lightninglabs/darepo-client/lib/tx" "github.com/lightninglabs/darepo-client/lib/types" "github.com/lightninglabs/darepo-client/round" + fn "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/require" ) @@ -268,13 +270,30 @@ func TestLiveStateForceUnroll(t *testing.T) { ).Return(nil) _, err := h.sendEvent(&ForceUnrollEvent{ - Reason: "manual unroll", + Reason: "recipient fraud spend", + Trigger: actormsg.UnrollTriggerFraudSpend, + ExitPolicy: fn.Some(actormsg.ExitPolicy{ + Kind: actormsg.ExitPolicyVHTLCRefundWithoutReceiver, + Ref: actormsg.ExitPolicyRef("recovery-7"), + }), }) require.NoError(t, err) exit := assertState[*UnilateralExitState](h) require.Equal(t, int32(100), exit.LastCheckedHeight) - assertOutboxContains[*ExpiringNotification](h) + + // The trigger and exit policy must ride the outgoing notification so + // the chain-resolver bridge admits the registry job under the right + // StartTrigger and policy rather than the critical-expiry default. + notif := assertOutboxContains[*ExpiringNotification](h) + require.Equal(t, actormsg.UnrollTriggerFraudSpend, notif.Trigger) + + policy := notif.ExitPolicy.UnwrapOrFail(t) + require.Equal( + t, actormsg.ExitPolicyVHTLCRefundWithoutReceiver, policy.Kind, + ) + require.Equal(t, actormsg.ExitPolicyRef("recovery-7"), policy.Ref) + assertOutboxContains[*VTXOStatusUpdate](h) assertOutboxLacks[*VTXOTerminatedNotification](h) } @@ -409,9 +428,10 @@ func TestUnilateralExitConfirms(t *testing.T) { require.Equal(t, "Spent", term.FinalState) } -// TestUnilateralExitSelfLoopsWhileExiting verifies that stray events received -// while the exit is in flight (block epochs, a duplicate force-unroll, resume) -// leave the VTXO in UnilateralExitState rather than erroring or transitioning. +// TestUnilateralExitSelfLoopsWhileExiting verifies that truly inert events +// received while the exit is in flight (block epochs, resume) leave the VTXO +// in UnilateralExitState and emit nothing: the exit is already at the chain +// resolver. func TestUnilateralExitSelfLoopsWhileExiting(t *testing.T) { t.Parallel() @@ -426,9 +446,6 @@ func TestUnilateralExitSelfLoopsWhileExiting(t *testing.T) { for _, evt := range []VTXOEvent{ h.newBlockEpochEvent(200), - &ForceUnrollEvent{ - Reason: "duplicate", - }, &ResumeVTXOEvent{}, } { _, err := h.sendEvent(evt) @@ -438,10 +455,53 @@ func TestUnilateralExitSelfLoopsWhileExiting(t *testing.T) { require.Empty( t, h.outboxMessages, - "self-loop while exiting should emit nothing", + "inert self-loop while exiting should emit nothing", ) } +// TestUnilateralExitReadmitsOnDuplicateForceUnroll verifies that a duplicate +// ForceUnrollEvent on an already-exiting VTXO is an idempotent re-admission: +// the VTXO stays in UnilateralExitState but re-emits the chain-resolver +// notification carrying the same trigger and exit policy, so a first admission +// lost before the registry recorded it (e.g. a crash between the status flip +// and the registry UpsertRecord) is re-driven. The registry dedups against a +// live record, so the redundant re-admit is harmless. +func TestUnilateralExitReadmitsOnDuplicateForceUnroll(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + + h.withState(&UnilateralExitState{ + VTXO: vtxo, + Reason: "vhtlc recovery", + LastCheckedHeight: 100, + }) + + _, err := h.sendEvent(&ForceUnrollEvent{ + Reason: "duplicate", + Trigger: actormsg.UnrollTriggerFraudSpend, + ExitPolicy: fn.Some(actormsg.ExitPolicy{ + Kind: actormsg.ExitPolicyVHTLCRefundWithoutReceiver, + Ref: actormsg.ExitPolicyRef("recovery-1"), + }), + }) + require.NoError(t, err) + assertState[*UnilateralExitState](h) + + // The re-admission emits the chain-resolver notification (and nothing + // else, in particular no status update: the VTXO is already exiting). + notif := assertOutboxContains[*ExpiringNotification](h) + require.Equal(t, actormsg.UnrollTriggerFraudSpend, notif.Trigger) + + policy := notif.ExitPolicy.UnwrapOrFail(t) + require.Equal( + t, actormsg.ExitPolicyVHTLCRefundWithoutReceiver, policy.Kind, + ) + require.Equal(t, actormsg.ExitPolicyRef("recovery-1"), policy.Ref) + require.Len(t, h.outboxMessages, 1) +} + // TestForfeitRequestFromLiveState verifies that LiveState transitions to // ForfeitingState on ForfeitRequest from round actor. func TestForfeitRequestFromLiveState(t *testing.T) { From 4d027a5ec3576af55cd13c2977d951edad5a520d Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 9 Jul 2026 15:27:55 -0700 Subject: [PATCH 02/11] vtxo: let the manager force-unroll a VTXO with no live actor In this commit, we teach Manager.handleForceUnroll to re-materialize a VTXO actor from its persisted descriptor when there is no live actor for the outpoint, then drive the ForceUnrollEvent through it carrying the request's trigger and exit policy. This is what lets the manager own the exit for triggers whose target is not a normal live coin. The vHTLC recovery target is materialized directly in the store and never had a manager actor, and any exiting VTXO that a restart left out of the live-recovery set (UnilateralExit is excluded from ListLiveVTXOs) has no actor either. Rather than let those callers admit the unroll registry behind the manager's back, the manager spawns the actor from the descriptor and runs the same transition every other trigger uses. A missing descriptor reports "no such vtxo" and a terminal descriptor reports "already terminal" instead of spawning an actor that would immediately reap itself, so the caller can tell a real transition apart from a no-op. --- vtxo/manager.go | 90 +++++++++++++++++++++++--- vtxo/manager_force_unroll_test.go | 104 ++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 vtxo/manager_force_unroll_test.go diff --git a/vtxo/manager.go b/vtxo/manager.go index 2d5378c3c..e219eadab 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -741,15 +741,19 @@ func (m *Manager) handleForceUnroll(ctx context.Context, actorRef, ok := m.actors[req.Outpoint] if !ok { + // No live actor. This is the common shape for the vHTLC + // recovery target and any exiting VTXO that a restart left out + // of the live-recovery set (UnilateralExit is excluded from + // ListLiveVTXOs). Re-materialize an actor from the persisted + // descriptor so the manager owns the exit for these triggers + // too, rather than letting the caller admit the unroll behind + // the manager's back. + spawned, res := m.spawnForceUnrollActor(ctx, req.Outpoint) + if res != nil { + return *res + } - // The VTXO actor is already gone (likely already terminal - // and cleaned up via handleVTXOTerminated). Report a - // specific reason so the caller can tell this apart from - // "event accepted but actor self-looped". - return fn.Ok[ManagerResp](&ForceUnrollResponse{ - Accepted: false, - Reason: "no such vtxo", - }) + actorRef = spawned } reason := req.Reason @@ -758,7 +762,9 @@ func (m *Manager) handleForceUnroll(ctx context.Context, } resp, err := actorRef.Ask(ctx, &ForceUnrollEvent{ - Reason: reason, + Reason: reason, + Trigger: req.Trigger, + ExitPolicy: req.ExitPolicy, }).Await(ctx).Unpack() if err != nil { return fn.Err[ManagerResp]( @@ -808,6 +814,72 @@ func (m *Manager) handleForceUnroll(ctx context.Context, }) } +// spawnForceUnrollActor re-materializes a VTXO actor from its persisted +// descriptor so handleForceUnroll can drive a force-unroll for a VTXO that has +// no live actor. This covers the vHTLC recovery target (materialized directly +// in the store, never admitted through the manager) and any exiting VTXO that +// a restart left out of the live-recovery set. It returns the spawned actor +// ref on success; on a miss (no descriptor) or a terminal descriptor it +// returns a non-nil *fn.Result carrying the ForceUnrollResponse the caller +// should return verbatim, so the transition is never attempted on a coin the +// manager cannot own. +func (m *Manager) spawnForceUnrollActor(ctx context.Context, + outpoint wire.OutPoint) (VTXOActorRef, *fn.Result[ManagerResp]) { + + descriptor, err := m.cfg.Store.GetVTXO(ctx, outpoint) + if err != nil { + res := fn.Err[ManagerResp]( + fmt.Errorf("load vtxo for force-unroll: %w", err), + ) + + return nil, &res + } + if descriptor == nil { + // No descriptor at all: the caller referenced an outpoint the + // wallet does not track. Report a specific reason so it reads + // apart from "accepted but self-looped". + res := fn.Ok[ManagerResp](&ForceUnrollResponse{ + Accepted: false, + Reason: "no such vtxo", + }) + + return nil, &res + } + + // A terminal descriptor (already Spent/Forfeited/Failed) has nothing + // left to unroll. Do not spawn an actor that would immediately reap + // itself; report the no-op so the caller sees it explicitly. + if statusToState( + ctx, descriptor, m.cfg.Store, m.logger(ctx), + ).IsTerminal() { + + res := fn.Ok[ManagerResp](&ForceUnrollResponse{ + Accepted: false, + Reason: "already terminal", + }) + + return nil, &res + } + + ref, err := m.spawnVTXOActor(ctx, descriptor) + if err != nil { + res := fn.Err[ManagerResp]( + fmt.Errorf("respawn vtxo actor for force-unroll: %w", + err), + ) + + return nil, &res + } + m.actors[outpoint] = ref + + m.logger(ctx).InfoS(ctx, "Re-materialized VTXO actor for force-unroll", + slog.String("outpoint", outpoint.String()), + slog.String("status", descriptor.Status.String()), + ) + + return ref, nil +} + // handleExitOutcome applies the terminal outcome of a unilateral-exit job // reported by the unroll subsystem. A recoverable failure (no on-chain // footprint) rolls the VTXO back to LiveState; a confirmed exit retires it diff --git a/vtxo/manager_force_unroll_test.go b/vtxo/manager_force_unroll_test.go new file mode 100644 index 000000000..eb8bdb6ab --- /dev/null +++ b/vtxo/manager_force_unroll_test.go @@ -0,0 +1,104 @@ +package vtxo + +import ( + "testing" + + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/darepo-client/lib/actormsg" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// TestHandleForceUnrollLiveActorTransitions verifies the manager drives a live +// VTXO actor into UnilateralExitState on a ForceUnrollRequest, so fraud and +// vHTLC recovery converge on the same admission gate as manual and +// critical-expiry exits. +func TestHandleForceUnrollLiveActorTransitions(t *testing.T) { + t.Parallel() + + vtxo := makeDescriptor(t, 50_000, 10) + mgr, _, ref := newExitTestManager(t, vtxo, &LiveState{ + VTXO: vtxo, + LastCheckedHeight: 100, + }) + + resp := mgr.Receive(t.Context(), &actormsg.ForceUnrollRequest{ + Outpoint: vtxo.Outpoint, + Reason: "recipient fraud spend", + Trigger: actormsg.UnrollTriggerFraudSpend, + }) + unpacked, err := resp.Unpack() + require.NoError(t, err) + + forceResp, ok := unpacked.(*ForceUnrollResponse) + require.True(t, ok) + require.True(t, forceResp.Accepted) + require.IsType(t, &UnilateralExitState{}, ref.state) +} + +// TestHandleForceUnrollAbsentActorNoDescriptor verifies that a force-unroll for +// an outpoint the wallet no longer tracks reports "no such vtxo" rather than +// spawning a phantom actor. +func TestHandleForceUnrollAbsentActorNoDescriptor(t *testing.T) { + t.Parallel() + + vtxo := makeDescriptor(t, 50_000, 11) + store := &MockVTXOStore{} + mgr := &Manager{ + cfg: &ManagerConfig{ + Store: store, + }, + actors: make(map[wire.OutPoint]VTXOActorRef), + } + + store.On("GetVTXO", t.Context(), vtxo.Outpoint).Return(nil, nil) + + resp := mgr.Receive(t.Context(), &actormsg.ForceUnrollRequest{ + Outpoint: vtxo.Outpoint, + Trigger: actormsg.UnrollTriggerManual, + }) + unpacked, err := resp.Unpack() + require.NoError(t, err) + + forceResp, ok := unpacked.(*ForceUnrollResponse) + require.True(t, ok) + require.False(t, forceResp.Accepted) + require.Equal(t, "no such vtxo", forceResp.Reason) + store.AssertExpectations(t) +} + +// TestHandleForceUnrollAbsentActorTerminalDescriptor verifies that a +// force-unroll for a VTXO whose persisted descriptor is already terminal +// (spent) is a reported no-op rather than respawning an actor that would +// immediately reap itself. +func TestHandleForceUnrollAbsentActorTerminalDescriptor(t *testing.T) { + t.Parallel() + + vtxo := makeDescriptor(t, 50_000, 12) + vtxo.Status = VTXOStatusSpent + store := &MockVTXOStore{} + mgr := &Manager{ + cfg: &ManagerConfig{ + Store: store, + }, + actors: make(map[wire.OutPoint]VTXOActorRef), + } + + store.On("GetVTXO", t.Context(), vtxo.Outpoint).Return(vtxo, nil) + + resp := mgr.Receive(t.Context(), &actormsg.ForceUnrollRequest{ + Outpoint: vtxo.Outpoint, + ExitPolicy: fn.Some(actormsg.ExitPolicy{ + Kind: actormsg.ExitPolicyVHTLCRefundWithoutReceiver, + Ref: actormsg.ExitPolicyRef("recovery-12"), + }), + }) + unpacked, err := resp.Unpack() + require.NoError(t, err) + + forceResp, ok := unpacked.(*ForceUnrollResponse) + require.True(t, ok) + require.False(t, forceResp.Accepted) + require.Equal(t, "already terminal", forceResp.Reason) + store.AssertExpectations(t) +} From fff776362b295197c2c22777738cda6be210361c Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 9 Jul 2026 15:29:21 -0700 Subject: [PATCH 03/11] fraud: route the recipient fraud exit through the VTXO manager In this commit, we point the recipient fraud watcher at the VTXO manager instead of the unroll registry. When a watched ancestor of a tracked OOR VTXO is seen spent on-chain, the watcher now Asks the manager to force the affected target into unilateral exit under TriggerFraudSpend, and the manager owns the state transition and starts the durable unroll job through its chain-resolver seam. Before this, fraud admitted the unroll registry directly, so the VTXO was never flipped to UnilateralExit: it stayed live in the store, leaked back into the live set on the next restart, and was invisible to the #400 orphan-recovery scan. Converging on the manager fixes both: the coin is persisted out of the live set the moment fraud fires, and the restart scan covers it. A declined transition (the coin is already terminal, or the wallet no longer tracks it) is logged rather than surfaced as a hard error: the fraud watch has done all it can, and failing would only wedge escalation for the other targets sharing the ancestor. --- darepod/server.go | 6 +-- fraud/actor.go | 48 +++++++++++++++++++----- fraud/actor_test.go | 89 ++++++++++++++++++++++++--------------------- 3 files changed, 88 insertions(+), 55 deletions(-) diff --git a/darepod/server.go b/darepod/server.go index 18b82463f..72c9b31d4 100644 --- a/darepod/server.go +++ b/darepod/server.go @@ -5590,9 +5590,9 @@ func (s *Server) initFraudWatcher(ctx context.Context, //nolint:contextcheck // watcher owns its own root context lifecycle watcher := fraud.NewWatcherActor(fraud.WatcherConfig{ - ChainSource: chainSourceRef, - UnrollRef: s.unrollRegistryRef.UnsafeFromSome(), - Log: fn.Some(s.subLogger(fraud.Subsystem)), + ChainSource: chainSourceRef, + VTXOManagerRef: s.vtxoMgrRef.UnsafeFromSome(), + Log: fn.Some(s.subLogger(fraud.Subsystem)), }) s.fraudWatcher = watcher s.fraudWatcherRef = fn.Some(watcher.Ref()) diff --git a/fraud/actor.go b/fraud/actor.go index f55b93a37..1f37b0f5b 100644 --- a/fraud/actor.go +++ b/fraud/actor.go @@ -9,7 +9,7 @@ import ( "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/darepo-client/baselib/actor" "github.com/lightninglabs/darepo-client/chainsource" - "github.com/lightninglabs/darepo-client/unroll" + "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/vtxo" fn "github.com/lightningnetwork/lnd/fn/v2" ) @@ -38,8 +38,14 @@ type WatcherConfig struct { chainsource.ChainSourceMsg, chainsource.ChainSourceResp, ] - // UnrollRef starts durable unroll jobs for affected targets. - UnrollRef actor.ActorRef[unroll.RegistryMsg, unroll.RegistryResp] + // VTXOManagerRef drives affected targets into unilateral exit through + // the VTXO manager's single admission gate. The manager transitions + // the VTXO to UnilateralExitState (persisting it out of the live set) + // and emits the chain-resolver notification that starts the durable + // unroll job under TriggerFraudSpend, so fraud escalation converges on + // the same path as manual and critical-expiry exits rather than + // admitting the registry job behind the manager's back. + VTXOManagerRef actor.ActorRef[vtxo.ManagerMsg, vtxo.ManagerResp] // Log is an optional logger. Log fn.Option[btclog.Logger] @@ -65,9 +71,10 @@ type trackedTarget struct { } // WatcherActor is the recipient fraud watcher: a passive ancestor-spend -// monitor that calls unroll.EnsureUnroll(..., TriggerFraudSpend) when -// any watched ancestor of a tracked OOR VTXO is observed spent on -// chain. It is both the public handle (Ref / Stop) and the +// monitor that forces the affected target into unilateral exit through the +// VTXO manager (ForceUnrollRequest under TriggerFraudSpend) when any watched +// ancestor of a tracked OOR VTXO is observed spent on chain. It is both the +// public handle (Ref / Stop) and the // actor.ActorBehavior implementation; the runtime is driven through // the embedded actor. type WatcherActor struct { @@ -445,18 +452,39 @@ func (w *WatcherActor) unregisterSpendWatchPoint(ctx context.Context, return nil } -// ensureUnroll asks the unroll registry to start (or reuse) a durable -// unroll job for one target VTXO under TriggerFraudSpend. +// ensureUnroll asks the VTXO manager to force one target VTXO into unilateral +// exit under TriggerFraudSpend. The manager owns the state transition and, via +// its chain-resolver seam, starts (or reuses) the durable unroll job. A +// declined transition (the coin is already terminal, or the wallet no longer +// tracks it) is logged rather than surfaced as a hard error: the fraud watch +// has done all it can, and neither case is one the watcher can drive forward, +// so failing would only wedge escalation for the other targets sharing the +// ancestor. func (w *WatcherActor) ensureUnroll(ctx context.Context, target wire.OutPoint) error { - _, err := w.cfg.UnrollRef.Ask(ctx, &unroll.EnsureUnrollRequest{ + resp, err := w.cfg.VTXOManagerRef.Ask(ctx, &actormsg.ForceUnrollRequest{ Outpoint: target, - Trigger: unroll.TriggerFraudSpend, + Reason: "recipient fraud spend", + Trigger: actormsg.UnrollTriggerFraudSpend, }).Await(ctx).Unpack() if err != nil { return fmt.Errorf("ensure fraud unroll for %s: %w", target, err) } + forceResp, ok := resp.(*actormsg.ForceUnrollResponse) + if !ok { + return fmt.Errorf("unexpected force-unroll response %T for %s", + resp, target) + } + + if !forceResp.Accepted { + w.log.WarnS(ctx, "VTXO manager declined fraud unroll", + nil, + slog.String("outpoint", target.String()), + slog.String("reason", forceResp.Reason), + ) + } + return nil } diff --git a/fraud/actor_test.go b/fraud/actor_test.go index 082ebf76e..fb90f9235 100644 --- a/fraud/actor_test.go +++ b/fraud/actor_test.go @@ -11,7 +11,7 @@ import ( "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/darepo-client/baselib/actor" "github.com/lightninglabs/darepo-client/chainsource" - "github.com/lightninglabs/darepo-client/unroll" + "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/vtxo" fn "github.com/lightningnetwork/lnd/fn/v2" "github.com/stretchr/testify/require" @@ -123,32 +123,36 @@ func (f *fakeChainSourceRef) emitSpend(t *testing.T, outpoint wire.OutPoint) { ) } -type fakeUnrollRef struct { +// fakeManagerRef stands in for the VTXO manager: it records the +// ForceUnrollRequests the fraud watcher sends and replies with a +// ForceUnrollResponse. A non-nil err makes the Ask fail so the fanout +// best-effort behavior can be exercised. +type fakeManagerRef struct { mu sync.Mutex - requests []*unroll.EnsureUnrollRequest + requests []*actormsg.ForceUnrollRequest err error } // ID returns the fake actor ID. -func (f *fakeUnrollRef) ID() string { - return "fake-unroll" +func (f *fakeManagerRef) ID() string { + return "fake-vtxo-manager" } // Tell is unused by these tests. -func (f *fakeUnrollRef) Tell(context.Context, unroll.RegistryMsg) error { +func (f *fakeManagerRef) Tell(context.Context, vtxo.ManagerMsg) error { return nil } -// Ask records ensure-unroll requests. -func (f *fakeUnrollRef) Ask(_ context.Context, - msg unroll.RegistryMsg) actor.Future[unroll.RegistryResp] { +// Ask records force-unroll requests. +func (f *fakeManagerRef) Ask(_ context.Context, + msg vtxo.ManagerMsg) actor.Future[vtxo.ManagerResp] { - promise := actor.NewPromise[unroll.RegistryResp]() - req, ok := msg.(*unroll.EnsureUnrollRequest) + promise := actor.NewPromise[vtxo.ManagerResp]() + req, ok := msg.(*actormsg.ForceUnrollRequest) if !ok { promise.Complete( - fn.Err[unroll.RegistryResp]( - fmt.Errorf("unexpected unroll msg %T", msg), + fn.Err[vtxo.ManagerResp]( + fmt.Errorf("unexpected manager msg %T", msg), ), ) @@ -159,16 +163,15 @@ func (f *fakeUnrollRef) Ask(_ context.Context, f.requests = append(f.requests, req) f.mu.Unlock() if f.err != nil { - promise.Complete(fn.Err[unroll.RegistryResp](f.err)) + promise.Complete(fn.Err[vtxo.ManagerResp](f.err)) return promise.Future() } promise.Complete( - fn.Ok[unroll.RegistryResp]( - &unroll.EnsureUnrollResp{ - ActorID: "child", - Created: true, + fn.Ok[vtxo.ManagerResp]( + &actormsg.ForceUnrollResponse{ + Accepted: true, }, ), ) @@ -176,7 +179,9 @@ func (f *fakeUnrollRef) Ask(_ context.Context, return promise.Future() } -func (f *fakeUnrollRef) lastRequest(t *testing.T) *unroll.EnsureUnrollRequest { +func (f *fakeManagerRef) lastRequest( + t *testing.T) *actormsg.ForceUnrollRequest { + t.Helper() f.mu.Lock() @@ -187,8 +192,8 @@ func (f *fakeUnrollRef) lastRequest(t *testing.T) *unroll.EnsureUnrollRequest { return f.requests[len(f.requests)-1] } -// requestCount returns the number of recorded ensure-unroll requests. -func (f *fakeUnrollRef) requestCount() int { +// requestCount returns the number of recorded force-unroll requests. +func (f *fakeManagerRef) requestCount() int { f.mu.Lock() defer f.mu.Unlock() @@ -203,11 +208,11 @@ func TestWatcherTriggersUnrollOnAncestorSpend(t *testing.T) { desc := testDescriptor(target, treePath) chainRef := &fakeChainSourceRef{} - unrollRef := &fakeUnrollRef{} + managerRef := &fakeManagerRef{} watcher := NewWatcherActor(WatcherConfig{ - ChainSource: chainRef, - UnrollRef: unrollRef, - Log: fn.None[btclog.Logger](), + ChainSource: chainRef, + VTXOManagerRef: managerRef, + Log: fn.None[btclog.Logger](), }) t.Cleanup(watcher.Stop) @@ -223,12 +228,12 @@ func TestWatcherTriggersUnrollOnAncestorSpend(t *testing.T) { chainRef.emitSpend(t, source) require.Eventually(t, func() bool { - return unrollRef.requestCount() == 1 + return managerRef.requestCount() == 1 }, testTimeout, 10*time.Millisecond) - req := unrollRef.lastRequest(t) + req := managerRef.lastRequest(t) require.Equal(t, target, req.Outpoint) - require.Equal(t, unroll.TriggerFraudSpend, req.Trigger) + require.Equal(t, actormsg.UnrollTriggerFraudSpend, req.Trigger) untrackResp, err := watcher.Ref().Ask( t.Context(), &UntrackRequest{TargetOutpoint: target}, @@ -257,9 +262,9 @@ func TestWatcherTracksOnlyLiveOORVTXOs(t *testing.T) { chainRef := &fakeChainSourceRef{} watcher := NewWatcherActor(WatcherConfig{ - ChainSource: chainRef, - UnrollRef: &fakeUnrollRef{}, - Log: fn.None[btclog.Logger](), + ChainSource: chainRef, + VTXOManagerRef: &fakeManagerRef{}, + Log: fn.None[btclog.Logger](), }) t.Cleanup(watcher.Stop) @@ -289,9 +294,9 @@ func TestWatcherRefcountsSharedWatchOutpoints(t *testing.T) { chainRef := &fakeChainSourceRef{} watcher := NewWatcherActor(WatcherConfig{ - ChainSource: chainRef, - UnrollRef: &fakeUnrollRef{}, - Log: fn.None[btclog.Logger](), + ChainSource: chainRef, + VTXOManagerRef: &fakeManagerRef{}, + Log: fn.None[btclog.Logger](), }) t.Cleanup(watcher.Stop) @@ -335,9 +340,9 @@ func TestWatcherBestEffortTrackKeepsValidDescriptors(t *testing.T) { chainRef := &fakeChainSourceRef{} watcher := NewWatcherActor(WatcherConfig{ - ChainSource: chainRef, - UnrollRef: &fakeUnrollRef{}, - Log: fn.None[btclog.Logger](), + ChainSource: chainRef, + VTXOManagerRef: &fakeManagerRef{}, + Log: fn.None[btclog.Logger](), }) t.Cleanup(watcher.Stop) @@ -353,12 +358,12 @@ func TestWatcherBestEffortTrackKeepsValidDescriptors(t *testing.T) { // attempted. func TestWatcherSpendFanoutBestEffort(t *testing.T) { treePath, source := testLeafTree(t, 50) - unrollRef := &fakeUnrollRef{err: fmt.Errorf("admission failed")} + managerRef := &fakeManagerRef{err: fmt.Errorf("admission failed")} chainRef := &fakeChainSourceRef{} watcher := NewWatcherActor(WatcherConfig{ - ChainSource: chainRef, - UnrollRef: unrollRef, - Log: fn.None[btclog.Logger](), + ChainSource: chainRef, + VTXOManagerRef: managerRef, + Log: fn.None[btclog.Logger](), }) t.Cleanup(watcher.Stop) @@ -376,5 +381,5 @@ func TestWatcherSpendFanoutBestEffort(t *testing.T) { Height: 33, }).Await(t.Context()).Unpack() require.Error(t, err) - require.Equal(t, 2, unrollRef.requestCount()) + require.Equal(t, 2, managerRef.requestCount()) } From 4fcf1a545f85dd0d2e0a68b34f58cecce5004f9d Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 9 Jul 2026 15:29:39 -0700 Subject: [PATCH 04/11] darepod+vhtlcrecovery: hand vHTLC recovery exit to the VTXO manager In this commit, we route vHTLC recovery through the VTXO manager and wire the chain-resolver bridge that converts a manager exit notification into a registry admission, completing the unification of all four unroll triggers (manual, critical expiry, fraud, vHTLC) behind the manager's single admission gate. The recovery coordinator now hands off to the manager's ForceExit seam instead of admitting the unroll registry itself, carrying the recovery row's exit policy so the registry records the refund policy rather than the standard timeout. The materializer persists the recovery target directly into VTXOStatusUnilateralExit rather than Spending: Spending is returned by ListLiveVTXOs (status < 3 OR status = 7), so a Spending target leaked back into the live set on restart and poisoned sweep-all, which offered the already-exiting coin as a forfeit and got the whole round rejected with "forfeit VTXO is not live: status is unrolled_by_client". This is the root cause of the reported wedge. Admission is asynchronous now, so the coordinator no longer reads the registry record back for a synchronous policy check on the happy path. It keeps a best-effort guard: a visible record under a different policy still fails the recovery closed, while a not-yet-visible record is left to the registry's own validation and the restart re-drive. On the darepod side, the chain-resolver bridge maps the threaded trigger and exit policy back into unroll.StartTrigger / unroll.ExitPolicyKind (an empty trigger stays critical expiry, matching the auto-expiry default). We also restore in-flight recovery jobs before the generic orphan scan: restore drives the policy-bearing admission, which the LazyChainResolver buffers and replays to the registry the instant it is wired, and the registry is first-writer-wins on exit policy, so the no-policy orphan scan must run after that replay or a refund target would silently exit under the standard timeout. --- darepod/rpc_vhtlc_recovery_test.go | 8 +- darepod/server.go | 121 ++++++++++++++++------ darepod/unroll_bridge_test.go | 79 ++++++++++++++ darepod/vhtlc_recovery_target.go | 56 +++++++++- vhtlcrecovery/coordinator/service.go | 116 ++++++++++++--------- vhtlcrecovery/coordinator/service_test.go | 61 +++++------ 6 files changed, 331 insertions(+), 110 deletions(-) create mode 100644 darepod/unroll_bridge_test.go diff --git a/darepod/rpc_vhtlc_recovery_test.go b/darepod/rpc_vhtlc_recovery_test.go index e0516ffe6..19dcfb828 100644 --- a/darepod/rpc_vhtlc_recovery_test.go +++ b/darepod/rpc_vhtlc_recovery_test.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/darepo-client/daemonrpc" "github.com/lightninglabs/darepo-client/db" + "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/unroll" "github.com/lightninglabs/darepo-client/vhtlcrecovery" "github.com/lightninglabs/darepo-client/vhtlcrecovery/coordinator" @@ -52,6 +53,7 @@ func TestCancelVHTLCRecoveryMissingIsIdempotent(t *testing.T) { coordinator.ServiceConfig{ Store: missingRecoveryStore{}, Unroll: noopUnrollRegistry{}, + Exiter: noopUnrollRegistry{}, }, ) require.NoError(t, err) @@ -157,10 +159,10 @@ func (missingRecoveryStore) FailRecovery(context.Context, string, error) error { type noopUnrollRegistry struct{} -func (noopUnrollRegistry) EnsureUnroll(context.Context, - unroll.EnsureUnrollRequest) (*unroll.EnsureUnrollResp, error) { +func (noopUnrollRegistry) ForceExit(context.Context, + actormsg.ForceUnrollRequest) error { - return nil, errors.New("unexpected ensure unroll") + return errors.New("unexpected force exit") } func (noopUnrollRegistry) GetStatus(context.Context, wire.OutPoint) ( diff --git a/darepod/server.go b/darepod/server.go index 72c9b31d4..e7839bd45 100644 --- a/darepod/server.go +++ b/darepod/server.go @@ -5317,10 +5317,17 @@ func (s *Server) initUnrollSubsystem(ctx context.Context, s.unrollRegistry = registry s.unrollRegistryRef = fn.Some(registry.Ref()) + if !s.vtxoMgrRef.IsSome() { + return fmt.Errorf("VTXO manager not initialized for vhtlc " + + "recovery") + } recoverySvc, err := coordinator.NewService(coordinator.ServiceConfig{ Store: recoveryStore, Unroll: coordinator.NewActorUnrollRegistry(registry.Ref()), - Log: fn.Some(s.subLogger(VHTLCRecoverySubsystem)), + Exiter: managerExitAdmitter{ + mgr: s.vtxoMgrRef.UnsafeFromSome(), + }, + Log: fn.Some(s.subLogger(VHTLCRecoverySubsystem)), TargetMaterializer: newVHTLCRecoveryTargetMaterializer( vtxoStore, oorStore, s.subLogger(VHTLCRecoverySubsystem), @@ -5349,27 +5356,19 @@ func (s *Server) initUnrollSubsystem(ctx context.Context, return fmt.Errorf("restore non-terminal unroll jobs: %w", err) } - // 3a. Convergent boot-time recovery for VTXOs that are already in - // VTXOStatusUnilateralExit in the VTXO store but have no matching - // unroll registry record. The two writes are not atomic: the VTXO - // actor flips status in its own DB tx and then Tells the chain - // resolver, which eventually triggers a separate registry - // UpsertRecord. A crash, full mailbox, or context cancel between - // those steps leaves the VTXO terminal-from-the-manager's - // perspective (it will not respawn a child actor) while the - // registry has nothing to drive forward. Without this scan such a - // VTXO stays stranded until the next manual EnsureUnroll. The - // scan is convergent: EnsureUnrollRequest dedups against - // r.active / r.pending / store.GetRecord, so a target that - // already has a record (e.g. just restored above) is a benign - // no-op. Per-target failures are collected and returned after the - // scan so startup fails closed instead of serving traffic with a - // known-stranded VTXO. - if err := s.recoverOrphanedUnrollJobs( - ctx, vtxoStore, registry, - ); err != nil { - return fmt.Errorf("recover orphaned unroll jobs: %w", err) - } + // 3a. Restore in-flight vHTLC recovery jobs BEFORE the generic orphan + // scan below, and before the chain resolver is wired. Restore drives + // each job through the VTXO manager's force-exit, which emits the + // admission notification carrying the job's exit policy (e.g. a vHTLC + // refund). While the chain resolver target is still unset, those + // notifications are buffered by the LazyChainResolver and replayed to + // the registry the instant it is wired (step 4). That replay is what + // makes the policy-bearing admission reach the registry first: the + // registry is first-writer-wins on exit policy, so the generic orphan + // scan (step 5, no policy) must not create the record before the + // refund-policy admission lands, or the target would silently exit + // under the standard timeout policy. Restore failures are non-fatal; + // the job is retried on the next escalation or restart. if err := recoverySvc.RestoreNonTerminal(ctx); err != nil { s.log.WarnS(ctx, "Failed to restore vhtlc recovery jobs", err) @@ -5380,10 +5379,7 @@ func (s *Server) initUnrollSubsystem(ctx context.Context, chainResolverRef := actor.NewMapInputRef( registry.Ref(), func(msg vtxo.ExpiringNotification) unroll.RegistryMsg { - return &unroll.EnsureUnrollRequest{ - Outpoint: msg.VTXO.Outpoint, - Trigger: unroll.TriggerCriticalExpiry, - } + return ensureUnrollFromExpiring(msg) }, ) @@ -5395,11 +5391,81 @@ func (s *Server) initUnrollSubsystem(ctx context.Context, s.lazyChainResolver.Set(chainResolverRef) } + // 5. Convergent boot-time recovery for VTXOs that are already in + // VTXOStatusUnilateralExit in the VTXO store but have no matching + // unroll registry record. The two writes are not atomic: the VTXO + // actor flips status in its own DB tx and then Tells the chain + // resolver, which eventually triggers a separate registry UpsertRecord. + // A crash, full mailbox, or context cancel between those steps leaves + // the VTXO terminal-from-the-manager's perspective (it will not respawn + // a child actor) while the registry has nothing to drive forward. + // Without this scan such a VTXO stays stranded until the next manual + // EnsureUnroll. The scan is convergent: EnsureUnrollRequest dedups + // against r.active / r.pending / store.GetRecord, so a target that + // already has a record (e.g. a vHTLC recovery whose refund-policy + // admission was just replayed by the Set above) is a benign no-op that + // preserves the existing policy. It runs AFTER the chain resolver is + // wired so those replayed policy-bearing admissions win the + // first-writer-wins registry record before this no-policy scan can + // claim it. Per-target failures are collected and returned after the + // scan so startup fails closed instead of serving traffic with a + // known-stranded VTXO. + if err := s.recoverOrphanedUnrollJobs( + ctx, vtxoStore, registry, + ); err != nil { + return fmt.Errorf("recover orphaned unroll jobs: %w", err) + } + s.log.InfoS(ctx, "Unroll subsystem initialized") return nil } +// ensureUnrollFromExpiring maps a VTXO manager ExpiringNotification into the +// unroll registry's EnsureUnrollRequest. It is the seam that converts the +// string-typed trigger and optional exit policy carried on the notification +// (kept string-typed to avoid a vtxo->unroll import cycle) back into the +// unroll package's own types. A None ExitPolicy leaves the registry on its +// standard VTXO timeout policy. +func ensureUnrollFromExpiring( + msg vtxo.ExpiringNotification) *unroll.EnsureUnrollRequest { + + req := &unroll.EnsureUnrollRequest{ + Outpoint: msg.VTXO.Outpoint, + Trigger: unrollStartTrigger(msg.Trigger), + } + + msg.ExitPolicy.WhenSome(func(p actormsg.ExitPolicy) { + req.ExitPolicyKind = unroll.ExitPolicyKind(p.Kind) + req.ExitPolicyRef = string(p.Ref) + }) + + return req +} + +// unrollStartTrigger converts the string-typed UnrollTrigger that rides the +// ForceUnroll path back into the unroll package's StartTrigger. The trigger is +// carried as a string on the vtxo/actormsg side to avoid an import cycle +// (unroll already imports vtxo); this bridge is the seam where both packages +// are in scope. An empty/unknown trigger admits as critical expiry, preserving +// the auto-expiry default and the historical behavior of manual exits, which +// carried no explicit trigger. +func unrollStartTrigger(t actormsg.UnrollTrigger) unroll.StartTrigger { + switch t { + case actormsg.UnrollTriggerManual: + return unroll.TriggerManual + + case actormsg.UnrollTriggerFraudSpend: + return unroll.TriggerFraudSpend + + case actormsg.UnrollTriggerCriticalExpiry: + return unroll.TriggerCriticalExpiry + + default: + return unroll.TriggerCriticalExpiry + } +} + // recoverOrphanedUnrollJobs closes the atomicity gap between the VTXO // store's status flip to VTXOStatusUnilateralExit and the unroll // registry's UpsertRecord (#400). It lists every VTXO that the store @@ -5581,9 +5647,6 @@ func (s *Server) initFraudWatcher(ctx context.Context, ], ) error { - if !s.unrollRegistryRef.IsSome() { - return fmt.Errorf("unroll registry not initialized") - } if !s.vtxoMgrRef.IsSome() { return fmt.Errorf("VTXO manager not initialized") } diff --git a/darepod/unroll_bridge_test.go b/darepod/unroll_bridge_test.go new file mode 100644 index 000000000..fa88b84a2 --- /dev/null +++ b/darepod/unroll_bridge_test.go @@ -0,0 +1,79 @@ +package darepod + +import ( + "testing" + + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/darepo-client/lib/actormsg" + "github.com/lightninglabs/darepo-client/unroll" + "github.com/lightninglabs/darepo-client/vtxo" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// TestUnrollStartTrigger verifies the string-typed UnrollTrigger that rides the +// ForceUnroll path maps back onto the right unroll.StartTrigger, and that an +// empty or unknown trigger falls back to critical expiry (preserving the +// auto-expiry default and the historical manual-exit behavior). +func TestUnrollStartTrigger(t *testing.T) { + t.Parallel() + + require.Equal( + t, unroll.TriggerCriticalExpiry, + unrollStartTrigger(actormsg.UnrollTriggerCriticalExpiry), + ) + require.Equal( + t, unroll.TriggerManual, + unrollStartTrigger(actormsg.UnrollTriggerManual), + ) + require.Equal( + t, unroll.TriggerFraudSpend, + unrollStartTrigger(actormsg.UnrollTriggerFraudSpend), + ) + require.Equal( + t, unroll.TriggerCriticalExpiry, + unrollStartTrigger( + actormsg.UnrollTrigger("not-a-real-trigger"), + ), + ) +} + +// TestEnsureUnrollFromExpiring verifies the chain-resolver bridge threads the +// trigger and optional exit policy from a VTXO ExpiringNotification into the +// registry EnsureUnrollRequest, and that a None policy keeps the registry on +// its standard timeout policy. +func TestEnsureUnrollFromExpiring(t *testing.T) { + t.Parallel() + + outpoint := wire.OutPoint{Hash: chainhash.Hash{0xaa}, Index: 3} + + // A vHTLC refund carries an explicit trigger and exit policy: both must + // survive into the registry request. + withPolicy := ensureUnrollFromExpiring(vtxo.ExpiringNotification{ + VTXO: &vtxo.Descriptor{Outpoint: outpoint}, + Trigger: actormsg.UnrollTriggerManual, + ExitPolicy: fn.Some(actormsg.ExitPolicy{ + Kind: actormsg.ExitPolicyVHTLCRefundWithoutReceiver, + Ref: actormsg.ExitPolicyRef("recovery-42"), + }), + }) + require.Equal(t, outpoint, withPolicy.Outpoint) + require.Equal(t, unroll.TriggerManual, withPolicy.Trigger) + require.Equal( + t, unroll.ExitPolicyKind( + actormsg.ExitPolicyVHTLCRefundWithoutReceiver, + ), + withPolicy.ExitPolicyKind, + ) + require.Equal(t, "recovery-42", withPolicy.ExitPolicyRef) + + // A critical-expiry notification carries no policy: the registry + // request must leave the policy identity empty (standard timeout). + autoExpiry := ensureUnrollFromExpiring(vtxo.ExpiringNotification{ + VTXO: &vtxo.Descriptor{Outpoint: outpoint}, + }) + require.Equal(t, unroll.TriggerCriticalExpiry, autoExpiry.Trigger) + require.Empty(t, autoExpiry.ExitPolicyKind) + require.Empty(t, autoExpiry.ExitPolicyRef) +} diff --git a/darepod/vhtlc_recovery_target.go b/darepod/vhtlc_recovery_target.go index 96b258aaf..5fa5fbfa8 100644 --- a/darepod/vhtlc_recovery_target.go +++ b/darepod/vhtlc_recovery_target.go @@ -13,7 +13,9 @@ import ( "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/db" + "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/lib/arkscript" "github.com/lightninglabs/darepo-client/vhtlcrecovery" "github.com/lightninglabs/darepo-client/vtxo" @@ -21,6 +23,43 @@ import ( "github.com/lightningnetwork/lnd/lntypes" ) +// managerExitAdmitter drives a vHTLC recovery target into unilateral exit +// through the VTXO manager, satisfying coordinator.ExitAdmitter. The manager +// owns the state transition (persisting the target into UnilateralExit, out of +// the live set) and starts the durable unroll job through its chain-resolver +// seam, so a vHTLC exit converges on the same admission path as manual, +// critical-expiry, and fraud exits. +type managerExitAdmitter struct { + mgr actor.ActorRef[vtxo.ManagerMsg, vtxo.ManagerResp] +} + +// ForceExit asks the VTXO manager to force the target into unilateral exit, +// returning an error unless the manager accepted the transition. A declined +// transition is an error here (unlike the fraud path): recovery just +// materialized the descriptor, so the manager should always be able to own it, +// and a decline is a real inconsistency the escalation/restore retry should +// surface rather than swallow. +func (a managerExitAdmitter) ForceExit(ctx context.Context, + req actormsg.ForceUnrollRequest) error { + + resp, err := a.mgr.Ask(ctx, &req).Await(ctx).Unpack() + if err != nil { + return fmt.Errorf("force exit ask: %w", err) + } + + forceResp, ok := resp.(*actormsg.ForceUnrollResponse) + if !ok { + return fmt.Errorf("unexpected force-unroll response %T", resp) + } + + if !forceResp.Accepted { + return fmt.Errorf("vtxo manager declined vhtlc exit: %s", + forceResp.Reason) + } + + return nil +} + var errRecoveryTargetPackageMissing = errors.New("recovery target package " + "not found") @@ -82,10 +121,23 @@ func (m *vhtlcRecoveryTargetMaterializer) EnsureRecoveryTarget( return fmt.Errorf("save recovery target descriptor: %w", err) } + // Persist the recovery target directly into VTXOStatusUnilateralExit + // rather than Spending. Spending is returned by ListLiveVTXOs (the + // live-recovery and coin-selection query is status < 3 OR status = 7), + // so a Spending recovery target leaks back into the live set on the + // next daemon restart and poisons cooperative consumption: sweep-all + // offers the already-exiting coin as a forfeit and the operator + // rejects the whole round with "forfeit VTXO is not live: status is + // unrolled_by_client". UnilateralExit is excluded from that query and + // is covered by the #400 restart orphan scan, which is exactly where an + // exiting coin belongs. The VTXO manager's force-exit (driven right + // after this materialization) spawns the actor straight into + // UnilateralExitState from this status and re-emits the chain-resolver + // admission. if err := m.vtxos.UpdateVTXOStatus( - ctx, desc.Outpoint, vtxo.VTXOStatusSpending, + ctx, desc.Outpoint, vtxo.VTXOStatusUnilateralExit, ); err != nil { - return fmt.Errorf("reserve recovery target descriptor: %w", err) + return fmt.Errorf("mark recovery target exiting: %w", err) } if err := m.bindRecoveryTarget(ctx, desc); err != nil { diff --git a/vhtlcrecovery/coordinator/service.go b/vhtlcrecovery/coordinator/service.go index aed51fc23..dacaaf0d8 100644 --- a/vhtlcrecovery/coordinator/service.go +++ b/vhtlcrecovery/coordinator/service.go @@ -10,6 +10,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/lib/actormsg" "github.com/lightninglabs/darepo-client/unroll" "github.com/lightninglabs/darepo-client/vhtlcrecovery" fn "github.com/lightningnetwork/lnd/fn/v2" @@ -56,22 +57,33 @@ type Store interface { FailRecovery(ctx context.Context, id string, failure error) error } -// UnrollRegistry is the small unroll control surface used by recovery. It is -// narrower than the actor ref so tests can model admission/status without -// spinning up the full unroll subsystem. +// UnrollRegistry is the small unroll status surface used by recovery. It is +// narrower than the actor ref so tests can model status without spinning up +// the full unroll subsystem. Admission no longer lives here: recovery forces +// the exit through the VTXO manager (see ExitAdmitter) so the target is +// visible to the manager and to restart recovery, and only reads status back +// from the registry. type UnrollRegistry interface { - // EnsureUnroll admits or deduplicates one unroll target. - EnsureUnroll(ctx context.Context, - req unroll.EnsureUnrollRequest) ( - *unroll.EnsureUnrollResp, - error, - ) - // GetStatus returns the current registry view for one target. GetStatus(ctx context.Context, target wire.OutPoint) (*unroll.GetStatusResp, error) } +// ExitAdmitter forces a recovery target into unilateral exit through the VTXO +// manager's single admission gate. The manager owns the state transition +// (persisting the target into VTXOStatusUnilateralExit, out of the live set) +// and, via its chain-resolver seam, starts the durable unroll job under the +// request's exit policy. Recovery hands off to the manager rather than +// admitting the registry job directly so a vHTLC exit converges on the same +// path as manual, critical-expiry, and fraud exits: the manager knows the +// coin is exiting, and the #400 restart orphan scan covers it. +type ExitAdmitter interface { + // ForceExit drives one target into unilateral exit and returns once + // the manager has accepted (or declined) the transition. The registry + // job is started asynchronously through the manager's outbox. + ForceExit(ctx context.Context, req actormsg.ForceUnrollRequest) error +} + // TargetMaterializer ensures the vHTLC target has the local descriptor and // package bindings that generic unroll needs before the recovery service admits // the target. Implementations are domain adapters: the coordinator owns the @@ -98,24 +110,6 @@ func NewActorUnrollRegistry(ref actor.ActorRef[ return ActorUnrollRegistry{ref: ref} } -// EnsureUnroll asks the live unroll registry to admit one target. -func (r ActorUnrollRegistry) EnsureUnroll(ctx context.Context, - req unroll.EnsureUnrollRequest) (*unroll.EnsureUnrollResp, error) { - - resp, err := r.ref.Ask(ctx, &req).Await(ctx).Unpack() - if err != nil { - return nil, err - } - - ensureResp, ok := resp.(*unroll.EnsureUnrollResp) - if !ok { - return nil, fmt.Errorf("unexpected unroll ensure response %T", - resp) - } - - return ensureResp, nil -} - // GetStatus asks the live unroll registry for one target's current status. func (r ActorUnrollRegistry) GetStatus(ctx context.Context, target wire.OutPoint) (*unroll.GetStatusResp, error) { @@ -141,9 +135,13 @@ type ServiceConfig struct { // Store persists recovery jobs and terminal reconciliation. Store Store - // Unroll admits and queries the generic unroll subsystem. + // Unroll queries the generic unroll subsystem for per-target status. Unroll UnrollRegistry + // Exiter forces a recovery target into unilateral exit through the + // VTXO manager's admission gate. + Exiter ExitAdmitter + // Log is an optional structured subsystem logger. Log fn.Option[btclog.Logger] @@ -172,12 +170,13 @@ type RecoveryStatus struct { type Service struct { store Store unroll UnrollRegistry + exiter ExitAdmitter targetMaterializer TargetMaterializer log btclog.Logger } -// NewService creates a vHTLC recovery service from durable storage and the -// unroll admission/status surface. +// NewService creates a vHTLC recovery service from durable storage, the unroll +// status surface, and the VTXO manager exit-admission seam. func NewService(cfg ServiceConfig) (*Service, error) { if cfg.Store == nil { return nil, fmt.Errorf("vhtlc recovery store is required") @@ -185,10 +184,14 @@ func NewService(cfg ServiceConfig) (*Service, error) { if cfg.Unroll == nil { return nil, fmt.Errorf("unroll registry is required") } + if cfg.Exiter == nil { + return nil, fmt.Errorf("exit admitter is required") + } return &Service{ store: cfg.Store, unroll: cfg.Unroll, + exiter: cfg.Exiter, targetMaterializer: cfg.TargetMaterializer, log: cfg.Log.UnwrapOr(btclog.Disabled), }, nil @@ -444,8 +447,17 @@ func (s *Service) RestoreNonTerminal(ctx context.Context) error { } // ensureUnroll admits the target into unroll using the recovery row's durable -// exit policy identity and verifies that any pre-existing unroll job did not -// claim the same target with a different policy. +// exit policy identity, then forces the target into unilateral exit through +// the VTXO manager, which owns the transition and starts the durable unroll +// job through its chain-resolver seam. +// +// Admission is asynchronous now: the manager Ask returns once the VTXO is +// transitioned to UnilateralExitState, but the registry job is started by the +// manager's outbox, so the coordinator no longer reads the registry record +// back for synchronous policy-conflict verification. The registry admission +// boundary still validates the (kind, ref) identity, and the recovery row's +// durable policy is re-driven on restart, so the exit policy survives without +// the inline check. func (s *Service) ensureUnroll(ctx context.Context, job vhtlcrecovery.RecoveryJob) error { @@ -458,37 +470,47 @@ func (s *Service) ensureUnroll(ctx context.Context, } } - resp, err := s.unroll.EnsureUnroll(ctx, unroll.EnsureUnrollRequest{ - Outpoint: job.VTXOOutpoint, - Trigger: unroll.TriggerManual, - ExitPolicyKind: unroll.ExitPolicyKind(job.ExitPolicyKind), - ExitPolicyRef: job.ID, + err := s.exiter.ForceExit(ctx, actormsg.ForceUnrollRequest{ + Outpoint: job.VTXOOutpoint, + Reason: "vhtlc recovery", + Trigger: actormsg.UnrollTriggerManual, + ExitPolicy: fn.Some(actormsg.ExitPolicy{ + Kind: actormsg.ExitPolicyKind(job.ExitPolicyKind), + Ref: actormsg.ExitPolicyRef(job.ID), + }), }) if err != nil { - return err + return fmt.Errorf("force vhtlc recovery exit: %w", err) } - s.log.InfoS(ctx, "starting unroll for vhtlc recovery", + s.log.InfoS(ctx, "forced vhtlc recovery exit through vtxo manager", append( recoveryLogAttrs(job), - slog.String("unroll_actor_id", resp.ActorID), - slog.Bool("created", resp.Created), slog.String("vtxo_outpoint", job.VTXOOutpoint.String()), + slog.String("exit_policy_kind", job.ExitPolicyKind), slog.String("exit_policy_ref", job.ID), )..., ) + // Best-effort policy-conflict guard. The registry admission boundary is + // first-writer-wins and does not reject a later request that names a + // different policy, so a pre-existing unroll record (e.g. a standard + // timeout exit that claimed this outpoint first) would silently keep + // its policy while this recovery believes it exits under the refund + // policy. Fail the recovery in that case rather than exit under the + // wrong policy. A not-yet-visible record is the normal case now that + // admission is asynchronous through the manager, so it is left to the + // registry's own validation plus the restart re-drive, not treated as + // an error. status, err := s.unroll.GetStatus(ctx, job.VTXOOutpoint) if err != nil { - s.log.WarnS(ctx, "unable to verify vhtlc recovery "+ - "unroll status after admission", err, - recoveryLogAttrs(job)...) + s.log.WarnS(ctx, "unable to verify vhtlc recovery unroll "+ + "status after force exit", err, recoveryLogAttrs(job)...) return nil } if !status.Found { - return fmt.Errorf("unroll admission returned without visible " + - "status") + return nil } if status.ExitPolicyKind != "" && string(status.ExitPolicyKind) != job.ExitPolicyKind { diff --git a/vhtlcrecovery/coordinator/service_test.go b/vhtlcrecovery/coordinator/service_test.go index d315850c1..b17f6a304 100644 --- a/vhtlcrecovery/coordinator/service_test.go +++ b/vhtlcrecovery/coordinator/service_test.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/unroll" "github.com/lightninglabs/darepo-client/vhtlcrecovery" "github.com/stretchr/testify/require" @@ -38,15 +39,17 @@ func TestServiceEscalatePersistsBeforeUnroll(t *testing.T) { ) require.NoError(t, err) require.Equal(t, vhtlcrecovery.StateUnrollStarted, status.Job.State) - require.Len(t, registry.ensureRequests, 1) + require.Len(t, registry.exitRequests, 1) - req := registry.ensureRequests[0] + req := registry.exitRequests[0] require.Equal(t, job.VTXOOutpoint, req.Outpoint) + require.Equal(t, actormsg.UnrollTriggerManual, req.Trigger) + + policy := req.ExitPolicy.UnwrapOrFail(t) require.Equal( - t, unroll.ExitPolicyKind(job.ExitPolicyKind), - req.ExitPolicyKind, + t, actormsg.ExitPolicyKind(job.ExitPolicyKind), policy.Kind, ) - require.Equal(t, job.ID, req.ExitPolicyRef) + require.Equal(t, actormsg.ExitPolicyRef(job.ID), policy.Ref) require.Equal(t, []string{"escalate"}, store.events) } @@ -94,7 +97,7 @@ func TestServiceEscalateKeepsRecoveryActiveAfterStatusProbeError(t *testing.T) { t.Context(), job.ID, "cooperative path unsafe", nil, ) require.ErrorContains(t, err, "status probe timed out") - require.Len(t, registry.ensureRequests, 1) + require.Len(t, registry.exitRequests, 1) stored, err := store.GetRecovery(t.Context(), job.ID) require.NoError(t, err) @@ -126,11 +129,14 @@ func TestServiceRestoreOnlyReissuesEscalatedJobs(t *testing.T) { service := newTestService(t, store, registry) require.NoError(t, service.RestoreNonTerminal(t.Context())) - require.Len(t, registry.ensureRequests, 1) + require.Len(t, registry.exitRequests, 1) + require.Equal( + t, active.VTXOOutpoint, registry.exitRequests[0].Outpoint, + ) require.Equal( - t, active.VTXOOutpoint, registry.ensureRequests[0].Outpoint, + t, actormsg.ExitPolicyRef(active.ID), + registry.exitRequests[0].ExitPolicy.UnwrapOrFail(t).Ref, ) - require.Equal(t, active.ID, registry.ensureRequests[0].ExitPolicyRef) } // TestServiceRestoreContinuesAfterPolicyMismatch verifies unrecoverable @@ -156,7 +162,7 @@ func TestServiceRestoreContinuesAfterPolicyMismatch(t *testing.T) { service := newTestService(t, store, registry) require.NoError(t, service.RestoreNonTerminal(t.Context())) - require.Len(t, registry.ensureRequests, 2) + require.Len(t, registry.exitRequests, 2) storedFirst, err := store.GetRecovery(t.Context(), first.ID) require.NoError(t, err) @@ -178,12 +184,12 @@ func TestServiceRestoreKeepsRecoveryActiveAfterTransientError(t *testing.T) { ) store := newFakeStore(job) registry := &fakeUnrollRegistry{ - ensureErr: fmt.Errorf("actor transport unavailable"), + exitErr: fmt.Errorf("actor transport unavailable"), } service := newTestService(t, store, registry) require.NoError(t, service.RestoreNonTerminal(t.Context())) - require.Len(t, registry.ensureRequests, 1) + require.Len(t, registry.exitRequests, 1) stored, err := store.GetRecovery(t.Context(), job.ID) require.NoError(t, err) @@ -257,13 +263,14 @@ func TestServiceCompletedStatusKeepsUnrollSweep(t *testing.T) { // newTestService builds a service from fake dependencies and fails the test if // the constructor rejects the dependency set. func newTestService(t *testing.T, store Store, - registry UnrollRegistry) *Service { + registry *fakeUnrollRegistry) *Service { t.Helper() service, err := NewService(ServiceConfig{ Store: store, Unroll: registry, + Exiter: registry, }) require.NoError(t, err) @@ -447,27 +454,23 @@ func (s *fakeStore) FailRecovery(_ context.Context, id string, return nil } -// fakeUnrollRegistry records ensure requests and returns one configured status. +// fakeUnrollRegistry records force-exit requests and returns one configured +// status. It satisfies both ExitAdmitter (admission via the VTXO manager) and +// UnrollRegistry (status reads). type fakeUnrollRegistry struct { - ensureRequests []unroll.EnsureUnrollRequest - ensureErr error - status *unroll.GetStatusResp - statusErr error + exitRequests []actormsg.ForceUnrollRequest + exitErr error + status *unroll.GetStatusResp + statusErr error } -// EnsureUnroll implements UnrollRegistry by recording the request. -func (r *fakeUnrollRegistry) EnsureUnroll(_ context.Context, - req unroll.EnsureUnrollRequest) (*unroll.EnsureUnrollResp, error) { +// ForceExit implements ExitAdmitter by recording the request. +func (r *fakeUnrollRegistry) ForceExit(_ context.Context, + req actormsg.ForceUnrollRequest) error { - r.ensureRequests = append(r.ensureRequests, req) - if r.ensureErr != nil { - return nil, r.ensureErr - } + r.exitRequests = append(r.exitRequests, req) - return &unroll.EnsureUnrollResp{ - ActorID: "actor-" + req.Outpoint.String(), - Created: true, - }, nil + return r.exitErr } // GetStatus implements UnrollRegistry by returning the configured status. From c42a037e54dad3f4f71af3f0adb95e2b511d2eb7 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 9 Jul 2026 15:56:32 -0700 Subject: [PATCH 05/11] multi: hold recovery-only targets in exit on recoverable failure In this commit, we fix a fund-safety regression the unification introduced: a recoverable unroll failure would relive a vHTLC recovery target into the live coin set. Because the manager now owns an actor for the recovery target and persists it as VTXOStatusUnilateralExit (both new in this series), the darepo-client#602 recovery edge (ExitOutcomeRecoverable rolls a no-footprint failure back to LiveState) now fires for it: the recovery output, which is a swap-contract output and not spendable liquidity, would become a live wallet coin, inflating balance and re-poisoning sweep-all. Before the series it was safe because the target was Spending with no manager actor, so both the store-path guard and boot reconciliation skipped it. We thread the unroll job's exit policy onto ExitOutcomeNotification (and ExitOutcomeResolution for the boot-reconcile path), sourced from the registry record in notifyVTXOExit and from the persisted unilateral-exit job in resolveExitOutcome. recoverExitedVTXO then refuses to relive a target whose policy is a known non-standard (recovery-only) policy, holding it in UnilateralExit instead. A clean refund failure is the owning recovery subsystem's job to retry or terminal-fail; the manager must not resurrect the coin as spendable in the meantime. --- darepod/server.go | 6 ++++ unroll/registry.go | 25 ++++++++++----- vtxo/manager.go | 33 ++++++++++++++++++-- vtxo/manager_exit_test.go | 64 +++++++++++++++++++++++++++++++++++++++ vtxo/messages.go | 7 +++++ 5 files changed, 124 insertions(+), 11 deletions(-) diff --git a/darepod/server.go b/darepod/server.go index e7839bd45..5009bc978 100644 --- a/darepod/server.go +++ b/darepod/server.go @@ -4194,12 +4194,18 @@ func resolveExitOutcome(ctx context.Context, return fn.Some(vtxo.ExitOutcomeResolution{ Outcome: vtxo.ExitOutcomeConfirmed, Reason: job.LastError, + ExitPolicyKind: actormsg.ExitPolicyKind( + job.ExitPolicyKind, + ), }), nil case db.UnilateralExitJobStatusFailedRecoverable: return fn.Some(vtxo.ExitOutcomeResolution{ Outcome: vtxo.ExitOutcomeRecoverable, Reason: job.LastError, + ExitPolicyKind: actormsg.ExitPolicyKind( + job.ExitPolicyKind, + ), }), nil default: diff --git a/unroll/registry.go b/unroll/registry.go index 35b5e3138..9f45611f0 100644 --- a/unroll/registry.go +++ b/unroll/registry.go @@ -13,6 +13,7 @@ import ( "github.com/lightninglabs/darepo-client/baselib/actor" "github.com/lightninglabs/darepo-client/chainsource" "github.com/lightninglabs/darepo-client/ledger" + "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/txconfirm" "github.com/lightninglabs/darepo-client/vtxo" fn "github.com/lightningnetwork/lnd/fn/v2" @@ -785,14 +786,16 @@ func (r *registryBehavior) failAdmittedChild(ctx context.Context, r.pending[target] = cloneRegistryRecord(record) // Roll the VTXO back to live. The terminal record below is the durable - // backstop if this best-effort notification is lost. + // backstop if this best-effort notification is lost. A recovery-only + // target is held in exit instead (see notifyVTXOExit / the manager), + // so its policy rides along. r.notifyVTXOExit(context.WithoutCancel(ctx), &UnrollTerminatedMsg{ Outpoint: target, ActorID: record.ActorID, Phase: PhaseFailed, FailReason: err.Error(), HadOnChainFootprint: false, - }) + }, record.ExitPolicyKind) markErr := r.cfg.Store.MarkTerminal( context.WithoutCancel(ctx), target, PhaseFailed, true, @@ -996,8 +999,10 @@ func (r *registryBehavior) handleTerminated(ctx context.Context, // Forward the terminal outcome to the VTXO manager so the VTXO's // lifecycle tracks the unroll job's terminal on-chain result rather // than the user's intent to exit (darepo-client#602). The handoff must - // survive caller-context cancellation, so detach the context. - r.notifyVTXOExit(context.WithoutCancel(ctx), req) + // survive caller-context cancellation, so detach the context. The + // record's exit policy rides along so the manager can hold a + // recovery-only target in exit rather than relive it as a live coin. + r.notifyVTXOExit(context.WithoutCancel(ctx), req, record.ExitPolicyKind) return fn.Ok[RegistryResp](&RegistryAckResp{}) } @@ -1019,7 +1024,7 @@ func (r *registryBehavior) handleTerminated(ctx context.Context, // terminal, so a dropped notification only delays re-convergence until the next // restart's reconciliation rather than losing funds. func (r *registryBehavior) notifyVTXOExit(ctx context.Context, - req *UnrollTerminatedMsg) { + req *UnrollTerminatedMsg, policyKind ExitPolicyKind) { if r.cfg.VTXOExitObserver.IsNone() { return @@ -1038,10 +1043,14 @@ func (r *registryBehavior) notifyVTXOExit(ctx context.Context, return } + // Carry the exit policy so the manager can tell a recovery-only target + // (a non-standard policy such as a vHTLC refund) apart from a normal + // coin and refuse to relive the former on a recoverable failure. err := observer.Tell(ctx, &vtxo.ExitOutcomeNotification{ - Outpoint: req.Outpoint, - Outcome: outcome, - Reason: req.FailReason, + Outpoint: req.Outpoint, + Outcome: outcome, + Reason: req.FailReason, + ExitPolicyKind: actormsg.ExitPolicyKind(policyKind), }) if err != nil { r.log.WarnS(ctx, "Failed to notify VTXO manager of exit "+ diff --git a/vtxo/manager.go b/vtxo/manager.go index e219eadab..bdd5474e4 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -39,6 +39,11 @@ type ExitOutcomeResolution struct { // Reason carries the terminal failure reason when Outcome is // ExitOutcomeRecoverable. Reason string + + // ExitPolicyKind is the exit-spend policy the unroll job ran under, so + // boot reconciliation can tell a recovery-only target apart from a + // normal coin and avoid reliving the former (see recoverExitedVTXO). + ExitPolicyKind actormsg.ExitPolicyKind } // ExitOutcomeResolver resolves the terminal unilateral-exit outcome, if any, @@ -516,9 +521,10 @@ func (m *Manager) reconcileUnilateralExits(ctx context.Context) { outcome := resolution.UnsafeFromSome() req := &ExitOutcomeNotification{ - Outpoint: desc.Outpoint, - Outcome: outcome.Outcome, - Reason: outcome.Reason, + Outpoint: desc.Outpoint, + Outcome: outcome.Outcome, + Reason: outcome.Reason, + ExitPolicyKind: outcome.ExitPolicyKind, } _, err = m.handleExitOutcome(ctx, req).Unpack() @@ -918,6 +924,27 @@ func (m *Manager) handleExitOutcome(ctx context.Context, func (m *Manager) recoverExitedVTXO(ctx context.Context, req *ExitOutcomeNotification) fn.Result[ManagerResp] { + // A recovery-only target (a non-standard exit policy, e.g. a vHTLC + // refund) must never be relived into the live coin set: it is a + // swap-contract output, not spendable wallet liquidity, so reliving it + // would inflate balance, feed coin selection and sweep-all, and + // re-poison cooperative consumption. A clean (no-footprint) failure of + // such a job means the refund attempt failed; the owning recovery + // subsystem is responsible for retrying or terminal-failing it. We hold + // the coin in UnilateralExit rather than resurrect it as spendable. + if req.ExitPolicyKind.Valid() { + m.logger(ctx).InfoS(ctx, "Holding recovery-only VTXO in exit "+ + "after recoverable unroll failure", + slog.String("outpoint", req.Outpoint.String()), + slog.String( + "exit_policy_kind", string(req.ExitPolicyKind), + ), + slog.String("reason", req.Reason), + ) + + return fn.Ok[ManagerResp](&ExitOutcomeResp{}) + } + if actorRef, ok := m.actors[req.Outpoint]; ok { _, err := m.askVTXOActor(ctx, actorRef, &ExitFailedEvent{ Reason: req.Reason, diff --git a/vtxo/manager_exit_test.go b/vtxo/manager_exit_test.go index 73cda28e8..bd9a516f7 100644 --- a/vtxo/manager_exit_test.go +++ b/vtxo/manager_exit_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/darepo-client/lib/actormsg" fn "github.com/lightningnetwork/lnd/fn/v2" "github.com/stretchr/testify/require" ) @@ -83,6 +84,69 @@ func TestHandleExitOutcomeConfirmedDrivesActorToSpent(t *testing.T) { ) } +// TestHandleExitOutcomeRecoverableHoldsRecoveryOnlyTarget verifies that a +// recoverable exit failure does NOT relive a recovery-only target (a +// non-standard exit policy, e.g. a vHTLC refund) into the live coin set: the +// live actor stays in UnilateralExitState. Reliving it would turn a +// swap-contract output into spendable wallet balance and re-poison sweep-all. +func TestHandleExitOutcomeRecoverableHoldsRecoveryOnlyTarget(t *testing.T) { + t.Parallel() + + vtxo := makeDescriptor(t, 1_799, 6) + mgr, _, ref := newExitTestManager(t, vtxo, &UnilateralExitState{ + VTXO: vtxo, + Reason: "vhtlc recovery", + }) + + resp := mgr.Receive(t.Context(), &ExitOutcomeNotification{ + Outpoint: vtxo.Outpoint, + Outcome: ExitOutcomeRecoverable, + Reason: "min relay fee not met", + ExitPolicyKind: actormsg.ExitPolicyVHTLCRefundWithoutReceiver, + }) + _, err := resp.Unpack() + require.NoError(t, err) + + require.IsType( + t, &UnilateralExitState{}, ref.state, + "recovery-only target must not be relived to live", + ) +} + +// TestHandleExitOutcomeRecoverableNoActorHoldsRecoveryOnlyTarget verifies the +// store-fallback path also holds a recovery-only target in exit: with no live +// actor, the recoverable outcome must not load the descriptor or write a Live +// status. The guard short-circuits before any store access. +func TestHandleExitOutcomeRecoverableNoActorHoldsRecoveryOnlyTarget( + t *testing.T) { + + t.Parallel() + + vtxo := makeDescriptor(t, 1_799, 7) + vtxo.Status = VTXOStatusUnilateralExit + store := &MockVTXOStore{} + mgr := &Manager{ + cfg: &ManagerConfig{ + Store: store, + }, + actors: make(map[wire.OutPoint]VTXOActorRef), + } + + // No GetVTXO / UpdateVTXOStatus expectations: the recovery-only guard + // returns before touching the store. + resp := mgr.Receive(t.Context(), &ExitOutcomeNotification{ + Outpoint: vtxo.Outpoint, + Outcome: ExitOutcomeRecoverable, + ExitPolicyKind: actormsg.ExitPolicyVHTLCRefundWithoutReceiver, + }) + _, err := resp.Unpack() + require.NoError(t, err) + + store.AssertExpectations(t) + store.AssertNotCalled(t, "GetVTXO") + store.AssertNotCalled(t, "UpdateVTXOStatus") +} + // TestHandleExitOutcomeConfirmedNoActorPersistsSpent verifies that, with no // live actor for the outpoint (e.g. after a restart where the exiting VTXO was // not part of the live-recovery set), a confirmed outcome still persists the diff --git a/vtxo/messages.go b/vtxo/messages.go index 4c984dca8..c2d79f5c6 100644 --- a/vtxo/messages.go +++ b/vtxo/messages.go @@ -193,6 +193,13 @@ type ExitOutcomeNotification struct { // Reason carries the unroll failure reason for ExitOutcomeRecoverable, // used for logging and the restored VTXO's audit trail. Reason string + + // ExitPolicyKind is the exit-spend policy the unroll job ran under. It + // distinguishes a recovery-only target (a non-standard policy such as a + // vHTLC refund) from a normal wallet coin (standard timeout or empty). + // A recoverable failure must NOT relive a recovery-only target into the + // live coin set: it is a swap-contract output, not spendable liquidity. + ExitPolicyKind actormsg.ExitPolicyKind } // MessageType returns the message type identifier. From bf7df8ab614cc6614db65846e1b99bdea73a6030 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 9 Jul 2026 15:56:43 -0700 Subject: [PATCH 06/11] darepod: re-admit orphaned recovery targets under their exit policy In this commit, we close a first-writer-wins hole in the boot orphan scan. The registry keeps whichever exit policy admits a target first and ignores later requests' policies. recoverOrphanedUnrollJobs re-admits every unilateral-exit VTXO under TriggerRestart with no policy, and vHTLC recovery targets are now in that scan (they are UnilateralExit, not Spending). If RestoreNonTerminal failed for a target on a prior boot (a transient error leaves it exiting on disk with no registry record), the no-policy scan would permanently claim it as a standard timeout exit, and a standard witness against a vHTLC taproot tree never sweeps. We hand the scan the durable exit policy of every non-terminal recovery target, indexed by outpoint, so it re-admits a refund target under the right policy even when it is the one that creates the record. The restore-before-scan ordering stays as belt-and-suspenders. We also pin the hand-maintained actormsg exit-policy enum to the canonical vhtlcrecovery constants with a mirror test, since actormsg can't import vhtlcrecovery without a cycle and a silent drift would break the round-trip. --- darepod/server.go | 72 ++++++++++++++++++++++++++++++----- darepod/unroll_bridge_test.go | 18 +++++++++ 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/darepod/server.go b/darepod/server.go index 5009bc978..86d7cc355 100644 --- a/darepod/server.go +++ b/darepod/server.go @@ -5410,14 +5410,24 @@ func (s *Server) initUnrollSubsystem(ctx context.Context, // against r.active / r.pending / store.GetRecord, so a target that // already has a record (e.g. a vHTLC recovery whose refund-policy // admission was just replayed by the Set above) is a benign no-op that - // preserves the existing policy. It runs AFTER the chain resolver is - // wired so those replayed policy-bearing admissions win the - // first-writer-wins registry record before this no-policy scan can - // claim it. Per-target failures are collected and returned after the - // scan so startup fails closed instead of serving traffic with a - // known-stranded VTXO. + // preserves the existing policy. + // + // The registry is first-writer-wins on exit policy, so a naive + // no-policy scan could permanently claim a vHTLC target under the + // standard timeout policy if RestoreNonTerminal failed for it (a + // transient error leaves it UnilateralExit on disk with no record). + // To close that, we hand the scan the durable exit policy of every + // non-terminal recovery target so it re-admits under the RIGHT policy + // even when it does create the record. The ordering above stays as + // belt-and-suspenders. Per-target failures are collected and returned + // after the scan so startup fails closed instead of serving traffic + // with a known-stranded VTXO. + recoveryPolicies, err := recoveryExitPolicies(ctx, recoveryStore) + if err != nil { + return fmt.Errorf("load recovery exit policies: %w", err) + } if err := s.recoverOrphanedUnrollJobs( - ctx, vtxoStore, registry, + ctx, vtxoStore, registry, recoveryPolicies, ); err != nil { return fmt.Errorf("recover orphaned unroll jobs: %w", err) } @@ -5472,6 +5482,37 @@ func unrollStartTrigger(t actormsg.UnrollTrigger) unroll.StartTrigger { } } +// recoveryExitPolicy is the durable exit-policy identity of one vHTLC recovery +// target, keyed by its VTXO outpoint in recoveryExitPolicies. +type recoveryExitPolicy struct { + kind unroll.ExitPolicyKind + ref string +} + +// recoveryExitPolicies indexes the exit policy of every non-terminal vHTLC +// recovery target by outpoint, so the orphan-recovery scan can re-admit a +// recovery target under its own policy instead of the standard timeout. The +// registry is first-writer-wins on exit policy, so a no-policy re-admission +// would otherwise permanently mislabel a refund target as a standard exit. +func recoveryExitPolicies(ctx context.Context, + store coordinator.Store) (map[wire.OutPoint]recoveryExitPolicy, error) { + + jobs, err := store.ListNonTerminalRecoveries(ctx) + if err != nil { + return nil, err + } + + policies := make(map[wire.OutPoint]recoveryExitPolicy, len(jobs)) + for _, job := range jobs { + policies[job.VTXOOutpoint] = recoveryExitPolicy{ + kind: unroll.ExitPolicyKind(job.ExitPolicyKind), + ref: job.ID, + } + } + + return policies, nil +} + // recoverOrphanedUnrollJobs closes the atomicity gap between the VTXO // store's status flip to VTXOStatusUnilateralExit and the unroll // registry's UpsertRecord (#400). It lists every VTXO that the store @@ -5487,7 +5528,8 @@ func unrollStartTrigger(t actormsg.UnrollTrigger) unroll.StartTrigger { // fails closed instead of serving traffic while known unilateral-exit VTXOs // remain stranded. func (s *Server) recoverOrphanedUnrollJobs(ctx context.Context, - vtxoStore vtxo.VTXOStore, registry *unroll.UnrollRegistryActor) error { + vtxoStore vtxo.VTXOStore, registry *unroll.UnrollRegistryActor, + recoveryPolicies map[wire.OutPoint]recoveryExitPolicy) error { descs, err := vtxoStore.ListVTXOsByStatus( ctx, vtxo.VTXOStatusUnilateralExit, @@ -5506,10 +5548,20 @@ func (s *Server) recoverOrphanedUnrollJobs(ctx context.Context, for _, desc := range descs { op := desc.Outpoint - resp, askErr := ref.Ask(ctx, &unroll.EnsureUnrollRequest{ + // A vHTLC recovery target carries a non-standard exit policy. + // Re-admit it under that policy so the first-writer-wins + // registry never locks it to the standard timeout: a standard + // witness against a vHTLC taproot tree would never sweep. + ensureReq := &unroll.EnsureUnrollRequest{ Outpoint: op, Trigger: unroll.TriggerRestart, - }).Await(ctx).Unpack() + } + if policy, ok := recoveryPolicies[op]; ok { + ensureReq.ExitPolicyKind = policy.kind + ensureReq.ExitPolicyRef = policy.ref + } + + resp, askErr := ref.Ask(ctx, ensureReq).Await(ctx).Unpack() if askErr != nil { s.log.WarnS(ctx, "Failed to recover orphaned "+ "unroll job; VTXO remains stranded until "+ diff --git a/darepod/unroll_bridge_test.go b/darepod/unroll_bridge_test.go index fa88b84a2..f67402a41 100644 --- a/darepod/unroll_bridge_test.go +++ b/darepod/unroll_bridge_test.go @@ -7,11 +7,29 @@ import ( "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/unroll" + "github.com/lightninglabs/darepo-client/vhtlcrecovery" "github.com/lightninglabs/darepo-client/vtxo" fn "github.com/lightningnetwork/lnd/fn/v2" "github.com/stretchr/testify/require" ) +// TestExitPolicyKindMirrorsRecoveryConstants pins the string-typed actormsg +// exit-policy enum to the canonical vhtlcrecovery constants. The two are kept +// in sync by hand (actormsg cannot import vhtlcrecovery without a cycle), so a +// drift would silently break the round-trip through the ForceUnroll path. +func TestExitPolicyKindMirrorsRecoveryConstants(t *testing.T) { + t.Parallel() + + require.Equal( + t, vhtlcrecovery.ExitPolicyKindClaim, + string(actormsg.ExitPolicyVHTLCClaim), + ) + require.Equal( + t, vhtlcrecovery.ExitPolicyKindRefundWithoutReceiver, + string(actormsg.ExitPolicyVHTLCRefundWithoutReceiver), + ) +} + // TestUnrollStartTrigger verifies the string-typed UnrollTrigger that rides the // ForceUnroll path maps back onto the right unroll.StartTrigger, and that an // empty or unknown trigger falls back to critical expiry (preserving the From 75f7d0adc921bf201b718de0aa71233747185e7a Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 9 Jul 2026 16:16:27 -0700 Subject: [PATCH 07/11] unroll: carry exit policy on the terminal message, not the cache In this commit, we source the exit policy the registry hands to the VTXO manager on a terminal unroll from the child's UnrollTerminatedMsg rather than from the registry's in-memory pending record. The prior F1 fix read record.ExitPolicyKind out of r.pending in handleTerminated. That cache is legitimately evicted the moment a child's async terminal persist completes (handlePersistRecordResult drops the entry once the store write lands), so a recovery-only vHTLC target can reach handleTerminated with no cached record at all. When that happens the kind arrives empty, the manager's Valid() guard misses, and the target gets relived to live: the exact darepo-client#602 relive bug the F1 fix was meant to close, just through a narrower window (one flaky admission-refinement persist under load). The child already knows its policy authoritatively from its own durable state via exitPolicyKind(), so we stamp it onto the terminal message and prefer it in handleTerminated. The record stays accurate for persistence and the manager sees the real kind whether or not the cache survived. --- unroll/actor.go | 1 + unroll/registry.go | 20 ++++++++++--- unroll/registry_exit_test.go | 58 ++++++++++++++++++++++++++++++++++++ unroll/registry_messages.go | 8 +++++ 4 files changed, 83 insertions(+), 4 deletions(-) diff --git a/unroll/actor.go b/unroll/actor.go index b12a849d7..d61199928 100644 --- a/unroll/actor.go +++ b/unroll/actor.go @@ -1980,6 +1980,7 @@ func (b *behavior) notifyRegistryIfTerminal(ctx context.Context) { Phase: phase, FailReason: job.FailReason, HadOnChainFootprint: jobHadOnChainFootprint(job), + ExitPolicyKind: b.exitPolicyKind(), } if sweepTxid := effectiveSweepTxid( diff --git a/unroll/registry.go b/unroll/registry.go index 9f45611f0..d000a2324 100644 --- a/unroll/registry.go +++ b/unroll/registry.go @@ -996,13 +996,25 @@ func (r *registryBehavior) handleTerminated(ctx context.Context, //nolint:contextcheck r.requestPersist(req.Outpoint, 0) + // The child's terminal message carries its durable exit policy, which + // outlives r.pending: a completed async persist can evict the cached + // record before this terminal handoff. Prefer the message's kind so a + // recovery-only target is still held in exit rather than relived as a + // live coin (darepo-client#602). We only feed it to the manager, not + // the persisted record: the message has no policy ref, so stamping its + // kind onto the record would drop the store's (kind, ref) identity. + policyKind := req.ExitPolicyKind + if policyKind == "" { + policyKind = record.ExitPolicyKind + } + // Forward the terminal outcome to the VTXO manager so the VTXO's // lifecycle tracks the unroll job's terminal on-chain result rather // than the user's intent to exit (darepo-client#602). The handoff must - // survive caller-context cancellation, so detach the context. The - // record's exit policy rides along so the manager can hold a - // recovery-only target in exit rather than relive it as a live coin. - r.notifyVTXOExit(context.WithoutCancel(ctx), req, record.ExitPolicyKind) + // survive caller-context cancellation, so detach the context. The exit + // policy rides along so the manager can hold a recovery-only target in + // exit rather than relive it as a live coin. + r.notifyVTXOExit(context.WithoutCancel(ctx), req, policyKind) return fn.Ok[RegistryResp](&RegistryAckResp{}) } diff --git a/unroll/registry_exit_test.go b/unroll/registry_exit_test.go index f1764f27a..519dcd88e 100644 --- a/unroll/registry_exit_test.go +++ b/unroll/registry_exit_test.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/lib/actormsg" "github.com/lightninglabs/darepo-client/vtxo" fn "github.com/lightningnetwork/lnd/fn/v2" "github.com/stretchr/testify/require" @@ -102,6 +103,63 @@ func TestRegistryForwardsCleanFailureAsRecoverable(t *testing.T) { require.Equal(t, "min relay fee not met", notes[0].Reason) } +// TestRegistryForwardsExitPolicyFromTerminalMsg verifies the exit policy the +// VTXO manager sees on a recoverable failure comes from the child's terminal +// message, not the registry's in-memory pending cache. The pending record is +// legitimately evicted once its async terminal persist completes +// (handlePersistRecordResult), so a recovery-only vHTLC target can reach +// handleTerminated with no cached record at all. If the policy were sourced +// from r.pending it would arrive empty, the manager's Valid() guard would miss, +// and the target would be relived to live: the exact darepo-client#602 relive +// bug. Here the pending map starts empty and the terminal message carries the +// vHTLC refund policy, so the forwarded notification must still name it. +func TestRegistryForwardsExitPolicyFromTerminalMsg(t *testing.T) { + target := wire.OutPoint{Hash: chainhash.Hash{4}, Index: 0} + behavior, observer := newExitObserverRegistry(target) + + // Model the post-persist eviction window: the child is long gone from + // both maps by the time its terminal handoff lands. + delete(behavior.pending, target) + + const vhtlcRefund ExitPolicyKind = "vhtlc_refund_without_receiver" + _, err := behavior.handleTerminated(t.Context(), &UnrollTerminatedMsg{ + Outpoint: target, + ActorID: actorIDForTarget(target), + Phase: PhaseFailed, + FailReason: "min relay fee not met", + HadOnChainFootprint: false, + ExitPolicyKind: vhtlcRefund, + }).Unpack() + require.NoError(t, err) + + notes := observer.notifications() + require.Len(t, notes, 1) + require.Equal(t, vtxo.ExitOutcomeRecoverable, notes[0].Outcome) + require.Equal( + t, actormsg.ExitPolicyVHTLCRefundWithoutReceiver, + notes[0].ExitPolicyKind, "the recovery-only exit policy "+ + "must survive an evicted pending cache", + ) + require.True( + t, notes[0].ExitPolicyKind.Valid(), + "a vHTLC policy must pass the manager's hold-in-exit guard", + ) + + // The message's kind reaches the manager but must NOT be stamped onto + // the persisted terminal record: the message has no policy ref, so a + // stamped kind would overwrite the store's (kind, ref) identity with + // (kind, ""). The no-cache record therefore leaves the policy empty, + // letting registryExitPolicy preserve the durable admission identity. + persisted, ok := behavior.pending[target] + require.True(t, ok) + require.Empty( + t, persisted.ExitPolicyKind, "the terminal record must not "+ + "carry a ref-less kind that clobbers the store "+ + "identity", + ) + require.Empty(t, persisted.ExitPolicyRef) +} + // TestRegistryDoesNotRecoverFailureWithFootprint verifies a terminal failure // that already broadcast on-chain is NOT forwarded as recoverable: the exit // has begun on-chain and the VTXO must stay in unilateral-exit. diff --git a/unroll/registry_messages.go b/unroll/registry_messages.go index a3407f322..d2cb00bc4 100644 --- a/unroll/registry_messages.go +++ b/unroll/registry_messages.go @@ -161,6 +161,14 @@ type UnrollTerminatedMsg struct { // to live (the operator still considers it live). See // darepo-client#602. HadOnChainFootprint bool + + // ExitPolicyKind is the child's durable exit policy for this target. + // The child sources it from its own persisted state, so it stays + // authoritative even after the registry has evicted its in-memory + // pending record. The registry threads it to the VTXO manager so a + // recovery-only target is held in exit rather than relived as a live + // coin on a recoverable failure (darepo-client#602). + ExitPolicyKind ExitPolicyKind } // MessageType returns the stable message type identifier. From d949c7557f1cd54faf43768a78b046594c26a885 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 9 Jul 2026 17:24:30 -0700 Subject: [PATCH 08/11] docs: reconcile package docs with the unified unroll flow In this commit, we update the per-package agent docs for the packages the unified-unroll change touches so they describe the flow as it now works: every unilateral-exit trigger (manual, critical expiry, fraud, vHTLC recovery) goes through the VTXO manager's admission gate. The fraud watcher and the vHTLC recovery coordinator docs now describe forcing the exit through the VTXO manager (VTXOManagerRef / the ExitAdmitter ForceExit seam) rather than talking to the unroll registry directly. The lib/actormsg, vtxo, and unroll docs pick up the trigger and exit-policy fields that ride the ForceUnroll path, the manager spawning an absent actor to force-unroll it, the recovery-only hold-in-exit on a recoverable failure, and the child-stamped ExitPolicyKind on the terminal handoff. The darepod doc picks up the policy-carrying boot ordering, the expiring-to-unroll bridge, and the recovery materializer persisting unilateral-exit. --- darepod/AGENTS.md | 30 +++++++++++++++ darepod/CLAUDE.md | 30 +++++++++++++++ fraud/AGENTS.md | 36 ++++++++++++------ fraud/CLAUDE.md | 36 ++++++++++++------ lib/actormsg/AGENTS.md | 4 +- lib/actormsg/CLAUDE.md | 4 +- unroll/AGENTS.md | 14 ++++++- unroll/CLAUDE.md | 14 ++++++- vhtlcrecovery/coordinator/AGENTS.md | 58 +++++++++++++++++++---------- vhtlcrecovery/coordinator/CLAUDE.md | 58 +++++++++++++++++++---------- vtxo/AGENTS.md | 13 +++++-- vtxo/CLAUDE.md | 13 +++++-- 12 files changed, 234 insertions(+), 76 deletions(-) diff --git a/darepod/AGENTS.md b/darepod/AGENTS.md index 15603127e..f26373a3f 100644 --- a/darepod/AGENTS.md +++ b/darepod/AGENTS.md @@ -57,6 +57,36 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/darep OOR actor (`initOORActor`). The VTXO manager is constructed with a `vtxo.LazyChainResolver` placeholder that `initUnrollSubsystem` fills in later; anything needing that seam must run after `initUnrollSubsystem`. +- `initUnrollSubsystem` boot ordering is policy-preserving. + `recoverySvc.RestoreNonTerminal` (in-flight vHTLC recovery jobs, each + carrying its durable exit policy) runs **before** the chain resolver is + `Set()`; the force-exit admissions it drives through the VTXO manager are + buffered by the `LazyChainResolver` and replayed to the unroll registry the + instant the resolver is wired. The registry is first-writer-wins on exit + policy, so the generic orphan-job scan (`recoverOrphanedUnrollJobs`) runs + **after** `Set()` and is itself policy-carrying: it is handed a per-outpoint + exit-policy map (`recoveryExitPolicies`, built from the recovery store) and + re-admits each orphaned recovery target under its own vHTLC exit policy + rather than mislabeling it as a standard timeout. +- The chain-resolver→unroll bridge (`ensureUnrollFromExpiring`) maps a VTXO + `ExpiringNotification`'s trigger and optional exit policy into the registry's + `EnsureUnrollRequest`. `unrollStartTrigger` converts the string-typed + `actormsg.UnrollTrigger` (kept string-typed to avoid a `vtxo → unroll` import + cycle) into `unroll.StartTrigger`; an empty or unknown trigger admits as + critical expiry. A `None` exit policy leaves the registry on its standard + VTXO timeout policy. +- The fraud watcher (`initFraudWatcher`) is wired with `VTXOManagerRef`, so + fraud spends drive exits through the VTXO manager — the same admission path + as manual, critical-expiry, and vHTLC recovery exits — rather than talking to + the unroll registry directly. +- The vHTLC recovery service is wired with an `Exiter: managerExitAdmitter`, a + `ForceExit` seam that `Ask`s the VTXO manager to force a materialized + recovery target into unilateral exit. The target materializer + (`EnsureRecoveryTarget`) persists the descriptor directly into + `VTXOStatusUnilateralExit` (not `VTXOStatusSpending`) so the exiting coin is + excluded from the live/coin-selection query and cannot leak back into a + cooperative round as a forfeit; the boot-time orphan scan re-admits it on + restart. - Boarding-sweep transaction construction, fee estimation, spend watching, and startup resumption live inside the **wallet actor** (`wallet.Ark.handleSweepBoardingUTXOs` / `handleResumeBoardingSweeps` in diff --git a/darepod/CLAUDE.md b/darepod/CLAUDE.md index 15603127e..f26373a3f 100644 --- a/darepod/CLAUDE.md +++ b/darepod/CLAUDE.md @@ -57,6 +57,36 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/darep OOR actor (`initOORActor`). The VTXO manager is constructed with a `vtxo.LazyChainResolver` placeholder that `initUnrollSubsystem` fills in later; anything needing that seam must run after `initUnrollSubsystem`. +- `initUnrollSubsystem` boot ordering is policy-preserving. + `recoverySvc.RestoreNonTerminal` (in-flight vHTLC recovery jobs, each + carrying its durable exit policy) runs **before** the chain resolver is + `Set()`; the force-exit admissions it drives through the VTXO manager are + buffered by the `LazyChainResolver` and replayed to the unroll registry the + instant the resolver is wired. The registry is first-writer-wins on exit + policy, so the generic orphan-job scan (`recoverOrphanedUnrollJobs`) runs + **after** `Set()` and is itself policy-carrying: it is handed a per-outpoint + exit-policy map (`recoveryExitPolicies`, built from the recovery store) and + re-admits each orphaned recovery target under its own vHTLC exit policy + rather than mislabeling it as a standard timeout. +- The chain-resolver→unroll bridge (`ensureUnrollFromExpiring`) maps a VTXO + `ExpiringNotification`'s trigger and optional exit policy into the registry's + `EnsureUnrollRequest`. `unrollStartTrigger` converts the string-typed + `actormsg.UnrollTrigger` (kept string-typed to avoid a `vtxo → unroll` import + cycle) into `unroll.StartTrigger`; an empty or unknown trigger admits as + critical expiry. A `None` exit policy leaves the registry on its standard + VTXO timeout policy. +- The fraud watcher (`initFraudWatcher`) is wired with `VTXOManagerRef`, so + fraud spends drive exits through the VTXO manager — the same admission path + as manual, critical-expiry, and vHTLC recovery exits — rather than talking to + the unroll registry directly. +- The vHTLC recovery service is wired with an `Exiter: managerExitAdmitter`, a + `ForceExit` seam that `Ask`s the VTXO manager to force a materialized + recovery target into unilateral exit. The target materializer + (`EnsureRecoveryTarget`) persists the descriptor directly into + `VTXOStatusUnilateralExit` (not `VTXOStatusSpending`) so the exiting coin is + excluded from the live/coin-selection query and cannot leak back into a + cooperative round as a forfeit; the boot-time orphan scan re-admits it on + restart. - Boarding-sweep transaction construction, fee estimation, spend watching, and startup resumption live inside the **wallet actor** (`wallet.Ark.handleSweepBoardingUTXOs` / `handleResumeBoardingSweeps` in diff --git a/fraud/AGENTS.md b/fraud/AGENTS.md index 2ec688fd4..4d544fc49 100644 --- a/fraud/AGENTS.md +++ b/fraud/AGENTS.md @@ -10,11 +10,12 @@ automatically triggers unilateral exit for all affected recipient VTXOs. - `WatcherActor` — Durable actor managing passive fraud detection. Registered under service key `"recipient-fraud-watcher"` via `ServiceKey()`. Tracks - a `WatchPlan` per live VTXO; on spend notification, asks the unroll - registry to `EnsureUnroll` with `TriggerFraudSpend` for each affected - target. -- `WatcherConfig` — Wiring: `ChainSource` (spend monitor), `UnrollRef` - (durable unroll job trigger), `Log`, `MailboxSize` (default 64). + a `WatchPlan` per live VTXO; on spend notification, asks the VTXO manager + to force each affected target into unilateral exit via + `actormsg.ForceUnrollRequest` under `UnrollTriggerFraudSpend`. +- `WatcherConfig` — Wiring: `ChainSource` (spend monitor), `VTXOManagerRef` + (VTXO manager handle that owns the exit transition and starts the durable + unroll job), `Log`, `MailboxSize` (default 64). - `WatchPlan` — Passive watch set for one VTXO. Contains a target outpoint and a list of `WatchPoint` ancestors to monitor. - `WatchPoint` — Single outpoint to watch: `Outpoint`, `PkScript`, @@ -24,20 +25,29 @@ automatically triggers unilateral exit for all affected recipient VTXOs. for fraud monitoring. - `UntrackRequest` / `UntrackResp` — Release all watches for a target outpoint. - - `SpendObservedMsg` / `AckResp` — Spend event fanout to unroll. + - `SpendObservedMsg` / `AckResp` — Spend event fanout to the VTXO + manager as force-unroll requests. ## Relationships - **Depends on**: `baselib/actor` (actor framework), `chainsource` (spend - event monitoring), `unroll` (durable unroll job registry), `vtxo` (VTXO - descriptor types), `lib/tree` (ancestry tree walk for watch-point assembly). + event monitoring), `vtxo` (VTXO manager message types and descriptor types), + `lib/actormsg` (`ForceUnrollRequest` / `ForceUnrollResponse`, + `UnrollTriggerFraudSpend`), `lib/tree` (ancestry tree walk for watch-point + assembly). - **Depended on by**: `darepod` (wires up on startup). - **Sends**: - → `chainsource`: `RegisterSpendRequest` per ancestor outpoint on `TrackVTXOsRequest`; `UnregisterSpendRequest` on `UntrackRequest` / when the last target referencing a watch point is removed. - - → `unroll` registry (via `UnrollRef.Ask`): `EnsureUnrollRequest` with - `TriggerFraudSpend` when a watched ancestor is spent. + - → `vtxo` manager (via `VTXOManagerRef.Ask`): `actormsg.ForceUnrollRequest` + with `UnrollTriggerFraudSpend` when a watched ancestor is spent. The + manager transitions the target into `UnilateralExitState` (persisting it + out of the live set) and starts the durable unroll job through its + chain-resolver seam, so fraud escalation converges on the same admission + gate as manual and critical-expiry exits. A declined transition (the coin + is already terminal, or the wallet no longer tracks it) is logged as a + warning rather than surfaced as an error. - **Receives**: - ← `darepod`: `TrackVTXOsRequest`, `UntrackRequest` - ← `chainsource`: spend notifications (re-dispatched internally as @@ -62,5 +72,7 @@ automatically triggers unilateral exit for all affected recipient VTXOs. ## Deep Docs - [ARCHITECTURE.md](../ARCHITECTURE.md) — System-wide package map. -- [unroll/CLAUDE.md](../unroll/CLAUDE.md) — Unilateral-exit registry that - fraud triggers. +- [vtxo/CLAUDE.md](../vtxo/CLAUDE.md) — VTXO manager that owns the exit + transition and admits the unroll job for a fraud-forced target. +- [unroll/CLAUDE.md](../unroll/CLAUDE.md) — Unilateral-exit registry the VTXO + manager drives on behalf of the fraud watcher. diff --git a/fraud/CLAUDE.md b/fraud/CLAUDE.md index 2ec688fd4..4d544fc49 100644 --- a/fraud/CLAUDE.md +++ b/fraud/CLAUDE.md @@ -10,11 +10,12 @@ automatically triggers unilateral exit for all affected recipient VTXOs. - `WatcherActor` — Durable actor managing passive fraud detection. Registered under service key `"recipient-fraud-watcher"` via `ServiceKey()`. Tracks - a `WatchPlan` per live VTXO; on spend notification, asks the unroll - registry to `EnsureUnroll` with `TriggerFraudSpend` for each affected - target. -- `WatcherConfig` — Wiring: `ChainSource` (spend monitor), `UnrollRef` - (durable unroll job trigger), `Log`, `MailboxSize` (default 64). + a `WatchPlan` per live VTXO; on spend notification, asks the VTXO manager + to force each affected target into unilateral exit via + `actormsg.ForceUnrollRequest` under `UnrollTriggerFraudSpend`. +- `WatcherConfig` — Wiring: `ChainSource` (spend monitor), `VTXOManagerRef` + (VTXO manager handle that owns the exit transition and starts the durable + unroll job), `Log`, `MailboxSize` (default 64). - `WatchPlan` — Passive watch set for one VTXO. Contains a target outpoint and a list of `WatchPoint` ancestors to monitor. - `WatchPoint` — Single outpoint to watch: `Outpoint`, `PkScript`, @@ -24,20 +25,29 @@ automatically triggers unilateral exit for all affected recipient VTXOs. for fraud monitoring. - `UntrackRequest` / `UntrackResp` — Release all watches for a target outpoint. - - `SpendObservedMsg` / `AckResp` — Spend event fanout to unroll. + - `SpendObservedMsg` / `AckResp` — Spend event fanout to the VTXO + manager as force-unroll requests. ## Relationships - **Depends on**: `baselib/actor` (actor framework), `chainsource` (spend - event monitoring), `unroll` (durable unroll job registry), `vtxo` (VTXO - descriptor types), `lib/tree` (ancestry tree walk for watch-point assembly). + event monitoring), `vtxo` (VTXO manager message types and descriptor types), + `lib/actormsg` (`ForceUnrollRequest` / `ForceUnrollResponse`, + `UnrollTriggerFraudSpend`), `lib/tree` (ancestry tree walk for watch-point + assembly). - **Depended on by**: `darepod` (wires up on startup). - **Sends**: - → `chainsource`: `RegisterSpendRequest` per ancestor outpoint on `TrackVTXOsRequest`; `UnregisterSpendRequest` on `UntrackRequest` / when the last target referencing a watch point is removed. - - → `unroll` registry (via `UnrollRef.Ask`): `EnsureUnrollRequest` with - `TriggerFraudSpend` when a watched ancestor is spent. + - → `vtxo` manager (via `VTXOManagerRef.Ask`): `actormsg.ForceUnrollRequest` + with `UnrollTriggerFraudSpend` when a watched ancestor is spent. The + manager transitions the target into `UnilateralExitState` (persisting it + out of the live set) and starts the durable unroll job through its + chain-resolver seam, so fraud escalation converges on the same admission + gate as manual and critical-expiry exits. A declined transition (the coin + is already terminal, or the wallet no longer tracks it) is logged as a + warning rather than surfaced as an error. - **Receives**: - ← `darepod`: `TrackVTXOsRequest`, `UntrackRequest` - ← `chainsource`: spend notifications (re-dispatched internally as @@ -62,5 +72,7 @@ automatically triggers unilateral exit for all affected recipient VTXOs. ## Deep Docs - [ARCHITECTURE.md](../ARCHITECTURE.md) — System-wide package map. -- [unroll/CLAUDE.md](../unroll/CLAUDE.md) — Unilateral-exit registry that - fraud triggers. +- [vtxo/CLAUDE.md](../vtxo/CLAUDE.md) — VTXO manager that owns the exit + transition and admits the unroll job for a fraud-forced target. +- [unroll/CLAUDE.md](../unroll/CLAUDE.md) — Unilateral-exit registry the VTXO + manager drives on behalf of the fraud watcher. diff --git a/lib/actormsg/AGENTS.md b/lib/actormsg/AGENTS.md index 85003e94a..31dc3b230 100644 --- a/lib/actormsg/AGENTS.md +++ b/lib/actormsg/AGENTS.md @@ -16,7 +16,9 @@ package boundaries. Lives in `lib/` to break import cycles between `vtxo`, - `SelectAndReserveForfeitRequest` / `SelectAndReserveForfeitResponse` — Ask-message to atomically select and reserve VTXOs for cooperative forfeit (directed sends). Combines coin selection and PendingForfeit reservation in one step to close a race window. - `ReserveForfeitRequest` / `ReleaseForfeitRequest` — Forfeit reservation admission messages. - `ReleaseSpendRequest` / `CompleteSpendRequest` — Spend lifecycle completion messages. -- `ForceUnrollRequest` / `ForceUnrollResponse` — Ask-message that routes an operator or chain-resolver unroll trigger through the VTXO manager into the per-VTXO FSM. `ForceUnrollResponse.Accepted` is true when the request caused a state transition; when false, `Reason` distinguishes `"no such vtxo"` from `"already terminal"` so callers don't misread a silent self-loop as success. +- `ForceUnrollRequest` / `ForceUnrollResponse` — Ask-message that routes an operator or chain-resolver unroll trigger through the VTXO manager into the per-VTXO FSM. `ForceUnrollRequest.Trigger` (a `UnrollTrigger`) names *why* the coin is exiting, and `ForceUnrollRequest.ExitPolicy` (an `fn.Option[ExitPolicy]`) names *which* exit-spend policy the target unrolls under, so a single admission path carries manual, critical-expiry, fraud, and vHTLC-recovery intent through to the unroll registry. `ForceUnrollResponse.Accepted` is true when the request caused a state transition; when false, `Reason` distinguishes `"no such vtxo"` from `"already terminal"` so callers don't misread a silent self-loop as success. +- `UnrollTrigger` — String-typed enum naming why a unilateral exit was started (`UnrollTriggerCriticalExpiry` is the empty-string zero value and preserves the historical critical-expiry admission, `UnrollTriggerManual`, `UnrollTriggerFraudSpend`). It mirrors the unroll package's `StartTrigger` so `vtxo` and `actormsg` can thread the trigger through `ForceUnroll` without importing `unroll` (which would form a cycle); the darepod chain resolver bridge converts it back at the seam. +- `ExitPolicyKind` / `ExitPolicyRef` / `ExitPolicy` — Durable exit-spend policy identity for a forced exit. `ExitPolicyKind` is a string-typed enum of the non-standard policies that ride `ForceUnroll` (`ExitPolicyVHTLCClaim`, `ExitPolicyVHTLCRefundWithoutReceiver`), with `Valid()` true only for those two vHTLC kinds; `ExitPolicyRef` is the policy-specific durable reference (e.g. a vHTLC recovery job id), kept a distinct type so `Kind` and `Ref` can't be transposed; `ExitPolicy` bundles the pair as one identity validated at the registry admission boundary. A `None` `ExitPolicy` selects the standard VTXO timeout policy. - `RegisterIntentMsg` — Carries pre-composed cooperative intent package to round actor. The `TriggerRegistration bool` field controls whether the round FSM immediately fires `IntentRequested` after accepting the intent diff --git a/lib/actormsg/CLAUDE.md b/lib/actormsg/CLAUDE.md index 85003e94a..31dc3b230 100644 --- a/lib/actormsg/CLAUDE.md +++ b/lib/actormsg/CLAUDE.md @@ -16,7 +16,9 @@ package boundaries. Lives in `lib/` to break import cycles between `vtxo`, - `SelectAndReserveForfeitRequest` / `SelectAndReserveForfeitResponse` — Ask-message to atomically select and reserve VTXOs for cooperative forfeit (directed sends). Combines coin selection and PendingForfeit reservation in one step to close a race window. - `ReserveForfeitRequest` / `ReleaseForfeitRequest` — Forfeit reservation admission messages. - `ReleaseSpendRequest` / `CompleteSpendRequest` — Spend lifecycle completion messages. -- `ForceUnrollRequest` / `ForceUnrollResponse` — Ask-message that routes an operator or chain-resolver unroll trigger through the VTXO manager into the per-VTXO FSM. `ForceUnrollResponse.Accepted` is true when the request caused a state transition; when false, `Reason` distinguishes `"no such vtxo"` from `"already terminal"` so callers don't misread a silent self-loop as success. +- `ForceUnrollRequest` / `ForceUnrollResponse` — Ask-message that routes an operator or chain-resolver unroll trigger through the VTXO manager into the per-VTXO FSM. `ForceUnrollRequest.Trigger` (a `UnrollTrigger`) names *why* the coin is exiting, and `ForceUnrollRequest.ExitPolicy` (an `fn.Option[ExitPolicy]`) names *which* exit-spend policy the target unrolls under, so a single admission path carries manual, critical-expiry, fraud, and vHTLC-recovery intent through to the unroll registry. `ForceUnrollResponse.Accepted` is true when the request caused a state transition; when false, `Reason` distinguishes `"no such vtxo"` from `"already terminal"` so callers don't misread a silent self-loop as success. +- `UnrollTrigger` — String-typed enum naming why a unilateral exit was started (`UnrollTriggerCriticalExpiry` is the empty-string zero value and preserves the historical critical-expiry admission, `UnrollTriggerManual`, `UnrollTriggerFraudSpend`). It mirrors the unroll package's `StartTrigger` so `vtxo` and `actormsg` can thread the trigger through `ForceUnroll` without importing `unroll` (which would form a cycle); the darepod chain resolver bridge converts it back at the seam. +- `ExitPolicyKind` / `ExitPolicyRef` / `ExitPolicy` — Durable exit-spend policy identity for a forced exit. `ExitPolicyKind` is a string-typed enum of the non-standard policies that ride `ForceUnroll` (`ExitPolicyVHTLCClaim`, `ExitPolicyVHTLCRefundWithoutReceiver`), with `Valid()` true only for those two vHTLC kinds; `ExitPolicyRef` is the policy-specific durable reference (e.g. a vHTLC recovery job id), kept a distinct type so `Kind` and `Ref` can't be transposed; `ExitPolicy` bundles the pair as one identity validated at the registry admission boundary. A `None` `ExitPolicy` selects the standard VTXO timeout policy. - `RegisterIntentMsg` — Carries pre-composed cooperative intent package to round actor. The `TriggerRegistration bool` field controls whether the round FSM immediately fires `IntentRequested` after accepting the intent diff --git a/unroll/AGENTS.md b/unroll/AGENTS.md index 3e258004e..cc3ef401e 100644 --- a/unroll/AGENTS.md +++ b/unroll/AGENTS.md @@ -70,7 +70,19 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/unrol `ExitOutcomeRecoverable` (roll back to live), a completed exit → `ExitOutcomeConfirmed` (retire to spent). `UnrollTerminatedMsg` carries `HadOnChainFootprint`, computed by `jobHadOnChainFootprint` (any - confirmed/in-flight proof node or a non-pending sweep). + confirmed/in-flight proof node or a non-pending sweep). It also carries + the child's `ExitPolicyKind`, which the child stamps from its own durable + exit policy (`exitPolicyKind`), so the terminal message is self-contained: + it stays authoritative after the registry has evicted its in-memory + `pending` record, which a completed async terminal persist can drop before + the terminal handoff lands. `handleTerminated` prefers the message's + `ExitPolicyKind` over the cached record and threads it to the manager via + `notifyVTXOExit`, so a recovery-only target (a non-standard policy such as + a vHTLC refund) is held in unilateral-exit rather than relived as a live + coin on a recoverable failure. The kind is forwarded only to the manager, + never stamped onto the persisted registry record — the message has no + policy ref, so stamping its kind would drop the store's durable + `(ExitPolicyKind, ExitPolicyRef)` identity. - `RegistryRecord` — control-plane row (`TargetOutpoint`, `ActorID`, `Phase`, `Trigger`, `FailReason`, `SweepTxid`, `ExitPolicyKind`, `ExitPolicyRef`). diff --git a/unroll/CLAUDE.md b/unroll/CLAUDE.md index 3e258004e..cc3ef401e 100644 --- a/unroll/CLAUDE.md +++ b/unroll/CLAUDE.md @@ -70,7 +70,19 @@ For field-level detail, use `go doc github.com/lightninglabs/darepo-client/unrol `ExitOutcomeRecoverable` (roll back to live), a completed exit → `ExitOutcomeConfirmed` (retire to spent). `UnrollTerminatedMsg` carries `HadOnChainFootprint`, computed by `jobHadOnChainFootprint` (any - confirmed/in-flight proof node or a non-pending sweep). + confirmed/in-flight proof node or a non-pending sweep). It also carries + the child's `ExitPolicyKind`, which the child stamps from its own durable + exit policy (`exitPolicyKind`), so the terminal message is self-contained: + it stays authoritative after the registry has evicted its in-memory + `pending` record, which a completed async terminal persist can drop before + the terminal handoff lands. `handleTerminated` prefers the message's + `ExitPolicyKind` over the cached record and threads it to the manager via + `notifyVTXOExit`, so a recovery-only target (a non-standard policy such as + a vHTLC refund) is held in unilateral-exit rather than relived as a live + coin on a recoverable failure. The kind is forwarded only to the manager, + never stamped onto the persisted registry record — the message has no + policy ref, so stamping its kind would drop the store's durable + `(ExitPolicyKind, ExitPolicyRef)` identity. - `RegistryRecord` — control-plane row (`TargetOutpoint`, `ActorID`, `Phase`, `Trigger`, `FailReason`, `SweepTxid`, `ExitPolicyKind`, `ExitPolicyRef`). diff --git a/vhtlcrecovery/coordinator/AGENTS.md b/vhtlcrecovery/coordinator/AGENTS.md index c5801a7c4..75e8e750e 100644 --- a/vhtlcrecovery/coordinator/AGENTS.md +++ b/vhtlcrecovery/coordinator/AGENTS.md @@ -3,51 +3,69 @@ ## Purpose Runtime coordinator for durable vHTLC recovery jobs. The package turns an armed -SQL recovery row into a generic unroll admission by passing -`(exit_policy_kind, recovery_id)` to the unroll registry. +SQL recovery row into a unilateral exit by forcing the target through the VTXO +manager's admission gate under the recovery row's +`(exit_policy_kind, recovery_id)` identity. The manager owns the state +transition (persisting the target out of the live set) and starts the durable +unroll job through its chain-resolver seam, so a vHTLC exit converges on the +same path as manual, critical-expiry, and fraud exits. This package exists as a child of `vhtlcrecovery` to avoid an import cycle: -`db` imports the parent package for row types, while the coordinator imports -`unroll` for admission and status. +`db` imports the parent package for row types, while the coordinator forces the +exit through the VTXO manager (`actormsg`) and reads status back from `unroll`. ## Key Types - `Service` — arm/escalate/cancel/status coordinator. - `Store` — durable SQL persistence surface used by the service. -- `UnrollRegistry` — narrow unroll admission/status surface (narrower than the - actor ref so tests can model admission without spinning up the full unroll - subsystem). +- `UnrollRegistry` — narrow unroll status surface (narrower than the actor ref + so tests can model status without spinning up the full unroll subsystem). + Admission no longer lives here; recovery only reads status back. +- `ExitAdmitter` — forces a recovery target into unilateral exit through the + VTXO manager's single admission gate via `ForceExit`. The manager owns the + transition and starts the registry job through its chain-resolver seam. - `ActorUnrollRegistry` — adapter from the live unroll registry actor to the - narrow service interface. + narrow status interface. - `TargetMaterializer` — adapter interface for ensuring the vHTLC target has local descriptor and package bindings that generic unroll needs. Implemented by `darepod.vhtlcRecoveryTargetMaterializer`. - `RecoveryStatus` — durable recovery row joined with current unroll status. -- `ServiceConfig` — wiring: `Store`, `UnrollRegistry`, `TargetMaterializer`. +- `ServiceConfig` — wiring: `Store`, `UnrollRegistry`, `ExitAdmitter`, + `TargetMaterializer`. ## Relationships -- **Depends on**: `vhtlcrecovery` (row types and state constants), `unroll` - (admission/status: `EnsureUnrollRequest`, `EnsureUnrollResp`, +- **Depends on**: `vhtlcrecovery` (row types and state constants), `lib/actormsg` + (exit admission: `ForceUnrollRequest`, `ExitPolicy`), `unroll` (status: `GetStatusRequest`, `GetStatusResp`), `baselib/actor` (actor refs for `ActorUnrollRegistry`). -- **Depended on by**: `darepod` (instantiates and wires the service; implements - `TargetMaterializer` via `vhtlcRecoveryTargetMaterializer`). -- **Messages to/from**: Sends `EnsureUnrollRequest` / `GetStatusRequest` -> - `unroll` registry (via `UnrollRegistry`). `Service` methods (`ArmRecovery`, - `EscalateRecovery`, `CancelRecovery`, `GetRecoveryStatus`, +- **Depended on by**: `darepod` (instantiates and wires the service; supplies + the VTXO manager as the `ExitAdmitter` and implements `TargetMaterializer` + via `vhtlcRecoveryTargetMaterializer`). +- **Messages to/from**: Sends `ForceUnrollRequest` -> VTXO manager (via + `ExitAdmitter`) to admit the exit, and `GetStatusRequest` -> `unroll` + registry (via `UnrollRegistry`) to read status back. `Service` methods + (`ArmRecovery`, `EscalateRecovery`, `CancelRecovery`, `GetRecoveryStatus`, `ListRecoveryStatuses`, `RestoreNonTerminal`) are called directly by `darepod.RPCServer`, not actor messages. ## Invariants - Recovery state is SQL-owned. The service keeps no durable in-memory state. -- Escalation writes `unroll_started` before asking unroll to admit the target, - so restart can reissue admission if the process dies during the handoff. +- Escalation writes `unroll_started` before forcing the exit through the VTXO + manager, so restart can reissue admission if the process dies during the + handoff. - Armed jobs are dormant. `RestoreNonTerminal` only reissues jobs that had already escalated before shutdown. -- Any existing unroll job for the same target must carry the same - `exit_policy_kind` and `exit_policy_ref`; mismatches fail closed. +- `ExitAdmitter.ForceExit` returns once the manager has transitioned the VTXO + to unilateral exit, but the registry job is started asynchronously through + the manager's outbox. The service does not read the registry record back for + synchronous verification: a not-yet-visible record is the normal case, left + to the registry's own admission-boundary validation and the restart re-drive. +- A best-effort policy-conflict guard reads status back after forcing the exit: + if an existing unroll job already claimed the target under a different + `exit_policy_kind`, the recovery fails closed rather than exit under the + wrong policy. - `EscalateRecovery` accepts an optional raw claim preimage, validates it against the job's `preimage_hash`, then hands it to `Store.EscalateRecovery` for persistence, but never logs it (`recoveryLogAttrs` omits `ClaimPreimage` diff --git a/vhtlcrecovery/coordinator/CLAUDE.md b/vhtlcrecovery/coordinator/CLAUDE.md index c5801a7c4..75e8e750e 100644 --- a/vhtlcrecovery/coordinator/CLAUDE.md +++ b/vhtlcrecovery/coordinator/CLAUDE.md @@ -3,51 +3,69 @@ ## Purpose Runtime coordinator for durable vHTLC recovery jobs. The package turns an armed -SQL recovery row into a generic unroll admission by passing -`(exit_policy_kind, recovery_id)` to the unroll registry. +SQL recovery row into a unilateral exit by forcing the target through the VTXO +manager's admission gate under the recovery row's +`(exit_policy_kind, recovery_id)` identity. The manager owns the state +transition (persisting the target out of the live set) and starts the durable +unroll job through its chain-resolver seam, so a vHTLC exit converges on the +same path as manual, critical-expiry, and fraud exits. This package exists as a child of `vhtlcrecovery` to avoid an import cycle: -`db` imports the parent package for row types, while the coordinator imports -`unroll` for admission and status. +`db` imports the parent package for row types, while the coordinator forces the +exit through the VTXO manager (`actormsg`) and reads status back from `unroll`. ## Key Types - `Service` — arm/escalate/cancel/status coordinator. - `Store` — durable SQL persistence surface used by the service. -- `UnrollRegistry` — narrow unroll admission/status surface (narrower than the - actor ref so tests can model admission without spinning up the full unroll - subsystem). +- `UnrollRegistry` — narrow unroll status surface (narrower than the actor ref + so tests can model status without spinning up the full unroll subsystem). + Admission no longer lives here; recovery only reads status back. +- `ExitAdmitter` — forces a recovery target into unilateral exit through the + VTXO manager's single admission gate via `ForceExit`. The manager owns the + transition and starts the registry job through its chain-resolver seam. - `ActorUnrollRegistry` — adapter from the live unroll registry actor to the - narrow service interface. + narrow status interface. - `TargetMaterializer` — adapter interface for ensuring the vHTLC target has local descriptor and package bindings that generic unroll needs. Implemented by `darepod.vhtlcRecoveryTargetMaterializer`. - `RecoveryStatus` — durable recovery row joined with current unroll status. -- `ServiceConfig` — wiring: `Store`, `UnrollRegistry`, `TargetMaterializer`. +- `ServiceConfig` — wiring: `Store`, `UnrollRegistry`, `ExitAdmitter`, + `TargetMaterializer`. ## Relationships -- **Depends on**: `vhtlcrecovery` (row types and state constants), `unroll` - (admission/status: `EnsureUnrollRequest`, `EnsureUnrollResp`, +- **Depends on**: `vhtlcrecovery` (row types and state constants), `lib/actormsg` + (exit admission: `ForceUnrollRequest`, `ExitPolicy`), `unroll` (status: `GetStatusRequest`, `GetStatusResp`), `baselib/actor` (actor refs for `ActorUnrollRegistry`). -- **Depended on by**: `darepod` (instantiates and wires the service; implements - `TargetMaterializer` via `vhtlcRecoveryTargetMaterializer`). -- **Messages to/from**: Sends `EnsureUnrollRequest` / `GetStatusRequest` -> - `unroll` registry (via `UnrollRegistry`). `Service` methods (`ArmRecovery`, - `EscalateRecovery`, `CancelRecovery`, `GetRecoveryStatus`, +- **Depended on by**: `darepod` (instantiates and wires the service; supplies + the VTXO manager as the `ExitAdmitter` and implements `TargetMaterializer` + via `vhtlcRecoveryTargetMaterializer`). +- **Messages to/from**: Sends `ForceUnrollRequest` -> VTXO manager (via + `ExitAdmitter`) to admit the exit, and `GetStatusRequest` -> `unroll` + registry (via `UnrollRegistry`) to read status back. `Service` methods + (`ArmRecovery`, `EscalateRecovery`, `CancelRecovery`, `GetRecoveryStatus`, `ListRecoveryStatuses`, `RestoreNonTerminal`) are called directly by `darepod.RPCServer`, not actor messages. ## Invariants - Recovery state is SQL-owned. The service keeps no durable in-memory state. -- Escalation writes `unroll_started` before asking unroll to admit the target, - so restart can reissue admission if the process dies during the handoff. +- Escalation writes `unroll_started` before forcing the exit through the VTXO + manager, so restart can reissue admission if the process dies during the + handoff. - Armed jobs are dormant. `RestoreNonTerminal` only reissues jobs that had already escalated before shutdown. -- Any existing unroll job for the same target must carry the same - `exit_policy_kind` and `exit_policy_ref`; mismatches fail closed. +- `ExitAdmitter.ForceExit` returns once the manager has transitioned the VTXO + to unilateral exit, but the registry job is started asynchronously through + the manager's outbox. The service does not read the registry record back for + synchronous verification: a not-yet-visible record is the normal case, left + to the registry's own admission-boundary validation and the restart re-drive. +- A best-effort policy-conflict guard reads status back after forcing the exit: + if an existing unroll job already claimed the target under a different + `exit_policy_kind`, the recovery fails closed rather than exit under the + wrong policy. - `EscalateRecovery` accepts an optional raw claim preimage, validates it against the job's `preimage_hash`, then hands it to `Store.EscalateRecovery` for persistence, but never logs it (`recoveryLogAttrs` omits `ClaimPreimage` diff --git a/vtxo/AGENTS.md b/vtxo/AGENTS.md index 4ea382d56..4897749d1 100644 --- a/vtxo/AGENTS.md +++ b/vtxo/AGENTS.md @@ -55,7 +55,11 @@ when the local wallet owns the receive script. non-local participant must sign, and the hook that supplies those signatures for custom VTXO policies. - `ExitOutcomeResolution` — Terminal result for an exiting VTXO: `Outcome` - (`ExitOutcomeRecoverable` or `ExitOutcomeConfirmed`) and `Reason`. + (`ExitOutcomeRecoverable` or `ExitOutcomeConfirmed`), `Reason`, and + `ExitPolicyKind` (`actormsg.ExitPolicyKind`) — the exit-spend policy the + unroll job ran under, so boot reconciliation can tell a recovery-only target + (a non-standard policy such as a vHTLC refund) apart from a normal wallet coin + and avoid reliving the former. - `ExitOutcomeResolver` — Function type `func(ctx, wire.OutPoint) (fn.Option[ExitOutcomeResolution], error)`. Returns `None` when the job has no terminal result yet. @@ -128,13 +132,14 @@ when the local wallet owns the receive script. on the actor turn context). This prevents a slow or blocking chain resolver from stalling the VTXO actor's turn and delays the notification delivery past the FSM transition without affecting the transition outcome. -- **Startup reconcile of unilateral-exit VTXOs.** When `ManagerConfig.ExitOutcomeResolver` is set, `Start` calls `reconcileUnilateralExits` after recovering actors. For each VTXO in `VTXOStatusUnilateralExit`, it resolves the terminal outcome: `ExitOutcomeRecoverable` (no on-chain footprint) rolls the VTXO back to `LiveState` and spawns a fresh actor; `ExitOutcomeConfirmed` retires it to `SpentState`. `None` (job still running) is left untouched. +- **Startup reconcile of unilateral-exit VTXOs.** When `ManagerConfig.ExitOutcomeResolver` is set, `Start` calls `reconcileUnilateralExits` after recovering actors. For each VTXO in `VTXOStatusUnilateralExit`, it resolves the terminal outcome (carrying the resolved `ExitPolicyKind` on the `ExitOutcomeNotification`): `ExitOutcomeRecoverable` (no on-chain footprint) rolls a standard-policy VTXO back to `LiveState` and spawns a fresh actor, but a recovery-only target (`ExitPolicyKind.Valid()`) is held in `UnilateralExitState` rather than relived; `ExitOutcomeConfirmed` retires it to `SpentState`. `None` (job still running) is left untouched. - **Startup sweep of orphaned Spending VTXOs.** When `ManagerConfig.ReservationStore` is set, `Start` calls `sweepOrphanedReservations` after all actors are recovered. A Spending VTXO with no reservation row in the durable index is provably orphaned (its spend session died before checkpointing) and is released back to `LiveState` via `SpendReleasedEvent`. The sweep aborts entirely if `ListReservedOutpoints` fails to avoid releasing VTXOs an in-flight spend still owns. - **Startup sweep of orphaned PendingForfeit VTXOs.** `Start` unconditionally calls `releaseOrphanedForfeits` after actor recovery. Any VTXO still in `VTXOStatusPendingForfeit` at startup is provably orphaned — forfeit signatures are submitted only on the PendingForfeit -> Forfeiting transition, so it has leaked no signature and is safe to release to `LiveState`. VTXOs already in `Forfeiting`/`Forfeited` are past the point of no return and are left untouched for chain-confirmation reconciliation. - **Atomic reservation cleanup.** `VTXOStore.UpdateVTXOStatusReleasingReservation` deletes the spending-reservation row in the same transaction as the VTXO status change when a VTXO leaves `SpendingState` (via `SpendReleasedEvent`, `SpendCompletedEvent`, or escalation to `UnilateralExitState`). This prevents the durable index from retaining stale rows that would mask a future orphan on the same outpoint. -- `ForceUnrollEvent` is accepted in `LiveState`, `PendingForfeitState`, `SpendingState`, and `ForfeitingState`: each transitions to `UnilateralExitState` and emits `ExpiringNotification` + `VTXOStatusUpdate{UnilateralExit}`. It does **not** emit `VTXOTerminatedNotification` on intent — `UnilateralExitState` is **non-terminal** (darepo-client#602), so the actor stays alive to observe the exit. Truly terminal states (`Spent`, `Forfeited`, `Failed`) self-loop; the manager maps that self-loop back to `ForceUnrollResponse{Accepted: false, Reason: "already terminal"}`. A re-unroll of a VTXO already in `UnilateralExitState` self-loops with no outbox; the `Unroll` RPC short-circuits it earlier via the persisted `VTXOStatusUnilateralExit` status. -- `UnilateralExitState` is **non-terminal** and observed, not fire-and-forget. The actor survives until the unroll job reports a terminal outcome via the manager's `ExitOutcomeNotification`: `ExitOutcomeRecoverable` (the unroll failed with no on-chain footprint) drives `ExitFailedEvent` → `LiveState` + `VTXOStatusUpdate{Live}`, while `ExitOutcomeConfirmed` (the exit confirmed on-chain) drives `ExitConfirmedEvent` → terminal `SpentState` + `VTXOTerminatedNotification` (the actor is reaped here, gated on a terminal on-chain event rather than the user's intent). When the actor is absent (e.g. a daemon restart, since exiting VTXOs are excluded from `ListLiveVTXOs` recovery) the manager re-materializes a live actor from the persisted descriptor (recover) or persists `VTXOStatusSpent` directly (confirm). +- `ForceUnrollEvent` unifies every unilateral-exit trigger (manual `Unroll` RPC, fraud spend, vHTLC recovery) behind the manager's admission gate. It carries a `Trigger actormsg.UnrollTrigger` (zero value admits as critical expiry) and an `ExitPolicy fn.Option[actormsg.ExitPolicy]` (None selects the standard VTXO timeout policy); both ride through to the emitted `ExpiringNotification` so the chain-resolver bridge admits the registry job under the right `StartTrigger` and persists the correct exit-spend policy. It is accepted in `LiveState`, `PendingForfeitState`, `SpendingState`, and `ForfeitingState`: each transitions to `UnilateralExitState` and emits `ExpiringNotification` (trigger + exit policy threaded through) + `VTXOStatusUpdate{UnilateralExit}`. It does **not** emit `VTXOTerminatedNotification` on intent — `UnilateralExitState` is **non-terminal**, so the actor stays alive to observe the exit. Truly terminal states (`Spent`, `Forfeited`, `Failed`) self-loop; the manager maps that self-loop back to `ForceUnrollResponse{Accepted: false, Reason: "already terminal"}`. A `ForceUnrollEvent` on a VTXO already in `UnilateralExitState` is an idempotent re-admission, not a no-op: the actor stays in `UnilateralExitState`, does not re-persist the status, and **re-emits** the `ExpiringNotification` under the same trigger/policy so the chain-resolver bridge re-admits the job (the first admission's best-effort Tell can be lost to a crash before the registry writes its record; the registry dedups against a live record, so a redundant re-admit is a benign no-op). +- `UnilateralExitState` is **non-terminal** and observed, not fire-and-forget. The actor survives until the unroll job reports a terminal outcome via the manager's `ExitOutcomeNotification`: `ExitOutcomeRecoverable` (the unroll failed with no on-chain footprint) drives `ExitFailedEvent` → `LiveState` + `VTXOStatusUpdate{Live}`, while `ExitOutcomeConfirmed` (the exit confirmed on-chain) drives `ExitConfirmedEvent` → terminal `SpentState` + `VTXOTerminatedNotification` (the actor is reaped here, gated on a terminal on-chain event rather than the user's intent). A recoverable failure of a **recovery-only** target (`ExitOutcomeNotification.ExitPolicyKind.Valid()`, e.g. a vHTLC refund) is the exception: the manager holds the coin in `UnilateralExitState` rather than reliving it, since it is a swap-contract output, not spendable wallet liquidity, and reliving it would inflate balance and feed coin selection and sweep-all. This guard short-circuits before any store access; the owning recovery subsystem is responsible for retrying or terminal-failing the refund. When the actor is absent (e.g. a daemon restart, since exiting VTXOs are excluded from `ListLiveVTXOs` recovery) the manager re-materializes a live actor from the persisted descriptor (recover) or persists `VTXOStatusSpent` directly (confirm). - `Manager.handleForceUnroll` uses `Ask` (not `Tell`) so FSM errors and self-loop no-ops surface as structured `ForceUnrollResponse{Accepted, Reason}` instead of a uniform `Accepted:true` that masks work that was never scheduled. +- When `handleForceUnroll` targets an outpoint with no live actor, `spawnForceUnrollActor` re-materializes an actor from the persisted descriptor so the manager still owns the exit rather than letting the caller admit the unroll behind its back. This is the common shape for the vHTLC recovery target (materialized directly in the store, never admitted through the manager) and any exiting VTXO a restart left out of the live-recovery set. It guards both ends: a missing descriptor returns `ForceUnrollResponse{Accepted: false, Reason: "no such vtxo"}`, and an already-terminal descriptor (`statusToState(...).IsTerminal()`) returns `Reason: "already terminal"` — neither spawns an actor that would immediately reap itself. - Admission types (`SelectAndReserveSpendRequest`, `SelectAndReserveForfeitRequest`, `ReserveForfeitRequest`, etc.) are defined in `lib/actormsg` and re-exported as type aliases to avoid wallet → vtxo → round → wallet import cycles. - `selectAndReserveVTXOs` is a shared helper parameterized by `reserveParams` that serves both the OOR spend and cooperative forfeit coin selection paths, avoiding code duplication. - `IncomingVTXOHandler` only handles `VTXO_EVENT_TYPE_CREATED` events. Other event kinds, missing/short outpoints, empty pkScripts, oversized values (`> int64` or `> MaxSatoshi`), and tapscript derivation failures all return success without persisting — they cannot crash the actor or block the indexer push stream. Real DB lookup/save errors are surfaced. diff --git a/vtxo/CLAUDE.md b/vtxo/CLAUDE.md index 4ea382d56..4897749d1 100644 --- a/vtxo/CLAUDE.md +++ b/vtxo/CLAUDE.md @@ -55,7 +55,11 @@ when the local wallet owns the receive script. non-local participant must sign, and the hook that supplies those signatures for custom VTXO policies. - `ExitOutcomeResolution` — Terminal result for an exiting VTXO: `Outcome` - (`ExitOutcomeRecoverable` or `ExitOutcomeConfirmed`) and `Reason`. + (`ExitOutcomeRecoverable` or `ExitOutcomeConfirmed`), `Reason`, and + `ExitPolicyKind` (`actormsg.ExitPolicyKind`) — the exit-spend policy the + unroll job ran under, so boot reconciliation can tell a recovery-only target + (a non-standard policy such as a vHTLC refund) apart from a normal wallet coin + and avoid reliving the former. - `ExitOutcomeResolver` — Function type `func(ctx, wire.OutPoint) (fn.Option[ExitOutcomeResolution], error)`. Returns `None` when the job has no terminal result yet. @@ -128,13 +132,14 @@ when the local wallet owns the receive script. on the actor turn context). This prevents a slow or blocking chain resolver from stalling the VTXO actor's turn and delays the notification delivery past the FSM transition without affecting the transition outcome. -- **Startup reconcile of unilateral-exit VTXOs.** When `ManagerConfig.ExitOutcomeResolver` is set, `Start` calls `reconcileUnilateralExits` after recovering actors. For each VTXO in `VTXOStatusUnilateralExit`, it resolves the terminal outcome: `ExitOutcomeRecoverable` (no on-chain footprint) rolls the VTXO back to `LiveState` and spawns a fresh actor; `ExitOutcomeConfirmed` retires it to `SpentState`. `None` (job still running) is left untouched. +- **Startup reconcile of unilateral-exit VTXOs.** When `ManagerConfig.ExitOutcomeResolver` is set, `Start` calls `reconcileUnilateralExits` after recovering actors. For each VTXO in `VTXOStatusUnilateralExit`, it resolves the terminal outcome (carrying the resolved `ExitPolicyKind` on the `ExitOutcomeNotification`): `ExitOutcomeRecoverable` (no on-chain footprint) rolls a standard-policy VTXO back to `LiveState` and spawns a fresh actor, but a recovery-only target (`ExitPolicyKind.Valid()`) is held in `UnilateralExitState` rather than relived; `ExitOutcomeConfirmed` retires it to `SpentState`. `None` (job still running) is left untouched. - **Startup sweep of orphaned Spending VTXOs.** When `ManagerConfig.ReservationStore` is set, `Start` calls `sweepOrphanedReservations` after all actors are recovered. A Spending VTXO with no reservation row in the durable index is provably orphaned (its spend session died before checkpointing) and is released back to `LiveState` via `SpendReleasedEvent`. The sweep aborts entirely if `ListReservedOutpoints` fails to avoid releasing VTXOs an in-flight spend still owns. - **Startup sweep of orphaned PendingForfeit VTXOs.** `Start` unconditionally calls `releaseOrphanedForfeits` after actor recovery. Any VTXO still in `VTXOStatusPendingForfeit` at startup is provably orphaned — forfeit signatures are submitted only on the PendingForfeit -> Forfeiting transition, so it has leaked no signature and is safe to release to `LiveState`. VTXOs already in `Forfeiting`/`Forfeited` are past the point of no return and are left untouched for chain-confirmation reconciliation. - **Atomic reservation cleanup.** `VTXOStore.UpdateVTXOStatusReleasingReservation` deletes the spending-reservation row in the same transaction as the VTXO status change when a VTXO leaves `SpendingState` (via `SpendReleasedEvent`, `SpendCompletedEvent`, or escalation to `UnilateralExitState`). This prevents the durable index from retaining stale rows that would mask a future orphan on the same outpoint. -- `ForceUnrollEvent` is accepted in `LiveState`, `PendingForfeitState`, `SpendingState`, and `ForfeitingState`: each transitions to `UnilateralExitState` and emits `ExpiringNotification` + `VTXOStatusUpdate{UnilateralExit}`. It does **not** emit `VTXOTerminatedNotification` on intent — `UnilateralExitState` is **non-terminal** (darepo-client#602), so the actor stays alive to observe the exit. Truly terminal states (`Spent`, `Forfeited`, `Failed`) self-loop; the manager maps that self-loop back to `ForceUnrollResponse{Accepted: false, Reason: "already terminal"}`. A re-unroll of a VTXO already in `UnilateralExitState` self-loops with no outbox; the `Unroll` RPC short-circuits it earlier via the persisted `VTXOStatusUnilateralExit` status. -- `UnilateralExitState` is **non-terminal** and observed, not fire-and-forget. The actor survives until the unroll job reports a terminal outcome via the manager's `ExitOutcomeNotification`: `ExitOutcomeRecoverable` (the unroll failed with no on-chain footprint) drives `ExitFailedEvent` → `LiveState` + `VTXOStatusUpdate{Live}`, while `ExitOutcomeConfirmed` (the exit confirmed on-chain) drives `ExitConfirmedEvent` → terminal `SpentState` + `VTXOTerminatedNotification` (the actor is reaped here, gated on a terminal on-chain event rather than the user's intent). When the actor is absent (e.g. a daemon restart, since exiting VTXOs are excluded from `ListLiveVTXOs` recovery) the manager re-materializes a live actor from the persisted descriptor (recover) or persists `VTXOStatusSpent` directly (confirm). +- `ForceUnrollEvent` unifies every unilateral-exit trigger (manual `Unroll` RPC, fraud spend, vHTLC recovery) behind the manager's admission gate. It carries a `Trigger actormsg.UnrollTrigger` (zero value admits as critical expiry) and an `ExitPolicy fn.Option[actormsg.ExitPolicy]` (None selects the standard VTXO timeout policy); both ride through to the emitted `ExpiringNotification` so the chain-resolver bridge admits the registry job under the right `StartTrigger` and persists the correct exit-spend policy. It is accepted in `LiveState`, `PendingForfeitState`, `SpendingState`, and `ForfeitingState`: each transitions to `UnilateralExitState` and emits `ExpiringNotification` (trigger + exit policy threaded through) + `VTXOStatusUpdate{UnilateralExit}`. It does **not** emit `VTXOTerminatedNotification` on intent — `UnilateralExitState` is **non-terminal**, so the actor stays alive to observe the exit. Truly terminal states (`Spent`, `Forfeited`, `Failed`) self-loop; the manager maps that self-loop back to `ForceUnrollResponse{Accepted: false, Reason: "already terminal"}`. A `ForceUnrollEvent` on a VTXO already in `UnilateralExitState` is an idempotent re-admission, not a no-op: the actor stays in `UnilateralExitState`, does not re-persist the status, and **re-emits** the `ExpiringNotification` under the same trigger/policy so the chain-resolver bridge re-admits the job (the first admission's best-effort Tell can be lost to a crash before the registry writes its record; the registry dedups against a live record, so a redundant re-admit is a benign no-op). +- `UnilateralExitState` is **non-terminal** and observed, not fire-and-forget. The actor survives until the unroll job reports a terminal outcome via the manager's `ExitOutcomeNotification`: `ExitOutcomeRecoverable` (the unroll failed with no on-chain footprint) drives `ExitFailedEvent` → `LiveState` + `VTXOStatusUpdate{Live}`, while `ExitOutcomeConfirmed` (the exit confirmed on-chain) drives `ExitConfirmedEvent` → terminal `SpentState` + `VTXOTerminatedNotification` (the actor is reaped here, gated on a terminal on-chain event rather than the user's intent). A recoverable failure of a **recovery-only** target (`ExitOutcomeNotification.ExitPolicyKind.Valid()`, e.g. a vHTLC refund) is the exception: the manager holds the coin in `UnilateralExitState` rather than reliving it, since it is a swap-contract output, not spendable wallet liquidity, and reliving it would inflate balance and feed coin selection and sweep-all. This guard short-circuits before any store access; the owning recovery subsystem is responsible for retrying or terminal-failing the refund. When the actor is absent (e.g. a daemon restart, since exiting VTXOs are excluded from `ListLiveVTXOs` recovery) the manager re-materializes a live actor from the persisted descriptor (recover) or persists `VTXOStatusSpent` directly (confirm). - `Manager.handleForceUnroll` uses `Ask` (not `Tell`) so FSM errors and self-loop no-ops surface as structured `ForceUnrollResponse{Accepted, Reason}` instead of a uniform `Accepted:true` that masks work that was never scheduled. +- When `handleForceUnroll` targets an outpoint with no live actor, `spawnForceUnrollActor` re-materializes an actor from the persisted descriptor so the manager still owns the exit rather than letting the caller admit the unroll behind its back. This is the common shape for the vHTLC recovery target (materialized directly in the store, never admitted through the manager) and any exiting VTXO a restart left out of the live-recovery set. It guards both ends: a missing descriptor returns `ForceUnrollResponse{Accepted: false, Reason: "no such vtxo"}`, and an already-terminal descriptor (`statusToState(...).IsTerminal()`) returns `Reason: "already terminal"` — neither spawns an actor that would immediately reap itself. - Admission types (`SelectAndReserveSpendRequest`, `SelectAndReserveForfeitRequest`, `ReserveForfeitRequest`, etc.) are defined in `lib/actormsg` and re-exported as type aliases to avoid wallet → vtxo → round → wallet import cycles. - `selectAndReserveVTXOs` is a shared helper parameterized by `reserveParams` that serves both the OOR spend and cooperative forfeit coin selection paths, avoiding code duplication. - `IncomingVTXOHandler` only handles `VTXO_EVENT_TYPE_CREATED` events. Other event kinds, missing/short outpoints, empty pkScripts, oversized values (`> int64` or `> MaxSatoshi`), and tapscript derivation failures all return success without persisting — they cannot crash the actor or block the indexer push stream. Real DB lookup/save errors are surfaced. From 314cc51b99f41cf297b18dd2f43c143cc4c83a57 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 9 Jul 2026 17:44:45 -0700 Subject: [PATCH 09/11] vtxo+db: return a typed ErrVTXONotFound from the VTXO store In this commit, we give VTXOStore.GetVTXO a domain-level miss sentinel so callers stop reaching for the persistence-layer sql.ErrNoRows. The store translates a row miss into vtxo.ErrVTXONotFound (keeping sql.ErrNoRows in the error chain so the call sites that still test for it keep working while they migrate), and the VTXO manager matches the sentinel instead. This fixes a real papercut on the force-unroll path: the manager's spawn-from-descriptor step treated a GetVTXO miss as a nil descriptor, but the store signals a miss with an error, not a nil. A manual or fraud force for an outpoint the wallet no longer tracks therefore surfaced an internal "load vtxo for force-unroll" error instead of the intended declined ForceUnrollResponse{Accepted: false, Reason: "no such vtxo"}. Matching the sentinel makes the decline read correctly, and it keeps the manager off a database/sql detail it had no business knowing. --- db/vtxo_store.go | 12 +++++++++- db/vtxo_store_test.go | 25 ++++++++++++++++++++ vtxo/interfaces.go | 10 +++++++- vtxo/manager.go | 25 +++++++++++++++----- vtxo/manager_admission_test.go | 7 +++--- vtxo/manager_force_unroll_test.go | 38 +++++++++++++++++++++++++++++++ 6 files changed, 105 insertions(+), 12 deletions(-) diff --git a/db/vtxo_store.go b/db/vtxo_store.go index 4ad9fee0f..1434b3beb 100644 --- a/db/vtxo_store.go +++ b/db/vtxo_store.go @@ -175,7 +175,7 @@ func (s *VTXOPersistenceStore) ensureRoundExists(ctx context.Context, } // GetVTXO retrieves a VTXO by its outpoint. Used for actor recovery on startup. -// Returns error if not found. +// Returns vtxo.ErrVTXONotFound if the outpoint is not stored. func (s *VTXOPersistenceStore) GetVTXO(ctx context.Context, outpoint wire.OutPoint) (*vtxo.Descriptor, error) { @@ -191,6 +191,16 @@ func (s *VTXOPersistenceStore) GetVTXO(ctx context.Context, row, err := q.GetVTXO(ctx, params) if err != nil { + // Translate the persistence-layer miss into the domain + // sentinel so callers match vtxo.ErrVTXONotFound rather + // than sql.ErrNoRows. We keep sql.ErrNoRows in the + // chain so existing call sites that still test for it + // keep working while they migrate. + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("get VTXO: %w: %w", + vtxo.ErrVTXONotFound, err) + } + return fmt.Errorf("get VTXO: %w", err) } diff --git a/db/vtxo_store_test.go b/db/vtxo_store_test.go index 2c679c10f..412ff13f2 100644 --- a/db/vtxo_store_test.go +++ b/db/vtxo_store_test.go @@ -1692,3 +1692,28 @@ func TestVTXOPersistenceStoreMetadataUpdate(t *testing.T) { "CommitmentTxid should be updated", ) } + +// TestVTXOPersistenceStoreGetVTXONotFound verifies that a miss surfaces as the +// domain sentinel vtxo.ErrVTXONotFound so callers match on it rather than the +// persistence-layer sql.ErrNoRows. The raw driver error stays in the chain so +// existing call sites that still test for it keep working during migration. +func TestVTXOPersistenceStoreGetVTXONotFound(t *testing.T) { + t.Parallel() + + vtxoStore, _, _ := newVTXOStoreForTest(t) + ctx := t.Context() + + unknown := wire.OutPoint{Hash: chainhash.Hash{0xde, 0xad}, Index: 7} + + fetched, err := vtxoStore.GetVTXO(ctx, unknown) + require.Nil(t, fetched) + require.Error(t, err) + require.ErrorIs( + t, err, vtxo.ErrVTXONotFound, + "a miss must surface the domain not-found sentinel", + ) + require.ErrorIs( + t, err, sql.ErrNoRows, + "the driver error stays in the chain for back-compat", + ) +} diff --git a/vtxo/interfaces.go b/vtxo/interfaces.go index d57f0b44a..eccf7cfc7 100644 --- a/vtxo/interfaces.go +++ b/vtxo/interfaces.go @@ -2,6 +2,7 @@ package vtxo import ( "context" + "errors" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil/v2" @@ -447,6 +448,13 @@ func (d *Descriptor) PrimaryAncestry() *Ancestry { return &d.Ancestry[0] } +// ErrVTXONotFound is returned by VTXOStore.GetVTXO when the store has no +// record of the requested outpoint. It is the domain-level miss signal, so +// callers match on it rather than a persistence-layer error like +// sql.ErrNoRows: the manager decides how a missing VTXO reads (e.g. a declined +// force-unroll) without depending on how the store is backed. +var ErrVTXONotFound = errors.New("vtxo not found") + // VTXOStore defines the persistence interface for VTXO lifecycle management. // The store provides per-VTXO operations since each VTXO has its own actor. // The VTXO manager (parent actor) tracks active VTXOs and routes block epochs. @@ -459,7 +467,7 @@ type VTXOStore interface { SaveVTXO(ctx context.Context, vtxo *Descriptor) error // GetVTXO retrieves a VTXO by its outpoint. Used for actor recovery on - // startup. Returns error if not found. + // startup. Returns ErrVTXONotFound if the outpoint is not stored. GetVTXO(ctx context.Context, outpoint wire.OutPoint) (*Descriptor, error) diff --git a/vtxo/manager.go b/vtxo/manager.go index bdd5474e4..09f282ba7 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -3,7 +3,6 @@ package vtxo import ( "bytes" "context" - "database/sql" "errors" "fmt" "log/slog" @@ -834,6 +833,20 @@ func (m *Manager) spawnForceUnrollActor(ctx context.Context, descriptor, err := m.cfg.Store.GetVTXO(ctx, outpoint) if err != nil { + // The store returns ErrVTXONotFound when the wallet no longer + // tracks the outpoint. That is a declined force-unroll, not an + // internal failure: report it the same as a nil descriptor so + // the caller reads "no such vtxo" rather than a hard error for + // an outpoint that simply is not ours to unroll. + if errors.Is(err, ErrVTXONotFound) { + res := fn.Ok[ManagerResp](&ForceUnrollResponse{ + Accepted: false, + Reason: "no such vtxo", + }) + + return nil, &res + } + res := fn.Err[ManagerResp]( fmt.Errorf("load vtxo for force-unroll: %w", err), ) @@ -841,9 +854,9 @@ func (m *Manager) spawnForceUnrollActor(ctx context.Context, return nil, &res } if descriptor == nil { - // No descriptor at all: the caller referenced an outpoint the - // wallet does not track. Report a specific reason so it reads - // apart from "accepted but self-looped". + // A store that signals a miss with a nil descriptor rather than + // ErrVTXONotFound lands here: the same declined force-unroll, + // reported so it reads apart from "accepted but self-looped". res := fn.Ok[ManagerResp](&ForceUnrollResponse{ Accepted: false, Reason: "no such vtxo", @@ -1866,7 +1879,7 @@ func (m *Manager) isPersistedSpent(ctx context.Context, op wire.OutPoint) (bool, desc, err := m.cfg.Store.GetVTXO(ctx, op) if err != nil { - if errors.Is(err, sql.ErrNoRows) { + if errors.Is(err, ErrVTXONotFound) { return false, nil } @@ -2238,7 +2251,7 @@ func (m *Manager) customForfeitInputIsSynthetic(ctx context.Context, return false, nil - case errors.Is(err, sql.ErrNoRows): + case errors.Is(err, ErrVTXONotFound): return true, nil default: diff --git a/vtxo/manager_admission_test.go b/vtxo/manager_admission_test.go index 33d3021c8..03430dcb7 100644 --- a/vtxo/manager_admission_test.go +++ b/vtxo/manager_admission_test.go @@ -2,7 +2,6 @@ package vtxo import ( "context" - "database/sql" "errors" "fmt" "reflect" @@ -273,7 +272,7 @@ func TestActivateCustomForfeitInputsPersistsPendingSigner(t *testing.T) { TreeDepth: 2, }} store.On("GetVTXO", mock.Anything, op).Return( - nil, sql.ErrNoRows, + nil, ErrVTXONotFound, ).Once() store.On( "SaveVTXO", mock.Anything, @@ -726,7 +725,7 @@ func TestActivateCustomForfeitInputsRollsBackPartialActivation(t *testing.T) { store := &MockVTXOStore{} store.On("GetVTXO", mock.Anything, first.Outpoint).Return( - nil, sql.ErrNoRows, + nil, ErrVTXONotFound, ).Once() store.On( "SaveVTXO", mock.Anything, @@ -1249,7 +1248,7 @@ func TestCompleteSpendMissingPersistedVTXOReturnsNoActor(t *testing.T) { mgr, store := newTestManager(t, nil) store.On( "GetVTXO", t.Context(), unknownOP, - ).Return(nil, sql.ErrNoRows).Once() + ).Return(nil, ErrVTXONotFound).Once() result := mgr.Receive(t.Context(), &CompleteSpendRequest{ Outpoints: []wire.OutPoint{unknownOP}, diff --git a/vtxo/manager_force_unroll_test.go b/vtxo/manager_force_unroll_test.go index eb8bdb6ab..bee964cd3 100644 --- a/vtxo/manager_force_unroll_test.go +++ b/vtxo/manager_force_unroll_test.go @@ -1,6 +1,7 @@ package vtxo import ( + "fmt" "testing" "github.com/btcsuite/btcd/wire/v2" @@ -67,6 +68,43 @@ func TestHandleForceUnrollAbsentActorNoDescriptor(t *testing.T) { store.AssertExpectations(t) } +// TestHandleForceUnrollAbsentActorNotFoundError verifies that a force-unroll +// for an outpoint the store reports missing via ErrVTXONotFound (the production +// contract, versus a nil-descriptor mock) is a declined force-unroll rather +// than a hard internal error. A miss on the store is not our VTXO to unroll. +func TestHandleForceUnrollAbsentActorNotFoundError(t *testing.T) { + t.Parallel() + + vtxo := makeDescriptor(t, 50_000, 13) + store := &MockVTXOStore{} + mgr := &Manager{ + cfg: &ManagerConfig{ + Store: store, + }, + actors: make(map[wire.OutPoint]VTXOActorRef), + } + + // The real store wraps sql.ErrNoRows in ErrVTXONotFound; mirror that + // wrapping so the test exercises the same errors.Is match the manager + // relies on, not just the bare sentinel. + store.On("GetVTXO", t.Context(), vtxo.Outpoint).Return( + nil, fmt.Errorf("get VTXO: %w", ErrVTXONotFound), + ) + + resp := mgr.Receive(t.Context(), &actormsg.ForceUnrollRequest{ + Outpoint: vtxo.Outpoint, + Trigger: actormsg.UnrollTriggerManual, + }) + unpacked, err := resp.Unpack() + require.NoError(t, err) + + forceResp, ok := unpacked.(*ForceUnrollResponse) + require.True(t, ok) + require.False(t, forceResp.Accepted) + require.Equal(t, "no such vtxo", forceResp.Reason) + store.AssertExpectations(t) +} + // TestHandleForceUnrollAbsentActorTerminalDescriptor verifies that a // force-unroll for a VTXO whose persisted descriptor is already terminal // (spent) is a reported no-op rather than respawning an actor that would From 07b39cb6c81b3bbd908b12b343f4c45ebf53ce5a Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 9 Jul 2026 17:45:03 -0700 Subject: [PATCH 10/11] vhtlcrecovery: guard the unroll status against a nil response In this commit, we nil-check the unroll status the coordinator reads back after forcing a recovery exit. Both the post-force policy-conflict guard and the status-reconcile path dereferenced the GetStatus result directly, so a status source that returns a nil status with no error would panic rather than read as "no record yet". A nil-with-no-error is a legitimate shape now that admission is asynchronous through the VTXO manager: the registry record may not be visible yet. We treat it the same as a not-found record, leaving the recovery active for the registry's own validation and the restart re-drive instead of crashing the service. --- vhtlcrecovery/coordinator/service.go | 4 +-- vhtlcrecovery/coordinator/service_test.go | 33 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/vhtlcrecovery/coordinator/service.go b/vhtlcrecovery/coordinator/service.go index dacaaf0d8..de22de35e 100644 --- a/vhtlcrecovery/coordinator/service.go +++ b/vhtlcrecovery/coordinator/service.go @@ -509,7 +509,7 @@ func (s *Service) ensureUnroll(ctx context.Context, return nil } - if !status.Found { + if status == nil || !status.Found { return nil } if status.ExitPolicyKind != "" && @@ -553,7 +553,7 @@ func (s *Service) reconcileLoaded(ctx context.Context, return nil, err } - if !unrollStatus.Found { + if unrollStatus == nil || !unrollStatus.Found { return status, nil } diff --git a/vhtlcrecovery/coordinator/service_test.go b/vhtlcrecovery/coordinator/service_test.go index b17f6a304..cf61082e4 100644 --- a/vhtlcrecovery/coordinator/service_test.go +++ b/vhtlcrecovery/coordinator/service_test.go @@ -197,6 +197,30 @@ func TestServiceRestoreKeepsRecoveryActiveAfterTransientError(t *testing.T) { require.Empty(t, stored.LastError) } +// TestServiceEscalateToleratesNilUnrollStatus verifies the post-force +// policy-conflict guard does not panic when the unroll status source returns a +// nil status with no error. That shape reads as "no record yet", which is the +// normal case now that admission is asynchronous through the manager, so the +// recovery stays active and is left to the registry's own validation plus the +// restart re-drive rather than being failed or crashing the service. +func TestServiceEscalateToleratesNilUnrollStatus(t *testing.T) { + t.Parallel() + + job := testRecoveryJob( + "recovery-nil-status", vhtlcrecovery.StateUnrollStarted, + ) + store := newFakeStore(job) + registry := &fakeUnrollRegistry{nilStatus: true} + service := newTestService(t, store, registry) + + require.NoError(t, service.RestoreNonTerminal(t.Context())) + require.Len(t, registry.exitRequests, 1) + + stored, err := store.GetRecovery(t.Context(), job.ID) + require.NoError(t, err) + require.Equal(t, vhtlcrecovery.StateUnrollStarted, stored.State) +} + // TestServiceStatusReconcilesTerminalUnroll verifies status polling folds a // terminal unroll result back into the durable recovery row. func TestServiceStatusReconcilesTerminalUnroll(t *testing.T) { @@ -462,6 +486,11 @@ type fakeUnrollRegistry struct { exitErr error status *unroll.GetStatusResp statusErr error + + // nilStatus makes GetStatus return (nil, nil), the shape a status + // source may produce for a not-yet-visible record. Used to pin the + // coordinator's nil-status guard. + nilStatus bool } // ForceExit implements ExitAdmitter by recording the request. @@ -481,6 +510,10 @@ func (r *fakeUnrollRegistry) GetStatus(_ context.Context, _ wire.OutPoint) ( return nil, r.statusErr } + if r.nilStatus { + return nil, nil + } + if r.status == nil { return &unroll.GetStatusResp{}, nil } From afc7b33ac52fa28e1155c3d0d73367c7f7f81220 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 9 Jul 2026 17:56:35 -0700 Subject: [PATCH 11/11] multi: pin reconcile nil guard and document the fraud-trigger gap In this commit, we close out the final review round. We add a test that drives the status-reconcile path (GetRecoveryStatus, which joins the durable row with unroll status without going through escalation) against a nil status source, pinning the second half of the nil-status guard independently of the escalation path. We also document, at the orphan-recovery re-admission, that a fraud-forced exit orphaned in the crash gap between the VTXO status flip and the registry admission re-admits as TriggerRestart and so loses its fraud checkpoint deferral. The effect is a premature but safe checkpoint broadcast (earlier fees, same funds outcome, no missed deadline), and the comment names the same-transaction trigger-stamp as the faithful follow-up shape should we ever want to close the gap. --- darepod/server.go | 16 ++++++++++++++++ vhtlcrecovery/coordinator/service_test.go | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/darepod/server.go b/darepod/server.go index 86d7cc355..9d24fcfa5 100644 --- a/darepod/server.go +++ b/darepod/server.go @@ -5552,6 +5552,22 @@ func (s *Server) recoverOrphanedUnrollJobs(ctx context.Context, // Re-admit it under that policy so the first-writer-wins // registry never locks it to the standard timeout: a standard // witness against a vHTLC taproot tree would never sweep. + // + // The trigger is not recovered the way the exit policy is. A + // target that was force-exited under TriggerFraudSpend but + // crashed in the gap between the VTXO status flip and the + // registry admission has no registry record, so it re-admits + // here as TriggerRestart. The only effect is that its ready + // checkpoints are broadcast immediately instead of deferred to + // the recipient's fraud backstop window (see + // unroll.shouldSubmitReadyFrontier): earlier fees, same funds + // outcome, no missed deadline. The exit policy is recoverable + // because it lives in the recovery store; the fraud trigger has + // no such durable home. A faithful fix would stamp the trigger + // onto the VTXO row in the same transaction that flips it to + // UnilateralExit and read it back off the listed descriptors + // here, which is a schema change left as separable follow-up + // tracked in darepo-client#914. ensureReq := &unroll.EnsureUnrollRequest{ Outpoint: op, Trigger: unroll.TriggerRestart, diff --git a/vhtlcrecovery/coordinator/service_test.go b/vhtlcrecovery/coordinator/service_test.go index cf61082e4..2c1f642ac 100644 --- a/vhtlcrecovery/coordinator/service_test.go +++ b/vhtlcrecovery/coordinator/service_test.go @@ -221,6 +221,27 @@ func TestServiceEscalateToleratesNilUnrollStatus(t *testing.T) { require.Equal(t, vhtlcrecovery.StateUnrollStarted, stored.State) } +// TestServiceStatusToleratesNilUnrollStatus pins the nil-status guard on the +// status-reconcile path specifically. GetRecoveryStatus joins the durable row +// with the current unroll status via reconcileLoaded without going through +// escalation, so a nil status here must read as "no observation yet" and return +// the row unchanged rather than panic. +func TestServiceStatusToleratesNilUnrollStatus(t *testing.T) { + t.Parallel() + + job := testRecoveryJob( + "recovery-status-nil", vhtlcrecovery.StateUnrollStarted, + ) + store := newFakeStore(job) + registry := &fakeUnrollRegistry{nilStatus: true} + service := newTestService(t, store, registry) + + status, err := service.GetRecoveryStatus(t.Context(), job.ID) + require.NoError(t, err) + require.NotNil(t, status) + require.Equal(t, vhtlcrecovery.StateUnrollStarted, status.Job.State) +} + // TestServiceStatusReconcilesTerminalUnroll verifies status polling folds a // terminal unroll result back into the durable recovery row. func TestServiceStatusReconcilesTerminalUnroll(t *testing.T) {