From cae92334114f08df6d2886aa4b7e64e2e04155c1 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Mon, 27 Jul 2026 14:15:45 -0700 Subject: [PATCH 1/7] vtxo: never read an untrustworthy expiry as expired Every expiry decision is derived from blocksRemaining = BatchExpiry - currentHeight and BatchExpiry was trusted unconditionally. It is copied verbatim from the wire by the incoming-VTXO handler, so a zero arriving from the operator reads back as "expired by the entire height of the chain" and classifies a brand-new VTXO as expired. Nothing validated it at ingress and nothing guarded the arithmetic. Add HasUsableBatchExpiry and reject three shapes: a non-positive expiry, and an expiry earlier than the height the VTXO was created at, since a VTXO cannot expire before it existed. CheckExpiry now returns the new ExpiryStatusUnknown for those rather than ExpiryStatusExpired. Unknown is deliberately its own status rather than folding into either extreme. Reporting "expired" surrenders live funds on the strength of a corrupt field; reporting "safe" silently skips the refresh a real deadline needs. LiveState holds the VTXO live and warns, so a data fault neither retires the coin nor wedges the actor on every block. The status is appended last so the existing numeric values are unchanged, and the remaining call sites already treat anything that is not Critical/Expired/NeedsRefresh as inaction. The incoming handler now drops an event carrying an unusable expiry instead of materializing it. Dropping is the safer failure: the wallet still re-derives the VTXO from ListVTXOsByScripts, which reads the authoritative expiry off the server's round row, whereas a poisoned expiry persists locally and is never rewritten. --- vtxo/expiry.go | 43 +++++++++ vtxo/expiry_validation_test.go | 164 +++++++++++++++++++++++++++++++++ vtxo/incoming_handler.go | 24 +++++ vtxo/incoming_handler_test.go | 71 ++++++++++++++ vtxo/transitions.go | 24 ++++- 5 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 vtxo/expiry_validation_test.go diff --git a/vtxo/expiry.go b/vtxo/expiry.go index 9a8214e48..38e403e65 100644 --- a/vtxo/expiry.go +++ b/vtxo/expiry.go @@ -16,6 +16,15 @@ const ( // ExpiryStatusExpired indicates the batch has already expired. ExpiryStatusExpired + + // ExpiryStatusUnknown indicates the VTXO carries no usable batch + // expiry, so no expiry conclusion can be drawn from it. Callers must + // decline to act rather than assume either extreme: treating a + // missing expiry as "expired" would surrender live funds, and + // treating it as "safe" would silently skip the refresh a real + // deadline needs. It is appended last so the numeric values of the + // existing statuses are unchanged. + ExpiryStatusUnknown ) // String returns a human-readable representation of the expiry status. @@ -33,11 +42,37 @@ func (s ExpiryStatus) String() string { case ExpiryStatusExpired: return "expired" + case ExpiryStatusUnknown: + return "unknown" + default: return "unknown" } } +// HasUsableBatchExpiry reports whether the descriptor carries a batch expiry +// that an expiry decision may be based on. +// +// A zero expiry is not a benign default. It is copied verbatim from the wire +// (see the incoming-VTXO handler) and every expiry calculation is +// `BatchExpiry - currentHeight`, so a zero reads back as "expired by the +// entire height of the chain" and would route a brand-new VTXO straight to +// the expiry path. An expiry earlier than the height the VTXO was created at +// is the same class of corruption: a VTXO cannot expire before it existed. +func HasUsableBatchExpiry(vtxo *Descriptor) bool { + if vtxo == nil || vtxo.BatchExpiry <= 0 { + return false + } + + // CreatedHeight is not always populated (recovery paths leave it + // zero), so only cross-check when it is. + if vtxo.CreatedHeight > 0 && vtxo.BatchExpiry < vtxo.CreatedHeight { + return false + } + + return true +} + // ExpiryConfig holds configurable thresholds for VTXO expiry monitoring. These // thresholds determine when refresh requests are sent and when VTXOs are // escalated to the chain resolver. @@ -104,6 +139,14 @@ func (c *ExpiryConfig) CheckExpiry( vtxo *Descriptor, currentHeight int32, ) ExpiryStatus { + // Refuse to draw any conclusion from an expiry we cannot trust. This + // must come first: every branch below is derived from BatchExpiry, so + // a corrupt value would otherwise be indistinguishable from a real + // deadline that has already passed. + if !HasUsableBatchExpiry(vtxo) { + return ExpiryStatusUnknown + } + blocksRemaining := vtxo.BatchExpiry - currentHeight // If batch has already expired, status is expired. diff --git a/vtxo/expiry_validation_test.go b/vtxo/expiry_validation_test.go new file mode 100644 index 000000000..db8c286ad --- /dev/null +++ b/vtxo/expiry_validation_test.go @@ -0,0 +1,164 @@ +package vtxo + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestHasUsableBatchExpiry asserts which descriptors carry an expiry that an +// expiry decision may be based on. Every rejected shape would otherwise be +// read as a deadline that has already passed, because all expiry arithmetic +// is `BatchExpiry - currentHeight`. +func TestHasUsableBatchExpiry(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + batchExpiry int32 + createdHeight int32 + nilDescriptor bool + expectUsable bool + }{ + { + name: "usable expiry", + batchExpiry: 1000, + createdHeight: 100, + expectUsable: true, + }, + { + name: "usable without created height", + batchExpiry: 1000, + createdHeight: 0, + expectUsable: true, + }, + { + name: "zero expiry", + batchExpiry: 0, + createdHeight: 100, + expectUsable: false, + }, + { + name: "negative expiry", + batchExpiry: -1, + createdHeight: 100, + expectUsable: false, + }, + { + name: "expires before it was created", + batchExpiry: 50, + createdHeight: 100, + expectUsable: false, + }, + { + name: "expires exactly at creation", + batchExpiry: 100, + createdHeight: 100, + expectUsable: true, + }, + { + name: "nil descriptor", + nilDescriptor: true, + expectUsable: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var desc *Descriptor + if !tc.nilDescriptor { + desc = &Descriptor{ + BatchExpiry: tc.batchExpiry, + CreatedHeight: tc.createdHeight, + } + } + + require.Equal( + t, tc.expectUsable, HasUsableBatchExpiry(desc), + ) + }) + } +} + +// TestCheckExpiryUnusableBatchExpiry asserts that an untrustworthy expiry +// yields ExpiryStatusUnknown rather than ExpiryStatusExpired. Reporting +// "expired" here is the dangerous direction: it would surrender live funds to +// the expiry path on the strength of a corrupt field. +func TestCheckExpiryUnusableBatchExpiry(t *testing.T) { + t.Parallel() + + cfg := DefaultExpiryConfig() + + t.Run("zero expiry is unknown not expired", func(t *testing.T) { + t.Parallel() + + desc := &Descriptor{BatchExpiry: 0, CreatedHeight: 100} + + status := cfg.CheckExpiry(desc, 200) + require.Equal(t, ExpiryStatusUnknown, status) + require.NotEqual(t, ExpiryStatusExpired, status) + }) + + t.Run("expiry before creation is unknown", func(t *testing.T) { + t.Parallel() + + desc := &Descriptor{BatchExpiry: 50, CreatedHeight: 100} + + require.Equal( + t, ExpiryStatusUnknown, cfg.CheckExpiry(desc, 200), + ) + }) + + t.Run("real expiry still reports expired", func(t *testing.T) { + t.Parallel() + + desc := &Descriptor{BatchExpiry: 150, CreatedHeight: 100} + + // The control: a trustworthy expiry that has genuinely passed + // must still classify as expired, so the guard above is not + // swallowing real deadlines. + require.Equal( + t, ExpiryStatusExpired, cfg.CheckExpiry(desc, 200), + ) + }) +} + +// TestExpiryStatusUnknownString asserts the new status renders, so log lines +// and the daemon RPC mapping do not surface a bare integer. +func TestExpiryStatusUnknownString(t *testing.T) { + t.Parallel() + + require.Equal(t, "unknown", ExpiryStatusUnknown.String()) +} + +// TestLiveStateBlockEpochUnusableExpiry asserts that a VTXO whose expiry +// cannot be trusted is held in LiveState: it is neither surrendered to the +// expiry path nor allowed to fail the transition, which would wedge the actor +// on every block. +func TestLiveStateBlockEpochUnusableExpiry(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + vtxo.BatchExpiry = 0 + vtxo.CreatedHeight = 100 + + h.withState(&LiveState{ + VTXO: vtxo, + LastCheckedHeight: 100, + }) + + // A height far beyond any plausible expiry: with the old arithmetic + // this VTXO would have been classified expired and retired. + evt := h.newBlockEpochEvent(900_000) + _, err := h.sendEvent(evt) + require.NoError(t, err) + + assertState[*LiveState](h) + require.Empty( + t, h.outboxMessages, + "an untrustworthy expiry must not drive any state change", + ) +} diff --git a/vtxo/incoming_handler.go b/vtxo/incoming_handler.go index 360c7672a..048f7f09c 100644 --- a/vtxo/incoming_handler.go +++ b/vtxo/incoming_handler.go @@ -222,6 +222,30 @@ func (h *IncomingVTXOHandler) Receive(ctx context.Context, return fn.Ok[IncomingVTXOResp](nil) } + // The batch expiry is copied verbatim onto the persisted descriptor + // and every later expiry decision is derived from it, so refuse to + // materialize a VTXO we could never reason about. A non-positive + // expiry reads back as "expired by the entire height of the chain" + // and would route a brand-new VTXO straight to the expiry path. + // + // Dropping is the safer failure: the wallet still re-derives this + // VTXO from ListVTXOsByScripts, which reads the authoritative expiry + // off the server's round row, whereas a poisoned expiry persists + // locally and is never rewritten. + if evt.GetBatchExpiryHeight() <= 0 { + h.log.WarnS(ctx, "IncomingVTXOEvent has unusable batch "+ + "expiry; not materializing", nil, + slog.Int( + "batch_expiry", + int( + evt.GetBatchExpiryHeight(), + ), + ), + ) + + return fn.Ok[IncomingVTXOResp](nil) + } + var outpoint wire.OutPoint copy(outpoint.Hash[:], op.Txid) outpoint.Index = op.Vout diff --git a/vtxo/incoming_handler_test.go b/vtxo/incoming_handler_test.go index da3b21ed1..7e61af8f1 100644 --- a/vtxo/incoming_handler_test.go +++ b/vtxo/incoming_handler_test.go @@ -3,6 +3,7 @@ package vtxo import ( "context" "database/sql" + "fmt" "testing" "github.com/btcsuite/btcd/btcec/v2" @@ -169,6 +170,76 @@ func TestIncomingVTXOHandlerNonCreatedEvent(t *testing.T) { require.Empty(t, saver.saved) } +// TestIncomingVTXOHandlerUnusableBatchExpiry verifies that an event carrying +// an expiry the wallet could never reason about is dropped rather than +// materialized. Persisting it would stamp the bad expiry onto the descriptor +// permanently, and every later expiry decision reads it back as a deadline +// that has already passed. +func TestIncomingVTXOHandlerUnusableBatchExpiry(t *testing.T) { + t.Parallel() + + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + operatorPriv, err := btcec.NewPrivateKey() + require.NoError(t, err) + + pkScript := []byte{0x51, 0x20, 0xaa, 0xbb} + + // The script IS owned, so a drop here can only be the expiry guard. + newLookup := func() *mockScriptLookup { + return &mockScriptLookup{ + scripts: map[string]*OwnedReceiveScript{ + string(pkScript): { + ClientKey: keychain.KeyDescriptor{ + PubKey: privKey.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: 44, + Index: 0, + }, + }, + OperatorPubKey: operatorPriv.PubKey(), + ExitDelay: 144, + }, + }, + } + } + + var txid chainhash.Hash + txid[0] = 0x01 + + for _, expiry := range []int32{0, -1} { + t.Run(fmt.Sprintf("expiry %d", expiry), func(t *testing.T) { + t.Parallel() + + saver := &mockVTXOSaver{} + handler := NewIncomingVTXOHandler( + IncomingVTXOHandlerConfig{ + ScriptStore: newLookup(), + VTXOStore: saver, + }, + ) + + evt := newTestEvent( + txid, 0, pkScript, 50_000, "round-1", + ) + evt.BatchExpiryHeight = expiry + + result := handler.Receive( + t.Context(), IncomingVTXOMsg{ + Event: evt, + }, + ) + _, resultErr := result.Unpack() + + // Dropping must stay silent: the handler cannot crash + // the actor or block the indexer push stream. + require.NoError(t, resultErr) + require.Empty(t, saver.saved) + }) + } +} + // TestIncomingVTXOHandlerNilEvent verifies that a nil event is // handled gracefully. func TestIncomingVTXOHandlerNilEvent(t *testing.T) { diff --git a/vtxo/transitions.go b/vtxo/transitions.go index feaeb2357..33f54b63e 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -3,12 +3,14 @@ package vtxo import ( "context" "fmt" + "log/slog" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/build" "github.com/lightninglabs/wavelength/lib/arkscript" "github.com/lightninglabs/wavelength/lib/tx" "github.com/lightninglabs/wavelength/lib/types" @@ -155,7 +157,7 @@ func (s *LiveState) handleForceUnroll(_ context.Context, // handleBlockEpoch processes a new block notification and checks if the VTXO // needs to be forfeited cooperatively or escalated to unilateral exit. -func (s *LiveState) handleBlockEpoch(_ context.Context, evt *BlockEpochEvent, +func (s *LiveState) handleBlockEpoch(ctx context.Context, evt *BlockEpochEvent, env *VTXOEnvironment) (*VTXOStateTransition, error) { s.LastCheckedHeight = evt.Height @@ -169,6 +171,26 @@ func (s *LiveState) handleBlockEpoch(_ context.Context, evt *BlockEpochEvent, NextState: s, }, nil + case ExpiryStatusUnknown: + // The descriptor carries no expiry we can reason about, so + // hold the VTXO live rather than guessing. Failing the + // transition instead would wedge the actor on every block, and + // treating it as expired would surrender funds that may well + // be live. The value is stamped at creation and never + // rewritten, so this is a persistent data fault worth a + // warning until an operator notices. + build.LoggerFromContext(ctx).WithPrefix(Subsystem).WarnS( + ctx, "VTXO has no usable batch expiry; holding live "+ + "without expiry monitoring", nil, + slog.String("outpoint", s.VTXO.Outpoint.String()), + slog.Int("batch_expiry", int(s.VTXO.BatchExpiry)), + slog.Int("created_height", int(s.VTXO.CreatedHeight)), + ) + + return &VTXOStateTransition{ + NextState: s, + }, nil + case ExpiryStatusNeedsRefresh: // Request cooperative forfeit before expiry becomes critical. // LastCheckedHeight carries the current block height into From f206b33fdef0e70e6b97267dd0075c0bb63ff8f8 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Mon, 27 Jul 2026 14:19:08 -0700 Subject: [PATCH 2/7] vtxo: budget OOR hops in the critical exit threshold CalculateCriticalThreshold sized the unilateral-exit window from the commitment-tree depth and the CSV delay alone. It ignored ChainDepth, the number of OOR checkpoint hops between the commitment and the VTXO. Those hops are not free. Each is a recovery transaction that must confirm before the exit's final CSV even starts, and they are strictly sequential because each checkpoint spends the previous one. unroll already budgets fees this way, one recovery tx per hop, so the time budget disagreed with the fee budget. The threshold exists precisely so a client never has to race the operator's sweep, and it was under-sized for exactly the deep OOR chains that need the most room. Factor the sequential transaction count into exitTxDepth: the deepest tree path, since parallel ancestry fragments confirm concurrently and the worst branch sets the pace, plus one transaction per OOR hop. A negative hop count is treated as zero rather than being allowed to shorten the budget. --- vtxo/expiry.go | 33 ++++++++++++- vtxo/expiry_validation_test.go | 88 ++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/vtxo/expiry.go b/vtxo/expiry.go index 38e403e65..3cc75a116 100644 --- a/vtxo/expiry.go +++ b/vtxo/expiry.go @@ -1,5 +1,7 @@ package vtxo +import "math" + // ExpiryStatus represents the result of an expiry check. type ExpiryStatus int @@ -202,10 +204,37 @@ func (c *ExpiryConfig) DetermineRefreshUrgency( return RefreshUrgencyNormal } +// exitTxDepth returns the number of transactions that must confirm in +// sequence before a unilateral exit can even begin its final CSV wait. +// +// Two segments stack. First the deepest commitment-tree path, since a VTXO +// with several ancestry fragments must land them all and they confirm in +// parallel, so the worst branch sets the pace. Then one recovery transaction +// per OOR hop between that commitment and this VTXO: those are strictly +// sequential, because each checkpoint spends the previous one. unroll already +// budgets fees this way (one recovery tx per ChainDepth hop), so the time +// budget has to agree or an exit is admitted with no room to finish. +func exitTxDepth(vtxo *Descriptor) int32 { + depth := int64(vtxo.MaxTreeDepth()) + + // A negative ChainDepth is rejected as invalid elsewhere; treat it as + // zero here rather than letting it shorten the budget. + if vtxo.ChainDepth > 0 { + depth += int64(vtxo.ChainDepth) + } + + if depth > math.MaxInt32 { + return math.MaxInt32 + } + + return int32(depth) +} + // CalculateCriticalThreshold returns the dynamic critical threshold for a VTXO -// based on its tree depth and CSV delay. +// based on the depth of its unilateral-exit transaction chain and its CSV +// delay. func (c *ExpiryConfig) CalculateCriticalThreshold(vtxo *Descriptor) int32 { - treeDepthBuffer := int32(vtxo.MaxTreeDepth()) * c.TreeDepthMultiplier + treeDepthBuffer := exitTxDepth(vtxo) * c.TreeDepthMultiplier csvBuffer := int32(vtxo.RelativeExpiry) safeExitBuffer := treeDepthBuffer + csvBuffer diff --git a/vtxo/expiry_validation_test.go b/vtxo/expiry_validation_test.go index db8c286ad..0ea1095fe 100644 --- a/vtxo/expiry_validation_test.go +++ b/vtxo/expiry_validation_test.go @@ -133,6 +133,94 @@ func TestExpiryStatusUnknownString(t *testing.T) { require.Equal(t, "unknown", ExpiryStatusUnknown.String()) } +// TestCriticalThresholdIncludesOORHops asserts that the critical threshold +// budgets time for the OOR checkpoint chain, not just the commitment tree. +// +// The critical threshold exists so the client never has to race the operator's +// sweep. An exit must confirm the deepest tree path AND one recovery +// transaction per OOR hop before its final CSV even starts, so omitting +// ChainDepth under-sizes exactly the deep OOR chains that need the most room. +func TestCriticalThresholdIncludesOORHops(t *testing.T) { + t.Parallel() + + const ( + treeDepthMultiplier = int32(6) + relativeExpiry = uint32(144) + chainDepth = 4 + ) + + cfg := &ExpiryConfig{ + // Floor kept low so the dynamic term is what is measured. + CriticalThresholdBlocks: 1, + RefreshThresholdBlocks: 1, + MinRefreshBuffer: 1, + TreeDepthMultiplier: treeDepthMultiplier, + } + + // Identical VTXOs except for the OOR hop count. Both carry a + // single-fragment ancestry so MaxTreeDepth is equal. + ancestry := []Ancestry{{TreeDepth: 3}} + + roundBorn := &Descriptor{ + Ancestry: ancestry, + RelativeExpiry: relativeExpiry, + ChainDepth: 0, + } + oorDerived := &Descriptor{ + Ancestry: ancestry, + RelativeExpiry: relativeExpiry, + ChainDepth: chainDepth, + } + + roundThreshold := cfg.CalculateCriticalThreshold(roundBorn) + oorThreshold := cfg.CalculateCriticalThreshold(oorDerived) + + require.Greater( + t, oorThreshold, roundThreshold, "an OOR-derived VTXO "+ + "needs a larger exit budget than an otherwise "+ + "identical round-born one", + ) + require.Equal( + t, roundThreshold+chainDepth*treeDepthMultiplier, oorThreshold, + "each OOR hop must cost one recovery transaction of budget", + ) +} + +// TestCriticalThresholdIgnoresNegativeChainDepth asserts that a corrupt hop +// count cannot shorten the exit budget below the round-born baseline. +func TestCriticalThresholdIgnoresNegativeChainDepth(t *testing.T) { + t.Parallel() + + cfg := &ExpiryConfig{ + CriticalThresholdBlocks: 1, + TreeDepthMultiplier: 6, + } + + desc := &Descriptor{ + Ancestry: []Ancestry{ + { + TreeDepth: 3, + }, + }, + RelativeExpiry: 144, + ChainDepth: -5, + } + baseline := &Descriptor{ + Ancestry: []Ancestry{ + { + TreeDepth: 3, + }, + }, + RelativeExpiry: 144, + ChainDepth: 0, + } + + require.Equal( + t, cfg.CalculateCriticalThreshold(baseline), + cfg.CalculateCriticalThreshold(desc), + ) +} + // TestLiveStateBlockEpochUnusableExpiry asserts that a VTXO whose expiry // cannot be trusted is held in LiveState: it is neither surrendered to the // expiry path nor allowed to fail the transition, which would wedge the actor From a307642fb3ad2710fc082b67e5b5afe092d64327 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 29 Jul 2026 14:13:27 +0200 Subject: [PATCH 3/7] fraud: Attribute operator sweeps by tapleaf Identify the witness path that actually spent each watched VTXO instead of inferring intent from chain height. Operator batch sweeps can then retire expired watches without being escalated as client fraud. --- fraud/AGENTS.md | 13 +++- fraud/CLAUDE.md | 13 +++- fraud/actor.go | 44 +++++++++-- fraud/actor_test.go | 170 +++++++++++++++++++++++++++++++++++++++++++ fraud/messages.go | 9 +++ fraud/watch_model.go | 86 ++++++++++++++++++++-- 6 files changed, 319 insertions(+), 16 deletions(-) diff --git a/fraud/AGENTS.md b/fraud/AGENTS.md index 5f7c3f956..6d6143362 100644 --- a/fraud/AGENTS.md +++ b/fraud/AGENTS.md @@ -17,7 +17,8 @@ automatically triggers unilateral exit for all affected recipient VTXOs. (VTXO manager handle that owns the exit transition and starts the durable unroll job), `Log`, `MailboxSize` (default 64). - `WatchPlan` — Passive watch set for one VTXO. Contains a target - outpoint and a list of `WatchPoint` ancestors to monitor. + outpoint, the target's absolute `BatchExpiry`, and a list of + `WatchPoint` ancestors to monitor. - `WatchPoint` — Single outpoint to watch: `Outpoint`, `PkScript`, `HeightHint`. - `Msg` / `Resp` — Sealed message interfaces (fraud-only). @@ -68,6 +69,16 @@ automatically triggers unilateral exit for all affected recipient VTXOs. - **Filter at admission.** Only VTXO descriptors with `Status=Live` and `ChainDepth > 0` are tracked; already-terminal or non-OOR VTXOs are skipped. +- **Escalation is suppressed only for a proven operator sweep.** A spend is + attributed to the operator when its witness reveals exactly the + unilateral-CSV timeout leaf committed in the watched output + (`WatchPoint.SweepLeafHash`, taken from the tree's `SweepTapscriptRoot`). + Chain height is deliberately NOT the test: maturity proves the operator + *could* sweep, not that this transaction did, so a hostile expansion + confirmed after expiry would be indistinguishable from a sweep under a + height check. An empty `SweepLeafHash` never suppresses, so a VTXO leaf + output (whose taproot commits only the collaborative and owner-timeout + paths) and an incomplete watch plan both keep escalating. ## Deep Docs diff --git a/fraud/CLAUDE.md b/fraud/CLAUDE.md index 5f7c3f956..6d6143362 100644 --- a/fraud/CLAUDE.md +++ b/fraud/CLAUDE.md @@ -17,7 +17,8 @@ automatically triggers unilateral exit for all affected recipient VTXOs. (VTXO manager handle that owns the exit transition and starts the durable unroll job), `Log`, `MailboxSize` (default 64). - `WatchPlan` — Passive watch set for one VTXO. Contains a target - outpoint and a list of `WatchPoint` ancestors to monitor. + outpoint, the target's absolute `BatchExpiry`, and a list of + `WatchPoint` ancestors to monitor. - `WatchPoint` — Single outpoint to watch: `Outpoint`, `PkScript`, `HeightHint`. - `Msg` / `Resp` — Sealed message interfaces (fraud-only). @@ -68,6 +69,16 @@ automatically triggers unilateral exit for all affected recipient VTXOs. - **Filter at admission.** Only VTXO descriptors with `Status=Live` and `ChainDepth > 0` are tracked; already-terminal or non-OOR VTXOs are skipped. +- **Escalation is suppressed only for a proven operator sweep.** A spend is + attributed to the operator when its witness reveals exactly the + unilateral-CSV timeout leaf committed in the watched output + (`WatchPoint.SweepLeafHash`, taken from the tree's `SweepTapscriptRoot`). + Chain height is deliberately NOT the test: maturity proves the operator + *could* sweep, not that this transaction did, so a hostile expansion + confirmed after expiry would be indistinguishable from a sweep under a + height check. An empty `SweepLeafHash` never suppresses, so a VTXO leaf + output (whose taproot commits only the collaborative and owner-timeout + paths) and an incomplete watch plan both keep escalating. ## Deep Docs diff --git a/fraud/actor.go b/fraud/actor.go index eff17f06d..839ef9bde 100644 --- a/fraud/actor.go +++ b/fraud/actor.go @@ -342,18 +342,44 @@ func (w *WatcherActor) handleSpendObserved(ctx context.Context, return &AckResp{}, nil } - var errs error - for target := range targets { - if err := w.ensureUnroll(ctx, target); err != nil { - errs = joinTrackError(errs, err) + // Establish what actually spent the ancestor before deciding. The + // operator's batch sweep spends exactly these outputs, so without this + // check every legitimate sweep would escalate a pointless unroll on + // each affected target. + // + // The test is provenance, not timing. Chain height only proves the + // operator's timeout path has matured; it says nothing about which + // path a given transaction took, so a hostile expansion confirmed + // after expiry would look identical to a sweep. Matching the revealed + // tapleaf against the one committed in the watched output settles it, + // and leaves genuine post-expiry fraud escalating. + operatorSweep := false + if watch, ok := w.watches.pointAt(msg.Outpoint); ok { + operatorSweep = watch.IsOperatorSweepSpend( + msg.SpendingTx, msg.SpenderInputIndex, + ) + } + + var ( + errs error + escalated int + ) + if !operatorSweep { + for target := range targets { + escalated++ + if err := w.ensureUnroll(ctx, target); err != nil { + errs = joinTrackError(errs, err) + } } } - w.log.DebugS(ctx, "Triggered recipient fraud unroll", + w.log.DebugS(ctx, "Handled watched ancestor spend", slog.String("watched_outpoint", msg.Outpoint.String()), slog.String("spending_txid", msg.SpendingTxid.String()), slog.Int("height", int(msg.Height)), slog.Int("targets", len(targets)), + slog.Int("escalated", escalated), + slog.Bool("operator_sweep", operatorSweep), ) if errs != nil { @@ -405,9 +431,11 @@ func (w *WatcherActor) registerSpendWatch(ctx context.Context, notifyRef := chainsource.MapSpendEvent( w.selfRef, func(event chainsource.SpendEvent) Msg { return &SpendObservedMsg{ - Outpoint: event.Outpoint, - SpendingTxid: event.SpendingTxid, - Height: event.SpendingHeight, + Outpoint: event.Outpoint, + SpendingTxid: event.SpendingTxid, + SpendingTx: event.SpendingTx, + SpenderInputIndex: event.SpenderInputIndex, + Height: event.SpendingHeight, } }, ) diff --git a/fraud/actor_test.go b/fraud/actor_test.go index 46631045e..9f4a655d3 100644 --- a/fraud/actor_test.go +++ b/fraud/actor_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/wavelength/baselib/actor" @@ -248,6 +249,175 @@ func TestWatcherTriggersUnrollOnAncestorSpend(t *testing.T) { chainRef.mu.Unlock() } +// testSweepScript is a stand-in for the operator's unilateral-CSV timeout +// script. Only its tap hash matters to the watcher. +var testSweepScript = []byte{0x51, 0xb2, 0x75} + +// testSweepLeafHash returns the tap hash the watcher expects to see revealed +// by a legitimate operator sweep of testSweepScript. +func testSweepLeafHash() []byte { + hash := txscript.NewBaseTapLeaf(testSweepScript).TapHash() + + return hash[:] +} + +// emitSpendWithWitness delivers a spend whose input carries the given witness, +// so a test can control which taproot path the spend appears to take. +func (f *fakeChainSourceRef) emitSpendWithWitness(t *testing.T, + outpoint wire.OutPoint, witness [][]byte) { + + t.Helper() + + f.mu.Lock() + ref := f.spendRefs[outpoint] + f.mu.Unlock() + require.NotNil(t, ref) + + spendingTx := wire.NewMsgTx(2) + spendingTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: outpoint, + Witness: witness, + }) + + require.NoError( + t, + ref.Tell( + t.Context(), chainsource.SpendEvent{ + Outpoint: outpoint, + SpendingTxid: spendingTx.TxHash(), + SpendingTx: spendingTx, + SpenderInputIndex: 0, + SpendingHeight: 33, + }, + ), + ) +} + +// sweepWitness is the witness shape of a taproot script-path spend revealing +// the operator's sweep leaf: signature, script, control block. +func sweepWitness(script []byte) [][]byte { + return [][]byte{{0x01}, script, {0xc0}} +} + +// TestWatcherSkipsUnrollOnOperatorSweep verifies that a spend revealing the +// operator's committed timeout leaf does not escalate. +// +// The operator's batch sweep spends exactly the outputs the watcher monitors, +// so without this the sweep would drive a pointless unroll on every affected +// target. +func TestWatcherSkipsUnrollOnOperatorSweep(t *testing.T) { + treePath, _ := testLeafTree(t, 60) + treePath.SweepTapscriptRoot = testSweepLeafHash() + + // The operator sweeps tree node outputs, not VTXO leaves: a leaf's + // taproot commits only the collaborative and owner-timeout paths. The + // node input is therefore what a sweep spends. + source := treePath.Root.Input + + target := testInput(61) + desc := testDescriptor(target, treePath) + + chainRef := &fakeChainSourceRef{} + managerRef := &fakeManagerRef{} + watcher := NewWatcherActor(WatcherConfig{ + ChainSource: chainRef, + VTXOManagerRef: managerRef, + Log: fn.None[btclog.Logger](), + }) + t.Cleanup(watcher.Stop) + + _, err := watcher.Ref().Ask(t.Context(), &TrackVTXOsRequest{ + VTXOs: []*vtxo.Descriptor{desc}, + }).Await(t.Context()).Unpack() + require.NoError(t, err) + + chainRef.emitSpendWithWitness( + t, source, sweepWitness(testSweepScript), + ) + + require.Never(t, func() bool { + return managerRef.requestCount() > 0 + }, 300*time.Millisecond, 20*time.Millisecond) +} + +// TestWatcherEscalatesHostileSpendRevealingOtherLeaf verifies that a spend +// which does NOT reveal the operator's sweep leaf still escalates, even though +// the operator's timeout path has matured. +// +// This is the case a height-based check gets wrong. Maturity says the operator +// COULD sweep; it does not say this transaction did. A sender materializing +// ancestry can win the race against a conflicting sweep, and suppressing there +// would guarantee inaction exactly when fraud response is needed. +func TestWatcherEscalatesHostileSpendRevealingOtherLeaf(t *testing.T) { + treePath, _ := testLeafTree(t, 70) + treePath.SweepTapscriptRoot = testSweepLeafHash() + source := treePath.Root.Input + + target := testInput(71) + desc := testDescriptor(target, treePath) + + chainRef := &fakeChainSourceRef{} + managerRef := &fakeManagerRef{} + watcher := NewWatcherActor(WatcherConfig{ + ChainSource: chainRef, + VTXOManagerRef: managerRef, + Log: fn.None[btclog.Logger](), + }) + t.Cleanup(watcher.Stop) + + _, err := watcher.Ref().Ask(t.Context(), &TrackVTXOsRequest{ + VTXOs: []*vtxo.Descriptor{desc}, + }).Await(t.Context()).Unpack() + require.NoError(t, err) + + // Same height a sweep would confirm at, but a different script. + chainRef.emitSpendWithWitness( + t, source, + sweepWitness( + []byte{0x52, 0xb2, 0x75}, + ), + ) + + require.Eventually(t, func() bool { + return managerRef.requestCount() == 1 + }, testTimeout, 10*time.Millisecond) + require.Equal(t, target, managerRef.lastRequest(t).Outpoint) +} + +// TestWatcherEscalatesWithoutCommittedSweepLeaf verifies that a tree carrying +// no sweep script never attributes a spend to the operator. An incomplete +// watch plan must not silently disarm fraud defense. +func TestWatcherEscalatesWithoutCommittedSweepLeaf(t *testing.T) { + treePath, _ := testLeafTree(t, 80) + treePath.SweepTapscriptRoot = nil + source := treePath.Root.Input + + target := testInput(81) + desc := testDescriptor(target, treePath) + + chainRef := &fakeChainSourceRef{} + managerRef := &fakeManagerRef{} + watcher := NewWatcherActor(WatcherConfig{ + ChainSource: chainRef, + VTXOManagerRef: managerRef, + Log: fn.None[btclog.Logger](), + }) + t.Cleanup(watcher.Stop) + + _, err := watcher.Ref().Ask(t.Context(), &TrackVTXOsRequest{ + VTXOs: []*vtxo.Descriptor{desc}, + }).Await(t.Context()).Unpack() + require.NoError(t, err) + + chainRef.emitSpendWithWitness( + t, source, sweepWitness(testSweepScript), + ) + + require.Eventually(t, func() bool { + return managerRef.requestCount() == 1 + }, testTimeout, 10*time.Millisecond) +} + // TestWatcherTracksOnlyLiveOORVTXOs verifies admission keeps passive fraud // watches limited to live out-of-round VTXOs. func TestWatcherTracksOnlyLiveOORVTXOs(t *testing.T) { diff --git a/fraud/messages.go b/fraud/messages.go index 02a3cc26c..a2f116916 100644 --- a/fraud/messages.go +++ b/fraud/messages.go @@ -95,6 +95,15 @@ type SpendObservedMsg struct { // SpendingTxid is the transaction that spent Outpoint. SpendingTxid chainhash.Hash + // SpendingTx is the full spending transaction. Its witness is what + // establishes which taproot path the spend took, which is the only + // way to tell the operator's legitimate batch sweep apart from a + // sender materializing ancestry. + SpendingTx *wire.MsgTx + + // SpenderInputIndex is the input of SpendingTx that consumes Outpoint. + SpenderInputIndex uint32 + // Height is the confirmation height of SpendingTxid. Height int32 } diff --git a/fraud/watch_model.go b/fraud/watch_model.go index 5879fe6fd..df935d56b 100644 --- a/fraud/watch_model.go +++ b/fraud/watch_model.go @@ -6,6 +6,8 @@ import ( "fmt" "sort" + "github.com/btcsuite/btcd/chainhash/v2" + "github.com/btcsuite/btcd/txscript/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/wavelength/lib/tree" "github.com/lightninglabs/wavelength/vtxo" @@ -41,6 +43,15 @@ type WatchPoint struct { // HeightHint is the earliest plausible spend height. HeightHint uint32 + + // SweepLeafHash is the tap hash of the operator's unilateral-CSV + // timeout leaf committed in this output's taproot. A spend that + // reveals exactly this leaf is the operator's legitimate batch sweep + // rather than a sender materializing ancestry, so it is what + // distinguishes the two. Empty when the tree carries no sweep script + // (connector trees), in which case no spend can be attributed to the + // operator and every spend escalates. + SweepLeafHash []byte } // BuildWatchPlan builds the passive fraud watch set for desc. @@ -69,7 +80,8 @@ func BuildWatchPlan(desc *vtxo.Descriptor) (*WatchPlan, error) { err := collectTreeWatches( treePath.Root, treePath.BatchOutput.PkScript, - heightHint, watchesByOutpoint, + heightHint, treePath.SweepTapscriptRoot, + watchesByOutpoint, ) if err != nil { return nil, fmt.Errorf("ancestry %d: %w", i, err) @@ -92,7 +104,8 @@ func BuildWatchPlan(desc *vtxo.Descriptor) (*WatchPlan, error) { // inputs detect tree materialization; leaf-output watches detect the first OOR // checkpoint spending the materialized source VTXO. func collectTreeWatches(node *tree.Node, inputPkScript []byte, - heightHint uint32, watches map[wire.OutPoint]WatchPoint) error { + heightHint uint32, sweepLeafHash []byte, + watches map[wire.OutPoint]WatchPoint) error { if node == nil { return fmt.Errorf("%w: nil tree node", ErrWatchInvalid) @@ -103,9 +116,10 @@ func collectTreeWatches(node *tree.Node, inputPkScript []byte, } watches[node.Input] = WatchPoint{ - Outpoint: node.Input, - PkScript: append([]byte(nil), inputPkScript...), - HeightHint: heightHint, + Outpoint: node.Input, + PkScript: append([]byte(nil), inputPkScript...), + HeightHint: heightHint, + SweepLeafHash: append([]byte(nil), sweepLeafHash...), } if node.IsLeaf() { @@ -119,6 +133,10 @@ func collectTreeWatches(node *tree.Node, inputPkScript []byte, ErrWatchInvalid, *leafOutpoint) } + // A VTXO leaf output commits no operator sweep leaf: its + // taproot carries only the collaborative and owner-timeout + // paths. Leave SweepLeafHash empty so no spend of it can ever + // be attributed to the operator. watches[*leafOutpoint] = WatchPoint{ Outpoint: *leafOutpoint, PkScript: append( @@ -148,7 +166,7 @@ func collectTreeWatches(node *tree.Node, inputPkScript []byte, err := collectTreeWatches( child, node.Outputs[outputIndex].PkScript, heightHint, - watches, + sweepLeafHash, watches, ) if err != nil { return err @@ -187,3 +205,59 @@ func outpointLess(a, b wire.OutPoint) bool { return a.Index < b.Index } + +// revealedTapLeafHash returns the tap hash of the script revealed by a +// tapscript spend of the given input, and whether the input was a tapscript +// spend at all. +// +// A taproot script-path witness ends with the control block and carries the +// revealed script immediately before it. A key-path spend (a single signature) +// reveals no script and returns false, as does any witness too short to be a +// script path. +func revealedTapLeafHash(tx *wire.MsgTx, + inputIndex uint32) (chainhash.Hash, bool) { + + if tx == nil || inputIndex >= uint32(len(tx.TxIn)) { + return chainhash.Hash{}, false + } + + witness := tx.TxIn[inputIndex].Witness + + // script + control block is the minimum for a script-path spend. + const minScriptPathWitnessItems = 2 + if len(witness) < minScriptPathWitnessItems { + return chainhash.Hash{}, false + } + + script := witness[len(witness)-2] + if len(script) == 0 { + return chainhash.Hash{}, false + } + + return txscript.NewBaseTapLeaf(script).TapHash(), true +} + +// IsOperatorSweepSpend reports whether the observed spend of this watch point +// took the operator's committed unilateral-CSV timeout leaf, i.e. whether it +// is the operator's legitimate batch sweep. +// +// Provenance, not timing, is the test. Chain height only proves the timeout +// path has matured; it says nothing about which path a given transaction +// actually used, so a hostile expansion confirmed after expiry would pass a +// height check while being exactly the thing fraud response exists to answer. +// Matching the revealed tapleaf against the one committed in the watched +// output settles it. +func (p WatchPoint) IsOperatorSweepSpend(tx *wire.MsgTx, + inputIndex uint32) bool { + + if len(p.SweepLeafHash) == 0 { + return false + } + + revealed, ok := revealedTapLeafHash(tx, inputIndex) + if !ok { + return false + } + + return bytes.Equal(revealed[:], p.SweepLeafHash) +} From 6340be1845462a016ebc08e62be0d0b439888883 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Mon, 27 Jul 2026 14:31:49 -0700 Subject: [PATCH 4/7] multi: persist the per-round sweep delay across a restart A round is checkpointed at input_sig_sent, the point of no return, and can confirm long afterwards. The confirmation handler derives each new VTXO's absolute batch expiry as confirmation_height + sweep_delay but the delay lived only in the in-memory FSM state. A daemon restart between checkpoint and confirmation rebuilt InputSigSentState without it, so the resumed round computed an expiry of confirmation_height + 0 and stamped every VTXO it created with BatchExpiry == CreatedHeight. The wallet reads that back as already expired, which retires a VTXO that was created seconds earlier. Add a sweep_delay column to the rounds table, carry the value on round.Round, and restore it onto both the round record and the FSM state. The upsert only adopts an incoming delay when it is non-zero, since the value is fixed for the life of a round and a later checkpoint must not clear what an earlier one recorded. Rounds checkpointed before this migration have no recorded delay. For those the confirmation path now leaves the expiry unstamped rather than stamping a wrong one, and logs at error level. An unstamped expiry classifies as ExpiryStatusUnknown, so the VTXO stays live and spendable with only expiry monitoring disabled, and the authoritative expiry is still recoverable from the operator's indexer. --- db/AGENTS.md | 8 ++- db/CLAUDE.md | 12 +++- db/migrations.go | 2 +- db/round_store.go | 14 +++++ db/round_store_test.go | 62 +++++++++++++++++++ .../000016_round_sweep_delay.down.sql | 1 + .../000016_round_sweep_delay.up.sql | 14 +++++ db/sqlc/models.go | 1 + db/sqlc/queries/round.sql | 13 +++- db/sqlc/round.sql.go | 30 ++++++--- db/sqlc/schemas/generated_schema.sql | 2 +- round/interfaces.go | 10 +++ round/transitions.go | 33 +++++++++- 13 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 db/sqlc/migrations/000016_round_sweep_delay.down.sql create mode 100644 db/sqlc/migrations/000016_round_sweep_delay.up.sql diff --git a/db/AGENTS.md b/db/AGENTS.md index 675c6584e..655d4910d 100644 --- a/db/AGENTS.md +++ b/db/AGENTS.md @@ -71,7 +71,7 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/db. 0 THEN excluded.sweep_delay + ELSE rounds.sweep_delay + END; -- name: GetRound :one SELECT * FROM rounds WHERE round_id = $1; diff --git a/db/sqlc/round.sql.go b/db/sqlc/round.sql.go index d710d0030..1f3713334 100644 --- a/db/sqlc/round.sql.go +++ b/db/sqlc/round.sql.go @@ -155,7 +155,7 @@ func (q *Queries) GetClientTreeTxids(ctx context.Context, arg GetClientTreeTxids } const GetRound = `-- name: GetRound :one -SELECT round_id, start_height, confirmation_height, confirmation_block_hash, commitment_tx, commitment_txid, vtxt_tree, status, creation_time, last_update_time, flow_version FROM rounds WHERE round_id = $1 +SELECT round_id, start_height, confirmation_height, confirmation_block_hash, commitment_tx, commitment_txid, vtxt_tree, status, creation_time, last_update_time, flow_version, sweep_delay FROM rounds WHERE round_id = $1 ` func (q *Queries) GetRound(ctx context.Context, roundID string) (Round, error) { @@ -173,6 +173,7 @@ func (q *Queries) GetRound(ctx context.Context, roundID string) (Round, error) { &i.CreationTime, &i.LastUpdateTime, &i.FlowVersion, + &i.SweepDelay, ) return i, err } @@ -216,7 +217,7 @@ func (q *Queries) GetRoundBoardingIntents(ctx context.Context, roundID string) ( } const GetRoundByCommitmentTxid = `-- name: GetRoundByCommitmentTxid :one -SELECT round_id, start_height, confirmation_height, confirmation_block_hash, commitment_tx, commitment_txid, vtxt_tree, status, creation_time, last_update_time, flow_version FROM rounds WHERE commitment_txid = $1 +SELECT round_id, start_height, confirmation_height, confirmation_block_hash, commitment_tx, commitment_txid, vtxt_tree, status, creation_time, last_update_time, flow_version, sweep_delay FROM rounds WHERE commitment_txid = $1 ` func (q *Queries) GetRoundByCommitmentTxid(ctx context.Context, commitmentTxid []byte) (Round, error) { @@ -234,6 +235,7 @@ func (q *Queries) GetRoundByCommitmentTxid(ctx context.Context, commitmentTxid [ &i.CreationTime, &i.LastUpdateTime, &i.FlowVersion, + &i.SweepDelay, ) return i, err } @@ -395,8 +397,8 @@ const InsertRound = `-- name: InsertRound :exec INSERT INTO rounds ( round_id, confirmation_height, confirmation_block_hash, commitment_tx, commitment_txid, vtxt_tree, status, creation_time, last_update_time, - start_height, flow_version -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + start_height, flow_version, sweep_delay +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT (round_id) DO UPDATE SET confirmation_height = COALESCE(excluded.confirmation_height, rounds.confirmation_height), confirmation_block_hash = COALESCE(excluded.confirmation_block_hash, rounds.confirmation_block_hash), @@ -404,7 +406,14 @@ ON CONFLICT (round_id) DO UPDATE SET commitment_txid = COALESCE(excluded.commitment_txid, rounds.commitment_txid), vtxt_tree = COALESCE(excluded.vtxt_tree, rounds.vtxt_tree), status = excluded.status, - last_update_time = excluded.last_update_time + last_update_time = excluded.last_update_time, + -- The sweep delay is fixed for the life of a round, so a later + -- checkpoint must never clear a value an earlier one recorded. Only + -- adopt the incoming value when it is actually set. + sweep_delay = CASE + WHEN excluded.sweep_delay > 0 THEN excluded.sweep_delay + ELSE rounds.sweep_delay + END ` type InsertRoundParams struct { @@ -419,6 +428,7 @@ type InsertRoundParams struct { LastUpdateTime int64 StartHeight int32 FlowVersion int32 + SweepDelay int32 } // Round queries. @@ -435,6 +445,7 @@ func (q *Queries) InsertRound(ctx context.Context, arg InsertRoundParams) error arg.LastUpdateTime, arg.StartHeight, arg.FlowVersion, + arg.SweepDelay, ) return err } @@ -651,7 +662,7 @@ func (q *Queries) InsertVTXOAncestryPath(ctx context.Context, arg InsertVTXOAnce } const ListActiveRounds = `-- name: ListActiveRounds :many -SELECT round_id, start_height, confirmation_height, confirmation_block_hash, commitment_tx, commitment_txid, vtxt_tree, status, creation_time, last_update_time, flow_version FROM rounds WHERE status = 'input_sig_sent' ORDER BY creation_time ASC +SELECT round_id, start_height, confirmation_height, confirmation_block_hash, commitment_tx, commitment_txid, vtxt_tree, status, creation_time, last_update_time, flow_version, sweep_delay FROM rounds WHERE status = 'input_sig_sent' ORDER BY creation_time ASC ` func (q *Queries) ListActiveRounds(ctx context.Context) ([]Round, error) { @@ -675,6 +686,7 @@ func (q *Queries) ListActiveRounds(ctx context.Context) ([]Round, error) { &i.CreationTime, &i.LastUpdateTime, &i.FlowVersion, + &i.SweepDelay, ); err != nil { return nil, err } @@ -788,7 +800,7 @@ func (q *Queries) ListLiveVTXOAncestryPaths(ctx context.Context) ([]VtxoAncestry } const ListRoundsByStatus = `-- name: ListRoundsByStatus :many -SELECT round_id, start_height, confirmation_height, confirmation_block_hash, commitment_tx, commitment_txid, vtxt_tree, status, creation_time, last_update_time, flow_version FROM rounds WHERE status = $1 ORDER BY creation_time DESC +SELECT round_id, start_height, confirmation_height, confirmation_block_hash, commitment_tx, commitment_txid, vtxt_tree, status, creation_time, last_update_time, flow_version, sweep_delay FROM rounds WHERE status = $1 ORDER BY creation_time DESC ` func (q *Queries) ListRoundsByStatus(ctx context.Context, status string) ([]Round, error) { @@ -812,6 +824,7 @@ func (q *Queries) ListRoundsByStatus(ctx context.Context, status string) ([]Roun &i.CreationTime, &i.LastUpdateTime, &i.FlowVersion, + &i.SweepDelay, ); err != nil { return nil, err } @@ -827,7 +840,7 @@ func (q *Queries) ListRoundsByStatus(ctx context.Context, status string) ([]Roun } const ListRoundsPaginated = `-- name: ListRoundsPaginated :many -SELECT round_id, start_height, confirmation_height, confirmation_block_hash, commitment_tx, commitment_txid, vtxt_tree, status, creation_time, last_update_time, flow_version FROM rounds +SELECT round_id, start_height, confirmation_height, confirmation_block_hash, commitment_tx, commitment_txid, vtxt_tree, status, creation_time, last_update_time, flow_version, sweep_delay FROM rounds WHERE ($1 = '' OR round_id > $1) AND ($2 = '' OR status = $2) AND ($3 = 0 OR creation_time >= $3) @@ -873,6 +886,7 @@ func (q *Queries) ListRoundsPaginated(ctx context.Context, arg ListRoundsPaginat &i.CreationTime, &i.LastUpdateTime, &i.FlowVersion, + &i.SweepDelay, ); err != nil { return nil, err } diff --git a/db/sqlc/schemas/generated_schema.sql b/db/sqlc/schemas/generated_schema.sql index 42335cca2..735ff3b69 100644 --- a/db/sqlc/schemas/generated_schema.sql +++ b/db/sqlc/schemas/generated_schema.sql @@ -1292,7 +1292,7 @@ CREATE TABLE rounds ( -- today is 0 (V1); a future, genuinely different round flow is added -- additively (V2 == 1, and so on). NOT NULL DEFAULT 0 keeps every row a -- valid V1 round. - flow_version INTEGER NOT NULL DEFAULT 0, + flow_version INTEGER NOT NULL DEFAULT 0, sweep_delay INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (status) REFERENCES round_statuses(status_name) ); diff --git a/round/interfaces.go b/round/interfaces.go index ead4dcdc2..5342ec09b 100644 --- a/round/interfaces.go +++ b/round/interfaces.go @@ -367,6 +367,16 @@ type Round struct { // starting point. StartHeight uint32 + // SweepDelay is this round's batch-wide sweep timelock in blocks, + // delivered by the operator with the commitment tx. It is persisted + // because a round checkpointed at input_sig_sent can confirm after a + // restart, and the confirmation handler derives every new VTXO's + // absolute batch expiry as confirmation_height + SweepDelay. Without + // it a resumed round stamps BatchExpiry == CreatedHeight, which reads + // back as already expired. Zero means unrecorded (rounds checkpointed + // before the column existed). + SweepDelay uint32 + // ConfInfo contains chain information about when the round's commitment // transaction was confirmed. None until the commitment tx is confirmed // on-chain. diff --git a/round/transitions.go b/round/transitions.go index 14d2bc3f6..84f14fe94 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -55,6 +55,11 @@ func cleanupSignerSessions(sessions map[SignerKey]*tree.SignerSession) error { return errors.Join(cleanupErrors...) } +// errNoRoundSweepDelay is reported when a confirming round carries no +// recorded sweep delay, so no absolute batch expiry can be derived for the +// VTXOs it creates. +var errNoRoundSweepDelay = errors.New("round has no recorded sweep delay") + // failWithNotification creates a state transition to ClientFailedState and // emits a RoundFailedNotification. This is the standard pattern for handling // internal errors without returning an error to the FSM (which would halt it). @@ -2946,6 +2951,7 @@ func (s *ForfeitSignaturesCollectingState) checkpointRound( return &Round{ RoundID: s.RoundID, StartHeight: startHeight, + SweepDelay: s.SweepDelay, CommitmentTx: fn.Some(s.CommitmentTx), VTXOTreePaths: fn.Some(s.VTXOTreePaths), Intents: intents, @@ -3392,6 +3398,7 @@ func (s *PartialSigsSentState) processEvent(ctx context.Context, round := &Round{ RoundID: s.RoundID, StartHeight: env.StartHeight, + SweepDelay: s.SweepDelay, CommitmentTx: fn.Some(s.CommitmentTx), VTXOTreePaths: fn.Some(s.VTXOTreePaths), Intents: intents, @@ -4551,9 +4558,31 @@ func (s *InputSigSentState) ProcessEvent(ctx context.Context, event ClientEvent, ) // Compute batch expiry as absolute block height using this - // round's sweep delay (delivered per round, not a global term). + // round's sweep delay (delivered per round, not a global + // term). The delay is persisted with the round checkpoint, so + // it survives a restart between input_sig_sent and + // confirmation. + // + // A zero delay means the round was checkpointed before the + // column existed and has no recorded value. Stamping + // BatchExpiry == CreatedHeight would be worse than leaving it + // unset: the wallet reads that back as long expired. Leave it + // unstamped instead, which classifies as + // ExpiryStatusUnknown — the VTXO stays live and spendable, + // only expiry monitoring is disabled, and the authoritative + // expiry can still be recovered from the operator's indexer. sweepDelay := int32(s.SweepDelay) - batchExpiry := evt.BlockHeight + sweepDelay + batchExpiry := int32(0) + if sweepDelay > 0 { + batchExpiry = evt.BlockHeight + sweepDelay + } else { + env.Log.ErrorS(ctx, "Round has no recorded sweep "+ + "delay; leaving batch expiry unstamped", + errNoRoundSweepDelay, + slog.String("round_id", s.RoundID.String()), + slog.Int("block_height", int(evt.BlockHeight)), + ) + } // Fill in round metadata so VTXOs are complete from the // first write. This avoids a race where callers read the From 63e6db249ff4eb435086e5676767b16e85fa67a0 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 29 Jul 2026 14:13:37 +0200 Subject: [PATCH 5/7] vtxo: Persist and automatically reclaim expired VTXOs Keep expired value recoverable but outside the spendable set. After chain catch-up, replay the synchronized tip through the ordinary refresh and forfeit flow while preserving in-flight locks and restart recovery. Co-authored-by: sputn1ck --- db/round_store.go | 5 + db/sqlc/querier.go | 9 + db/sqlc/queries/vtxo.sql | 13 + db/sqlc/vtxo.sql.go | 61 ++++ db/vtxo_store.go | 83 +++++ db/vtxo_store_test.go | 71 +++++ oor/local_persistence_handler_test.go | 8 + unroll/actor_test.go | 6 + vtxo/AGENTS.md | 19 +- vtxo/CLAUDE.md | 19 +- vtxo/actor.go | 26 +- vtxo/actor_test.go | 91 ++++++ vtxo/expiry_validation_test.go | 431 ++++++++++++++++++++++++++ vtxo/filter.go | 3 +- vtxo/filter_test.go | 6 +- vtxo/harness_test.go | 8 + vtxo/interfaces.go | 35 ++- vtxo/manager.go | 124 +++++++- vtxo/manager_admission_test.go | 48 ++- vtxo/manager_expiry_reconcile_test.go | 139 +++++++++ vtxo/messages.go | 25 ++ vtxo/states.go | 45 +++ vtxo/transitions.go | 370 ++++++++++++++++++---- waved/server.go | 28 ++ waved/wallet_ops_test.go | 6 + 25 files changed, 1614 insertions(+), 65 deletions(-) create mode 100644 vtxo/manager_expiry_reconcile_test.go diff --git a/db/round_store.go b/db/round_store.go index cc4ec312f..de751704e 100644 --- a/db/round_store.go +++ b/db/round_store.go @@ -128,6 +128,11 @@ type RoundStore interface { // VTXO lifecycle status queries. ListLiveVTXOs(ctx context.Context) ([]VTXORow, error) + // ListRecoverableVTXOs returns the non-terminal set plus expired + // VTXOs, whose actors must still be restored so their value can be + // reclaimed by forfeiting them in a round. + ListRecoverableVTXOs(ctx context.Context) ([]VTXORow, error) + ListVTXOsByStatus(ctx context.Context, status int32) ([]sqlc.ListVTXOsByStatusRow, error) diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 875610f5e..307c905d0 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -261,6 +261,15 @@ type Querier interface { // Only status = 'pending' rows replay; a 'failed' intent is terminally // retired and must not be re-submitted on restart. ListPendingSendIntents(ctx context.Context) ([]ListPendingSendIntentsRow, error) + // ListRecoverableVTXOs returns every VTXO whose actor must be restored at + // startup: the non-terminal set of ListLiveVTXOs plus Expired (8). + // + // Expired is deliberately absent from ListLiveVTXOs, which feeds spendable + // balance and refresh estimation, because an expired VTXO holds no spendable + // value until it has been reissued. Its actor still has to exist though: the + // value is recoverable by forfeiting the VTXO in an ordinary round, and the + // actor is what holds the descriptor and signing material that forfeit needs. + ListRecoverableVTXOs(ctx context.Context) ([]Vtxo, error) ListRoundsByStatus(ctx context.Context, status string) ([]Round, error) // ListRoundsPaginated returns rounds ordered by round_id with cursor- // based pagination. When cursor is empty, returns from the beginning. diff --git a/db/sqlc/queries/vtxo.sql b/db/sqlc/queries/vtxo.sql index bd813c32d..9c1d7b573 100644 --- a/db/sqlc/queries/vtxo.sql +++ b/db/sqlc/queries/vtxo.sql @@ -60,6 +60,19 @@ SELECT * FROM vtxos WHERE (status < 3 OR status = 7) AND spent = FALSE ORDER BY creation_time DESC; +-- name: ListRecoverableVTXOs :many +-- ListRecoverableVTXOs returns every VTXO whose actor must be restored at +-- startup: the non-terminal set of ListLiveVTXOs plus Expired (8). +-- +-- Expired is deliberately absent from ListLiveVTXOs, which feeds spendable +-- balance and refresh estimation, because an expired VTXO holds no spendable +-- value until it has been reissued. Its actor still has to exist though: the +-- value is recoverable by forfeiting the VTXO in an ordinary round, and the +-- actor is what holds the descriptor and signing material that forfeit needs. +SELECT * FROM vtxos +WHERE (status < 3 OR status = 7 OR status = 8) AND spent = FALSE +ORDER BY creation_time DESC; + -- name: UpdateVTXOStatus :exec -- UpdateVTXOStatus atomically updates a VTXO's status. This is the primary -- method for state transitions that don't require additional data. diff --git a/db/sqlc/vtxo.sql.go b/db/sqlc/vtxo.sql.go index 6927b56c0..04da85f4c 100644 --- a/db/sqlc/vtxo.sql.go +++ b/db/sqlc/vtxo.sql.go @@ -189,6 +189,67 @@ func (q *Queries) ListLiveVTXOs(ctx context.Context) ([]Vtxo, error) { return items, nil } +const ListRecoverableVTXOs = `-- name: ListRecoverableVTXOs :many +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, policy_template, client_key_id, operator_pubkey, batch_expiry, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth, construction_version FROM vtxos +WHERE (status < 3 OR status = 7 OR status = 8) AND spent = FALSE +ORDER BY creation_time DESC +` + +// ListRecoverableVTXOs returns every VTXO whose actor must be restored at +// startup: the non-terminal set of ListLiveVTXOs plus Expired (8). +// +// Expired is deliberately absent from ListLiveVTXOs, which feeds spendable +// balance and refresh estimation, because an expired VTXO holds no spendable +// value until it has been reissued. Its actor still has to exist though: the +// value is recoverable by forfeiting the VTXO in an ordinary round, and the +// actor is what holds the descriptor and signing material that forfeit needs. +func (q *Queries) ListRecoverableVTXOs(ctx context.Context) ([]Vtxo, error) { + rows, err := q.db.QueryContext(ctx, ListRecoverableVTXOs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Vtxo + for rows.Next() { + var i Vtxo + if err := rows.Scan( + &i.OutpointHash, + &i.OutpointIndex, + &i.RoundID, + &i.Amount, + &i.PkScript, + &i.Expiry, + &i.PolicyTemplate, + &i.ClientKeyID, + &i.OperatorPubkey, + &i.BatchExpiry, + &i.CreatedHeight, + &i.CommitmentTxid, + &i.Spent, + &i.Status, + &i.ForfeitRoundID, + &i.ForfeitTx, + &i.ForfeitTxid, + &i.ReplacedByHash, + &i.ReplacedByIndex, + &i.CreationTime, + &i.LastUpdateTime, + &i.ChainDepth, + &i.ConstructionVersion, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListVTXOSelectionCandidatesByStatus = `-- name: ListVTXOSelectionCandidatesByStatus :many SELECT outpoint_hash, outpoint_index, amount, pk_script FROM vtxos diff --git a/db/vtxo_store.go b/db/vtxo_store.go index adf5fc0ed..e9874b8c0 100644 --- a/db/vtxo_store.go +++ b/db/vtxo_store.go @@ -266,6 +266,89 @@ func (s *VTXOPersistenceStore) ListLiveVTXOs(ctx context.Context) ( return result, err } +// ListRecoverableVTXOsLight returns the recoverable set without the ancestry +// join, for callers that only need each descriptor's amount. +func (s *VTXOPersistenceStore) ListRecoverableVTXOsLight(ctx context.Context) ( + []*vtxo.Descriptor, error) { + + readTxOpts := ReadTxOption() + + var result []*vtxo.Descriptor + + err := s.db.ExecTx(ctx, readTxOpts, func(q RoundStore) error { + rows, err := q.ListRecoverableVTXOs(ctx) + if err != nil { + return fmt.Errorf("list recoverable VTXOs: %w", err) + } + + descs, err := s.rowsToDescriptorsNoAncestry(ctx, q, rows) + if err != nil { + return err + } + + result = descs + + return nil + }) + + return result, err +} + +// ListRecoverableVTXOs returns every VTXO whose actor must be restored at +// startup: the ListLiveVTXOs set plus expired ones. +// +// Expired VTXOs are excluded from ListLiveVTXOs because they hold no +// spendable value until reissued, but their actors must still exist so the +// value can be reclaimed by forfeiting them in an ordinary round. +func (s *VTXOPersistenceStore) ListRecoverableVTXOs(ctx context.Context) ( + []*vtxo.Descriptor, error) { + + readTxOpts := ReadTxOption() + + var result []*vtxo.Descriptor + + err := s.db.ExecTx(ctx, readTxOpts, func(q RoundStore) error { + rows, err := q.ListRecoverableVTXOs(ctx) + if err != nil { + return fmt.Errorf("list recoverable VTXOs: %w", err) + } + + // The ancestry projection is keyed by outpoint and filtered on + // "unspent", which is a superset of the recoverable set, so + // the expired rows find their paths here too. + ancestryRows, err := q.ListUnspentVTXOAncestryPaths(ctx) + if err != nil { + return fmt.Errorf("list unspent ancestry paths: %w", + err) + } + + ancestryByOutpoint, err := groupAncestryRowsWithCache( + ancestryRows, s.ancestryCache, + ) + if err != nil { + return fmt.Errorf("group ancestry rows: %w", err) + } + + descs := make([]*vtxo.Descriptor, 0, len(rows)) + for _, row := range rows { + desc, err := s.rowToDescriptor( + ctx, q, row, ancestryByOutpoint, + ) + if err != nil { + return fmt.Errorf("convert VTXO: %w", err) + } + + descs = append(descs, desc) + } + + result = descs + + return nil + }) + + return result, err +} + // ListVTXOsByStatus returns all VTXOs matching the given status. This // enables the ListVTXOs RPC to query terminal states (spent, forfeited) // directly from the database instead of filtering in memory. Like diff --git a/db/vtxo_store_test.go b/db/vtxo_store_test.go index 48b7d0de5..08cce1a10 100644 --- a/db/vtxo_store_test.go +++ b/db/vtxo_store_test.go @@ -1917,3 +1917,74 @@ func TestVTXOPersistenceStoreGetVTXONotFound(t *testing.T) { "the driver error stays in the chain for back-compat", ) } + +// TestVTXOStoreExpiredExcludedFromLiveSet asserts the split between the two +// list queries: an expired VTXO must not count as spendable liquidity, but +// its actor must still be recovered so the value can be reclaimed by +// forfeiting it in an ordinary round. +// +// Getting this backwards is a real hazard in both directions. Including +// expired in the live set inflates the wallet's balance with value it cannot +// spend; excluding it from recovery strands the value permanently, because +// with no actor there is nothing to hold the descriptor and signing material +// a forfeit needs. +func TestVTXOStoreExpiredExcludedFromLiveSet(t *testing.T) { + t.Parallel() + + vtxoStore, roundStore, _ := newVTXOStoreForTest(t) + ctx := t.Context() + + roundID := testRoundIDDB("test-round-expired-split") + testRound := createTestRound(t, roundID) + state := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + require.NoError(t, roundStore.CommitState(ctx, testRound, state)) + + liveDesc := createTestVTXODescriptor(t, roundID, 11) + require.NoError(t, vtxoStore.SaveVTXO(ctx, liveDesc)) + + expiredDesc := createTestVTXODescriptor(t, roundID, 12) + require.NoError(t, vtxoStore.SaveVTXO(ctx, expiredDesc)) + require.NoError( + t, vtxoStore.UpdateVTXOStatus( + ctx, expiredDesc.Outpoint, vtxo.VTXOStatusExpired, + ), + ) + + outpoints := func(descs []*vtxo.Descriptor) []wire.OutPoint { + out := make([]wire.OutPoint, 0, len(descs)) + for _, desc := range descs { + out = append(out, desc.Outpoint) + } + + return out + } + + live, err := vtxoStore.ListLiveVTXOs(ctx) + require.NoError(t, err) + require.Equal( + t, []wire.OutPoint{liveDesc.Outpoint}, outpoints(live), + "an expired VTXO holds no spendable value", + ) + + // The light variant must agree: it feeds the boarding-headroom cap, + // where omitting expired value would let a client board up to the cap + // and then reclaim on top of it. + light, err := vtxoStore.ListRecoverableVTXOsLight(ctx) + require.NoError(t, err) + require.ElementsMatch( + t, []wire.OutPoint{liveDesc.Outpoint, expiredDesc.Outpoint}, + outpoints(light), + ) + + recoverable, err := vtxoStore.ListRecoverableVTXOs(ctx) + require.NoError(t, err) + require.ElementsMatch( + t, []wire.OutPoint{liveDesc.Outpoint, expiredDesc.Outpoint}, + outpoints(recoverable), + "an expired VTXO's actor must still be restored so its "+ + "value can be reclaimed", + ) +} diff --git a/oor/local_persistence_handler_test.go b/oor/local_persistence_handler_test.go index 8b8be0f4c..e7d3e0dc6 100644 --- a/oor/local_persistence_handler_test.go +++ b/oor/local_persistence_handler_test.go @@ -122,6 +122,14 @@ func (s *testVTXOStore) ListLiveVTXOs(_ context.Context) ([]*vtxo.Descriptor, return out, nil } +// ListRecoverableVTXOs mirrors ListLiveVTXOs: this fixture holds no expired +// records, so the recoverable and live sets coincide. +func (s *testVTXOStore) ListRecoverableVTXOs(ctx context.Context) ( + []*vtxo.Descriptor, error) { + + return s.ListLiveVTXOs(ctx) +} + // ListVTXOsByStatus returns descriptors matching the given status. func (s *testVTXOStore) ListVTXOsByStatus(_ context.Context, status vtxo.VTXOStatus) ([]*vtxo.Descriptor, error) { diff --git a/unroll/actor_test.go b/unroll/actor_test.go index 4e3e0ac25..9570f41e9 100644 --- a/unroll/actor_test.go +++ b/unroll/actor_test.go @@ -74,6 +74,12 @@ func (m *mockVTXOStore) ListLiveVTXOs(context.Context) ([]*vtxo.Descriptor, return nil, nil } +func (m *mockVTXOStore) ListRecoverableVTXOs(context.Context) ( + []*vtxo.Descriptor, error) { + + return nil, nil +} + // ListVTXOsByStatus is unused in these tests. func (m *mockVTXOStore) ListVTXOsByStatus(context.Context, vtxo.VTXOStatus) ( []*vtxo.Descriptor, error) { diff --git a/vtxo/AGENTS.md b/vtxo/AGENTS.md index 33639af24..8bf83bcec 100644 --- a/vtxo/AGENTS.md +++ b/vtxo/AGENTS.md @@ -13,7 +13,7 @@ when the local wallet owns the receive script. ## Key Types -- `VTXOState` — Sealed interface for all states (Live, Spending, Spent, PendingForfeit, Forfeiting, Forfeited, UnilateralExit, Failed). +- `VTXOState` — Sealed interface for all states (Live, Spending, Spent, PendingForfeit, Forfeiting, Forfeited, UnilateralExit, Expired, Failed). - `Descriptor` — Complete VTXO metadata: `Outpoint`, `Amount`, `PolicyTemplate` (the authoritative semantic policy for ownership/spend semantics), `PkScript`, `ClientKey` (keychain.KeyDescriptor), `OperatorKey`, @@ -122,6 +122,23 @@ when the local wallet owns the receive script. ## Invariants +- **Expiry is persisted and non-terminal.** `LiveState` + `ExpiryStatusExpired` + transitions to `ExpiredState` and emits `VTXOStatusUpdate{Expired}`. It does + NOT emit `VTXOTerminatedNotification`: the value is still recoverable by + forfeiting the VTXO in an ordinary round, so the actor stays alive to hold + the descriptor and signing material forfeit needs. `ExpiredState` accepts + `PendingForfeitEvent`/`ForfeitRequestEvent` (the reclaim path, which reuses + `LiveState.handleForfeitRequest` verbatim so the two cannot drift), refuses + `SpendReserveEvent` (nothing left to spend cooperatively until reissued), + and refuses `ForceUnrollEvent` — an exit started past expiry must confirm + the whole ancestry and then wait out the exit CSV while racing an + already-spendable operator sweep, so it burns fees on an exit that cannot + land. +- **Expired VTXOs are recovered but not spendable.** `ListLiveVTXOs` excludes + them (it feeds spendable balance and refresh estimation); + `ListRecoverableVTXOs` includes them and is what `Manager.Start` recovers + actors from. Coin selection is already `VTXOStatusLive`-scoped via + `ListSelectionCandidatesByStatus`, so it excludes them by construction. - VTXO actor state is the single source of truth for availability. - Forfeit transaction is not broadcast until the connector output's round confirms (atomic replacement). - Refresh is auto-triggered at configurable height before expiry. diff --git a/vtxo/CLAUDE.md b/vtxo/CLAUDE.md index f9fbef3ab..b5408bb5d 100644 --- a/vtxo/CLAUDE.md +++ b/vtxo/CLAUDE.md @@ -13,7 +13,7 @@ when the local wallet owns the receive script. ## Key Types -- `VTXOState` — Sealed interface for all states (Live, Spending, Spent, PendingForfeit, Forfeiting, Forfeited, UnilateralExit, Failed). +- `VTXOState` — Sealed interface for all states (Live, Spending, Spent, PendingForfeit, Forfeiting, Forfeited, UnilateralExit, Expired, Failed). - `Descriptor` — Complete VTXO metadata: `Outpoint`, `Amount`, `PolicyTemplate` (the authoritative semantic policy for ownership/spend semantics), `PkScript`, `ClientKey` (keychain.KeyDescriptor), `OperatorKey`, @@ -122,6 +122,23 @@ when the local wallet owns the receive script. ## Invariants +- **Expiry is persisted and non-terminal.** `LiveState` + `ExpiryStatusExpired` + transitions to `ExpiredState` and emits `VTXOStatusUpdate{Expired}`. It does + NOT emit `VTXOTerminatedNotification`: the value is still recoverable by + forfeiting the VTXO in an ordinary round, so the actor stays alive to hold + the descriptor and signing material forfeit needs. `ExpiredState` accepts + `PendingForfeitEvent`/`ForfeitRequestEvent` (the reclaim path, which reuses + `LiveState.handleForfeitRequest` verbatim so the two cannot drift), refuses + `SpendReserveEvent` (nothing left to spend cooperatively until reissued), + and refuses `ForceUnrollEvent` — an exit started past expiry must confirm + the whole ancestry and then wait out the exit CSV while racing an + already-spendable operator sweep, so it burns fees on an exit that cannot + land. +- **Expired VTXOs are recovered but not spendable.** `ListLiveVTXOs` excludes + them (it feeds spendable balance and refresh estimation); + `ListRecoverableVTXOs` includes them and is what `Manager.Start` recovers + actors from. Coin selection is already `VTXOStatusLive`-scoped via + `ListSelectionCandidatesByStatus`, so it excludes them by construction. - VTXO actor state is the single source of truth for availability. - Forfeit transaction is not broadcast until the connector output's round confirms (atomic replacement). - Refresh is auto-triggered at configurable height before expiry. diff --git a/vtxo/actor.go b/vtxo/actor.go index 88d72fa90..29f58f16b 100644 --- a/vtxo/actor.go +++ b/vtxo/actor.go @@ -327,6 +327,16 @@ func (a *VTXOActor) tellManager(ctx context.Context, msg ManagerMsg) { func (a *VTXOActor) quoteRefreshFee(ctx context.Context, vtxo *Descriptor, lastCheckedHeight int32) btcutil.Amount { + // Expired recovery is a pure one-for-one ordinary refresh whose + // authoritative seal-time fee is waived by the operator. Avoid an + // advisory EstimateFee call with remaining_blocks=0: that value means + // "use the full lifetime" to the generic estimator and would report a + // fee the round will not charge. + if HasUsableBatchExpiry(vtxo) && + lastCheckedHeight >= vtxo.BatchExpiry { + return 0 + } + if a.cfg.RefreshFeeQuoter == nil { return 0 } @@ -535,10 +545,16 @@ func (a *VTXOActor) processOutboxWithOperatorKey(ctx context.Context, ), ) + rollbackStatus := VTXOStatusLive + _, expired := a.state.(*ExpiredState) + if expired { + rollbackStatus = VTXOStatusExpired + } + rollbackErr := a.processStatusUpdate( ctx, &VTXOStatusUpdate{ Outpoint: vtxo.Outpoint, - NewStatus: VTXOStatusLive, + NewStatus: rollbackStatus, }, ) if rollbackErr != nil { @@ -796,6 +812,14 @@ func statusToState(ctx context.Context, vtxo *Descriptor, store VTXOStore, LastCheckedHeight: vtxo.CreatedHeight, } + case VTXOStatusExpired: + // Non-terminal: the actor is restored so the VTXO can still be + // forfeited into a round to recover its value. + return &ExpiredState{ + VTXO: vtxo, + ObservedHeight: vtxo.CreatedHeight, + } + case VTXOStatusPendingForfeit: return &PendingForfeitState{VTXO: vtxo, RequestedAtHeight: 0} diff --git a/vtxo/actor_test.go b/vtxo/actor_test.go index 1850c3703..7d4f768a8 100644 --- a/vtxo/actor_test.go +++ b/vtxo/actor_test.go @@ -461,6 +461,97 @@ func TestProcessOutboxForfeitRequestQuotesFee(t *testing.T) { ) } +// TestExpiredCatchupWaitsForStartupReconcile verifies the actor persists local +// expiry without immediately entering a refresh round. The manager rechecks +// the actor after the round service is registered. +func TestExpiredCatchupWaitsForStartupReconcile(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + desc := h.newTestDescriptor() + height := desc.BatchExpiry + + h.store.On( + "UpdateVTXOStatus", h.ctx, desc.Outpoint, + VTXOStatusExpired, + ).Return(nil).Once() + manager := newMockManagerRef(t) + actorUnderTest := &VTXOActor{ + cfg: &VTXOActorConfig{ + VTXO: desc, + Store: h.store, + Wallet: h.wallet, + ChainParams: &chaincfg.RegressionNetParams, + Manager: manager, + RefreshFeeQuoter: func(context.Context, btcutil.Amount, + uint32) btcutil.Amount { + + t.Fatal( + "expired recovery must not request " + + "a paid quote", + ) + + return 0 + }, + }, + state: &LiveState{ + VTXO: desc, + }, + env: h.env, + } + + epoch := h.newBlockEpochEvent(height) + result := actorUnderTest.Receive(h.ctx, epoch) + _, err := result.Unpack() + require.NoError(t, err) + require.IsType(t, &ExpiredState{}, actorUnderTest.state) + require.Empty(t, manager.getMessages()) + + h.store.AssertExpectations(t) +} + +// TestExpiredRefreshPreflightFailureStaysExpired verifies an unavailable +// operator rolls the durable reservation back to Expired rather than +// accidentally restoring swept value to the spendable set. +func TestExpiredRefreshPreflightFailureStaysExpired(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + desc := h.newTestDescriptor() + height := desc.BatchExpiry + fetchErr := errors.New("operator unavailable") + + h.store.On( + "UpdateVTXOStatus", h.ctx, desc.Outpoint, + VTXOStatusPendingForfeit, + ).Return(nil).Once() + h.store.On( + "UpdateVTXOStatus", h.ctx, desc.Outpoint, + VTXOStatusExpired, + ).Return(nil).Once() + + manager := newMockManagerRef(t) + actorUnderTest := newRefreshTestActor( + h, desc, manager, + func(context.Context) (*btcec.PublicKey, error) { + return nil, fetchErr + }, + ) + actorUnderTest.state = &ExpiredState{ + VTXO: desc, + ObservedHeight: height, + } + + result := actorUnderTest.Receive( + h.ctx, h.newBlockEpochEvent(height), + ) + _, err := result.Unpack() + require.ErrorIs(t, err, fetchErr) + require.IsType(t, &ExpiredState{}, actorUnderTest.state) + require.Empty(t, manager.getMessages()) + h.store.AssertExpectations(t) +} + // TestAutoRefreshTermsLookupFailureRetries verifies a transient GetInfo // failure leaves the VTXO live and retries on the next block without writing a // PendingForfeit reservation. diff --git a/vtxo/expiry_validation_test.go b/vtxo/expiry_validation_test.go index 0ea1095fe..02188ac1a 100644 --- a/vtxo/expiry_validation_test.go +++ b/vtxo/expiry_validation_test.go @@ -1,6 +1,7 @@ package vtxo import ( + "fmt" "testing" "github.com/stretchr/testify/require" @@ -250,3 +251,433 @@ func TestLiveStateBlockEpochUnusableExpiry(t *testing.T) { "an untrustworthy expiry must not drive any state change", ) } + +// TestLiveStateBlockEpochExpiredPersists asserts that reaching batch expiry +// persists the new status and lands in the non-terminal ExpiredState. +// +// The old behaviour returned a terminal FailedState with no outbox at all, so +// the row stayed Live: the VTXO kept counting toward spendable balance, kept +// being offered to coin selection, and was recovered as Live on every restart +// only to re-fail on the next block. +func TestLiveStateBlockEpochExpiredPersists(t *testing.T) { + t.Parallel() + + const expiryHeight = int32(1_000) + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + vtxo.BatchExpiry = expiryHeight + vtxo.CreatedHeight = 100 + + h.withState(&LiveState{ + VTXO: vtxo, + LastCheckedHeight: 900, + }) + + _, err := h.sendEvent(h.newBlockEpochEvent(expiryHeight)) + require.NoError(t, err) + + state := assertState[*ExpiredState](h) + require.Equal(t, expiryHeight, state.ObservedHeight) + + // Expiry must be durable, and the actor must not be reaped: the value + // is still recoverable by forfeiting this VTXO in an ordinary round. + require.False( + t, state.IsTerminal(), + "expired VTXOs stay recoverable, so the actor must live on", + ) + + var sawStatusUpdate bool + for _, msg := range h.outboxMessages { + switch typed := msg.(type) { + case *VTXOStatusUpdate: + require.Equal(t, VTXOStatusExpired, typed.NewStatus) + sawStatusUpdate = true + + case *VTXOTerminatedNotification: + t.Fatal("expired VTXO must not be reaped") + } + } + require.True( + t, sawStatusUpdate, + "expiry must be persisted, not held in memory only", + ) +} + +// TestExpiredStateRefusesUnilateralExit asserts that an expired VTXO declines +// to start an exit. Completing one means confirming the whole ancestry and +// then waiting out the exit CSV while racing an operator whose sweep is +// already spendable, so it burns fees on an exit that cannot land. +func TestExpiredStateRefusesUnilateralExit(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + vtxo.BatchExpiry = 1_000 + + h.withState(&ExpiredState{VTXO: vtxo, ObservedHeight: 1_000}) + + _, err := h.sendEvent(&ForceUnrollEvent{Reason: "manual"}) + require.NoError(t, err) + + assertState[*ExpiredState](h) + require.Empty( + t, h.outboxMessages, + "an expired VTXO must not hand itself to the chain resolver", + ) +} + +// TestExpiredStateAcceptsForfeitForReclaim asserts that an expired VTXO can +// still be committed to a round, which is the whole recovery path: a reclaim +// is an ordinary refresh whose input happens to be expired. +func TestExpiredStateAcceptsForfeitForReclaim(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + vtxo.BatchExpiry = 1_000 + + h.withState(&ExpiredState{VTXO: vtxo, ObservedHeight: 1_000}) + + _, err := h.sendEvent(&PendingForfeitEvent{}) + require.NoError(t, err) + + assertState[*PendingForfeitState](h) + + var sawStatusUpdate bool + for _, msg := range h.outboxMessages { + if typed, ok := msg.(*VTXOStatusUpdate); ok { + require.Equal( + t, VTXOStatusPendingForfeit, typed.NewStatus, + ) + sawStatusUpdate = true + } + } + require.True(t, sawStatusUpdate) +} + +// TestExpiredStateBlockEpochQueuesRefresh asserts that an expired VTXO +// automatically enters the ordinary refresh flow as soon as the wallet has a +// synchronized chain height. No operator sweep observation or redeemability +// RPC is involved. +func TestExpiredStateBlockEpochQueuesRefresh(t *testing.T) { + t.Parallel() + + const currentHeight = int32(1_005) + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + vtxo.BatchExpiry = 1_000 + + h.withState(&ExpiredState{VTXO: vtxo, ObservedHeight: 1_000}) + + _, err := h.sendEvent(h.newBlockEpochEvent(currentHeight)) + require.NoError(t, err) + + state := assertState[*PendingForfeitState](h) + require.Equal(t, currentHeight, state.RequestedAtHeight) + requireStatusUpdate(t, h, VTXOStatusPendingForfeit) + + var refresh *ForfeitRequest + for _, msg := range h.outboxMessages { + request, ok := msg.(*ForfeitRequest) + if ok { + refresh = request + } + } + require.NotNil(t, refresh) + require.Equal(t, vtxo.Outpoint, refresh.VTXOOutpoint) + require.Equal(t, currentHeight, refresh.LastCheckedHeight) +} + +// TestExpiredStateRefusesOrdinarySpend asserts an expired VTXO cannot be +// claimed for an out-of-round spend. There is nothing left to spend +// cooperatively until it has been reissued. +func TestExpiredStateRefusesOrdinarySpend(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + vtxo.BatchExpiry = 1_000 + + h.withState(&ExpiredState{VTXO: vtxo, ObservedHeight: 1_000}) + + _, err := h.sendEvent(&SpendReserveEvent{}) + require.NoError(t, err) + + assertState[*ExpiredState](h) + require.Empty(t, h.outboxMessages) +} + +// TestForfeitReleaseRestoresExpired asserts that releasing a reclaim's forfeit +// reservation rolls the VTXO back to Expired rather than Live. +// +// Restoring Live would put value the operator may already have swept back into +// spendable balance and coin selection. A payment funded from it would build +// on a dead lineage and the recipient would receive nothing. The +// in_flight-preserving sweep query only protects the success path; this covers +// the failure path. +func TestForfeitReleaseRestoresExpired(t *testing.T) { + t.Parallel() + + const ( + batchExpiry = int32(1_000) + pastExpiry = batchExpiry + 5 + ) + + t.Run("pending forfeit release", func(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + vtxo.BatchExpiry = batchExpiry + + h.withState(&PendingForfeitState{ + VTXO: vtxo, + RequestedAtHeight: pastExpiry, + }) + + _, err := h.sendEvent(&ForfeitReleasedEvent{}) + require.NoError(t, err) + + assertState[*ExpiredState](h) + requireStatusUpdate(t, h, VTXOStatusExpired) + }) + + t.Run("forfeiting release", func(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + vtxo.BatchExpiry = batchExpiry + + h.withState(&ForfeitingState{ + VTXO: vtxo, + LastCheckedHeight: pastExpiry, + }) + + _, err := h.sendEvent(&ForfeitReleasedEvent{}) + require.NoError(t, err) + + assertState[*ExpiredState](h) + requireStatusUpdate(t, h, VTXOStatusExpired) + }) + + t.Run("ordinary refresh still returns to live", func(t *testing.T) { + t.Parallel() + + // The control: a released refresh of a VTXO that is nowhere + // near expiry must not be quarantined. + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + vtxo.BatchExpiry = batchExpiry + + h.withState(&PendingForfeitState{ + VTXO: vtxo, + RequestedAtHeight: 100, + }) + + _, err := h.sendEvent(&ForfeitReleasedEvent{}) + require.NoError(t, err) + + assertState[*LiveState](h) + requireStatusUpdate(t, h, VTXOStatusLive) + }) + + t.Run("unknown height returns to live", func(t *testing.T) { + t.Parallel() + + // Without a height there is no evidence of expiry, so the + // VTXO must not be quarantined. This is the restart path, + // where the first block epoch reclassifies almost immediately. + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + vtxo.BatchExpiry = batchExpiry + + h.withState(&PendingForfeitState{ + VTXO: vtxo, + RequestedAtHeight: 0, + }) + + _, err := h.sendEvent(&ForfeitReleasedEvent{}) + require.NoError(t, err) + + assertState[*LiveState](h) + }) +} + +// TestExpiredStateReturnsToLiveWhenNotExpired asserts ExpiredState is +// self-correcting. A VTXO can reach it without the chain having been +// consulted, so a block epoch proving the deadline has not passed must return +// it to the spendable set rather than stranding it. +func TestExpiredStateReturnsToLiveWhenNotExpired(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + vtxo.BatchExpiry = 100_000 + vtxo.CreatedHeight = 100 + + h.withState(&ExpiredState{VTXO: vtxo, ObservedHeight: 0}) + + _, err := h.sendEvent(h.newBlockEpochEvent(200)) + require.NoError(t, err) + + assertState[*LiveState](h) + requireStatusUpdate(t, h, VTXOStatusLive) +} + +// requireStatusUpdate asserts the outbox persisted exactly the given status. +func requireStatusUpdate(t *testing.T, h *vtxoTestHarness, want VTXOStatus) { + t.Helper() + + for _, msg := range h.outboxMessages { + if update, ok := msg.(*VTXOStatusUpdate); ok { + require.Equal(t, want, update.NewStatus) + + return + } + } + + t.Fatalf("no VTXOStatusUpdate emitted, wanted %v", want) +} + +// TestExpiredStateRejectsUnexpectedEvent asserts that a genuinely unexpected +// event surfaces as an error rather than being silently absorbed. A blanket +// default would let a real routing or lifecycle bug sit invisible for the +// whole life of an expired VTXO, which is long. +func TestExpiredStateRejectsUnexpectedEvent(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + vtxo.BatchExpiry = 1_000 + + h.withState(&ExpiredState{VTXO: vtxo, ObservedHeight: 1_000}) + + _, err := h.sendEvent(&ForfeitConfirmedEvent{}) + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected event") + + // The actor must survive the error rather than transitioning. + assertState[*ExpiredState](h) +} + +// TestExpiredStateAbsorbsStaleEvents asserts the enumerated stale events are +// still absorbed. They can legitimately arrive long after expiry from a path +// that ran before it. +func TestExpiredStateAbsorbsStaleEvents(t *testing.T) { + t.Parallel() + + events := []VTXOEvent{ + &SpendReleasedEvent{}, + &SpendCompletedEvent{}, + &ForfeitReleasedEvent{}, + &ExitFailedEvent{}, + &ExitConfirmedEvent{}, + } + + for _, event := range events { + t.Run(fmt.Sprintf("%T", event), func(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + vtxo.BatchExpiry = 1_000 + + h.withState(&ExpiredState{ + VTXO: vtxo, + ObservedHeight: 1_000, + }) + + _, err := h.sendEvent(event) + require.NoError(t, err) + assertState[*ExpiredState](h) + }) + } +} + +// TestSpendingStateExpiredTerminates asserts that an outgoing spend whose +// batch expires mid-flight still terminates, and terminates correctly. +// +// SpendingState no longer escalates to unilateral exit on expiry, which is a +// broader change than reclaim: it affects every OOR send. The justification is +// that an exit started past the deadline cannot complete, so escalating would +// abandon a spend that may still settle. That is only sound if the spend has +// a terminating path either way, which is what this covers. +func TestSpendingStateExpiredTerminates(t *testing.T) { + t.Parallel() + + const ( + batchExpiry = int32(1_000) + pastExpiry = batchExpiry + 5 + ) + + newSpending := func(t *testing.T) *vtxoTestHarness { + t.Helper() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + vtxo.BatchExpiry = batchExpiry + + h.withState(&SpendingState{ + VTXO: vtxo, + LastCheckedHeight: pastExpiry, + }) + + return h + } + + t.Run("block epoch does not escalate", func(t *testing.T) { + t.Parallel() + + h := newSpending(t) + + _, err := h.sendEvent(h.newBlockEpochEvent(pastExpiry)) + require.NoError(t, err) + + // The in-flight OOR must be left alone to settle. + assertState[*SpendingState](h) + require.Empty(t, h.outboxMessages) + }) + + t.Run("completion still retires the VTXO", func(t *testing.T) { + t.Parallel() + + h := newSpending(t) + + _, err := h.sendEvent(&SpendCompletedEvent{}) + require.NoError(t, err) + + assertState[*SpentState](h) + }) + + t.Run("release returns to expired not live", func(t *testing.T) { + t.Parallel() + + h := newSpending(t) + + _, err := h.sendEvent(&SpendReleasedEvent{}) + require.NoError(t, err) + + // Releasing to Live would put value the operator may already + // have swept back into coin selection. + assertState[*ExpiredState](h) + + var released bool + for _, msg := range h.outboxMessages { + update, ok := msg.(*VTXOStatusUpdate) + if !ok { + continue + } + + require.Equal(t, VTXOStatusExpired, update.NewStatus) + + // The spend reservation must still be cleared, or the + // durable index would keep a stale row. + require.True(t, update.ReleaseSpendReservation) + released = true + } + require.True(t, released, "expected a status update") + }) +} diff --git a/vtxo/filter.go b/vtxo/filter.go index b55d2829e..455c00cf6 100644 --- a/vtxo/filter.go +++ b/vtxo/filter.go @@ -86,7 +86,8 @@ func SumPendingBalance(descs []*Descriptor) btcutil.Amount { // Spendable, terminal, or separately accounted. case VTXOStatusLive, VTXOStatusForfeited, VTXOStatusSpent, - VTXOStatusUnilateralExit, VTXOStatusFailed: + VTXOStatusUnilateralExit, VTXOStatusFailed, + VTXOStatusExpired: } } diff --git a/vtxo/filter_test.go b/vtxo/filter_test.go index e3c5043ed..0645e177c 100644 --- a/vtxo/filter_test.go +++ b/vtxo/filter_test.go @@ -81,7 +81,7 @@ func TestSumSpendableBalanceEmpty(t *testing.T) { } // TestSumPendingBalance checks that only PendingForfeit, Forfeiting, and -// Spending are summed, excluding Live and the terminal states. +// Spending are summed, excluding Live, Expired, and the terminal states. func TestSumPendingBalance(t *testing.T) { t.Parallel() @@ -118,6 +118,10 @@ func TestSumPendingBalance(t *testing.T) { Amount: 19_000, Status: VTXOStatusFailed, }, + { + Amount: 23_000, + Status: VTXOStatusExpired, + }, } require.Equal( diff --git a/vtxo/harness_test.go b/vtxo/harness_test.go index b0d869904..c5db783e9 100644 --- a/vtxo/harness_test.go +++ b/vtxo/harness_test.go @@ -61,6 +61,14 @@ func (m *MockVTXOStore) ListLiveVTXOs(ctx context.Context) ([]*Descriptor, return vtxos, args.Error(1) } +// ListRecoverableVTXOs shares ListLiveVTXOs' expectation so existing tests +// that only stub the live set keep driving actor recovery unchanged. +func (m *MockVTXOStore) ListRecoverableVTXOs(ctx context.Context) ( + []*Descriptor, error) { + + return m.ListLiveVTXOs(ctx) +} + //nolint:forcetypeassert func (m *MockVTXOStore) ListVTXOsByStatus(ctx context.Context, status VTXOStatus) ([]*Descriptor, error) { diff --git a/vtxo/interfaces.go b/vtxo/interfaces.go index d7e420be9..8ce32aa4e 100644 --- a/vtxo/interfaces.go +++ b/vtxo/interfaces.go @@ -291,6 +291,26 @@ const ( // NOTE: Placed after VTXOStatusFailed to preserve the numeric // values of existing statuses used in SQL queries. VTXOStatusSpending + + // VTXOStatusExpired indicates the VTXO's batch expiry has passed. The + // wallet missed both its refresh and its critical-exit window, so the + // operator's sweep path is mature and no client-driven exit can be + // completed any more. + // + // This is NOT terminal. The value is recoverable: the owner can join + // an ordinary round and forfeit the expired VTXO, and the operator + // admits it because the forfeit signature proves ownership and gives + // the operator the same protection it relies on for a refresh. The + // actor therefore stays alive and keeps the descriptor and signing + // material an eventual forfeit needs. + // + // The VTXO must not be selectable for ordinary spends while in this + // state — there is nothing left to spend cooperatively until it has + // been reissued. + // + // NOTE: Appended last so the numeric values of existing statuses used + // in SQL queries are unchanged. + VTXOStatusExpired ) // String returns a human-readable representation of the VTXO status. @@ -320,6 +340,9 @@ func (s VTXOStatus) String() string { case VTXOStatusSpending: return "spending" + case VTXOStatusExpired: + return "expired" + default: return "unknown" } @@ -500,10 +523,18 @@ type VTXOStore interface { GetVTXO(ctx context.Context, outpoint wire.OutPoint) (*Descriptor, error) - // ListLiveVTXOs returns all VTXOs not in a terminal state. Used during - // startup to recover active VTXO actors after restart. + // ListLiveVTXOs returns all VTXOs not in a terminal state, excluding + // expired ones. This is the spendable-liquidity view: an expired VTXO + // holds no spendable value until it has been reissued. ListLiveVTXOs(ctx context.Context) ([]*Descriptor, error) + // ListRecoverableVTXOs returns every VTXO whose actor must be restored + // at startup: the ListLiveVTXOs set plus expired ones. An expired VTXO + // is not spendable, but its value is recoverable by forfeiting it in + // an ordinary round, so its actor must exist to hold the descriptor + // and signing material that forfeit needs. + ListRecoverableVTXOs(ctx context.Context) ([]*Descriptor, error) + // ListVTXOsByStatus returns all VTXOs matching the given status. // This enables querying terminal states (spent, forfeited) that // ListLiveVTXOs excludes. diff --git a/vtxo/manager.go b/vtxo/manager.go index 1e76b4ad3..1edb21f91 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -437,9 +437,12 @@ func (m *Manager) Start(ctx context.Context, m.managerRef = selfRef - vtxos, err := m.cfg.Store.ListLiveVTXOs(ctx) + // Recover actors for expired VTXOs too, not just spendable ones: an + // expired VTXO's value is still reclaimable by forfeiting it in a + // round, and the actor is what holds the material that forfeit needs. + vtxos, err := m.cfg.Store.ListRecoverableVTXOs(ctx) if err != nil { - return fmt.Errorf("list live vtxos: %w", err) + return fmt.Errorf("list recoverable vtxos: %w", err) } for _, vtxo := range vtxos { @@ -620,6 +623,9 @@ func (m *Manager) Receive(ctx context.Context, Descriptors: descs, }) + case *ReconcileExpiryRequest: + return m.handleReconcileExpiry(ctx) + case *ForceUnrollRequest: return m.handleForceUnroll(ctx, req) @@ -633,6 +639,101 @@ func (m *Manager) Receive(ctx context.Context, } } +// handleReconcileExpiry applies the current chain tip to every recovered VTXO +// after the round actor has registered. A Live VTXO needs one event to persist +// Expired and a second to enter the ordinary refresh flow; a VTXO recovered in +// Expired needs only the latter. Querying the tip explicitly also covers chain +// backends whose subscription starts after the current epoch and therefore +// emits nothing until the next block. +func (m *Manager) handleReconcileExpiry( + ctx context.Context) fn.Result[ManagerResp] { + + // A recovered Expired actor may have received a subscription epoch + // while the round actor was still starting. Release that startup + // reservation before driving the now-ready refresh path. + m.releaseOrphanedForfeits(ctx) + + result := m.cfg.ChainSource.Ask( + ctx, &chainsource.BestHeightRequest{}, + ).Await(ctx) + response, err := result.Unpack() + if err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("get best height for expiry reconcile: %w", + err), + ) + } + + tip, ok := response.(*chainsource.BestHeightResponse) + if !ok { + return fn.Err[ManagerResp]( + fmt.Errorf("unexpected best height response: %T", + response), + ) + } + + epoch := &BlockEpochEvent{ + Height: tip.Height, + Hash: tip.Hash, + } + + var checked int + for outpoint, ref := range m.actors { + actorResult := m.askForfeitVTXOActor(ctx, ref, epoch) + actorResponse, err := actorResult.Unpack() + if err != nil { + m.logger(ctx).WarnS( + ctx, + "Startup VTXO expiry check failed", + err, + slog.String("outpoint", outpoint.String()), + ) + + continue + } + + checked++ + transition, ok := actorResponse.(VTXOActorResponse) + if !ok { + m.logger(ctx).WarnS( + ctx, "Unexpected startup VTXO response", nil, + slog.String("outpoint", outpoint.String()), + slog.String( + "response_type", fmt.Sprintf("%T", + actorResponse), + ), + ) + + continue + } + + _, becameExpired := transition.NewState.(*ExpiredState) + _, wasExpired := transition.PriorState.(*ExpiredState) + if !becameExpired || wasExpired { + continue + } + + _, err = m.askForfeitVTXOActor(ctx, ref, epoch).Unpack() + if err != nil { + m.logger(ctx).WarnS( + ctx, + "Startup expired VTXO refresh failed", + err, + slog.String("outpoint", outpoint.String()), + ) + } + } + + m.logger(ctx).InfoS(ctx, "Startup VTXO expiry check complete", + slog.Int("checked", checked), + slog.Int("height", int(tip.Height)), + ) + + return fn.Ok[ManagerResp](&ReconcileExpiryResponse{ + Checked: checked, + }) +} + // handleVTXOCreated spawns a new VTXO actor for each created VTXO. func (m *Manager) handleVTXOCreated(ctx context.Context, msg *round.VTXOCreatedNotification) fn.Result[ManagerResp] { @@ -808,6 +909,25 @@ func (m *Manager) handleForceUnroll(ctx context.Context, }) } + // An expired VTXO also refuses the unroll, but it is not terminal, so + // the check above does not catch it and the caller would be told the + // exit was accepted when nothing was scheduled. Past the deadline an + // exit cannot complete: it would have to confirm the whole ancestry + // and then wait out the exit CSV while racing an operator sweep that + // is already spendable. Recovery goes through a cooperative forfeit + // instead, so say so. + if _, expired := actorResp.NewState.(*ExpiredState); expired { + m.logger(ctx).InfoS(ctx, "Force-unroll refused on expired VTXO", + slog.String("outpoint", req.Outpoint.String()), + ) + + return fn.Ok[ManagerResp](&ForceUnrollResponse{ + Accepted: false, + Reason: "batch expired; recover by refreshing this " + + "VTXO into a round", + }) + } + m.logger(ctx).InfoS(ctx, "Force-unroll accepted by VTXO actor", slog.String("outpoint", req.Outpoint.String()), slog.String("reason", reason), diff --git a/vtxo/manager_admission_test.go b/vtxo/manager_admission_test.go index 188dccb08..800e14179 100644 --- a/vtxo/manager_admission_test.go +++ b/vtxo/manager_admission_test.go @@ -72,6 +72,7 @@ func (m *mockVTXOActorRef) Ask(ctx context.Context, return promise.Future() } + priorState := m.state transition, err := m.state.ProcessEvent(ctx, vtxoEvent, m.env) if err != nil { promise.Complete(fn.Err[actormsg.VTXOActorResp](err)) @@ -87,7 +88,8 @@ func (m *mockVTXOActorRef) Ask(ctx context.Context, promise.Complete( fn.Ok[actormsg.VTXOActorResp]( VTXOActorResponse{ - NewState: m.state, + PriorState: priorState, + NewState: m.state, }, ), ) @@ -2357,3 +2359,47 @@ func TestSelectAndReserveForfeitPartialRollback(t *testing.T) { actorRef.state, ) } + +// TestReserveForfeitAdmitsExpiredVTXO verifies that an expired VTXO can be +// reserved for cooperative consumption. This is the whole client-side +// recovery path: an offline wallet that missed its refresh window gets its +// value back by forfeiting the expired VTXO in an ordinary round, so the +// admission gate must not treat expiry as a dead end. +func TestReserveForfeitAdmitsExpiredVTXO(t *testing.T) { + t.Parallel() + + expired := makeDescriptor(t, 50_000, 0) + expired.Status = VTXOStatusExpired + + store := &MockVTXOStore{} + mgr := &Manager{ + cfg: &ManagerConfig{ + Store: store, + RoundActor: newMockRoundActorRef(t), + }, + actors: make(map[wire.OutPoint]VTXOActorRef), + } + + // The actor is restored in ExpiredState, which is what + // ListRecoverableVTXOs makes possible after a restart. + ref := newMockVTXOActorRef( + expired.Outpoint.String(), + &ExpiredState{ + VTXO: expired, + ObservedHeight: expired.CreatedHeight, + }, + ) + mgr.actors[expired.Outpoint] = ref + + result := mgr.Receive(t.Context(), &ReserveForfeitRequest{ + Outpoints: []wire.OutPoint{expired.Outpoint}, + }) + _, err := result.Unpack() + require.NoError(t, err, "an expired VTXO must still be reclaimable") + + _, ok := ref.state.(*PendingForfeitState) + require.True( + t, ok, "reserving an expired VTXO must commit it to the "+ + "round, got %T", ref.state, + ) +} diff --git a/vtxo/manager_expiry_reconcile_test.go b/vtxo/manager_expiry_reconcile_test.go new file mode 100644 index 000000000..f425331c2 --- /dev/null +++ b/vtxo/manager_expiry_reconcile_test.go @@ -0,0 +1,139 @@ +package vtxo + +import ( + "context" + "errors" + "testing" + + "github.com/btcsuite/btcd/wire/v2" + "github.com/lightninglabs/wavelength/baselib/actor" + "github.com/lightninglabs/wavelength/chainsource" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/stretchr/testify/require" +) + +// bestHeightRef returns a fixed chain tip without publishing a subscription +// epoch. It models backends where startup must explicitly query the already +// synchronized height instead of waiting for the next block. +type bestHeightRef struct { + height int32 +} + +// ID returns the test actor identifier. +func (b *bestHeightRef) ID() string { + return "best-height" +} + +// Tell implements the chain source actor reference. +func (b *bestHeightRef) Tell(_ context.Context, + _ chainsource.ChainSourceMsg) error { + + return nil +} + +// Ask returns the configured best height. +func (b *bestHeightRef) Ask(_ context.Context, + msg chainsource.ChainSourceMsg, +) actor.Future[chainsource.ChainSourceResp] { + + promise := actor.NewPromise[chainsource.ChainSourceResp]() + if _, ok := msg.(*chainsource.BestHeightRequest); !ok { + promise.Complete( + fn.Err[chainsource.ChainSourceResp]( + errors.New("unexpected chain source message"), + ), + ) + + return promise.Future() + } + + promise.Complete( + fn.Ok[chainsource.ChainSourceResp]( + &chainsource.BestHeightResponse{ + Height: b.height, + }, + ), + ) + + return promise.Future() +} + +// TestReconcileExpiryUsesCurrentTip verifies startup does not depend on a new +// block notification. A locally Live but already-expired VTXO is classified +// and then driven into the ordinary refresh reservation, while a safe VTXO is +// left untouched. +func TestReconcileExpiryUsesCurrentTip(t *testing.T) { + t.Parallel() + + expired := makeDescriptor(t, 50_000, 0) + safe := makeDescriptor(t, 40_000, 1) + tip := expired.BatchExpiry + safe.BatchExpiry = tip + 1_000 + + store := &MockVTXOStore{} + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusPendingForfeit, + ).Return([]*Descriptor{}, nil) + + mgr := NewManager(&ManagerConfig{ + Store: store, + ChainSource: &bestHeightRef{height: tip}, + }) + mgr.actors = map[wire.OutPoint]VTXOActorRef{ + expired.Outpoint: newMockVTXOActorRef( + expired.Outpoint.String(), &LiveState{ + VTXO: expired, + }, + ), + safe.Outpoint: newMockVTXOActorRef( + safe.Outpoint.String(), &LiveState{ + VTXO: safe, + }, + ), + } + + response, err := mgr.handleReconcileExpiry(t.Context()).Unpack() + require.NoError(t, err) + reconcileResponse, ok := response.(*ReconcileExpiryResponse) + require.True(t, ok) + require.Equal(t, 2, reconcileResponse.Checked) + require.IsType( + t, &PendingForfeitState{}, actorState(t, mgr, expired.Outpoint), + ) + require.IsType(t, &LiveState{}, actorState(t, mgr, safe.Outpoint)) + store.AssertExpectations(t) +} + +// TestReconcileExpiryContinuesRecoveredExpired verifies a VTXO already +// persisted as Expired needs only one application of the startup tip to enter +// the ordinary refresh flow. +func TestReconcileExpiryContinuesRecoveredExpired(t *testing.T) { + t.Parallel() + + expired := makeDescriptor(t, 50_000, 0) + tip := expired.BatchExpiry + + store := &MockVTXOStore{} + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusPendingForfeit, + ).Return([]*Descriptor{}, nil) + + mgr := NewManager(&ManagerConfig{ + Store: store, + ChainSource: &bestHeightRef{height: tip}, + }) + mgr.actors = map[wire.OutPoint]VTXOActorRef{ + expired.Outpoint: newMockVTXOActorRef( + expired.Outpoint.String(), &ExpiredState{ + VTXO: expired, + }, + ), + } + + _, err := mgr.handleReconcileExpiry(t.Context()).Unpack() + require.NoError(t, err) + require.IsType( + t, &PendingForfeitState{}, actorState(t, mgr, expired.Outpoint), + ) + store.AssertExpectations(t) +} diff --git a/vtxo/messages.go b/vtxo/messages.go index 984353c1f..ec257335e 100644 --- a/vtxo/messages.go +++ b/vtxo/messages.go @@ -142,6 +142,31 @@ type ListLiveDescriptorsResponse struct { // VTXOManagerResp implements actormsg.VTXOManagerResp marker interface. func (r *ListLiveDescriptorsResponse) VTXOManagerResp() {} +// ReconcileExpiryRequest asks the manager to apply the current chain tip to +// every recovered VTXO. The server sends this once the round actor is ready so +// an offline VTXO can enter the ordinary refresh flow without racing actor +// registration during startup. +type ReconcileExpiryRequest struct { + actor.BaseMessage +} + +// MessageType returns the message type identifier. +func (r *ReconcileExpiryRequest) MessageType() string { + return "ReconcileExpiryRequest" +} + +// VTXOManagerMsg implements actormsg.VTXOManagerMsg marker interface. +func (r *ReconcileExpiryRequest) VTXOManagerMsg() {} + +// ReconcileExpiryResponse reports how many recovered VTXOs were checked. +type ReconcileExpiryResponse struct { + // Checked is the number of VTXO actors that accepted the current tip. + Checked int +} + +// VTXOManagerResp implements actormsg.VTXOManagerResp marker interface. +func (r *ReconcileExpiryResponse) VTXOManagerResp() {} + // ExitOutcome classifies the terminal outcome of a unilateral-exit (unroll) // job, as reported by the unroll subsystem back to the VTXO manager. type ExitOutcome uint8 diff --git a/vtxo/states.go b/vtxo/states.go index 381ae4e4b..511e50042 100644 --- a/vtxo/states.go +++ b/vtxo/states.go @@ -124,6 +124,13 @@ type ForfeitingState struct { // VTXO is the descriptor for this VTXO. VTXO *Descriptor + // LastCheckedHeight is the most recent chain height observed before + // entering this state, carried forward from PendingForfeitState. A + // release needs it to tell an ordinary refresh apart from a reclaim of + // an already-expired VTXO, which must roll back to Expired rather than + // re-entering the spendable set. + LastCheckedHeight int32 + // NewRoundID is the round where the refreshed VTXO will be created. NewRoundID string @@ -246,3 +253,41 @@ func (s *FailedState) IsTerminal() bool { } func (s *FailedState) vtxoStateSealed() {} + +// ExpiredState is the non-terminal state of a VTXO whose batch expiry has +// passed. The wallet missed both its refresh and its critical-exit window, so +// the operator's sweep path is mature and no client-driven exit can be +// completed any more. +// +// There is deliberately no post-expiry unilateral fallback. Completing an exit +// from here means confirming the whole ancestry and then waiting out the exit +// CSV while racing an operator whose sweep is already spendable. The +// critical-expiry threshold exists precisely so the wallet never has to depend +// on winning that race, and attempting it anyway only burns fees. +// +// The state is NOT terminal, because the value is still recoverable: the owner +// can join an ordinary round and forfeit this VTXO. The operator admits it +// because the forfeit signature both proves ownership and gives the operator +// the protection it relies on for any refresh. Keeping the actor alive is what +// preserves the descriptor and signing material that forfeit needs, and lets +// the wallet retry on a later block if the first attempt does not land. +type ExpiredState struct { + // VTXO is the descriptor for this VTXO. + VTXO *Descriptor + + // ObservedHeight is the chain height at which expiry was established. + ObservedHeight int32 +} + +// String returns a human-readable state name. +func (s *ExpiredState) String() string { + return "Expired" +} + +// IsTerminal returns false: the VTXO's value is still recoverable through a +// cooperative forfeit, so the actor must stay alive to carry it out. +func (s *ExpiredState) IsTerminal() bool { + return false +} + +func (s *ExpiredState) vtxoStateSealed() {} diff --git a/vtxo/transitions.go b/vtxo/transitions.go index 33f54b63e..f4a80a2dd 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -17,6 +17,64 @@ import ( fn "github.com/lightningnetwork/lnd/fn/v2" ) +// stateAfterForfeitRelease picks the state a VTXO returns to when a round +// releases its forfeit reservation before the point of no return. +// +// The naive answer, LiveState, is wrong for a reclaim. A reclaim commits an +// already-expired VTXO to a round; if that round fails, restoring the VTXO to +// live puts value the operator may already have swept back into spendable +// balance and coin selection. A payment funded from it would build on a dead +// lineage and the recipient would receive nothing. +// +// lastHeight is the most recent chain height the releasing state observed. +// Zero means "unknown" and resolves to live, which is the pre-existing +// behaviour: without a height there is no evidence of expiry, and wrongly +// quarantining an ordinary refresh would drop live value out of the wallet's +// balance. That case only arises on the restart path, where the first block +// epoch arrives almost immediately and reclassifies before anything can spend. +func stateAfterForfeitRelease(cfg *ExpiryConfig, vtxo *Descriptor, + lastHeight int32) VTXOState { + + expired := lastHeight > 0 && + cfg.CheckExpiry(vtxo, lastHeight) == ExpiryStatusExpired + + if !expired { + return &LiveState{ + VTXO: vtxo, + LastCheckedHeight: lastHeight, + } + } + + return &ExpiredState{ + VTXO: vtxo, + ObservedHeight: lastHeight, + } +} + +// forfeitReleaseTransition builds the release transition for the state +// returned by stateAfterForfeitRelease, persisting the matching status so the +// durable row agrees with the in-memory state across a restart. +func forfeitReleaseTransition(next VTXOState, + outpoint wire.OutPoint) *VTXOStateTransition { + + status := VTXOStatusLive + if _, expired := next.(*ExpiredState); expired { + status = VTXOStatusExpired + } + + return &VTXOStateTransition{ + NextState: next, + NewEvents: fn.Some(VTXOEmittedEvent{ + Outbox: []VTXOOutMsg{ + &VTXOStatusUpdate{ + Outpoint: outpoint, + NewStatus: status, + }, + }, + }), + } +} + // ProcessEvent handles events in LiveState. The VTXO monitors block epochs for // expiry and can receive forfeit requests from the round actor. func (s *LiveState) ProcessEvent(ctx context.Context, event VTXOEvent, @@ -248,15 +306,33 @@ func (s *LiveState) handleBlockEpoch(ctx context.Context, evt *BlockEpochEvent, }, nil case ExpiryStatusExpired: - // Batch has expired - this should not happen if monitoring - // works correctly. + // The batch expired while we were not watching — the wallet + // was offline through both the refresh and critical-exit + // windows. + // + // Persist the expiry rather than only recording it in memory. + // Without the status update the row stays Live, so the VTXO + // keeps counting toward spendable balance, keeps being offered + // to coin selection, and is recovered as Live on every restart + // only to re-fail on the next block. + // + // No VTXOTerminatedNotification: ExpiredState is not terminal. + // The value is still recoverable by forfeiting this VTXO in an + // ordinary round, and the actor has to stay alive to hold the + // descriptor and signing material that forfeit needs. return &VTXOStateTransition{ - NextState: &FailedState{ - VTXO: s.VTXO, - Reason: "batch expired before " + - "cooperative forfeit", - Recoverable: false, + NextState: &ExpiredState{ + VTXO: s.VTXO, + ObservedHeight: evt.Height, }, + NewEvents: fn.Some(VTXOEmittedEvent{ + Outbox: []VTXOOutMsg{ + &VTXOStatusUpdate{ + Outpoint: s.VTXO.Outpoint, + NewStatus: VTXOStatusExpired, + }, + }, + }), }, nil default: @@ -331,6 +407,7 @@ func (s *LiveState) handleForfeitRequest(ctx context.Context, return &VTXOStateTransition{ NextState: &ForfeitingState{ VTXO: s.VTXO, + LastCheckedHeight: s.LastCheckedHeight, NewRoundID: evt.RoundID, ConnectorOutpoint: evt.ConnectorOutpoint, ForfeitTxID: forfeitTxID, @@ -640,9 +717,13 @@ func (s *PendingForfeitState) ProcessEvent(ctx context.Context, event VTXOEvent, // forfeit details. expiryStatus := env.ExpiryConfig.CheckExpiry(s.VTXO, evt.Height) - if expiryStatus == ExpiryStatusCritical || - expiryStatus == ExpiryStatusExpired { - + // 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 @@ -772,6 +853,7 @@ func (s *PendingForfeitState) ProcessEvent(ctx context.Context, event VTXOEvent, return &VTXOStateTransition{ NextState: &ForfeitingState{ VTXO: s.VTXO, + LastCheckedHeight: s.RequestedAtHeight, NewRoundID: evt.RoundID, ConnectorOutpoint: evt.ConnectorOutpoint, ForfeitTxID: forfeitTxID, @@ -798,25 +880,18 @@ func (s *PendingForfeitState) ProcessEvent(ctx context.Context, event VTXOEvent, }, nil case *ForfeitReleasedEvent: - // Release this VTXO back to LiveState. This happens when - // cooperative round registration fails after admission. - // Restore RequestedAtHeight as LastCheckedHeight so expiry - // checking resumes from where it left off rather than - // re-evaluating from zero. - return &VTXOStateTransition{ - NextState: &LiveState{ - VTXO: s.VTXO, - LastCheckedHeight: s.RequestedAtHeight, - }, - NewEvents: fn.Some(VTXOEmittedEvent{ - Outbox: []VTXOOutMsg{ - &VTXOStatusUpdate{ - Outpoint: s.VTXO.Outpoint, - NewStatus: VTXOStatusLive, - }, - }, - }), - }, nil + // Release this VTXO. This happens when cooperative round + // registration fails after admission. RequestedAtHeight is the + // most recent height this state observed, so expiry checking + // resumes from where it left off rather than re-evaluating + // from zero — and a released reclaim returns to Expired rather + // than re-entering the spendable set. + return forfeitReleaseTransition( + stateAfterForfeitRelease( + env.ExpiryConfig, s.VTXO, s.RequestedAtHeight, + ), + s.VTXO.Outpoint, + ), nil case *SpendReserveEvent: // Cannot claim for OOR spend while pending forfeit. @@ -859,6 +934,7 @@ func (s *ForfeitingState) ProcessEvent(ctx context.Context, event VTXOEvent, return &VTXOStateTransition{ NextState: &ForfeitingState{ VTXO: s.VTXO, + LastCheckedHeight: s.LastCheckedHeight, NewRoundID: s.NewRoundID, ConnectorOutpoint: s.ConnectorOutpoint, ForfeitTxID: evt.ForfeitTxID, @@ -900,9 +976,13 @@ func (s *ForfeitingState) ProcessEvent(ctx context.Context, event VTXOEvent, // must escalate to chain resolver for unilateral exit. expiryStatus := env.ExpiryConfig.CheckExpiry(s.VTXO, evt.Height) - if expiryStatus == ExpiryStatusCritical || - expiryStatus == ExpiryStatusExpired { - + // 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 @@ -995,19 +1075,16 @@ func (s *ForfeitingState) ProcessEvent(ctx context.Context, event VTXOEvent, // tracks no block height, so LastCheckedHeight stays zero and // the next block epoch re-seeds expiry checking (mirroring the // ForceUnrollEvent recovery path above). - return &VTXOStateTransition{ - NextState: &LiveState{ - VTXO: s.VTXO, - }, - NewEvents: fn.Some(VTXOEmittedEvent{ - Outbox: []VTXOOutMsg{ - &VTXOStatusUpdate{ - Outpoint: s.VTXO.Outpoint, - NewStatus: VTXOStatusLive, - }, - }, - }), - }, nil + // + // LastCheckedHeight is carried in from PendingForfeitState so + // a released reclaim returns to Expired rather than + // re-entering the spendable set. + return forfeitReleaseTransition( + stateAfterForfeitRelease( + env.ExpiryConfig, s.VTXO, s.LastCheckedHeight, + ), + s.VTXO.Outpoint, + ), nil case *VTXOFailedEvent: return &VTXOStateTransition{ @@ -1057,18 +1134,27 @@ func (s *SpendingState) ProcessEvent(_ context.Context, event VTXOEvent, }, nil case *SpendReleasedEvent: - // OOR operation failed or was cancelled. Return to LiveState - // so the VTXO can be used again. + // OOR operation failed or was cancelled. Return the VTXO so it + // can be used again — but not to LiveState if its batch + // expired while the spend was in flight, since that would put + // value the operator may already have swept back into + // spendable balance and coin selection. + next := stateAfterForfeitRelease( + env.ExpiryConfig, s.VTXO, s.LastCheckedHeight, + ) + + status := VTXOStatusLive + if _, expired := next.(*ExpiredState); expired { + status = VTXOStatusExpired + } + return &VTXOStateTransition{ - NextState: &LiveState{ - VTXO: s.VTXO, - LastCheckedHeight: s.LastCheckedHeight, - }, + NextState: next, NewEvents: fn.Some(VTXOEmittedEvent{ Outbox: []VTXOOutMsg{ &VTXOStatusUpdate{ Outpoint: s.VTXO.Outpoint, - NewStatus: VTXOStatusLive, + NewStatus: status, ReleaseSpendReservation: true, }, @@ -1079,15 +1165,27 @@ func (s *SpendingState) ProcessEvent(_ context.Context, event VTXOEvent, case *BlockEpochEvent: // Expiry safety: even while spending, we must escalate to // unilateral exit if critical expiry is reached. + // + // Expiry itself does NOT escalate. An exit started past the + // deadline cannot complete, so escalating would abandon an + // in-flight OOR that may still settle. The spend terminates + // either way: SpendCompletedEvent retires the VTXO to Spent, + // and SpendReleasedEvent returns it above — to Expired rather + // than Live, so an expired outgoing spend is not left + // spendable once its session gives up. s.LastCheckedHeight = evt.Height expiryStatus := env.ExpiryConfig.CheckExpiry( s.VTXO, evt.Height, ) - if expiryStatus == ExpiryStatusCritical || - expiryStatus == ExpiryStatusExpired { - + // 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, ) @@ -1336,3 +1434,165 @@ func (s *FailedState) ProcessEvent(_ context.Context, _ VTXOEvent, NextState: s, }, nil } + +// ProcessEvent handles events in ExpiredState. The batch expiry has passed, so +// the VTXO has no cooperative spend or unilateral exit left of its own — but +// its value is still recoverable by forfeiting it in an ordinary round, so the +// state is not terminal and the actor keeps serving forfeit requests. +func (s *ExpiredState) ProcessEvent(ctx context.Context, event VTXOEvent, + env *VTXOEnvironment) (*VTXOStateTransition, error) { + + switch evt := event.(type) { + case *BlockEpochEvent: + s.ObservedHeight = evt.Height + + // Re-evaluate rather than assuming expiry is permanent. A real + // deadline never moves, so the common case is a no-op, but a + // VTXO can reach this state without the chain having been + // consulted: a released or restart-orphaned reclaim rolls back + // here deliberately, because quarantining value that might be + // swept is safer than offering it for a payment that would + // build on a dead lineage. This is the check that undoes that + // caution once a real height proves the VTXO is still live. + if env.ExpiryConfig.CheckExpiry(s.VTXO, evt.Height) != + ExpiryStatusExpired { + + build.LoggerFromContext(ctx).WithPrefix(Subsystem). + InfoS(ctx, "VTXO is not expired after all; "+ + "returning it to the live set", nil, + slog.String( + "outpoint", + s.VTXO.Outpoint.String(), + ), + slog.Int("height", int(evt.Height)), + slog.Int( + "batch_expiry", + int(s.VTXO.BatchExpiry), + ), + ) + + live := &LiveState{ + VTXO: s.VTXO, + LastCheckedHeight: evt.Height, + } + + return forfeitReleaseTransition( + live, s.VTXO.Outpoint, + ), nil + } + + // The VTXO is still expired, so recover it through the ordinary + // refresh path. The server admits the input from its effective + // batch expiry and the normal connector-bound forfeit protects + // the operator; no sweep-state handshake is needed. + outbox := []VTXOOutMsg{ + &ForfeitRequest{ + VTXOOutpoint: s.VTXO.Outpoint, + LastCheckedHeight: evt.Height, + }, + &VTXOStatusUpdate{ + Outpoint: s.VTXO.Outpoint, + NewStatus: VTXOStatusPendingForfeit, + }, + } + + return &VTXOStateTransition{ + NextState: &PendingForfeitState{ + VTXO: s.VTXO, + RequestedAtHeight: evt.Height, + }, + NewEvents: fn.Some(VTXOEmittedEvent{Outbox: outbox}), + }, nil + + case *PendingForfeitEvent: + // The reclaim path: the wallet has committed this expired + // VTXO to a round. From here it follows the ordinary forfeit + // choreography, because a reclaim IS an ordinary refresh whose + // input happens to be expired. + update := &VTXOStatusUpdate{ + Outpoint: s.VTXO.Outpoint, + NewStatus: VTXOStatusPendingForfeit, + } + + return &VTXOStateTransition{ + NextState: &PendingForfeitState{ + VTXO: s.VTXO, + RequestedAtHeight: s.ObservedHeight, + }, + NewEvents: fn.Some(VTXOEmittedEvent{ + Outbox: []VTXOOutMsg{update}, + }), + }, nil + + case *ForfeitRequestEvent: + // The round supplied connector details directly. Reuse + // LiveState's handler verbatim: the forfeit an expired VTXO + // signs is byte-for-byte the one it would sign while live, and + // duplicating the construction here would be a second place + // for the two to drift apart. + live := &LiveState{ + VTXO: s.VTXO, + LastCheckedHeight: s.ObservedHeight, + } + + return live.handleForfeitRequest(ctx, evt, env) + + case *SpendReserveEvent: + // Refuse ordinary spends. There is nothing left to spend + // cooperatively until the VTXO has been reissued, so staying + // put is what keeps the expired value out of coin selection. + return &VTXOStateTransition{ + NextState: s, + }, nil + + case *ResumeVTXOEvent: + return &VTXOStateTransition{ + NextState: s, + }, nil + + case *ForceUnrollEvent: + // Deliberately refused. Completing an exit from here means + // confirming the whole ancestry and then waiting out the exit + // CSV while racing an operator whose sweep is already + // spendable, so it burns fees on an exit that cannot land. + // Recovery goes through the cooperative forfeit instead. + build.LoggerFromContext(ctx).WithPrefix(Subsystem).WarnS( + ctx, "Refusing unilateral exit for expired VTXO; "+ + "recover it by forfeiting in a round", nil, + slog.String("outpoint", s.VTXO.Outpoint.String()), + slog.Int("batch_expiry", int(s.VTXO.BatchExpiry)), + ) + + return &VTXOStateTransition{ + NextState: s, + }, nil + + case *VTXOFailedEvent: + return &VTXOStateTransition{ + NextState: &FailedState{ + VTXO: s.VTXO, + Reason: evt.Reason, + Error: evt.Error, + Recoverable: evt.Recoverable, + }, + }, nil + + case *SpendReleasedEvent, *SpendCompletedEvent, *ForfeitReleasedEvent, + *ExitFailedEvent, *ExitConfirmedEvent: + // Stale events from a path that ran before this VTXO expired. + // An expired VTXO is long-lived, so these can arrive well + // after the fact; absorbing them keeps the actor alive without + // acting on state that no longer applies. + return &VTXOStateTransition{ + NextState: s, + }, nil + + default: + // Anything else is a genuine protocol surprise. Surfacing it + // beats silently absorbing it: a blanket default would let a + // real routing or lifecycle bug sit invisible for the whole + // life of the VTXO. The FSM reports the error without + // transitioning, so the actor survives. + return nil, fmt.Errorf("expired: unexpected event: %T", event) + } +} diff --git a/waved/server.go b/waved/server.go index b766d0bee..d6ec6d958 100644 --- a/waved/server.go +++ b/waved/server.go @@ -2429,6 +2429,14 @@ func (s *Server) startWalletDependentActors(ctx context.Context, return err } + // Apply the already-synchronized chain tip only after the round actor + // is available. This lets VTXOs that expired while the client was + // offline enter the ordinary refresh flow without racing the round + // service during actor recovery. + if err := s.reconcileVTXOExpiry(ctx, vtxoManagerRef); err != nil { + s.log.WarnS(ctx, "Failed to reconcile VTXO expiry", err) + } + // ------------------------------------------------------- // 12. Register the unilateral-exit subsystem. // ------------------------------------------------------- @@ -2474,6 +2482,26 @@ func (s *Server) startWalletDependentActors(ctx context.Context, return nil } +// reconcileVTXOExpiry asks the VTXO manager to apply the current chain tip to +// all actors recovered during startup. Failure is non-fatal because ordinary +// block subscriptions continue to drive expiry after startup. +func (s *Server) reconcileVTXOExpiry(ctx context.Context, + managerRef actor.ActorRef[vtxo.ManagerMsg, vtxo.ManagerResp]) error { + + response, err := managerRef.Ask( + ctx, &vtxo.ReconcileExpiryRequest{}, + ).Await(ctx).Unpack() + if err != nil { + return fmt.Errorf("ask VTXO expiry reconcile: %w", err) + } + if _, ok := response.(*vtxo.ReconcileExpiryResponse); !ok { + return fmt.Errorf("unexpected VTXO expiry response: %T", + response) + } + + return nil +} + // replayPendingIntents Asks the wallet actor to replay any persisted user // intent (Board, SendOnChain, ...) across daemon restart. Called once during // startup, after the round-client actor has registered and authenticated diff --git a/waved/wallet_ops_test.go b/waved/wallet_ops_test.go index 6d280f730..57281017d 100644 --- a/waved/wallet_ops_test.go +++ b/waved/wallet_ops_test.go @@ -49,6 +49,12 @@ func (s *testCustomInputStore) ListLiveVTXOs(context.Context) ( return nil, fmt.Errorf("unexpected ListLiveVTXOs call") } +func (s *testCustomInputStore) ListRecoverableVTXOs(context.Context) ( + []*vtxo.Descriptor, error) { + + return nil, fmt.Errorf("unexpected ListRecoverableVTXOs call") +} + func (s *testCustomInputStore) ListVTXOsByStatus(context.Context, vtxo.VTXOStatus) ([]*vtxo.Descriptor, error) { From 6b54930affdee4182b93ec3f6b75bf56d3075a38 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Mon, 27 Jul 2026 15:43:54 -0700 Subject: [PATCH 6/7] waverpc: surface the expired VTXO status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vtxoStatusToProto had no case for the new expired status, so an expired VTXO was reported to clients as VTXO_STATUS_UNSPECIFIED. Expiry is not terminal — the value is recovered by forfeiting the VTXO in an ordinary round — so a wallet UI has to be able to tell "expired, recoverable, not counted in spendable balance" apart from "the daemon does not know what this is". Add VTXO_STATUS_EXPIRED and map it in both directions. --- waved/rpc_server.go | 6 ++++++ waved/vtxo_status_expired_test.go | 30 +++++++++++++++++++++++++++ waverpc/daemon.pb.go | 34 ++++++++++++++++++++----------- waverpc/daemon.proto | 7 +++++++ 4 files changed, 65 insertions(+), 12 deletions(-) create mode 100644 waved/vtxo_status_expired_test.go diff --git a/waved/rpc_server.go b/waved/rpc_server.go index db7c3e0af..dfa7d1c99 100644 --- a/waved/rpc_server.go +++ b/waved/rpc_server.go @@ -1356,6 +1356,9 @@ func protoStatusToDomain(s waverpc.VTXOStatus) (vtxo.VTXOStatus, error) { case waverpc.VTXOStatus_VTXO_STATUS_FAILED: return vtxo.VTXOStatusFailed, nil + case waverpc.VTXOStatus_VTXO_STATUS_EXPIRED: + return vtxo.VTXOStatusExpired, nil + default: return 0, fmt.Errorf("unknown VTXO status: %v", s) } @@ -1388,6 +1391,9 @@ func vtxoStatusToProto(s vtxo.VTXOStatus) waverpc.VTXOStatus { case vtxo.VTXOStatusSpending: return waverpc.VTXOStatus_VTXO_STATUS_SPENDING + case vtxo.VTXOStatusExpired: + return waverpc.VTXOStatus_VTXO_STATUS_EXPIRED + default: return waverpc.VTXOStatus_VTXO_STATUS_UNSPECIFIED } diff --git a/waved/vtxo_status_expired_test.go b/waved/vtxo_status_expired_test.go new file mode 100644 index 000000000..e28b783bd --- /dev/null +++ b/waved/vtxo_status_expired_test.go @@ -0,0 +1,30 @@ +package waved + +import ( + "testing" + + "github.com/lightninglabs/wavelength/vtxo" + "github.com/lightninglabs/wavelength/waverpc" + "github.com/stretchr/testify/require" +) + +// TestVTXOStatusExpiredRoundTripsThroughRPC asserts the expired status is +// surfaced by the daemon RPC rather than collapsing to UNSPECIFIED. +// +// Expiry is not terminal — the value is recovered by forfeiting the VTXO in a +// round — so a wallet UI has to be able to tell "expired, recoverable" apart +// from "unknown". +func TestVTXOStatusExpiredRoundTripsThroughRPC(t *testing.T) { + t.Parallel() + + proto := vtxoStatusToProto(vtxo.VTXOStatusExpired) + require.Equal(t, waverpc.VTXOStatus_VTXO_STATUS_EXPIRED, proto) + require.NotEqual( + t, waverpc.VTXOStatus_VTXO_STATUS_UNSPECIFIED, proto, + "an expired VTXO must not surface as an unknown status", + ) + + back, err := protoStatusToDomain(proto) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusExpired, back) +} diff --git a/waverpc/daemon.pb.go b/waverpc/daemon.pb.go index 035ab4ca7..6f821976b 100644 --- a/waverpc/daemon.pb.go +++ b/waverpc/daemon.pb.go @@ -128,21 +128,28 @@ const ( // amount, originating round_id, and (once known) the commitment // txid that will create them. VTXOStatus_VTXO_STATUS_PENDING_ROUND VTXOStatus = 9 + // VTXO_STATUS_EXPIRED indicates the VTXO's batch expiry has passed, so + // the operator's sweep path is mature and no client-driven exit can + // complete any more. It is NOT terminal and NOT spendable: the value is + // recovered by forfeiting the VTXO in an ordinary round, which reissues + // it as a fresh VTXO. It is excluded from spendable balance until then. + VTXOStatus_VTXO_STATUS_EXPIRED VTXOStatus = 10 ) // Enum value maps for VTXOStatus. var ( VTXOStatus_name = map[int32]string{ - 0: "VTXO_STATUS_UNSPECIFIED", - 1: "VTXO_STATUS_LIVE", - 2: "VTXO_STATUS_PENDING_FORFEIT", - 3: "VTXO_STATUS_FORFEITING", - 4: "VTXO_STATUS_FORFEITED", - 5: "VTXO_STATUS_SPENT", - 6: "VTXO_STATUS_UNILATERAL_EXIT", - 7: "VTXO_STATUS_FAILED", - 8: "VTXO_STATUS_SPENDING", - 9: "VTXO_STATUS_PENDING_ROUND", + 0: "VTXO_STATUS_UNSPECIFIED", + 1: "VTXO_STATUS_LIVE", + 2: "VTXO_STATUS_PENDING_FORFEIT", + 3: "VTXO_STATUS_FORFEITING", + 4: "VTXO_STATUS_FORFEITED", + 5: "VTXO_STATUS_SPENT", + 6: "VTXO_STATUS_UNILATERAL_EXIT", + 7: "VTXO_STATUS_FAILED", + 8: "VTXO_STATUS_SPENDING", + 9: "VTXO_STATUS_PENDING_ROUND", + 10: "VTXO_STATUS_EXPIRED", } VTXOStatus_value = map[string]int32{ "VTXO_STATUS_UNSPECIFIED": 0, @@ -155,6 +162,7 @@ var ( "VTXO_STATUS_FAILED": 7, "VTXO_STATUS_SPENDING": 8, "VTXO_STATUS_PENDING_ROUND": 9, + "VTXO_STATUS_EXPIRED": 10, } ) @@ -10856,7 +10864,7 @@ const file_daemon_proto_rawDesc = "" + "\x11WALLET_STATE_NONE\x10\x01\x12\x17\n" + "\x13WALLET_STATE_LOCKED\x10\x02\x12\x16\n" + "\x12WALLET_STATE_READY\x10\x03\x12\x18\n" + - "\x14WALLET_STATE_SYNCING\x10\x04*\xa0\x02\n" + + "\x14WALLET_STATE_SYNCING\x10\x04*\xb9\x02\n" + "\n" + "VTXOStatus\x12\x1b\n" + "\x17VTXO_STATUS_UNSPECIFIED\x10\x00\x12\x14\n" + @@ -10868,7 +10876,9 @@ const file_daemon_proto_rawDesc = "" + "\x1bVTXO_STATUS_UNILATERAL_EXIT\x10\x06\x12\x16\n" + "\x12VTXO_STATUS_FAILED\x10\a\x12\x18\n" + "\x14VTXO_STATUS_SPENDING\x10\b\x12\x1d\n" + - "\x19VTXO_STATUS_PENDING_ROUND\x10\t*\xb6\x01\n" + + "\x19VTXO_STATUS_PENDING_ROUND\x10\t\x12\x17\n" + + "\x13VTXO_STATUS_EXPIRED\x10\n" + + "*\xb6\x01\n" + "\x10VTXOExpiryStatus\x12\x1e\n" + "\x1aVTXO_EXPIRY_STATUS_UNKNOWN\x10\x00\x12\x1b\n" + "\x17VTXO_EXPIRY_STATUS_SAFE\x10\x01\x12$\n" + diff --git a/waverpc/daemon.proto b/waverpc/daemon.proto index 6c5dd30b7..fefe44044 100644 --- a/waverpc/daemon.proto +++ b/waverpc/daemon.proto @@ -602,6 +602,13 @@ enum VTXOStatus { // amount, originating round_id, and (once known) the commitment // txid that will create them. VTXO_STATUS_PENDING_ROUND = 9; + + // VTXO_STATUS_EXPIRED indicates the VTXO's batch expiry has passed, so + // the operator's sweep path is mature and no client-driven exit can + // complete any more. It is NOT terminal and NOT spendable: the value is + // recovered by forfeiting the VTXO in an ordinary round, which reissues + // it as a fresh VTXO. It is excluded from spendable balance until then. + VTXO_STATUS_EXPIRED = 10; } // VTXOExpiryStatus describes how close a VTXO is to batch expiry using the From 4171deb1cf6d465d762ee3d08b48a264fb315803 Mon Sep 17 00:00:00 2001 From: Elle Mouton Date: Wed, 29 Jul 2026 14:13:45 +0200 Subject: [PATCH 7/7] waved: Refresh expired VTXOs at full value Allow explicit and automatic expired refresh intents through the daemon. Count quarantined value against the boarding limit and preview the exact one-for-one recovery as fee-free. Co-authored-by: sputn1ck --- waved/rpc_refresh_estimate.go | 57 ++++++++++++++++++++---------- waved/rpc_refresh_estimate_test.go | 9 +++-- waved/server.go | 11 ++++-- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/waved/rpc_refresh_estimate.go b/waved/rpc_refresh_estimate.go index 8879685d9..1c85bf656 100644 --- a/waved/rpc_refresh_estimate.go +++ b/waved/rpc_refresh_estimate.go @@ -106,19 +106,26 @@ func (r *RPCServer) resolveRefreshPreviewTargets(ctx context.Context, explicit []wire.OutPoint, all bool) ([]*vtxo.Descriptor, error) { if all { - liveVTXOs, err := r.server.vtxoStore.ListLiveVTXOs(ctx) + // Read the recoverable set rather than the live one so + // "refresh all" also reclaims expired VTXOs. An expired VTXO + // is exactly what a user wants swept up here: it holds no + // spendable value until it is reissued, and forfeiting it in + // a round is the only way to get that value back. + candidates, err := r.server.vtxoStore.ListRecoverableVTXOs(ctx) if err != nil { - return nil, status.Errorf(codes.Internal, "list live "+ - "VTXOs: %v", err) + return nil, status.Errorf(codes.Internal, "list "+ + "recoverable VTXOs: %v", err) } - // Keep only VTXOs actually in LiveState, matching the - // real refresh path's filter: anything already on its - // way through a round must not be double-counted in the - // preview either. - descs := make([]*vtxo.Descriptor, 0, len(liveVTXOs)) - for _, desc := range liveVTXOs { - if desc.Status != vtxo.VTXOStatusLive { + // Keep only VTXOs actually in LiveState or ExpiredState, + // matching the real refresh path's filter: anything already + // on its way through a round must not be double-counted in + // the preview either. + descs := make([]*vtxo.Descriptor, 0, len(candidates)) + for _, desc := range candidates { + switch desc.Status { + case vtxo.VTXOStatusLive, vtxo.VTXOStatusExpired: + default: continue } @@ -143,11 +150,20 @@ func (r *RPCServer) resolveRefreshPreviewTargets(ctx context.Context, } // GetVTXO resolves rows in any lifecycle state; the --all - // branch filters to LiveState, and the explicit branch - // must be as strict — a spent or in-flight VTXO would + // branch filters to Live and Expired, and the explicit + // branch must match — a spent or in-flight VTXO would // preview as refreshable (and be priced) while the real // dispatch is guaranteed to fail on it. - if desc.Status != vtxo.VTXOStatusLive { + // + // Expired is accepted because refreshing an expired VTXO is + // exactly how its owner recovers the value: the operator + // admits the forfeit, the round reissues the amount, and the + // dispatch below succeeds. Rejecting it here would make the + // recovery path unreachable through the RPC even though every + // layer beneath it supports the flow. + switch desc.Status { + case vtxo.VTXOStatusLive, vtxo.VTXOStatusExpired: + default: return nil, status.Errorf(codes.InvalidArgument, "VTXO %s:%d is not refreshable (status %v)", op.Hash, op.Index, desc.Status) @@ -266,9 +282,11 @@ func (r *RPCServer) estimateRefreshFees(ctx context.Context, // quote's remaining-blocks figure is clamped to 1 because the // operator treats zero as "use the full sweep-delay lifetime", // which would massively over-quote an expiring VTXO; window - // membership uses the unclamped value (an expired VTXO is not - // waiver-eligible). - allInWindow := true + // membership uses the unclamped value. Expired VTXOs are also + // waiver-eligible when their expiry is usable: recovery is an exact + // ordinary refresh and must not charge value the operator already owes + // back to the client. + allWaiverEligible := true for _, desc := range descs { remaining := vtxo.BlocksUntilExpiry(desc, height) clamped := remaining @@ -278,8 +296,9 @@ func (r *RPCServer) estimateRefreshFees(ctx context.Context, inWindow := window > 0 && remaining > 0 && uint32(remaining) <= window - if !inWindow { - allInWindow = false + expired := vtxo.HasUsableBatchExpiry(desc) && remaining <= 0 + if !inWindow && !expired { + allWaiverEligible = false } est.Outpoints = append( @@ -294,7 +313,7 @@ func (r *RPCServer) estimateRefreshFees(ctx context.Context, }, ) } - est.FreeRefreshEligible = allInWindow + est.FreeRefreshEligible = allWaiverEligible // Second pass: fetch and validate the operator quotes, deduped // on (amount, remaining blocks), and pre-compute the selection diff --git a/waved/rpc_refresh_estimate_test.go b/waved/rpc_refresh_estimate_test.go index cc753dace..89ecd3475 100644 --- a/waved/rpc_refresh_estimate_test.go +++ b/waved/rpc_refresh_estimate_test.go @@ -542,8 +542,9 @@ func TestRefreshDryRunEstimateOperatorDownFreeWindow(t *testing.T) { // TestRefreshDryRunEstimateExpiredClampsRemaining verifies an already // expired VTXO quotes at a clamped remaining lifetime of 1 instead of // 0: the operator treats zero as "use the full sweep-delay lifetime", -// which would massively over-quote. An expired VTXO is also never -// waiver-eligible. +// which would massively over-quote. The selection-level result still reports +// the expired-recovery waiver, so the quoted components are advisory and the +// total is zero. func TestRefreshDryRunEstimateExpiredClampsRemaining(t *testing.T) { t.Parallel() @@ -574,7 +575,9 @@ func TestRefreshDryRunEstimateExpiredClampsRemaining(t *testing.T) { require.Len(t, est.Outpoints, 1) require.Equal(t, uint32(1), est.Outpoints[0].RemainingBlocks) require.False(t, est.Outpoints[0].InFreeRefreshWindow) - require.False(t, est.FreeRefreshEligible) + require.True(t, est.FreeRefreshEligible) + require.NotNil(t, est.EstimatedTotalFeeSat) + require.Zero(t, *est.EstimatedTotalFeeSat) require.NotNil(t, svc.lastRequest) require.Equal(t, uint32(1), svc.lastRequest.RemainingBlocks) diff --git a/waved/server.go b/waved/server.go index d6ec6d958..1deeec87b 100644 --- a/waved/server.go +++ b/waved/server.go @@ -781,11 +781,16 @@ func (s *Server) fetchCachedOperatorTerms(ctx context.Context) ( // fetchLiveVTXOBalance sums the wallet's non-terminal VTXO holdings for // the boarding headroom computation. The full non-terminal set (Live, -// PendingForfeit, Forfeiting, Spending) is deliberately counted rather -// than just the spendable subset: in-flight outbound value still +// PendingForfeit, Forfeiting, Spending, Expired) is deliberately counted +// rather than just the spendable subset: in-flight outbound value still // occupies the user's balance until it terminally leaves, so counting // it keeps back-to-back boards from overshooting the operator's cap. // +// Expired value is counted for the same reason even though it is not +// spendable. The operator still owes it — the owner can reclaim it by +// refreshing the VTXO into a round — so leaving it out would let a client +// board up to the cap, then reclaim on top and end up above it. +// // We use the "light" variant: balance summing only reads each // descriptor's amount, so we skip the ancestry side-table join whose // TLV tree fragments grow with OOR chain depth and sort through SQLite's @@ -798,7 +803,7 @@ func (s *Server) fetchLiveVTXOBalance(ctx context.Context) (btcutil.Amount, return 0, fmt.Errorf("vtxo store is not initialized") } - descs, err := s.vtxoStore.ListLiveVTXOsLight(ctx) + descs, err := s.vtxoStore.ListRecoverableVTXOsLight(ctx) if err != nil { return 0, err }