Skip to content

Commit e5a89c8

Browse files
authored
Merge pull request #1215 from lightninglabs/codex/investigate-1212-20260828
vtxo: prefer cooperative refresh when exit is infeasible
2 parents dfbf7c2 + 6b75a92 commit e5a89c8

11 files changed

Lines changed: 408 additions & 65 deletions

File tree

vtxo/actor.go

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,24 @@ func VTXOActorServiceKey(outpoint wire.OutPoint) actor.ServiceKey[
5656
type RefreshFeeQuoter func(ctx context.Context,
5757
amount btcutil.Amount, remainingBlocks uint32) btcutil.Amount
5858

59+
// CriticalExitAssessment reports whether the backing wallet can execute an
60+
// automatic unilateral exit at the current fee rate. Reason is a short,
61+
// stable diagnostic when Feasible is false.
62+
type CriticalExitAssessment struct {
63+
// Feasible permits the existing automatic unilateral-exit transition.
64+
Feasible bool
65+
66+
// Reason explains an infeasible verdict in logs.
67+
Reason string
68+
}
69+
70+
// CriticalExitAssessor checks the whole exit package before a live VTXO enters
71+
// unilateral exit due to critical expiry. Errors preserve the existing direct
72+
// exit behavior so an unavailable assessment cannot suppress the safety path.
73+
type CriticalExitAssessor func(context.Context, *Descriptor) (
74+
CriticalExitAssessment, error,
75+
)
76+
5977
// VTXOActorConfig holds configuration for a single VTXO actor.
6078
type VTXOActorConfig struct {
6179
VTXO *Descriptor
@@ -97,6 +115,13 @@ type VTXOActorConfig struct {
97115
// the seal-time quote is still the source of truth.
98116
RefreshFeeQuoter RefreshFeeQuoter
99117

118+
// CriticalExitAssessor, when set, checks wallet funding and package
119+
// economics before an automatic critical-expiry exit. An infeasible
120+
// verdict starts or continues cooperative refresh; the actor reassesses
121+
// each block and exits when viable. Nil, errors, and feasible verdicts
122+
// retain the existing direct exit.
123+
CriticalExitAssessor CriticalExitAssessor
124+
100125
// FetchOperatorKey, when set, returns the operator's current
101126
// long-term public key by issuing a fresh GetInfo round-trip to
102127
// the operator at the moment of an auto-refresh emission. The
@@ -139,7 +164,7 @@ type VTXOActor struct {
139164

140165
// autoRefreshRetryHeight is an in-memory maintenance cooldown. It is
141166
// deliberately fail-safe on restart: clearing it can cause one earlier
142-
// retry, but can never delay the critical unilateral-exit path.
167+
// retry, but can never delay the critical path decision.
143168
autoRefreshRetryHeight int32
144169

145170
// autoRefreshCohortLeader owns a manager-forced pending reservation.
@@ -298,6 +323,83 @@ func (a *VTXOActor) preflightAutoRefresh(ctx context.Context, event VTXOEvent) (
298323
return currentKey, true, nil
299324
}
300325

326+
// preflightCriticalExit chooses between the normal critical-expiry event and
327+
// cooperative refresh. Only a live or pending-forfeit critical VTXO with an
328+
// explicit infeasible assessment is diverted. The check repeats each block,
329+
// so funding the wallet restores the unilateral path without another state
330+
// transition. Missing or failed assessments retain the unilateral path.
331+
func (a *VTXOActor) preflightCriticalExit(ctx context.Context,
332+
event VTXOEvent) VTXOEvent {
333+
334+
blockEvent, ok := event.(*BlockEpochEvent)
335+
if !ok || a.cfg.CriticalExitAssessor == nil {
336+
return event
337+
}
338+
339+
var desc *Descriptor
340+
switch state := a.state.(type) {
341+
case *LiveState:
342+
desc = state.VTXO
343+
344+
case *PendingForfeitState:
345+
desc = state.VTXO
346+
347+
default:
348+
return event
349+
}
350+
351+
status := a.env.ExpiryConfig.CheckExpiry(
352+
desc, blockEvent.Height,
353+
)
354+
if status != ExpiryStatusCritical {
355+
return event
356+
}
357+
358+
blocksRemaining := BlocksUntilExpiry(
359+
desc, blockEvent.Height,
360+
)
361+
assessment, err := a.cfg.CriticalExitAssessor(ctx, desc)
362+
if err != nil {
363+
a.logger(ctx).WarnS(ctx, "Automatic expiry decision",
364+
err,
365+
slog.String("action", "unilateral_exit"),
366+
slog.String("reason", "exit assessment unavailable"),
367+
slog.Int("height", int(blockEvent.Height)),
368+
slog.Int("blocks_remaining", int(blocksRemaining)),
369+
slog.String("outpoint", desc.Outpoint.String()),
370+
)
371+
372+
return event
373+
}
374+
375+
if assessment.Feasible {
376+
a.logger(ctx).InfoS(ctx, "Automatic expiry decision",
377+
slog.String("action", "unilateral_exit"),
378+
slog.String("reason", "exit funding is feasible"),
379+
slog.Int("height", int(blockEvent.Height)),
380+
slog.Int("blocks_remaining", int(blocksRemaining)),
381+
slog.String("outpoint", desc.Outpoint.String()),
382+
)
383+
384+
return event
385+
}
386+
387+
reason := assessment.Reason
388+
if reason == "" {
389+
reason = "exit funding is infeasible"
390+
}
391+
a.logger(ctx).InfoS(ctx, "Automatic expiry decision",
392+
slog.String("action", "cooperative_refresh"),
393+
slog.String("reason", reason),
394+
slog.Int("height", int(blockEvent.Height)),
395+
slog.Int("blocks_remaining", int(blocksRemaining)),
396+
slog.Bool("reassess_next_block", true),
397+
slog.String("outpoint", desc.Outpoint.String()),
398+
)
399+
400+
return &criticalRefreshEvent{Height: blockEvent.Height}
401+
}
402+
301403
// emitExitCost is the VTXO-actor entry point for unilateral-exit accounting.
302404
// It is intentionally empty: the VTXO actor hands off to unroll before the
303405
// final sweep is built, so it never sees the confirmed miner fee or height.
@@ -473,6 +575,7 @@ func (a *VTXOActor) Receive(ctx context.Context,
473575
if preflightKey != nil {
474576
refreshKey = preflightKey
475577
}
578+
vtxoEvent = a.preflightCriticalExit(ctx, vtxoEvent)
476579

477580
transition, err := a.state.ProcessEvent(ctx, vtxoEvent, a.env)
478581
if err != nil {

vtxo/auto_refresh_safety_test.go

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package vtxo
22

33
import (
4+
"context"
45
"testing"
56

67
"github.com/btcsuite/btcd/chaincfg/v2"
@@ -70,10 +71,10 @@ func TestAutomaticRefreshCooldownSuppressesBlockRetries(t *testing.T) {
7071
h.store.AssertExpectations(t)
7172
}
7273

73-
// TestAutomaticRefreshCooldownNeverBlocksCriticalExit verifies a critical
74-
// block inside the six-block cooldown clears it and immediately hands the VTXO
75-
// to the unilateral-exit path.
76-
func TestAutomaticRefreshCooldownNeverBlocksCriticalExit(t *testing.T) {
74+
// TestAutomaticRefreshCooldownNeverBlocksFundedCriticalExit verifies a
75+
// critical block inside the six-block cooldown clears it and immediately
76+
// hands a funded VTXO to the unilateral-exit path.
77+
func TestAutomaticRefreshCooldownNeverBlocksFundedCriticalExit(t *testing.T) {
7778
t.Parallel()
7879

7980
h := newVTXOTestHarness(t)
@@ -105,6 +106,11 @@ func TestAutomaticRefreshCooldownNeverBlocksCriticalExit(t *testing.T) {
105106
resolver := newMockChainResolverRef(t)
106107
actor := newRefreshTestActor(h, desc, manager, nil)
107108
actor.cfg.ChainResolver = resolver
109+
actor.cfg.CriticalExitAssessor = func(context.Context, *Descriptor) (
110+
CriticalExitAssessment, error) {
111+
112+
return CriticalExitAssessment{Feasible: true}, nil
113+
}
108114

109115
_, err := actor.Receive(
110116
h.ctx, h.newBlockEpochEvent(firstHeight),
@@ -125,6 +131,78 @@ func TestAutomaticRefreshCooldownNeverBlocksCriticalExit(t *testing.T) {
125131
h.store.AssertExpectations(t)
126132
}
127133

134+
// TestCriticalUnderfundedExitUsesCooperativeRefresh pins the incident shape
135+
// from wavelength#1212: a live VTXO first observed 187 blocks before expiry
136+
// cannot fund its unilateral package, so it stays on cooperative refresh until
137+
// a later block observes that the wallet can execute the exit.
138+
func TestCriticalUnderfundedExitUsesCooperativeRefresh(t *testing.T) {
139+
t.Parallel()
140+
141+
h := newVTXOTestHarness(t)
142+
desc := h.newTestDescriptor()
143+
desc.BatchExpiry = 1_000
144+
desc.RelativeExpiry = 0
145+
146+
expiryCfg := &ExpiryConfig{
147+
RefreshThresholdBlocks: 264,
148+
CriticalThresholdBlocks: 192,
149+
TreeDepthMultiplier: 0,
150+
}
151+
h.withExpiryConfig(expiryCfg)
152+
153+
h.store.On(
154+
"UpdateVTXOStatus", h.ctx, desc.Outpoint,
155+
VTXOStatusPendingForfeit,
156+
).Return(nil).Once()
157+
h.store.On(
158+
"UpdateVTXOStatus", h.ctx, desc.Outpoint,
159+
VTXOStatusUnilateralExit,
160+
).Return(nil).Once()
161+
162+
manager := newMockManagerRef(t)
163+
resolver := newMockChainResolverRef(t)
164+
actor := newRefreshTestActor(h, desc, manager, nil)
165+
actor.cfg.ChainResolver = resolver
166+
exitFeasible := false
167+
actor.cfg.CriticalExitAssessor = func(context.Context, *Descriptor) (
168+
CriticalExitAssessment, error) {
169+
170+
return CriticalExitAssessment{
171+
Feasible: exitFeasible,
172+
Reason: "wallet_too_few_inputs: need 2 usable " +
173+
"fee inputs, have 0",
174+
}, nil
175+
}
176+
177+
_, err := actor.Receive(
178+
h.ctx, h.newBlockEpochEvent(813),
179+
).Unpack()
180+
require.NoError(t, err)
181+
pending, ok := actor.state.(*PendingForfeitState)
182+
require.True(t, ok)
183+
require.Equal(t, int32(813), pending.RequestedAtHeight)
184+
require.Len(t, manager.getMessages(), 1)
185+
require.Empty(t, resolver.getMessages())
186+
187+
_, err = actor.Receive(
188+
h.ctx, h.newBlockEpochEvent(814),
189+
).Unpack()
190+
require.NoError(t, err)
191+
require.IsType(t, &PendingForfeitState{}, actor.state)
192+
require.Len(t, manager.getMessages(), 1)
193+
require.Empty(t, resolver.getMessages())
194+
195+
exitFeasible = true
196+
_, err = actor.Receive(
197+
h.ctx, h.newBlockEpochEvent(815),
198+
).Unpack()
199+
require.NoError(t, err)
200+
require.IsType(t, &UnilateralExitState{}, actor.state)
201+
require.Len(t, manager.getMessages(), 1)
202+
require.Len(t, resolver.getMessages(), 1)
203+
h.store.AssertExpectations(t)
204+
}
205+
128206
// TestCohortRollbackGenerationRejectsStaleRelease verifies an ABA-delayed
129207
// rollback from attempt A cannot release attempt B when the same leader
130208
// retries.

vtxo/events.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,28 @@ type (
6262
ForfeitReleasedEvent = round.ForfeitReleasedEvent
6363
)
6464

65+
// criticalRefreshEvent tells LiveState or PendingForfeitState that the actor
66+
// established that the automatic unilateral path is not currently viable.
67+
// LiveState starts cooperative refresh; PendingForfeitState keeps waiting for
68+
// that round. It is actor-local: external block notifications remain
69+
// BlockEpochEvent, and a funded or unassessed critical VTXO follows the
70+
// existing unilateral-exit transition.
71+
type criticalRefreshEvent struct {
72+
actor.BaseMessage
73+
74+
// Height is the critical block height used for the durable automatic
75+
// refresh reservation and repeated funding assessment.
76+
Height int32
77+
}
78+
79+
// VTXOActorMsg implements actormsg.VTXOActorMsg.
80+
func (e *criticalRefreshEvent) VTXOActorMsg() {}
81+
82+
// MessageType returns the message type used by actor diagnostics.
83+
func (e *criticalRefreshEvent) MessageType() string {
84+
return "criticalRefreshEvent"
85+
}
86+
6587
// CohortRefreshEvent asks an eligible sibling VTXO to join an automatic
6688
// refresh already triggered by another VTXO from the same batch. The manager
6789
// sends these requests with bounded Ask backpressure before forwarding the

vtxo/expiry.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ const (
1212
// ExpiryStatusNeedsRefresh indicates the VTXO should request refresh.
1313
ExpiryStatusNeedsRefresh
1414

15-
// ExpiryStatusCritical indicates the VTXO must be sent to chain
16-
// resolver.
15+
// ExpiryStatusCritical indicates the unilateral time budget is active.
16+
// Automatic handling assesses package viability before choosing between
17+
// unilateral exit and continued cooperative refresh.
1718
ExpiryStatusCritical
1819

1920
// ExpiryStatusExpired indicates the batch has already expired.
@@ -88,8 +89,9 @@ type ExpiryConfig struct {
8889
RefreshThresholdBlocks int32
8990

9091
// CriticalThresholdBlocks is the base number of blocks before batch
91-
// expiry at which the VTXO is escalated to the chain resolver for
92-
// unilateral exit. Must be less than RefreshThresholdBlocks.
92+
// expiry at which automatic handling assesses whether the VTXO's
93+
// unilateral package is viable. Must be less than
94+
// RefreshThresholdBlocks.
9395
CriticalThresholdBlocks int32
9496

9597
// MinRefreshBuffer is the minimum buffer (blocks) between refresh and

vtxo/manager.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,11 @@ type ManagerConfig struct {
139139
// fills in the residual via the JoinRoundQuote.
140140
RefreshFeeQuoter RefreshFeeQuoter
141141

142+
// CriticalExitAssessor is propagated to each spawned VTXOActor. It
143+
// lets automatic critical-expiry handling prefer cooperative refresh
144+
// only while the unilateral package is not viable.
145+
CriticalExitAssessor CriticalExitAssessor
146+
142147
// FetchOperatorKey is propagated to each spawned VTXOActor so
143148
// the auto-refresh emission can fetch the operator's current
144149
// long-term key at join time and rebuild the NEW VTXO output's
@@ -1433,8 +1438,10 @@ func (m *Manager) handleVTXOTerminated(ctx context.Context,
14331438
// Liveness guarantee: when a VTXO approaches expiry, the VTXO actor
14341439
// autonomously emits a ForfeitRequest (wrapped in RelayToRoundMsg) without
14351440
// requiring wallet input. The manager relays this immediately, ensuring
1436-
// cooperative action is always attempted before critical expiry. This
1437-
// default policy means safety does not depend on wallet reaction time.
1441+
// cooperative action is attempted before critical expiry and remains the
1442+
// fallback at critical expiry while the backing wallet cannot execute the
1443+
// unilateral package. This default policy means safety does not depend on
1444+
// wallet reaction time or on entering an exit that cannot make progress.
14381445
// Auto-expiry forfeits intentionally bypass admission gating because
14391446
// the VTXO actor has already determined that cooperative action is
14401447
// urgent. Delaying relay would risk missing the expiry window.
@@ -1864,6 +1871,9 @@ func (m *Manager) respawnActorFromStore(ctx context.Context,
18641871
return ref, nil
18651872
}
18661873

1874+
// spawnVTXOActor creates and starts the per-outpoint actor with the manager's
1875+
// shared expiry, funding-assessment, round, wallet, and persistence seams. A
1876+
// start failure removes the actor registration before returning.
18671877
func (m *Manager) spawnVTXOActor(ctx context.Context, vtxo *Descriptor) (
18681878
VTXOActorRef, error) {
18691879

@@ -1882,6 +1892,7 @@ func (m *Manager) spawnVTXOActor(ctx context.Context, vtxo *Descriptor) (
18821892
Manager: m.managerRef,
18831893
LedgerSink: m.cfg.LedgerSink,
18841894
RefreshFeeQuoter: m.cfg.RefreshFeeQuoter,
1895+
CriticalExitAssessor: m.cfg.CriticalExitAssessor,
18851896
FetchOperatorKey: m.cfg.FetchOperatorKey,
18861897
ForfeitParticipantSigner: m.cfg.ForfeitParticipantSigner,
18871898
}

0 commit comments

Comments
 (0)