Skip to content

vtxo: prefer cooperative refresh when exit is infeasible - #1215

Merged
bhandras merged 1 commit into
mainfrom
codex/investigate-1212-20260828
Aug 28, 2026
Merged

vtxo: prefer cooperative refresh when exit is infeasible#1215
bhandras merged 1 commit into
mainfrom
codex/investigate-1212-20260828

Conversation

@bhandras

Copy link
Copy Markdown
Member

Summary

  • Reuse the unilateral-exit feasibility model for automatic critical-expiry
    decisions.
  • Start or continue cooperative refresh while the backing wallet cannot
    execute the exit package.
  • Reassess on every critical block. Move to unilateral exit as soon as the
    package becomes viable.
  • Log the selected action, concrete reason, height, blocks remaining, and
    outpoint.

Fixes #1212.

Why

The affected VTXO was first observed after restart with 187 blocks remaining.
That was inside its conservative 192-block unilateral-exit budget, so the old
FSM moved directly from Live to UnilateralExit.

The exit package needed distinct wallet fee inputs for its recovery paths. The
backing wallet had none. txconfirm therefore kept retrying the first fee
parent, while a cooperative round was still available.

Urgency and path viability are separate decisions. Entering the critical
window should trigger an assessment. It should not force an automatic exit
that the wallet cannot execute.

Behavior

Critical VTXO state Exit assessment Action
Live feasible Keep the existing direct unilateral exit
Live infeasible Reserve the VTXO and request cooperative refresh
PendingForfeit infeasible Keep waiting for the cooperative round
PendingForfeit becomes feasible Fall back to unilateral exit
Live or PendingForfeit assessment error Preserve the existing unilateral fallback

Manual exits, fraud-triggered exits, vHTLC recovery, and VTXOs already past the
forfeit-signature boundary are unchanged.

Observability

The decision now produces structured Automatic expiry decision logs with:

  • action=cooperative_refresh or action=unilateral_exit;
  • a concrete reason such as the required and available fee-input counts;
  • height, blocks_remaining, and outpoint.

This does not fully solve the user-facing status gap. A focused follow-up
should persist and expose:

  • the automatic decision trigger, chosen action, and reason;
  • the last decision height and blocks remaining;
  • the latest txconfirm retry error for a materializing exit.

The natural read surfaces are GetVTXOExpiryInfo before an exit and
GetUnrollStatus after one starts. That work crosses VTXO, unroll, txconfirm,
protobuf, and CLI ownership, so it is intentionally not folded into this bug
fix.

Tests

  • go test ./...
  • go test ./vtxo ./waved -count=1
  • pinned custom lint on ./vtxo ./waved: 0 issues
  • make rpc
  • make fmt

The regression test pins the issue shape: 187 blocks remaining, infeasible exit
funding, cooperative refresh retained across the next block, and unilateral
fallback once the assessment becomes feasible.

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.
@bhandras bhandras mentioned this pull request Aug 28, 2026
@bhandras

Copy link
Copy Markdown
Member Author

Fable local review: vtxo: prefer cooperative refresh when exit is infeasible

Head reviewed: 6b75a92c11591faab9927de721b341e09efb3bd3
Verdict: READY — no blockers.

Local: go test ./vtxo ./waved -count=1 passes.

Verified

Item Result
Decision placement preflightCriticalExit runs after preflightAutoRefresh and the cooldown check, only for BlockEpochEvent in Live/PendingForfeit at ExpiryStatusCritical. Manual exits, fraud exits, vHTLC recovery, and other states are untouched. Nil assessor, assessment error, or feasible verdict all return the original event, so the existing unilateral path is preserved unchanged.
Live + infeasible criticalRefreshEventautoRefreshTransition(height, false), the same emission as the NeedsRefresh path. The operator key is fetched lazily at emission when refreshKey is nil (vtxo/actor.go:230-237), so skipping the preflightAutoRefresh fetch at Critical is safe.
PendingForfeit + infeasible Stays in state. Feasible → handleBlockEpoch, which is the previous escalation code moved verbatim. Past-expiry "keep waiting" behavior is unchanged.
Shared feasibility model assessUnrollFeasibility is the same PlanExitFunding call manual preflightUnrollFeasibility uses; both go through one function so manual admission and automatic path selection cannot disagree.
Wiring s.rpcServer is set in runInner (waved/server.go:1433) before startWalletReadyServicesstartWalletDependentActorsinitVTXOManager (:1572, :2624). The method value is bound on a non-nil receiver.
Regression test TestCriticalUnderfundedExitUsesCooperativeRefresh pins the 187-block incident: infeasible → PendingForfeit with one manager relay and no resolver message; held across the next block; unilateral exit once feasible.
Proto Comment-only change; generated file matches.

Notes (non-blocking)

  1. Per-block cost. The assessor runs ListWalletUnspent + fee estimate + lineage resolution synchronously inside each critical VTXO actor's Receive on every block. A batch with many VTXOs going critical together means N wallet RPCs per block. Acceptable at current scale; if it becomes a problem, cache the wallet snapshot per height in the manager.
  2. Re-request churn on rollback. If the cooperative round fails and the VTXO rolls back to Live while still critical and infeasible, the cooldown is cleared at Critical (deferAutomaticRefresh), so it re-requests refresh every block. Churn only, no fund risk, and arguably correct given the exit is infeasible.
  3. Permanently infeasible reasons (ExitUneconomical, ExitSweepBelowDust) now correctly keep the VTXO on cooperative refresh instead of entering an exit that can never pay for itself.

Findings

Blockers: none.
Follow-ups: none.

@bhandras
bhandras merged commit e5a89c8 into main Aug 28, 2026
18 of 19 checks passed
@bhandras
bhandras deleted the codex/investigate-1212-20260828 branch August 28, 2026 15:46
@bhandras bhandras added the backport-v0.1.x-branch Backport this merged PR to v0.1.x-branch label Aug 28, 2026
@github-actions

Copy link
Copy Markdown

Successfully created backport PR for v0.1.x-branch:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-v0.1.x-branch Backport this merged PR to v0.1.x-branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unable to refresh vtxo

1 participant