@@ -56,6 +56,24 @@ func VTXOActorServiceKey(outpoint wire.OutPoint) actor.ServiceKey[
5656type 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.
6078type 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 {
0 commit comments