Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 104 additions & 1 deletion vtxo/actor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
86 changes: 82 additions & 4 deletions vtxo/auto_refresh_safety_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package vtxo

import (
"context"
"testing"

"github.com/btcsuite/btcd/chaincfg/v2"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand All @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions vtxo/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions vtxo/expiry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions vtxo/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {

Expand All @@ -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,
}
Expand Down
Loading
Loading