From 6b75a92c11591faab9927de721b341e09efb3bd3 Mon Sep 17 00:00:00 2001 From: Andras Banki-Horvath Date: Fri, 28 Aug 2026 14:03:16 +0200 Subject: [PATCH] vtxo: prefer viable expiry recovery A critical VTXO currently enters unilateral exit even when the backing wallet cannot fund its recovery package. That can leave automatic recovery retrying while a cooperative round remains available. Reuse the manual exit feasibility model for automatic expiry decisions. Keep or start cooperative refresh while exit is infeasible, reassess on each block, and retain direct unilateral exit for feasible or unassessed paths. Log the selected action and concrete reason, and cover the 187-block incident shape with a regression test. --- vtxo/actor.go | 105 ++++++++++++++++++++++++++++++- vtxo/auto_refresh_safety_test.go | 86 +++++++++++++++++++++++-- vtxo/events.go | 22 +++++++ vtxo/expiry.go | 10 +-- vtxo/manager.go | 15 ++++- vtxo/transitions.go | 98 ++++++++++++++++------------- waved/rpc_server.go | 79 +++++++++++++++++++++-- waved/rpc_server_test.go | 46 ++++++++++++++ waved/server.go | 2 + waverpc/daemon.pb.go | 5 +- waverpc/daemon.proto | 5 +- 11 files changed, 408 insertions(+), 65 deletions(-) diff --git a/vtxo/actor.go b/vtxo/actor.go index efc09769a..06e54cbc6 100644 --- a/vtxo/actor.go +++ b/vtxo/actor.go @@ -56,6 +56,24 @@ func VTXOActorServiceKey(outpoint wire.OutPoint) actor.ServiceKey[ type RefreshFeeQuoter func(ctx context.Context, amount btcutil.Amount, remainingBlocks uint32) btcutil.Amount +// CriticalExitAssessment reports whether the backing wallet can execute an +// automatic unilateral exit at the current fee rate. Reason is a short, +// stable diagnostic when Feasible is false. +type CriticalExitAssessment struct { + // Feasible permits the existing automatic unilateral-exit transition. + Feasible bool + + // Reason explains an infeasible verdict in logs. + Reason string +} + +// CriticalExitAssessor checks the whole exit package before a live VTXO enters +// unilateral exit due to critical expiry. Errors preserve the existing direct +// exit behavior so an unavailable assessment cannot suppress the safety path. +type CriticalExitAssessor func(context.Context, *Descriptor) ( + CriticalExitAssessment, error, +) + // VTXOActorConfig holds configuration for a single VTXO actor. type VTXOActorConfig struct { VTXO *Descriptor @@ -97,6 +115,13 @@ type VTXOActorConfig struct { // the seal-time quote is still the source of truth. RefreshFeeQuoter RefreshFeeQuoter + // CriticalExitAssessor, when set, checks wallet funding and package + // economics before an automatic critical-expiry exit. An infeasible + // verdict starts or continues cooperative refresh; the actor reassesses + // each block and exits when viable. Nil, errors, and feasible verdicts + // retain the existing direct exit. + CriticalExitAssessor CriticalExitAssessor + // FetchOperatorKey, when set, returns the operator's current // long-term public key by issuing a fresh GetInfo round-trip to // the operator at the moment of an auto-refresh emission. The @@ -139,7 +164,7 @@ type VTXOActor struct { // autoRefreshRetryHeight is an in-memory maintenance cooldown. It is // deliberately fail-safe on restart: clearing it can cause one earlier - // retry, but can never delay the critical unilateral-exit path. + // retry, but can never delay the critical path decision. autoRefreshRetryHeight int32 // autoRefreshCohortLeader owns a manager-forced pending reservation. @@ -298,6 +323,83 @@ func (a *VTXOActor) preflightAutoRefresh(ctx context.Context, event VTXOEvent) ( return currentKey, true, nil } +// preflightCriticalExit chooses between the normal critical-expiry event and +// cooperative refresh. Only a live or pending-forfeit critical VTXO with an +// explicit infeasible assessment is diverted. The check repeats each block, +// so funding the wallet restores the unilateral path without another state +// transition. Missing or failed assessments retain the unilateral path. +func (a *VTXOActor) preflightCriticalExit(ctx context.Context, + event VTXOEvent) VTXOEvent { + + blockEvent, ok := event.(*BlockEpochEvent) + if !ok || a.cfg.CriticalExitAssessor == nil { + return event + } + + var desc *Descriptor + switch state := a.state.(type) { + case *LiveState: + desc = state.VTXO + + case *PendingForfeitState: + desc = state.VTXO + + default: + return event + } + + status := a.env.ExpiryConfig.CheckExpiry( + desc, blockEvent.Height, + ) + if status != ExpiryStatusCritical { + return event + } + + blocksRemaining := BlocksUntilExpiry( + desc, blockEvent.Height, + ) + assessment, err := a.cfg.CriticalExitAssessor(ctx, desc) + if err != nil { + a.logger(ctx).WarnS(ctx, "Automatic expiry decision", + err, + slog.String("action", "unilateral_exit"), + slog.String("reason", "exit assessment unavailable"), + slog.Int("height", int(blockEvent.Height)), + slog.Int("blocks_remaining", int(blocksRemaining)), + slog.String("outpoint", desc.Outpoint.String()), + ) + + return event + } + + if assessment.Feasible { + a.logger(ctx).InfoS(ctx, "Automatic expiry decision", + slog.String("action", "unilateral_exit"), + slog.String("reason", "exit funding is feasible"), + slog.Int("height", int(blockEvent.Height)), + slog.Int("blocks_remaining", int(blocksRemaining)), + slog.String("outpoint", desc.Outpoint.String()), + ) + + return event + } + + reason := assessment.Reason + if reason == "" { + reason = "exit funding is infeasible" + } + a.logger(ctx).InfoS(ctx, "Automatic expiry decision", + slog.String("action", "cooperative_refresh"), + slog.String("reason", reason), + slog.Int("height", int(blockEvent.Height)), + slog.Int("blocks_remaining", int(blocksRemaining)), + slog.Bool("reassess_next_block", true), + slog.String("outpoint", desc.Outpoint.String()), + ) + + return &criticalRefreshEvent{Height: blockEvent.Height} +} + // emitExitCost is the VTXO-actor entry point for unilateral-exit accounting. // It is intentionally empty: the VTXO actor hands off to unroll before the // 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, if preflightKey != nil { refreshKey = preflightKey } + vtxoEvent = a.preflightCriticalExit(ctx, vtxoEvent) transition, err := a.state.ProcessEvent(ctx, vtxoEvent, a.env) if err != nil { diff --git a/vtxo/auto_refresh_safety_test.go b/vtxo/auto_refresh_safety_test.go index fe24eec04..cbc750ee5 100644 --- a/vtxo/auto_refresh_safety_test.go +++ b/vtxo/auto_refresh_safety_test.go @@ -1,6 +1,7 @@ package vtxo import ( + "context" "testing" "github.com/btcsuite/btcd/chaincfg/v2" @@ -70,10 +71,10 @@ func TestAutomaticRefreshCooldownSuppressesBlockRetries(t *testing.T) { h.store.AssertExpectations(t) } -// TestAutomaticRefreshCooldownNeverBlocksCriticalExit verifies a critical -// block inside the six-block cooldown clears it and immediately hands the VTXO -// to the unilateral-exit path. -func TestAutomaticRefreshCooldownNeverBlocksCriticalExit(t *testing.T) { +// TestAutomaticRefreshCooldownNeverBlocksFundedCriticalExit verifies a +// critical block inside the six-block cooldown clears it and immediately +// hands a funded VTXO to the unilateral-exit path. +func TestAutomaticRefreshCooldownNeverBlocksFundedCriticalExit(t *testing.T) { t.Parallel() h := newVTXOTestHarness(t) @@ -105,6 +106,11 @@ func TestAutomaticRefreshCooldownNeverBlocksCriticalExit(t *testing.T) { resolver := newMockChainResolverRef(t) actor := newRefreshTestActor(h, desc, manager, nil) actor.cfg.ChainResolver = resolver + actor.cfg.CriticalExitAssessor = func(context.Context, *Descriptor) ( + CriticalExitAssessment, error) { + + return CriticalExitAssessment{Feasible: true}, nil + } _, err := actor.Receive( h.ctx, h.newBlockEpochEvent(firstHeight), @@ -125,6 +131,78 @@ func TestAutomaticRefreshCooldownNeverBlocksCriticalExit(t *testing.T) { h.store.AssertExpectations(t) } +// TestCriticalUnderfundedExitUsesCooperativeRefresh pins the incident shape +// from wavelength#1212: a live VTXO first observed 187 blocks before expiry +// cannot fund its unilateral package, so it stays on cooperative refresh until +// a later block observes that the wallet can execute the exit. +func TestCriticalUnderfundedExitUsesCooperativeRefresh(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + desc := h.newTestDescriptor() + desc.BatchExpiry = 1_000 + desc.RelativeExpiry = 0 + + expiryCfg := &ExpiryConfig{ + RefreshThresholdBlocks: 264, + CriticalThresholdBlocks: 192, + TreeDepthMultiplier: 0, + } + h.withExpiryConfig(expiryCfg) + + h.store.On( + "UpdateVTXOStatus", h.ctx, desc.Outpoint, + VTXOStatusPendingForfeit, + ).Return(nil).Once() + h.store.On( + "UpdateVTXOStatus", h.ctx, desc.Outpoint, + VTXOStatusUnilateralExit, + ).Return(nil).Once() + + manager := newMockManagerRef(t) + resolver := newMockChainResolverRef(t) + actor := newRefreshTestActor(h, desc, manager, nil) + actor.cfg.ChainResolver = resolver + exitFeasible := false + actor.cfg.CriticalExitAssessor = func(context.Context, *Descriptor) ( + CriticalExitAssessment, error) { + + return CriticalExitAssessment{ + Feasible: exitFeasible, + Reason: "wallet_too_few_inputs: need 2 usable " + + "fee inputs, have 0", + }, nil + } + + _, err := actor.Receive( + h.ctx, h.newBlockEpochEvent(813), + ).Unpack() + require.NoError(t, err) + pending, ok := actor.state.(*PendingForfeitState) + require.True(t, ok) + require.Equal(t, int32(813), pending.RequestedAtHeight) + require.Len(t, manager.getMessages(), 1) + require.Empty(t, resolver.getMessages()) + + _, err = actor.Receive( + h.ctx, h.newBlockEpochEvent(814), + ).Unpack() + require.NoError(t, err) + require.IsType(t, &PendingForfeitState{}, actor.state) + require.Len(t, manager.getMessages(), 1) + require.Empty(t, resolver.getMessages()) + + exitFeasible = true + _, err = actor.Receive( + h.ctx, h.newBlockEpochEvent(815), + ).Unpack() + require.NoError(t, err) + require.IsType(t, &UnilateralExitState{}, actor.state) + require.Len(t, manager.getMessages(), 1) + require.Len(t, resolver.getMessages(), 1) + h.store.AssertExpectations(t) +} + // TestCohortRollbackGenerationRejectsStaleRelease verifies an ABA-delayed // rollback from attempt A cannot release attempt B when the same leader // retries. diff --git a/vtxo/events.go b/vtxo/events.go index 26031e97a..7b0a45003 100644 --- a/vtxo/events.go +++ b/vtxo/events.go @@ -62,6 +62,28 @@ type ( ForfeitReleasedEvent = round.ForfeitReleasedEvent ) +// criticalRefreshEvent tells LiveState or PendingForfeitState that the actor +// established that the automatic unilateral path is not currently viable. +// LiveState starts cooperative refresh; PendingForfeitState keeps waiting for +// that round. It is actor-local: external block notifications remain +// BlockEpochEvent, and a funded or unassessed critical VTXO follows the +// existing unilateral-exit transition. +type criticalRefreshEvent struct { + actor.BaseMessage + + // Height is the critical block height used for the durable automatic + // refresh reservation and repeated funding assessment. + Height int32 +} + +// VTXOActorMsg implements actormsg.VTXOActorMsg. +func (e *criticalRefreshEvent) VTXOActorMsg() {} + +// MessageType returns the message type used by actor diagnostics. +func (e *criticalRefreshEvent) MessageType() string { + return "criticalRefreshEvent" +} + // CohortRefreshEvent asks an eligible sibling VTXO to join an automatic // refresh already triggered by another VTXO from the same batch. The manager // sends these requests with bounded Ask backpressure before forwarding the diff --git a/vtxo/expiry.go b/vtxo/expiry.go index 78c381702..094f4b96c 100644 --- a/vtxo/expiry.go +++ b/vtxo/expiry.go @@ -12,8 +12,9 @@ const ( // ExpiryStatusNeedsRefresh indicates the VTXO should request refresh. ExpiryStatusNeedsRefresh - // ExpiryStatusCritical indicates the VTXO must be sent to chain - // resolver. + // ExpiryStatusCritical indicates the unilateral time budget is active. + // Automatic handling assesses package viability before choosing between + // unilateral exit and continued cooperative refresh. ExpiryStatusCritical // ExpiryStatusExpired indicates the batch has already expired. @@ -88,8 +89,9 @@ type ExpiryConfig struct { RefreshThresholdBlocks int32 // CriticalThresholdBlocks is the base number of blocks before batch - // expiry at which the VTXO is escalated to the chain resolver for - // unilateral exit. Must be less than RefreshThresholdBlocks. + // expiry at which automatic handling assesses whether the VTXO's + // unilateral package is viable. Must be less than + // RefreshThresholdBlocks. CriticalThresholdBlocks int32 // MinRefreshBuffer is the minimum buffer (blocks) between refresh and diff --git a/vtxo/manager.go b/vtxo/manager.go index 559ede8dc..42a667b90 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -139,6 +139,11 @@ type ManagerConfig struct { // fills in the residual via the JoinRoundQuote. RefreshFeeQuoter RefreshFeeQuoter + // CriticalExitAssessor is propagated to each spawned VTXOActor. It + // lets automatic critical-expiry handling prefer cooperative refresh + // only while the unilateral package is not viable. + CriticalExitAssessor CriticalExitAssessor + // FetchOperatorKey is propagated to each spawned VTXOActor so // the auto-refresh emission can fetch the operator's current // long-term key at join time and rebuild the NEW VTXO output's @@ -1433,8 +1438,10 @@ func (m *Manager) handleVTXOTerminated(ctx context.Context, // Liveness guarantee: when a VTXO approaches expiry, the VTXO actor // autonomously emits a ForfeitRequest (wrapped in RelayToRoundMsg) without // requiring wallet input. The manager relays this immediately, ensuring -// cooperative action is always attempted before critical expiry. This -// default policy means safety does not depend on wallet reaction time. +// cooperative action is attempted before critical expiry and remains the +// fallback at critical expiry while the backing wallet cannot execute the +// unilateral package. This default policy means safety does not depend on +// wallet reaction time or on entering an exit that cannot make progress. // Auto-expiry forfeits intentionally bypass admission gating because // the VTXO actor has already determined that cooperative action is // urgent. Delaying relay would risk missing the expiry window. @@ -1864,6 +1871,9 @@ func (m *Manager) respawnActorFromStore(ctx context.Context, return ref, nil } +// spawnVTXOActor creates and starts the per-outpoint actor with the manager's +// shared expiry, funding-assessment, round, wallet, and persistence seams. A +// start failure removes the actor registration before returning. func (m *Manager) spawnVTXOActor(ctx context.Context, vtxo *Descriptor) ( VTXOActorRef, error) { @@ -1882,6 +1892,7 @@ func (m *Manager) spawnVTXOActor(ctx context.Context, vtxo *Descriptor) ( Manager: m.managerRef, LedgerSink: m.cfg.LedgerSink, RefreshFeeQuoter: m.cfg.RefreshFeeQuoter, + CriticalExitAssessor: m.cfg.CriticalExitAssessor, FetchOperatorKey: m.cfg.FetchOperatorKey, ForfeitParticipantSigner: m.cfg.ForfeitParticipantSigner, } diff --git a/vtxo/transitions.go b/vtxo/transitions.go index ec68ee6cc..8ce37285e 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -86,6 +86,11 @@ func (s *LiveState) ProcessEvent(ctx context.Context, event VTXOEvent, case *BlockEpochEvent: return s.handleBlockEpoch(ctx, evt, env) + case *criticalRefreshEvent: + s.LastCheckedHeight = evt.Height + + return s.autoRefreshTransition(evt.Height, false), nil + case *CohortRefreshEvent: return s.handleCohortRefresh(evt, env) @@ -775,51 +780,10 @@ func (s *PendingForfeitState) ProcessEvent(ctx context.Context, event VTXOEvent, }, nil case *BlockEpochEvent: - // Check if we've hit critical expiry while waiting for - // forfeit details. - expiryStatus := env.ExpiryConfig.CheckExpiry(s.VTXO, evt.Height) - - // Only critical expiry escalates. Past the deadline a - // unilateral exit can no longer complete — it would have to - // confirm the whole ancestry and then wait out the exit CSV - // while racing an already-spendable operator sweep — and - // escalating would abort the in-flight cooperative spend that - // IS the recovery. Staying put lets it finish. - if expiryStatus == ExpiryStatusCritical { - blocksRemaining := BlocksUntilExpiry(s.VTXO, evt.Height) - - // Non-terminal exit: no VTXOTerminatedNotification, so - // a failed unroll can recover the VTXO - // (wavelength#602). - outbox := []VTXOOutMsg{ - &ExpiringNotification{ - VTXO: s.VTXO, - BlocksRemaining: blocksRemaining, - Reason: "forfeit timeout", - }, - &VTXOStatusUpdate{ - Outpoint: s.VTXO.Outpoint, - NewStatus: VTXOStatusUnilateralExit, - }, - } - - return &VTXOStateTransition{ - NextState: &UnilateralExitState{ - VTXO: s.VTXO, - Reason: "critical expiry pending " + - "forfeit", - LastCheckedHeight: evt.Height, - }, - NewEvents: fn.Some(VTXOEmittedEvent{ - Outbox: outbox, - }), - }, nil - } + return s.handleBlockEpoch(evt, env), nil - // Still waiting, stay in this state. - return &VTXOStateTransition{ - NextState: s, - }, nil + case *criticalRefreshEvent: + return s.handleCriticalRefresh(), nil case *ForceUnrollEvent: // Client requested unilateral exit while forfeit is @@ -1005,6 +969,52 @@ func (s *PendingForfeitState) ProcessEvent(ctx context.Context, event VTXOEvent, } } +// handleBlockEpoch escalates a pending cooperative reservation only inside +// the critical window. Past expiry it keeps waiting because an exit can no +// longer complete its ancestry and CSV delay before an operator sweep, while +// the cooperative recovery can still finish. +func (s *PendingForfeitState) handleBlockEpoch(evt *BlockEpochEvent, + env *VTXOEnvironment) *VTXOStateTransition { + + expiryStatus := env.ExpiryConfig.CheckExpiry(s.VTXO, evt.Height) + if expiryStatus != ExpiryStatusCritical { + return &VTXOStateTransition{NextState: s} + } + + blocksRemaining := BlocksUntilExpiry(s.VTXO, evt.Height) + outbox := []VTXOOutMsg{ + &ExpiringNotification{ + VTXO: s.VTXO, + BlocksRemaining: blocksRemaining, + Reason: "forfeit timeout", + }, + &VTXOStatusUpdate{ + Outpoint: s.VTXO.Outpoint, + NewStatus: VTXOStatusUnilateralExit, + }, + } + + return &VTXOStateTransition{ + NextState: &UnilateralExitState{ + VTXO: s.VTXO, + Reason: "critical expiry pending " + + "forfeit", + LastCheckedHeight: evt.Height, + }, + NewEvents: fn.Some(VTXOEmittedEvent{ + Outbox: outbox, + }), + } +} + +// handleCriticalRefresh keeps the cooperative reservation alive while the +// backing wallet cannot execute the unilateral package. The actor reassesses +// on every critical block and restores the normal BlockEpochEvent as soon as +// the exit becomes viable. +func (s *PendingForfeitState) handleCriticalRefresh() *VTXOStateTransition { + return &VTXOStateTransition{NextState: s} +} + // forfeitSignatureIssued reports whether this VTXO's forfeit signature has // already left the client. // diff --git a/waved/rpc_server.go b/waved/rpc_server.go index 55f58edfa..647b51960 100644 --- a/waved/rpc_server.go +++ b/waved/rpc_server.go @@ -5169,21 +5169,88 @@ func (r *RPCServer) preflightUnrollFeasibility(ctx context.Context, return nil } + verdict, err := r.assessUnrollFeasibility(ctx, desc) + if err != nil { + return status.Errorf(codes.Internal, "preflight exit: %v", err) + } + if verdict.Feasible { + return nil + } + + return unrollInfeasibleError(verdict) +} + +// assessUnrollFeasibility gathers the current wallet, fee, and lineage inputs +// for one unilateral package and returns the shared pure-model verdict. It is +// used by both manual admission and automatic critical-expiry path selection +// so they cannot disagree about whether the wallet can execute the exit. +func (r *RPCServer) assessUnrollFeasibility(ctx context.Context, + desc *vtxo.Descriptor) (unroll.ExitFeasibility, error) { + walletSnapshot, err := r.walletExitFundingSnapshot(ctx) if err != nil { - return status.Errorf(codes.Internal, "preflight wallet "+ - "unspent: %v", err) + return unroll.ExitFeasibility{}, fmt.Errorf("wallet "+ + "unspent: %w", err) } + mat := r.resolveExitLineage(ctx, desc.Outpoint, desc) plan := unroll.PlanExitFunding( desc, mat, r.estimateUnrollFeeRate(ctx), walletSnapshot, ) - verdict := plan.Feasibility - if verdict.Feasible { - return nil + + return plan.Feasibility, nil +} + +// assessAutomaticCriticalExit adapts the shared unroll feasibility verdict to +// the VTXO actor's cycle-free callback. The reason includes the concrete +// wallet/package constraint so the automatic path decision is understandable +// from one structured log entry. +func (r *RPCServer) assessAutomaticCriticalExit(ctx context.Context, + desc *vtxo.Descriptor) (vtxo.CriticalExitAssessment, error) { + + verdict, err := r.assessUnrollFeasibility(ctx, desc) + if err != nil { + return vtxo.CriticalExitAssessment{}, err } - return unrollInfeasibleError(verdict) + return vtxo.CriticalExitAssessment{ + Feasible: verdict.Feasible, + Reason: automaticExitDecisionReason(verdict), + }, nil +} + +// automaticExitDecisionReason renders the first failed feasibility invariant +// with the values a user needs to understand the path choice. Feasible +// verdicts return a stable short label because the actor logs the action +// separately. +func automaticExitDecisionReason(f unroll.ExitFeasibility) string { + switch f.Reason { + case unroll.ExitFeasible: + return "exit funding is feasible" + + case unroll.ExitSweepBelowDust: + return fmt.Sprintf("sweep_below_dust: net %d sat, dust "+ + "limit %d sat", int64(f.NetRecoveredSat), + int64(f.DustLimitSat)) + + case unroll.ExitUneconomical: + return fmt.Sprintf("uneconomical: estimated cost %d sat, VTXO "+ + "value %d sat", int64(f.TotalRecoveryCostSat), + int64(f.VTXOAmountSat)) + + case unroll.ExitWalletUnderfunded: + return fmt.Sprintf("wallet_underfunded: need %d sat for CPFP, "+ + "have %d sat confirmed", int64(f.CPFPFeeTotalSat), + int64(f.WalletConfirmedSat)) + + case unroll.ExitWalletTooFewInputs: + return fmt.Sprintf("wallet_too_few_inputs: need %d usable fee "+ + "inputs, have %d", f.RequiredWalletInputs, + f.WalletUsableInputs) + + default: + return f.Reason.String() + } } // resolveExitLineage resolves the OOR lineage material for an exit target so diff --git a/waved/rpc_server_test.go b/waved/rpc_server_test.go index f016e4b8e..e7f86eb30 100644 --- a/waved/rpc_server_test.go +++ b/waved/rpc_server_test.go @@ -1693,3 +1693,49 @@ func TestUnrollInfeasibleError(t *testing.T) { }) } } + +// TestAutomaticExitDecisionReason verifies the automatic critical-expiry log +// names the exact failed funding invariant and its actionable values. +func TestAutomaticExitDecisionReason(t *testing.T) { + t.Parallel() + + underfunded := unroll.ExitWalletUnderfunded + tooFewInputs := unroll.ExitWalletTooFewInputs + tests := []struct { + name string + verdict unroll.ExitFeasibility + want string + }{ + { + name: "wallet balance", + verdict: unroll.ExitFeasibility{ + Reason: underfunded, + CPFPFeeTotalSat: 1_850, + WalletConfirmedSat: 0, + }, + want: "wallet_underfunded: need 1850 sat for CPFP, " + + "have 0 sat confirmed", + }, + { + name: "fee input count", + verdict: unroll.ExitFeasibility{ + Reason: tooFewInputs, + RequiredWalletInputs: 2, + WalletUsableInputs: 0, + }, + want: "wallet_too_few_inputs: need 2 usable " + + "fee inputs, have 0", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + require.Equal( + t, tc.want, + automaticExitDecisionReason(tc.verdict), + ) + }) + } +} diff --git a/waved/server.go b/waved/server.go index 35791016f..b0976ca19 100644 --- a/waved/server.go +++ b/waved/server.go @@ -4575,6 +4575,7 @@ func (s *Server) initVTXOManager(ctx context.Context, reservationStore := dbStore.NewSpendingReservationStore(s.clk) roundActor := round.NewServiceKey().Ref(s.actorSystem) ledgerSink := ledger.NewSink(s.actorSystem) + criticalExitAssessor := s.rpcServer.assessAutomaticCriticalExit manager := vtxo.NewManager(&vtxo.ManagerConfig{ Store: vtxoStore, @@ -4589,6 +4590,7 @@ func (s *Server) initVTXOManager(ctx context.Context, LedgerSink: fn.Some(ledgerSink), ChainResolver: chainResolver, RefreshFeeQuoter: s.autoRefreshFeeQuoter(), + CriticalExitAssessor: criticalExitAssessor, FetchOperatorKey: s.fetchCurrentOperatorPubKey, ForfeitParticipantSigner: s.forfeitSignatures.sign, TerminalVTXOObserver: func(ctx context.Context, diff --git a/waverpc/daemon.pb.go b/waverpc/daemon.pb.go index 91ae393c6..a6a76c5dc 100644 --- a/waverpc/daemon.pb.go +++ b/waverpc/daemon.pb.go @@ -207,8 +207,9 @@ const ( // VTXO_EXPIRY_STATUS_NEEDS_REFRESH indicates the VTXO has entered the // cooperative refresh window but is not yet critical. VTXOExpiryStatus_VTXO_EXPIRY_STATUS_NEEDS_REFRESH VTXOExpiryStatus = 2 - // VTXO_EXPIRY_STATUS_CRITICAL indicates the VTXO should stop waiting for - // cooperative refresh and move to unilateral exit/recovery handling. + // VTXO_EXPIRY_STATUS_CRITICAL indicates the unilateral time budget is + // active. Automatic handling assesses package viability before choosing + // unilateral exit or continued cooperative refresh. VTXOExpiryStatus_VTXO_EXPIRY_STATUS_CRITICAL VTXOExpiryStatus = 3 // VTXO_EXPIRY_STATUS_EXPIRED indicates the VTXO's batch expiry has // passed. diff --git a/waverpc/daemon.proto b/waverpc/daemon.proto index c95edb626..7d0c29e6a 100644 --- a/waverpc/daemon.proto +++ b/waverpc/daemon.proto @@ -635,8 +635,9 @@ enum VTXOExpiryStatus { // cooperative refresh window but is not yet critical. VTXO_EXPIRY_STATUS_NEEDS_REFRESH = 2; - // VTXO_EXPIRY_STATUS_CRITICAL indicates the VTXO should stop waiting for - // cooperative refresh and move to unilateral exit/recovery handling. + // VTXO_EXPIRY_STATUS_CRITICAL indicates the unilateral time budget is + // active. Automatic handling assesses package viability before choosing + // unilateral exit or continued cooperative refresh. VTXO_EXPIRY_STATUS_CRITICAL = 3; // VTXO_EXPIRY_STATUS_EXPIRED indicates the VTXO's batch expiry has