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/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/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/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/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) +} 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/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 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.go b/vtxo/expiry.go index 9a8214e48..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 @@ -16,6 +18,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 +44,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 +141,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. @@ -159,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 new file mode 100644 index 000000000..02188ac1a --- /dev/null +++ b/vtxo/expiry_validation_test.go @@ -0,0 +1,683 @@ +package vtxo + +import ( + "fmt" + "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()) +} + +// 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 +// 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", + ) +} + +// 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/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/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 feaeb2357..f4a80a2dd 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -3,18 +3,78 @@ 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" 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, @@ -155,7 +215,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 +229,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 @@ -226,15 +306,33 @@ func (s *LiveState) handleBlockEpoch(_ 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: @@ -309,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, @@ -618,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 @@ -750,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, @@ -776,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. @@ -837,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, @@ -878,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 @@ -973,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{ @@ -1035,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, }, @@ -1057,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, ) @@ -1314,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/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/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/server.go b/waved/server.go index b766d0bee..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 } @@ -2429,6 +2434,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 +2487,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/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/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) { 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