diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 926885193..a2b23b3c9 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -242,8 +242,9 @@ type Querier interface { // Status 1 = Completed, 2 = Failed (anchored to Go iota in // db/oor_session_registry_store.go OORSessionStatus). ListNonTerminalOORSessionRegistry(ctx context.Context) ([]OorSessionRegistry, error) - // Status 4 = Completed, 5 = Failed, 7 = FailedRecoverable (anchored to Go - // iota in db/unilateral_exit_store.go UnilateralExitJobStatus). + // Status 4 = Completed, 5 = Failed, 7 = FailedRecoverable, 8 = + // FailedConflicted (anchored to Go iota in db/unilateral_exit_store.go + // UnilateralExitJobStatus). All four are terminal and excluded from restore. ListNonTerminalUnilateralExitJobs(ctx context.Context) ([]UnilateralExitJob, error) ListNonTerminalVHTLCRecoveryJobs(ctx context.Context) ([]VhtlcRecoveryJob, error) ListOORPackageCheckpoints(ctx context.Context, sessionID []byte) ([]OorPackageCheckpoint, error) diff --git a/db/sqlc/queries/unilateral_exit.sql b/db/sqlc/queries/unilateral_exit.sql index 69c5cb665..805ec2441 100644 --- a/db/sqlc/queries/unilateral_exit.sql +++ b/db/sqlc/queries/unilateral_exit.sql @@ -26,10 +26,11 @@ WHERE target_outpoint_hash = $1 ; -- name: ListNonTerminalUnilateralExitJobs :many --- Status 4 = Completed, 5 = Failed, 7 = FailedRecoverable (anchored to Go --- iota in db/unilateral_exit_store.go UnilateralExitJobStatus). +-- Status 4 = Completed, 5 = Failed, 7 = FailedRecoverable, 8 = +-- FailedConflicted (anchored to Go iota in db/unilateral_exit_store.go +-- UnilateralExitJobStatus). All four are terminal and excluded from restore. SELECT * FROM unilateral_exit_jobs -WHERE status NOT IN (4, 5, 7) +WHERE status NOT IN (4, 5, 7, 8) ORDER BY created_at ASC ; diff --git a/db/sqlc/unilateral_exit.sql.go b/db/sqlc/unilateral_exit.sql.go index a9286c906..c9f5132e6 100644 --- a/db/sqlc/unilateral_exit.sql.go +++ b/db/sqlc/unilateral_exit.sql.go @@ -93,12 +93,13 @@ func (q *Queries) InsertExitFundingAddress(ctx context.Context, arg InsertExitFu const ListNonTerminalUnilateralExitJobs = `-- name: ListNonTerminalUnilateralExitJobs :many SELECT target_outpoint_hash, target_outpoint_index, actor_id, status, trigger, last_error, sweep_txid, created_at, updated_at, exit_policy_kind, exit_policy_ref FROM unilateral_exit_jobs -WHERE status NOT IN (4, 5, 7) +WHERE status NOT IN (4, 5, 7, 8) ORDER BY created_at ASC ` -// Status 4 = Completed, 5 = Failed, 7 = FailedRecoverable (anchored to Go -// iota in db/unilateral_exit_store.go UnilateralExitJobStatus). +// Status 4 = Completed, 5 = Failed, 7 = FailedRecoverable, 8 = +// FailedConflicted (anchored to Go iota in db/unilateral_exit_store.go +// UnilateralExitJobStatus). All four are terminal and excluded from restore. func (q *Queries) ListNonTerminalUnilateralExitJobs(ctx context.Context) ([]UnilateralExitJob, error) { rows, err := q.db.QueryContext(ctx, ListNonTerminalUnilateralExitJobs) if err != nil { diff --git a/db/unilateral_exit_store.go b/db/unilateral_exit_store.go index 02c59aae9..5e27fa58f 100644 --- a/db/unilateral_exit_store.go +++ b/db/unilateral_exit_store.go @@ -60,13 +60,26 @@ const ( // the exit has begun on-chain) so boot-time reconciliation can decide // whether to recover the VTXO (wavelength#602). UnilateralExitJobStatusFailedRecoverable + + // UnilateralExitJobStatusFailedConflicted means the job failed + // terminally because a confirmed foreign spend conflicts with the + // recovery tree — the operator swept a source batch commitment output + // the exit depends on, so the exit can never complete. Unlike a plain + // Failed job, boot-time reconciliation must retire the target VTXO out + // of unilateral-exit (clearing it from pending balance) rather than + // leaving it pending forever, and unlike a recoverable failure it must + // NOT roll the VTXO back to live: the coin is provably gone + // (wavelength#1050). Appended after the original enum so existing rows' + // numeric meaning never shifts. + UnilateralExitJobStatusFailedConflicted ) // IsTerminal reports whether the control-plane job status is terminal. func (s UnilateralExitJobStatus) IsTerminal() bool { return s == UnilateralExitJobStatusCompleted || s == UnilateralExitJobStatusFailed || - s == UnilateralExitJobStatusFailedRecoverable + s == UnilateralExitJobStatusFailedRecoverable || + s == UnilateralExitJobStatusFailedConflicted } // UnilateralExitJobTrigger records what started an exit job. diff --git a/lib/recovery/proof.go b/lib/recovery/proof.go index c5ce275de..ab2d09e8d 100644 --- a/lib/recovery/proof.go +++ b/lib/recovery/proof.go @@ -447,6 +447,68 @@ func (p *Proof) RootTxids() []chainhash.Hash { return append([]chainhash.Hash(nil), p.layers[0]...) } +// RootExternalInputs returns the outpoints consumed by root transactions that +// are NOT themselves produced by any node in this proof — the external funding +// inputs the whole recovery graph hangs off of. For a round-direct VTXO this is +// the batch/commitment output the tree root spends; for an OOR-chained or +// multi-input fan-in VTXO it is every distinct commitment output rooting a +// local lineage fragment. +// +// These are exactly the outpoints a competing party (an operator sweeping an +// expired batch, a fraud spend) can consume out from under the exit: a +// confirmed foreign spend of any one of them makes every root that depends on +// it — and therefore the whole proof — permanently unbroadcastable. Callers arm +// spend watches on them so such a conflict fails the exit terminally instead of +// spinning forever on a tree that can never confirm (wavelength#1050). +// +// The result is deduplicated and sorted deterministically (by hash then index) +// so two proofs built from the same node set yield identical output regardless +// of map iteration order. +func (p *Proof) RootExternalInputs() []wire.OutPoint { + seen := make(map[wire.OutPoint]struct{}) + external := make([]wire.OutPoint, 0) + + for _, rootTxid := range p.RootTxids() { + root, ok := p.nodes[rootTxid] + if !ok || root.Tx == nil { + continue + } + + for _, txIn := range root.Tx.TxIn { + if txIn == nil { + continue + } + + outpoint := txIn.PreviousOutPoint + + // An input produced by another node is an in-graph + // dependency, not an external funding input. + if _, isNode := p.nodes[outpoint.Hash]; isNode { + continue + } + + if _, dup := seen[outpoint]; dup { + continue + } + + seen[outpoint] = struct{}{} + external = append(external, outpoint) + } + } + + sort.Slice(external, func(i, j int) bool { + if c := bytes.Compare( + external[i].Hash[:], external[j].Hash[:], + ); c != 0 { + return c < 0 + } + + return external[i].Index < external[j].Index + }) + + return external +} + // Layer returns the topological layer index for the requested txid. Layer 0 // is the set of roots; the target node's layer is always the maximum layer // index. diff --git a/lib/recovery/proof_accessors_test.go b/lib/recovery/proof_accessors_test.go index 31280b9a2..dfe3de759 100644 --- a/lib/recovery/proof_accessors_test.go +++ b/lib/recovery/proof_accessors_test.go @@ -1,6 +1,7 @@ package recovery import ( + "bytes" "testing" "github.com/btcsuite/btcd/chainhash/v2" @@ -167,3 +168,50 @@ func TestProofAccessors(t *testing.T) { _, err = proof.ChildTxids(chainhash.Hash{0xff}) require.ErrorContains(t, err, "unknown txid") } + +// TestProofRootExternalInputs verifies that RootExternalInputs returns exactly +// the external funding inputs of the proof's root transactions — the outpoints +// a competing spend can conflict with — deduplicated, deterministically +// ordered, and excluding in-graph (node-produced) inputs. +func TestProofRootExternalInputs(t *testing.T) { + // Two independent roots (a fan-in lineage), each spending its own + // external commitment output, feeding one target. + rootA := makeProofTx('a', nil) + rootB := makeProofTx('b', nil) + target := makeProofTx('t', []wire.OutPoint{ + {Hash: rootA.TxHash(), Index: 0}, + {Hash: rootB.TxHash(), Index: 0}, + }) + + proof, err := NewProof( + wire.OutPoint{ + Hash: target.TxHash(), + }, + 5, &Node{ + Kind: NodeKindTree, + Tx: rootA, + }, &Node{ + Kind: NodeKindTree, + Tx: rootB, + }, &Node{ + Kind: NodeKindArk, + Tx: target, + }, + ) + require.NoError(t, err) + + // makeProofTx seeds a root's external input at {Hash{tag,0xff}, tag}. + wantA := wire.OutPoint{Hash: chainhash.Hash{'a', 0xff}, Index: 'a'} + wantB := wire.OutPoint{Hash: chainhash.Hash{'b', 0xff}, Index: 'b'} + + got := proof.RootExternalInputs() + + // Both roots' external inputs are present; the target's in-graph inputs + // (produced by rootA/rootB) are excluded. + require.Len(t, got, 2) + require.Contains(t, got, wantA) + require.Contains(t, got, wantB) + + // Deterministic ordering: sorted by hash bytes then index. + require.True(t, bytes.Compare(got[0].Hash[:], got[1].Hash[:]) <= 0) +} diff --git a/unroll/actor.go b/unroll/actor.go index 13f6c30d4..7c1b77c33 100644 --- a/unroll/actor.go +++ b/unroll/actor.go @@ -152,10 +152,21 @@ type behavior struct { session *Session pending *actorCheckpoint - sweepTx *wire.MsgTx - blockSubActive bool - spendWatchActive bool - proofSpendWatches map[wire.OutPoint]struct{} + sweepTx *wire.MsgTx + blockSubActive bool + spendWatchActive bool + proofSpendWatches map[wire.OutPoint]struct{} + + // sourceSpendWatches tracks the external (non-proof) funding outpoints + // the recovery roots hang off of — the round batch/commitment outputs. + // A confirmed foreign spend of one of these (an operator sweeping an + // expired batch) makes the whole proof unbroadcastable, so we watch + // them to fail the exit terminally instead of materializing forever + // (wavelength#1050). Membership also lets handleSpendObserved recognise + // a source conflict and report it distinctly from a generic external + // spend. + sourceSpendWatches map[wire.OutPoint]struct{} + terminalNotified bool exitCostNotified bool abandonedBroadcastsRemoved bool @@ -325,6 +336,7 @@ func (b *behavior) OnStop(ctx context.Context) error { b.unsubscribeBlocks(ctx) b.unregisterSpendWatch(ctx) b.unregisterProofSpendWatches(ctx) + b.unregisterSourceSpendWatches(ctx) if b.session != nil && b.session.FSM != nil { b.session.FSM.Stop() @@ -1296,6 +1308,12 @@ func (b *behavior) ensureLoaded(ctx context.Context) error { return err } + // Arming the source-commitment watches is best-effort and never blocks + // loading: the exit still materializes without them, they only add the + // swept-source conflict detection on top, so a registration failure is + // logged inside rather than surfaced here. + b.ensureSourceSpendWatches(ctx) + state, err := b.currentState() if err != nil { return err @@ -1685,6 +1703,166 @@ func (b *behavior) proofSpendCallerID(outpoint wire.OutPoint) string { b.cfg.TargetOutpoint.String(), outpoint.String()) } +// ensureSourceSpendWatches registers spend watches on the external funding +// inputs of the recovery roots — the round batch/commitment outputs the whole +// proof hangs off of. Unlike the proof-node watches (which watch outputs +// consumed by in-proof children to catch a parent confirmation), these watch +// the outpoints an adversary can consume out from under us: once the operator +// sweeps an expired batch commitment output, every recovery transaction that +// spends it is permanently invalid, so the exit can never complete. Without a +// watch the job would sit in materialization forever because txconfirm never +// gives up on a no-mempool transaction (wavelength#1050). +// +// A foreign spend of one of these routes through handleSpendObserved case 4 +// and fails the job; a spend by our own root routes through case 2 as a +// benign parent-confirmation signal, so watching them is safe in both +// outcomes. Arming is best-effort: a per-outpoint registration failure is +// logged and skipped rather than failing the whole load, since the exit still +// functions (just without swept-source detection for that outpoint). +func (b *behavior) ensureSourceSpendWatches(ctx context.Context) { + if b.proof == nil { + return + } + + external := b.proof.RootExternalInputs() + if len(external) == 0 { + return + } + + if b.sourceSpendWatches == nil { + b.sourceSpendWatches = make(map[wire.OutPoint]struct{}) + } + + // The batch outputs carry the pkScript that neutrino needs to match a + // spend in the BIP-158 block filter (lwwallet detects by outpoint + // alone). It rides along on the descriptor ancestry when known; an + // unknown script still arms an outpoint-only watch, which covers the + // Esplora backend. + scripts := b.sourcePkScripts() + + for _, outpoint := range external { + if outpoint == b.cfg.TargetOutpoint { + continue + } + if _, ok := b.sourceSpendWatches[outpoint]; ok { + continue + } + + notifyRef := chainsource.MapSpendEvent( + b.selfRef, + func(event chainsource.SpendEvent) Msg { + return &SpendObservedMsg{ + Outpoint: event.Outpoint, + SpendingTxid: event.SpendingTxid, + SpendingHeight: event.SpendingHeight, + } + }, + ) + + // The batch output cannot be spent before its own commitment tx + // confirms, so the min-commitment-height floor (or the bounded + // lookback fallback) is a sound, tight rescan hint. + req := &chainsource.RegisterSpendRequest{ + CallerID: b.sourceSpendCallerID(outpoint), + Outpoint: &outpoint, + HeightHint: b.proofNodeConfHeightHint( + ctx, outpoint.Hash, + ), + NotifyActor: fn.Some(notifyRef), + } + if script := scripts[outpoint]; len(script) > 0 { + req.PkScript = script + } else { + // No batch output script resolved for this source + // outpoint, so the watch is outpoint-only. That is fine + // for Esplora/lwwallet (they match a spend by + // outpoint), but a neutrino backend matches spends via + // the prevout script in the BIP-158 block filter, so it + // cannot detect this sweep -- the swept-source conflict + // would go undetected on neutrino. Leave a breadcrumb + // so a stuck exit is diagnosable rather than silently + // degraded. + b.log.DebugS(ctx, "Source spend watch armed without a "+ + "pkScript; swept-source detection is "+ + "outpoint-only and will not match on a "+ + "neutrino backend", + slog.String( + "source_outpoint", outpoint.String(), + ), + ) + } + + _, err := b.cfg.ChainSource.Ask(ctx, req).Await(ctx).Unpack() + if err != nil { + b.log.WarnS(ctx, "Failed to arm source-commitment "+ + "spend watch; swept-source conflict may go "+ + "undetected", err, + slog.String( + "source_outpoint", outpoint.String(), + ), + ) + + continue + } + + b.sourceSpendWatches[outpoint] = struct{}{} + } +} + +// sourcePkScripts maps each known batch/commitment outpoint to its output +// script, drawn from the target descriptor's ancestry. The batch output is the +// tree root's external input, so its script is exactly what a source spend +// watch needs. Fragments without a resolved tree path (e.g. an empty-ancestry +// legacy descriptor) simply contribute no entry, leaving those watches +// outpoint-only. +func (b *behavior) sourcePkScripts() map[wire.OutPoint][]byte { + if b.desc == nil { + return nil + } + + scripts := make(map[wire.OutPoint][]byte) + for i := range b.desc.Ancestry { + tp := b.desc.Ancestry[i].TreePath + if tp == nil || tp.BatchOutput == nil { + continue + } + + scripts[tp.BatchOutpoint] = tp.BatchOutput.PkScript + } + + return scripts +} + +// unregisterSourceSpendWatches cancels all source-commitment spend watches on +// stop. +func (b *behavior) unregisterSourceSpendWatches(ctx context.Context) { + for outpoint := range b.sourceSpendWatches { + err := b.cfg.ChainSource.Tell( + ctx, &chainsource.UnregisterSpendRequest{ + CallerID: b.sourceSpendCallerID(outpoint), + Outpoint: &outpoint, + }, + ) + if err != nil { + b.log.WarnS(ctx, "Failed to unregister source spend "+ + "watch", err, + slog.String("outpoint", outpoint.String()), + ) + + continue + } + + delete(b.sourceSpendWatches, outpoint) + } +} + +// sourceSpendCallerID returns the stable source-commitment spend-watch +// registration ID. +func (b *behavior) sourceSpendCallerID(outpoint wire.OutPoint) string { + return fmt.Sprintf("unroll-source-spend.%s.%s", + b.cfg.TargetOutpoint.String(), outpoint.String()) +} + // handleSpendObserved processes a chainsource spend notification on the // target or proof-node outpoint. Spend watches are a safety net — they fire // when a proof output is consumed on chain, and we have to classify what we @@ -1767,6 +1945,24 @@ func (b *behavior) handleSpendObserved(ctx context.Context, if msg.Outpoint != (wire.OutPoint{}) { spentOutpoint = msg.Outpoint } + + // A spend of one of the roots' external funding inputs is a + // source-batch conflict: the operator swept the commitment output our + // recovery tree depends on, so the exit is provably impossible rather + // than merely slow. Report it with a message the user can act on + // (wavelength#1050). + if _, isSource := b.sourceSpendWatches[spentOutpoint]; isSource { + reason := fmt.Sprintf("source batch %s was swept by the "+ + "operator (tx %s at height %d); unilateral exit is no "+ + "longer possible", spentOutpoint, msg.SpendingTxid, + msg.SpendingHeight) + + return b.handleEvent(ctx, ax, &FailEvent{ + Reason: reason, + Conflict: true, + }) + } + reason := fmt.Sprintf("watched outpoint %s spent externally by tx %s "+ "at height %d", spentOutpoint, msg.SpendingTxid, msg.SpendingHeight) @@ -2204,6 +2400,7 @@ func (b *behavior) notifyRegistryIfTerminal(ctx context.Context) { FailReason: job.FailReason, HadOnChainFootprint: jobHadOnChainFootprint(job), ExitPolicyKind: b.exitPolicyKind(), + Conflicted: job.Conflicted, } if sweepTxid := effectiveSweepTxid( diff --git a/unroll/actor_test.go b/unroll/actor_test.go index cd143c4d2..eaba77110 100644 --- a/unroll/actor_test.go +++ b/unroll/actor_test.go @@ -25,6 +25,7 @@ import ( "github.com/lightninglabs/wavelength/ledger" "github.com/lightninglabs/wavelength/lib/arkscript" "github.com/lightninglabs/wavelength/lib/recovery" + "github.com/lightninglabs/wavelength/lib/tree" "github.com/lightninglabs/wavelength/txconfirm" "github.com/lightninglabs/wavelength/unrollplan" "github.com/lightninglabs/wavelength/vtxo" @@ -422,6 +423,7 @@ type fakeChainSourceRef struct { blockRef actor.TellOnlyRef[chainsource.BlockEpoch] spendRefs map[wire.OutPoint]spendEventRef spendRegs []wire.OutPoint + spendScrpt map[wire.OutPoint][]byte removedTxes []chainhash.Hash confRefs map[chainhash.Hash]confRef confReqs map[chainhash.Hash]*confReq @@ -540,8 +542,14 @@ func (f *fakeChainSourceRef) Ask(_ context.Context, if msg.Outpoint != nil { outpoint = *msg.Outpoint } + if f.spendScrpt == nil { + f.spendScrpt = make(map[wire.OutPoint][]byte) + } f.spendRefs[outpoint] = msg.NotifyActor.UnwrapOr(nil) f.spendRegs = append(f.spendRegs, outpoint) + f.spendScrpt[outpoint] = append( + []byte(nil), msg.PkScript..., + ) f.mu.Unlock() promise.Complete( fn.Ok[chainsource.ChainSourceResp]( @@ -681,6 +689,15 @@ func (f *fakeChainSourceRef) removedTxSnapshot() []chainhash.Hash { return append([]chainhash.Hash(nil), f.removedTxes...) } +// spendPkScript returns the pkScript a spend watch was registered with for the +// given outpoint, or nil if none was registered or it was outpoint-only. +func (f *fakeChainSourceRef) spendPkScript(outpoint wire.OutPoint) []byte { + f.mu.Lock() + defer f.mu.Unlock() + + return append([]byte(nil), f.spendScrpt[outpoint]...) +} + // fakeSweepWallet is a minimal signer plus wallet-destination test double. type fakeSweepWallet struct{} @@ -2651,6 +2668,182 @@ func TestAbandonedBroadcastRemovalSkipsConfirmedTxids(t *testing.T) { } } +// sourceOutpoint is the external funding input the linear test proof's root +// spends — the round batch/commitment output the recovery tree hangs off of. +var sourceOutpoint = wire.OutPoint{Hash: chainhash.Hash{1}, Index: 0} + +// TestSourceSpendWatchArmed verifies the fix's core mechanism: on +// materialization the actor arms a spend watch on the roots' external funding +// input (the batch commitment output), which nothing watched before +// wavelength#1050 — leaving a swept-source exit stuck in materialization +// forever. +func TestSourceSpendWatchArmed(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + unrollActor, beh, _, _ := newActorHarness(t, proof, desc) + + chainSource, ok := beh.cfg.ChainSource.(*fakeChainSourceRef) + require.True(t, ok) + + mustAsk(t, unrollActor.Ref(), &StartUnrollRequest{ + Height: 100, + Trigger: TriggerManual, + }) + + require.Eventually(t, func() bool { + for _, outpoint := range chainSource.spendRegistrations() { + if outpoint == sourceOutpoint { + return true + } + } + + return false + }, testTimeout, 10*time.Millisecond) +} + +// TestSourceSpendWatchCarriesBatchPkScript verifies the source watch is armed +// WITH the batch output's pkScript when the descriptor ancestry carries it. +// This matters for neutrino: it matches a spend via the prevout script in the +// BIP-158 block filter, so an outpoint-only watch would silently miss the sweep +// (lwwallet/Esplora match by outpoint alone, so they are unaffected). The other +// source-watch tests use an empty-ancestry descriptor and so only exercise the +// outpoint; this one pins the script ride-along. +func TestSourceSpendWatchCarriesBatchPkScript(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + + // Give the descriptor an ancestry fragment whose batch outpoint is the + // proof root's external funding input (sourceOutpoint), carrying a + // distinctive pkScript that must ride along on the spend watch. + batchScript := []byte{txscript.OP_1, 0xab, 0xcd, 0xef} + desc.Ancestry = []vtxo.Ancestry{{ + TreePath: &tree.Tree{ + BatchOutpoint: sourceOutpoint, + BatchOutput: &wire.TxOut{ + Value: 90_000, + PkScript: batchScript, + }, + }, + }} + + unrollActor, beh, _, _ := newActorHarness(t, proof, desc) + + chainSource, ok := beh.cfg.ChainSource.(*fakeChainSourceRef) + require.True(t, ok) + + mustAsk(t, unrollActor.Ref(), &StartUnrollRequest{ + Height: 100, + Trigger: TriggerManual, + }) + + require.Eventually(t, func() bool { + return chainSource.spendPkScript(sourceOutpoint) != nil + }, testTimeout, 10*time.Millisecond) + + require.Equal( + t, batchScript, chainSource.spendPkScript(sourceOutpoint), + "source watch must carry the batch output pkScript so a "+ + "neutrino BIP-158 filter can match the sweep", + ) +} + +// TestSweptSourceFailsActor reproduces wavelength#1050: once the operator +// sweeps the source batch commitment output (a foreign spend of the roots' +// external input), the exit is provably impossible, so the actor must fail +// terminally with a source-conflict reason rather than materialize forever. +func TestSweptSourceFailsActor(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + unrollActor, beh, _, _ := newActorHarness(t, proof, desc) + + chainSource, ok := beh.cfg.ChainSource.(*fakeChainSourceRef) + require.True(t, ok) + + mustAsk(t, unrollActor.Ref(), &StartUnrollRequest{ + Height: 100, + Trigger: TriggerManual, + }) + + require.Eventually(t, func() bool { + for _, outpoint := range chainSource.spendRegistrations() { + if outpoint == sourceOutpoint { + return true + } + } + + return false + }, testTimeout, 10*time.Millisecond) + + // The operator's expired-batch sweep is a foreign tx (not one of our + // proof nodes) consuming the batch commitment output. + operatorSweep := chainhash.Hash{0xaa} + chainSource.emitSpendForOutpoint(t, sourceOutpoint, operatorSweep, 101) + + require.Eventually(t, func() bool { + stateResp, ok := mustAsk( + t, unrollActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + + return stateResp.Phase == PhaseFailed + }, testTimeout, 10*time.Millisecond) + + stateResp, ok := mustAsk( + t, unrollActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + require.Contains(t, stateResp.FailReason, "source batch") + require.Contains(t, stateResp.FailReason, "swept") + require.Contains(t, stateResp.FailReason, sourceOutpoint.String()) + require.Contains(t, stateResp.FailReason, operatorSweep.String()) +} + +// TestOwnRootSpendOfSourceConfirms guards the benign side of the source watch: +// when OUR own root spends the batch output (the exit winning the race), the +// spend notification must be read as a parent confirmation, never as a +// source-conflict failure. +func TestOwnRootSpendOfSourceConfirms(t *testing.T) { + proof := buildLinearProof(t) + desc := testDescriptor(t, proof.TargetOutpoint(), proof.CSVDelay()) + unrollActor, beh, txconfirmRef, _ := newActorHarness(t, proof, desc) + + chainSource, ok := beh.cfg.ChainSource.(*fakeChainSourceRef) + require.True(t, ok) + + mustAsk(t, unrollActor.Ref(), &StartUnrollRequest{ + Height: 100, + Trigger: TriggerManual, + }) + + require.Eventually(t, func() bool { + for _, outpoint := range chainSource.spendRegistrations() { + if outpoint == sourceOutpoint { + return true + } + } + + return false + }, testTimeout, 10*time.Millisecond) + + // Our own root tx spends the batch output: a parent confirmation, not a + // conflict. The actor should advance to requesting the target rather + // than failing. + rootTxid := proof.RootTxids()[0] + chainSource.emitSpendForOutpoint(t, sourceOutpoint, rootTxid, 101) + + require.Eventually(t, func() bool { + return txconfirmRef.requestCountForTxid( + proof.TargetOutpoint().Hash, + ) == 1 + }, testTimeout, 10*time.Millisecond) + + stateResp, ok := mustAsk( + t, unrollActor.Ref(), &GetStateRequest{}, + ).(*GetStateResp) + require.True(t, ok) + require.NotEqual(t, PhaseFailed, stateResp.Phase) +} + // TestConfirmedNodesAdvanceToSweep verifies that node confirmations move the // actor from proof materialization into final sweep submission. func TestConfirmedNodesAdvanceToSweep(t *testing.T) { diff --git a/unroll/db_store.go b/unroll/db_store.go index fc1b8ef47..391611a7c 100644 --- a/unroll/db_store.go +++ b/unroll/db_store.go @@ -145,6 +145,8 @@ func recordFromDB(job db.UnilateralExitJobRecord) RegistryRecord { SweepTxid: sweepTxidFromBytes(job.SweepTxid), RecoverableFailure: job.Status == db.UnilateralExitJobStatusFailedRecoverable, + ConflictedFailure: job.Status == + db.UnilateralExitJobStatusFailedConflicted, } } @@ -196,8 +198,15 @@ func registryExitPolicy(record RegistryRecord, // statusForRecord maps a registry record into the DB status enum, routing a // recoverable (no-footprint) failure to the distinct FailedRecoverable status -// so it round-trips back to RecoverableFailure=true on the next read. +// and a source-batch conflict to the distinct FailedConflicted status, so each +// round-trips back to the matching flag on the next read. A conflict takes +// precedence: it is never a recoverable no-footprint failure, and boot-time +// reconciliation must retire the coin rather than relive it (wavelength#1050). func statusForRecord(record RegistryRecord) db.UnilateralExitJobStatus { + if record.Phase == PhaseFailed && record.ConflictedFailure { + return db.UnilateralExitJobStatusFailedConflicted + } + if record.Phase == PhaseFailed && record.RecoverableFailure { return db.UnilateralExitJobStatusFailedRecoverable } @@ -254,7 +263,8 @@ func phaseFromDB(status db.UnilateralExitJobStatus) Phase { return PhaseCompleted case db.UnilateralExitJobStatusFailed, - db.UnilateralExitJobStatusFailedRecoverable: + db.UnilateralExitJobStatusFailedRecoverable, + db.UnilateralExitJobStatusFailedConflicted: return PhaseFailed default: diff --git a/unroll/db_store_test.go b/unroll/db_store_test.go index 8b5c0c413..a33e72354 100644 --- a/unroll/db_store_test.go +++ b/unroll/db_store_test.go @@ -113,6 +113,49 @@ func TestRecoverableFailureDBRoundTrip(t *testing.T) { }) } +// TestConflictedFailureDBRoundTrip pins the mapping for a source-batch +// conflict: it persists as the dedicated FailedConflicted status, decodes back +// to ConflictedFailure=true (and NOT RecoverableFailure), and stays terminal. +// Boot-time reconciliation relies on this to retire the coin out of pending +// rather than relive it (wavelength#1050). +func TestConflictedFailureDBRoundTrip(t *testing.T) { + t.Parallel() + + rec := RegistryRecord{ + Phase: PhaseFailed, + ConflictedFailure: true, + } + status := statusForRecord(rec) + require.Equal( + t, db.UnilateralExitJobStatusFailedConflicted, status, + ) + require.True(t, status.IsTerminal()) + + got := recordFromDB(db.UnilateralExitJobRecord{Status: status}) + require.Equal(t, PhaseFailed, got.Phase) + require.True(t, got.ConflictedFailure) + require.False(t, got.RecoverableFailure) +} + +// TestConflictTakesPrecedenceOverRecoverable guards the classification order: +// a record flagged both conflicted and recoverable (the child never sets both, +// but the store must fail safe) maps to FailedConflicted so the coin is retired +// rather than relived. +func TestConflictTakesPrecedenceOverRecoverable(t *testing.T) { + t.Parallel() + + rec := RegistryRecord{ + Phase: PhaseFailed, + ConflictedFailure: true, + RecoverableFailure: true, + } + + require.Equal( + t, db.UnilateralExitJobStatusFailedConflicted, + statusForRecord(rec), + ) +} + // TestTriggerDBRoundTrip pins the StartTrigger↔UnilateralExitJobTrigger // mapping so FraudSpend rows round-trip through a dedicated constant // rather than silently decoding as TriggerManual. diff --git a/unroll/fsm_logic.go b/unroll/fsm_logic.go index e9f0df9ac..f87689e77 100644 --- a/unroll/fsm_logic.go +++ b/unroll/fsm_logic.go @@ -101,6 +101,7 @@ func processEventWithJob(ctx context.Context, job *JobState, event Event, case *FailEvent: nextJob.FailReason = e.Reason + nextJob.Conflicted = e.Conflict case *StartEvent: if e.Height > nextJob.Height { diff --git a/unroll/fsm_types.go b/unroll/fsm_types.go index bab30dedb..5c65d950d 100644 --- a/unroll/fsm_types.go +++ b/unroll/fsm_types.go @@ -100,6 +100,15 @@ type JobState struct { // FailReason records a terminal failure reason, if any. FailReason string + // Conflicted marks a terminal failure that was caused by a confirmed + // spend conflicting with the recovery tree — the operator swept a + // source batch commitment output the exit depends on (wavelength#1050). + // It is persisted so the terminal handoff to the VTXO manager stays + // classified as a conflict (retire the coin out of pending, do not + // relive it) even if the child crashes after the failure checkpoint but + // before the registry records it. + Conflicted bool + // SweepAttempts counts sweep build or broadcast failures so the actor // can retry up to maxSweepAttempts before giving up. SweepAttempts int @@ -120,6 +129,7 @@ func (j *JobState) Copy() *JobState { PlannerState: copyPlannerState(j.PlannerState), DeferredCheckpoints: deferred, FailReason: j.FailReason, + Conflicted: j.Conflicted, SweepAttempts: j.SweepAttempts, } @@ -217,6 +227,14 @@ func (e *SweepBroadcastedEvent) eventSealed() {} type FailEvent struct { // Reason is the stable human-readable failure reason. Reason string + + // Conflict marks the failure as a source-batch conflict: a confirmed + // foreign spend consumed a commitment output the recovery tree depends + // on, so the exit is provably impossible (wavelength#1050). It is + // carried into JobState.Conflicted so the terminal handoff retires the + // VTXO out of pending rather than leaving it exit-pending or reliving + // it. + Conflict bool } // eventSealed marks FailEvent as an FSM event. diff --git a/unroll/registry.go b/unroll/registry.go index be41ec765..ffa2e5859 100644 --- a/unroll/registry.go +++ b/unroll/registry.go @@ -82,6 +82,15 @@ type RegistryRecord struct { // reconciliation can recover a VTXO whose recovery notification was // lost before the manager applied it (wavelength#602). RecoverableFailure bool + + // ConflictedFailure is set on a terminal failure caused by a confirmed + // spend conflicting with the recovery tree (the operator swept a source + // batch commitment output the exit depends on). It is persisted as a + // distinct DB status so boot-time reconciliation retires the target + // VTXO out of unilateral-exit (FAILED) — clearing it from pending + // balance — rather than leaving it pending forever or reliving it + // (wavelength#1050). + ConflictedFailure bool } // IsTerminal reports whether the record reached a terminal phase. @@ -964,7 +973,14 @@ func (r *registryBehavior) handleTerminated(ctx context.Context, // A terminal failure with no on-chain footprint is recoverable: the // VTXO never left off-chain custody, so it can be rolled back to live. - recoverable := req.Phase == PhaseFailed && !req.HadOnChainFootprint + // A source-batch conflict is a distinct, non-recoverable terminal: the + // coin is provably gone, so it must be retired out of pending rather + // than relived (wavelength#1050). The child never sets both at once, + // but a conflict is not a no-footprint failure, so it is never + // recoverable. + conflicted := req.Phase == PhaseFailed && req.Conflicted + recoverable := req.Phase == PhaseFailed && !req.HadOnChainFootprint && + !conflicted record := RegistryRecord{ TargetOutpoint: req.Outpoint, @@ -973,6 +989,7 @@ func (r *registryBehavior) handleTerminated(ctx context.Context, FailReason: req.FailReason, SweepTxid: copyHash(req.SweepTxid), RecoverableFailure: recoverable, + ConflictedFailure: conflicted, } if cached, ok := r.pending[req.Outpoint]; ok { @@ -981,6 +998,7 @@ func (r *registryBehavior) handleTerminated(ctx context.Context, record.FailReason = req.FailReason record.SweepTxid = copyHash(req.SweepTxid) record.RecoverableFailure = recoverable + record.ConflictedFailure = conflicted if record.ActorID == "" { record.ActorID = req.ActorID } @@ -1030,10 +1048,13 @@ func (r *registryBehavior) handleTerminated(ctx context.Context, // - PhaseFailed with no on-chain footprint: the unroll never broadcast, // so the VTXO is still live from the operator's perspective. Ask the // manager to roll it back to live (ExitOutcomeRecoverable). +// - PhaseFailed from a source-batch conflict: a confirmed spend consumed a +// commitment output the exit depends on, so the coin is provably gone. +// Ask the manager to retire it out of pending (ExitOutcomeConflicted). // - PhaseCompleted: the exit was swept and confirmed on-chain, so ask the // manager to retire the VTXO to spent (ExitOutcomeConfirmed). -// - PhaseFailed with an on-chain footprint: the exit has begun on-chain; -// leave the VTXO in unilateral-exit (no notification). +// - PhaseFailed with an on-chain footprint but no conflict: the exit has +// begun on-chain; leave the VTXO in unilateral-exit (no notification). // // Delivery is best-effort: a failed Tell is logged, not retried. This is the // fast runtime path; the durable backstop is the VTXO manager's startup @@ -1054,6 +1075,12 @@ func (r *registryBehavior) notifyVTXOExit(ctx context.Context, case req.Phase == PhaseCompleted: outcome = vtxo.ExitOutcomeConfirmed + case req.Phase == PhaseFailed && req.Conflicted: + // A confirmed conflicting spend defeated the exit (the operator + // swept a source batch commitment output). Retire the coin out + // of pending rather than relive it (wavelength#1050). + outcome = vtxo.ExitOutcomeConflicted + case req.Phase == PhaseFailed && !req.HadOnChainFootprint: outcome = vtxo.ExitOutcomeRecoverable @@ -1735,7 +1762,8 @@ func sameRegistryRecord(a, b RegistryRecord) bool { a.ExitPolicyRef != b.ExitPolicyRef || a.Phase != b.Phase || a.FailReason != b.FailReason || - a.RecoverableFailure != b.RecoverableFailure { + a.RecoverableFailure != b.RecoverableFailure || + a.ConflictedFailure != b.ConflictedFailure { return false } diff --git a/unroll/registry_exit_test.go b/unroll/registry_exit_test.go index 852c07d9a..4b6a07fa2 100644 --- a/unroll/registry_exit_test.go +++ b/unroll/registry_exit_test.go @@ -209,6 +209,46 @@ func TestRegistryForwardsCompletionAsConfirmed(t *testing.T) { require.Equal(t, vtxo.ExitOutcomeConfirmed, notes[0].Outcome) } +// TestRegistryForwardsSourceConflictAsConflicted verifies a terminal failure +// flagged as a source-batch conflict is forwarded as ExitOutcomeConflicted — +// even though it has an on-chain footprint — so the VTXO manager retires the +// coin out of pending rather than leaving it exit-pending forever +// (wavelength#1050). It also pins the durable record to ConflictedFailure so a +// restart reconciles the same outcome. +func TestRegistryForwardsSourceConflictAsConflicted(t *testing.T) { + target := wire.OutPoint{Hash: chainhash.Hash{5}, Index: 0} + behavior, observer := newExitObserverRegistry(target) + + const reason = "source batch was swept by the operator; unilateral " + + "exit is no longer possible" + _, err := behavior.handleTerminated(t.Context(), &UnrollTerminatedMsg{ + Outpoint: target, + ActorID: actorIDForTarget(target), + Phase: PhaseFailed, + FailReason: reason, + // A conflict has broadcast the doomed root, so it bears a + // footprint — yet it must still be forwarded (not held in + // exit). + HadOnChainFootprint: true, + Conflicted: true, + }).Unpack() + require.NoError(t, err) + + notes := observer.notifications() + require.Len(t, notes, 1) + require.Equal(t, target, notes[0].Outpoint) + require.Equal(t, vtxo.ExitOutcomeConflicted, notes[0].Outcome) + require.Equal(t, reason, notes[0].Reason) + + // The durable record is classified conflicted (and not recoverable), so + // boot-time reconciliation retires the coin rather than reliving it. + // The record→DB-status mapping itself is pinned in db_store_test.go. + persisted, ok := behavior.pending[target] + require.True(t, ok) + require.True(t, persisted.ConflictedFailure) + require.False(t, persisted.RecoverableFailure) +} + // TestRegistryRecoversCleanFailureEndToEnd is the in-process integration test // for the wavelength#602 recovery path. Unlike the unit tests above (which // call handleTerminated directly with a synthetic UnrollTerminatedMsg), this diff --git a/unroll/registry_messages.go b/unroll/registry_messages.go index e6dc0a538..d2277b44b 100644 --- a/unroll/registry_messages.go +++ b/unroll/registry_messages.go @@ -169,6 +169,14 @@ type UnrollTerminatedMsg struct { // recovery-only target is held in exit rather than relived as a live // coin on a recoverable failure (wavelength#602). ExitPolicyKind ExitPolicyKind + + // Conflicted reports that the terminal failure was a source-batch + // conflict — a confirmed foreign spend consumed a commitment output the + // recovery tree depends on, so the exit is provably impossible + // (wavelength#1050). The registry maps it to ExitOutcomeConflicted so + // the VTXO manager retires the coin out of pending (FAILED) rather than + // leaving it exit-pending forever or reliving it as live. + Conflicted bool } // MessageType returns the stable message type identifier. diff --git a/unroll/snapshot.go b/unroll/snapshot.go index 9ba2733af..c90c87ac9 100644 --- a/unroll/snapshot.go +++ b/unroll/snapshot.go @@ -93,6 +93,13 @@ const ( // checkpointExitPolicyRefRecordType carries the policy-specific // durable-state reference. checkpointExitPolicyRefRecordType tlv.Type = 21 + + // checkpointConflictedRecordType is optional; present (a 1-byte true) + // only when the terminal failure was a source-batch conflict — the + // operator swept a commitment output the exit depends on. It is omitted + // entirely when false so a non-conflicted checkpoint encodes to the + // same bytes it did before this field existed (wavelength#1050). + checkpointConflictedRecordType tlv.Type = 23 ) // actorCheckpoint is the durable checkpoint shape for one VTXO unroll actor. @@ -106,6 +113,7 @@ type actorCheckpoint struct { ExitPolicyRef string SweepTx *wire.MsgTx Fail string + Conflicted bool SweepAttempts int DeferredCheckpoints []DeferredCheckpoint } @@ -226,6 +234,19 @@ func encodeCheckpoint(value *actorCheckpoint) ([]byte, error) { ) } + // Conflicted (type 23) is the highest record type, so it is appended + // last to keep records in the ascending order tlv.NewStream requires. + // It is emitted only when true, so a non-conflicted checkpoint is + // byte-for- byte identical to one written before this field existed. + if value.Conflicted { + conflicted := uint8(1) + records = append( + records, tlv.MakePrimitiveRecord( + checkpointConflictedRecordType, &conflicted, + ), + ) + } + stream, err := tlv.NewStream(records...) if err != nil { return nil, fmt.Errorf("create checkpoint stream: %w", err) @@ -266,6 +287,7 @@ func decodeCheckpoint(raw []byte) (*actorCheckpoint, error) { deferredBytes []byte policyKind []byte policyRef []byte + conflicted uint8 ) stream, err := tlv.NewStream( @@ -302,6 +324,9 @@ func decodeCheckpoint(raw []byte) (*actorCheckpoint, error) { tlv.MakePrimitiveRecord( checkpointExitPolicyRefRecordType, &policyRef, ), + tlv.MakePrimitiveRecord( + checkpointConflictedRecordType, &conflicted, + ), ) if err != nil { return nil, fmt.Errorf("create checkpoint stream: %w", err) @@ -358,6 +383,10 @@ func decodeCheckpoint(raw []byte) (*actorCheckpoint, error) { checkpoint.Fail = string(failBytes) } + if _, ok := parsed[checkpointConflictedRecordType]; ok { + checkpoint.Conflicted = conflicted != 0 + } + if _, ok := parsed[checkpointDeferredCheckpointsRecordType]; ok { checkpoints, err := decodeDeferredCheckpoints(deferredBytes) if err != nil { diff --git a/unroll/snapshot_test.go b/unroll/snapshot_test.go index 33c40a62f..f278b79c8 100644 --- a/unroll/snapshot_test.go +++ b/unroll/snapshot_test.go @@ -111,6 +111,24 @@ func TestCheckpointCodecRoundTripHandcrafted(t *testing.T) { }}, }, }, + { + name: "failed_conflicted", + checkpoint: &actorCheckpoint{ + Version: checkpointVersion, + Height: 314626, + Started: true, + Trigger: TriggerManual, + State: unrollplan.State{ + InFlightTxids: []chainhash.Hash{ + targetTxid, + }, + }, + Fail: "source batch swept by the operator; " + + "unilateral exit is no longer possible", + Conflicted: true, + SweepAttempts: 0, + }, + }, } for _, tc := range cases { @@ -135,6 +153,37 @@ func TestCheckpointCodecRoundTripHandcrafted(t *testing.T) { } } +// TestCheckpointConflictedRecordOmittedWhenFalse guards backward +// compatibility: a non-conflicted checkpoint must encode without the +// conflicted record, so it is byte-identical to one written before the field +// existed. Only a true value adds the record (wavelength#1050). +func TestCheckpointConflictedRecordOmittedWhenFalse(t *testing.T) { + base := &actorCheckpoint{ + Version: checkpointVersion, + Height: 100, + Started: true, + Trigger: TriggerManual, + State: unrollplan.State{}, + Fail: "some failure", + } + + notConflicted, err := encodeCheckpoint(base) + require.NoError(t, err) + + base.Conflicted = true + conflicted, err := encodeCheckpoint(base) + require.NoError(t, err) + + // The false encoding omits the record entirely; the true encoding is + // strictly longer by exactly that record. + require.Less(t, len(notConflicted), len(conflicted)) + + // The false encoding round-trips to Conflicted=false. + decoded, err := decodeCheckpoint(notConflicted) + require.NoError(t, err) + require.False(t, decoded.Conflicted) +} + // TestCheckpointCodecVersionMismatch verifies that a checkpoint encoded with // an unsupported version byte is rejected by the decoder. This is the safety // net that prevents us from silently loading data written by an older or @@ -572,6 +621,7 @@ func requireCheckpointEqual(t *testing.T, want, got *actorCheckpoint) { require.Equal(t, want.Started, got.Started) require.Equal(t, want.Trigger, got.Trigger) require.Equal(t, want.Fail, got.Fail) + require.Equal(t, want.Conflicted, got.Conflicted) require.Equal(t, want.SweepAttempts, got.SweepAttempts) require.ElementsMatch( t, want.DeferredCheckpoints, got.DeferredCheckpoints, diff --git a/unroll/state_snapshot.go b/unroll/state_snapshot.go index d0693ba6c..c75033da7 100644 --- a/unroll/state_snapshot.go +++ b/unroll/state_snapshot.go @@ -39,6 +39,7 @@ func checkpointFromState(state State, sweepTx *wire.MsgTx) *actorCheckpoint { checkpoint.State.Sweep.Txid = fn.Some(*sweepTxid) } checkpoint.Fail = job.FailReason + checkpoint.Conflicted = job.Conflicted checkpoint.SweepAttempts = job.SweepAttempts return checkpoint @@ -109,6 +110,7 @@ func stateFromCheckpoint(checkpoint *actorCheckpoint) State { PlannerState: copyPlannerState(checkpoint.State), DeferredCheckpoints: deferred, FailReason: checkpoint.Fail, + Conflicted: checkpoint.Conflicted, SweepAttempts: checkpoint.SweepAttempts, } diff --git a/vtxo/events.go b/vtxo/events.go index 26031e97a..03906af6f 100644 --- a/vtxo/events.go +++ b/vtxo/events.go @@ -187,3 +187,27 @@ func (e *ExitConfirmedEvent) VTXOActorMsg() {} func (e *ExitConfirmedEvent) MessageType() string { return "ExitConfirmedEvent" } + +// ExitConflictedEvent is delivered to a VTXO actor in UnilateralExitState when +// the downstream unroll job terminated because a confirmed foreign spend +// conflicts with the recovery tree — the operator swept a source batch +// commitment output the exit depends on (wavelength#1050). The VTXO is retired +// to the terminal FailedState and the actor is reaped: the exit is provably +// impossible, so the coin must leave pending balance and read as FAILED, but it +// must NOT roll back to live (as a clean recoverable failure would) because the +// operator has taken the underlying output. +type ExitConflictedEvent struct { + actor.BaseMessage + + // Reason explains the conflict, for logging and the failed VTXO's audit + // trail. + Reason string +} + +// VTXOActorMsg implements actormsg.VTXOActorMsg marker interface. +func (e *ExitConflictedEvent) VTXOActorMsg() {} + +// MessageType returns the message type for logging. +func (e *ExitConflictedEvent) MessageType() string { + return "ExitConflictedEvent" +} diff --git a/vtxo/manager.go b/vtxo/manager.go index 59468c6f5..916247543 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -1236,6 +1236,9 @@ func (m *Manager) handleExitOutcome(ctx context.Context, case ExitOutcomeConfirmed: return m.confirmExitedVTXO(ctx, req) + case ExitOutcomeConflicted: + return m.conflictExitedVTXO(ctx, req) + default: return fn.Err[ManagerResp]( fmt.Errorf("unknown exit outcome: %d", req.Outcome), @@ -1397,6 +1400,102 @@ func (m *Manager) confirmExitedVTXO(ctx context.Context, return fn.Ok[ManagerResp](&ExitOutcomeResp{}) } +// conflictExitedVTXO retires a VTXO to the terminal FailedState after its +// unilateral exit was defeated by a confirmed conflicting spend — the operator +// swept a source batch commitment output the recovery tree depends on, so the +// unilateral exit is provably impossible (wavelength#1050). The operator can +// only sweep that output past batch expiry, so the coin is expired, not lost: +// its value is still recoverable through the ordinary refresh path +// (wavelength#1000). Unlike confirmExitedVTXO the exit did NOT succeed, and +// unlike a terminal failure the value is NOT gone — so the VTXO is routed to +// the non-terminal ExpiredState (quarantined from coin selection, reclaimed by +// the next block epoch) rather than the terminal FailedState. When the actor is +// alive it drives the ExitConflictedEvent through the FSM; otherwise it +// re-materializes an expired actor from the persisted descriptor so a daemon +// that restarted mid-exit still reclaims the coin. +func (m *Manager) conflictExitedVTXO(ctx context.Context, + req *ExitOutcomeNotification) fn.Result[ManagerResp] { + + // A recovery-only target (a non-standard exit policy, e.g. a vHTLC + // refund) must never be reclaimed into the live coin set: it is a + // swap-contract output, not spendable wallet liquidity, so refreshing + // it into a round would be wrong. Hold it in UnilateralExit and let the + // owning recovery subsystem decide the terminal outcome, mirroring + // recoverExitedVTXO's recovery-only guard. + if req.ExitPolicyKind.Valid() { + m.logger(ctx).InfoS(ctx, "Holding recovery-only VTXO in exit "+ + "after source-batch conflict", + slog.String("outpoint", req.Outpoint.String()), + slog.String( + "exit_policy_kind", string(req.ExitPolicyKind), + ), + slog.String("reason", req.Reason), + ) + + return fn.Ok[ManagerResp](&ExitOutcomeResp{}) + } + + if actorRef, ok := m.actors[req.Outpoint]; ok { + _, err := m.askVTXOActor(ctx, actorRef, &ExitConflictedEvent{ + Reason: req.Reason, + }).Unpack() + if err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("ask exit-conflicted: %w", err), + ) + } + + return fn.Ok[ManagerResp](&ExitOutcomeResp{}) + } + + // No live actor: re-materialize one in ExpiredState from the persisted + // descriptor. This covers the restart case where an exiting VTXO was + // not part of the live-recovery set, so no actor was spawned at Start. + // Only act on a VTXO still in the exit state so a re-delivered conflict + // cannot stomp a VTXO that has since been reissued or recovered. + descriptor, err := m.cfg.Store.GetVTXO(ctx, req.Outpoint) + if err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("load vtxo for conflict: %w", err), + ) + } + if descriptor == nil || descriptor.Status != VTXOStatusUnilateralExit { + return fn.Ok[ManagerResp](&ExitOutcomeResp{}) + } + + // Spawn the expired actor BEFORE persisting the status flip, mirroring + // recoverExitedVTXO: the actor is what drives the reclaim, so if we + // persisted Expired first and the spawn then failed, the VTXO would be + // expired in the DB with nothing reclaiming it until the next restart. + // A failed status write is re-converged by boot reconciliation (the + // VTXO stays in unilateral-exit on disk, so the next boot re-drives + // this). + descriptor.Status = VTXOStatusExpired + + ref, err := m.spawnVTXOActor(ctx, descriptor) + if err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("respawn conflicted vtxo actor: %w", err), + ) + } + m.actors[req.Outpoint] = ref + + if err := m.cfg.Store.UpdateVTXOStatus( + ctx, req.Outpoint, VTXOStatusExpired, + ); err != nil { + return fn.Err[ManagerResp]( + fmt.Errorf("persist expired status: %w", err), + ) + } + + m.logger(ctx).InfoS(ctx, "Routed conflicted exit to expired reclaim", + slog.String("outpoint", req.Outpoint.String()), + slog.String("reason", req.Reason), + ) + + return fn.Ok[ManagerResp](&ExitOutcomeResp{}) +} + // handleVTXOTerminated removes a VTXO actor from tracking when it reaches // a terminal state (Forfeited, Failed, etc.). func (m *Manager) handleVTXOTerminated(ctx context.Context, diff --git a/vtxo/manager_exit_test.go b/vtxo/manager_exit_test.go index 31c101881..bf7383c77 100644 --- a/vtxo/manager_exit_test.go +++ b/vtxo/manager_exit_test.go @@ -84,6 +84,127 @@ func TestHandleExitOutcomeConfirmedDrivesActorToSpent(t *testing.T) { ) } +// TestHandleExitOutcomeConflictedDrivesActorToExpired verifies a source-batch +// conflict drives the live actor's FSM to the non-terminal ExpiredState — not +// back to live (as a recoverable failure would), not to spent, and not to a +// terminal Failed — so the coin is quarantined from coin selection but stays +// reclaimable through the ordinary refresh path (wavelength#1050 / #1000). +func TestHandleExitOutcomeConflictedDrivesActorToExpired(t *testing.T) { + t.Parallel() + + vtxo := makeDescriptor(t, 30_745, 1) + mgr, _, ref := newExitTestManager(t, vtxo, &UnilateralExitState{ + VTXO: vtxo, + Reason: "manual unroll", + }) + + resp := mgr.Receive(t.Context(), &ExitOutcomeNotification{ + Outpoint: vtxo.Outpoint, + Outcome: ExitOutcomeConflicted, + Reason: "source batch swept by the operator", + }) + _, err := resp.Unpack() + require.NoError(t, err) + + require.IsType( + t, &ExpiredState{}, ref.state, + "conflicted outcome should route the actor to expired reclaim", + ) +} + +// TestHandleExitOutcomeConflictedHoldsRecoveryOnlyTarget verifies that a +// source-batch conflict does NOT reclaim a recovery-only target (a non-standard +// exit policy, e.g. a vHTLC refund) into the wallet: refreshing a swap-contract +// output would be wrong, so the live actor is held in UnilateralExitState and +// the owning recovery subsystem decides the terminal outcome. +func TestHandleExitOutcomeConflictedHoldsRecoveryOnlyTarget(t *testing.T) { + t.Parallel() + + vtxo := makeDescriptor(t, 1_799, 8) + mgr, _, ref := newExitTestManager(t, vtxo, &UnilateralExitState{ + VTXO: vtxo, + Reason: "vhtlc recovery", + }) + + resp := mgr.Receive(t.Context(), &ExitOutcomeNotification{ + Outpoint: vtxo.Outpoint, + Outcome: ExitOutcomeConflicted, + Reason: "source batch swept by the operator", + ExitPolicyKind: actormsg.ExitPolicyVHTLCRefundWithoutReceiver, + }) + _, err := resp.Unpack() + require.NoError(t, err) + + require.IsType( + t, &UnilateralExitState{}, ref.state, + "recovery-only target must not be reclaimed via refresh", + ) +} + +// TestHandleExitOutcomeConflictedNoActorHoldsRecoveryOnlyTarget verifies the +// store-fallback path also holds a recovery-only target: with no live actor, +// the conflicted outcome must not load the descriptor or spawn/persist. The +// guard short-circuits before any store access. +func TestHandleExitOutcomeConflictedNoActorHoldsRecoveryOnlyTarget( + t *testing.T) { + + t.Parallel() + + vtxo := makeDescriptor(t, 1_799, 10) + vtxo.Status = VTXOStatusUnilateralExit + store := &MockVTXOStore{} + mgr := &Manager{ + cfg: &ManagerConfig{ + Store: store, + }, + actors: make(map[wire.OutPoint]VTXOActorRef), + } + + resp := mgr.Receive(t.Context(), &ExitOutcomeNotification{ + Outpoint: vtxo.Outpoint, + Outcome: ExitOutcomeConflicted, + ExitPolicyKind: actormsg.ExitPolicyVHTLCRefundWithoutReceiver, + }) + _, err := resp.Unpack() + require.NoError(t, err) + + store.AssertNotCalled(t, "GetVTXO") + store.AssertNotCalled(t, "UpdateVTXOStatus") +} + +// TestHandleExitOutcomeConflictedNoActorSkipsNonExiting verifies the +// idempotency guard on the store-fallback path: a re-delivered conflict for a +// VTXO that has since moved off the exit state (e.g. already reissued or +// recovered) is a no-op — it must not spawn an actor or overwrite the status. +func TestHandleExitOutcomeConflictedNoActorSkipsNonExiting(t *testing.T) { + t.Parallel() + + vtxo := makeDescriptor(t, 30_745, 9) + vtxo.Status = VTXOStatusLive + store := &MockVTXOStore{} + mgr := &Manager{ + cfg: &ManagerConfig{ + Store: store, + }, + actors: make(map[wire.OutPoint]VTXOActorRef), + } + + // The conflict path loads the descriptor to guard against reclaiming a + // VTXO that has since moved off the exit state. + store.On("GetVTXO", t.Context(), vtxo.Outpoint).Return(vtxo, nil) + + resp := mgr.Receive(t.Context(), &ExitOutcomeNotification{ + Outpoint: vtxo.Outpoint, + Outcome: ExitOutcomeConflicted, + Reason: "source batch swept by the operator", + }) + _, err := resp.Unpack() + require.NoError(t, err) + + store.AssertExpectations(t) + store.AssertNotCalled(t, "UpdateVTXOStatus") +} + // TestHandleExitOutcomeRecoverableHoldsRecoveryOnlyTarget verifies that a // recoverable exit failure does NOT relive a recovery-only target (a // non-standard exit policy, e.g. a vHTLC refund) into the live coin set: the diff --git a/vtxo/messages.go b/vtxo/messages.go index ec257335e..0c513cd59 100644 --- a/vtxo/messages.go +++ b/vtxo/messages.go @@ -182,6 +182,18 @@ const ( // confirmed on-chain, so the VTXO should be retired to the terminal // SpentState. ExitOutcomeConfirmed + + // ExitOutcomeConflicted indicates the unilateral exit is provably + // impossible because a confirmed foreign spend conflicts with the + // recovery tree — the operator swept a source batch commitment output + // the exit depends on (wavelength#1050). The exit did NOT succeed + // (unlike ExitOutcomeConfirmed), but the coin is NOT lost: the operator + // can only sweep that output past batch expiry, so the VTXO is expired + // and its value is still recoverable through the ordinary refresh path + // (wavelength#1000). The VTXO manager routes it to the non-terminal + // ExpiredState — quarantined from coin selection, reclaimed by the next + // block epoch — rather than retiring it to a terminal FailedState. + ExitOutcomeConflicted ) // String returns a human-readable label for the exit outcome. @@ -193,6 +205,9 @@ func (o ExitOutcome) String() string { case ExitOutcomeConfirmed: return "confirmed" + case ExitOutcomeConflicted: + return "conflicted" + default: return "unknown" } diff --git a/vtxo/transitions.go b/vtxo/transitions.go index ec68ee6cc..76402f9b4 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -108,13 +108,14 @@ func (s *LiveState) ProcessEvent(ctx context.Context, event VTXOEvent, case *ForceUnrollEvent: return s.handleForceUnroll(ctx, evt) - case *ExitFailedEvent, *ExitConfirmedEvent: + case *ExitFailedEvent, *ExitConfirmedEvent, *ExitConflictedEvent: // A duplicate or stale exit-outcome event for a VTXO that is // already live (e.g. boot reconciliation re-delivering a // recovery that already landed). Idempotent no-op: the VTXO is - // live, which is the recovered state. ExitConfirmedEvent should - // never reach a live VTXO, but ignoring it is safer than - // retiring a live coin to spent on a stray signal. + // live, which is the recovered state. ExitConfirmedEvent and + // ExitConflictedEvent should never reach a live VTXO, but + // ignoring them is safer than retiring a live coin on a stray + // signal. return &VTXOStateTransition{ NextState: s, }, nil @@ -1484,7 +1485,7 @@ func (s *ForfeitedState) ProcessEvent(_ context.Context, _ VTXOEvent, // alive to observe the outcome: a clean failure rolls the VTXO back to // LiveState, an on-chain confirmation retires it to the terminal // SpentState, and everything else self-loops while the exit is in flight. -func (s *UnilateralExitState) ProcessEvent(_ context.Context, event VTXOEvent, +func (s *UnilateralExitState) ProcessEvent(ctx context.Context, event VTXOEvent, _ *VTXOEnvironment) (*VTXOStateTransition, error) { switch evt := event.(type) { @@ -1566,6 +1567,63 @@ func (s *UnilateralExitState) ProcessEvent(_ context.Context, event VTXOEvent, }), }, nil + case *ExitConflictedEvent: + // A confirmed foreign spend conflicts with the recovery tree + // (the operator swept a source batch commitment output the exit + // depends on): the unilateral exit is provably impossible. The + // operator can only sweep that output past batch expiry, so the + // coin is expired, not lost — its value is still recoverable + // through the ordinary refresh path (wavelength#1000). Route it + // to the non-terminal ExpiredState rather than the terminal + // FailedState: this quarantines the value from coin selection + // (its lineage is dead) while the next block epoch drives the + // cooperative reclaim. Unlike ExitConfirmedEvent this did not + // spend our coin, and unlike a terminal failure the value is + // recoverable, so — like the recoverable ExitFailedEvent — the + // actor stays alive and emits no terminated notification + // (wavelength#1050). + // + // Soundness of the ExpiredState landing: ExpiredState's + // block-epoch handler rolls a VTXO back to LiveState when + // CheckExpiry reports it is NOT expired — a relive that must + // never happen to a dead-lineage coin. It provably cannot fire + // for a conflicted coin. The operator can only spend a batch + // commitment output past that batch's expiry, and a VTXO's + // BatchExpiry is the most-restrictive expiry across every + // contributing commitment (the operator-supplied value the + // client trusts here as everywhere: see + // oor.IncomingVTXOMetadata.BatchExpiry, "most-restrictive + // across all contributing rounds"). So the confirmed spend we + // just observed proves currentHeight >= swept-source expiry >= + // this VTXO's BatchExpiry, i.e. CheckExpiry reports Expired at + // this height and every height after it. The rollback branch is + // therefore unreachable for a conflicted coin and the only exit + // from ExpiredState is the cooperative reclaim, across restarts + // too (a respawn re-enters ExpiredState at a height that is + // still past BatchExpiry). Pinned by + // TestUnilateralExitConflictReclaimsWhenExpired. + build.LoggerFromContext(ctx).WithPrefix(Subsystem).InfoS( + ctx, "Source-batch conflict: routing exit to expired "+ + "reclaim", + slog.String("outpoint", s.VTXO.Outpoint.String()), + slog.String("reason", evt.Reason), + ) + + return &VTXOStateTransition{ + NextState: &ExpiredState{ + VTXO: s.VTXO, + ObservedHeight: s.LastCheckedHeight, + }, + NewEvents: fn.Some(VTXOEmittedEvent{ + Outbox: []VTXOOutMsg{ + &VTXOStatusUpdate{ + Outpoint: s.VTXO.Outpoint, + NewStatus: VTXOStatusExpired, + }, + }, + }), + }, nil + default: // Still exiting (block epochs, a duplicate ForceUnroll, resume, // stray admission requests): self-loop. The exit is already in diff --git a/vtxo/transitions_test.go b/vtxo/transitions_test.go index bb21ecebf..ede64f013 100644 --- a/vtxo/transitions_test.go +++ b/vtxo/transitions_test.go @@ -503,6 +503,101 @@ func TestUnilateralExitConfirms(t *testing.T) { require.Equal(t, "Spent", term.FinalState) } +// TestUnilateralExitConflicts verifies that an ExitConflictedEvent routes the +// VTXO to the non-terminal ExpiredState (not back to live, not to spent, not to +// a terminal Failed) and emits NO VTXOTerminatedNotification, so the actor +// stays alive to reclaim the coin. This is the swept-source path: the operator +// can only sweep the source batch past expiry, so the coin is expired, not +// lost, and is recoverable through the ordinary refresh path +// (wavelength#1050 / #1000). +func TestUnilateralExitConflicts(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + + h.withState(&UnilateralExitState{ + VTXO: vtxo, + Reason: "manual unroll", + LastCheckedHeight: 200, + }) + + h.store.On( + "UpdateVTXOStatus", h.ctx, vtxo.Outpoint, VTXOStatusExpired, + ).Return(nil) + + const reason = "source batch swept by the operator; unilateral exit " + + "is no longer possible" + _, err := h.sendEvent(&ExitConflictedEvent{Reason: reason}) + require.NoError(t, err) + + expired := assertState[*ExpiredState](h) + require.Equal(t, int32(200), expired.ObservedHeight) + update := assertOutboxContains[*VTXOStatusUpdate](h) + require.Equal(t, VTXOStatusExpired, update.NewStatus) + // No terminated notification: ExpiredState is non-terminal so the actor + // survives to drive the reclaim. + assertOutboxLacks[*VTXOTerminatedNotification](h) +} + +// TestUnilateralExitConflictReclaimsWhenExpired pins the soundness of routing a +// source-batch conflict to ExpiredState (wavelength#1050): the following block +// epoch must drive the cooperative reclaim and must NEVER roll the coin back to +// LiveState. ExpiredState's block-epoch handler relives a coin when CheckExpiry +// reports it is not expired; that branch is unreachable for a conflicted coin +// because the operator can only sweep a source batch output past its expiry and +// BatchExpiry is the most-restrictive expiry across the VTXO's contributing +// commitments — so the conflict is always observed at height >= BatchExpiry. +// This test exercises that guaranteed condition and asserts the reclaim, not a +// relive. +func TestUnilateralExitConflictReclaimsWhenExpired(t *testing.T) { + t.Parallel() + + h := newVTXOTestHarness(t) + vtxo := h.newTestDescriptor() + + // The default test descriptor has BatchExpiry = 1000. A source-batch + // sweep can only confirm past that height, so the conflict — and every + // block epoch after it — is observed at a height already past expiry. + const conflictHeight = 1005 + + h.withState(&UnilateralExitState{ + VTXO: vtxo, + Reason: "manual unroll", + LastCheckedHeight: conflictHeight, + }) + + // The conflict routes the coin to the non-terminal ExpiredState. + _, err := h.sendEvent(&ExitConflictedEvent{ + Reason: "source batch swept by the operator", + }) + require.NoError(t, err) + require.IsType(t, &ExpiredState{}, h.currentState) + + // The next block epoch, still past BatchExpiry, must drive the + // cooperative reclaim (ForfeitRequest -> PendingForfeit) rather than + // relive the dead-lineage coin as spendable. + _, err = h.sendEvent(h.newBlockEpochEvent(conflictHeight)) + require.NoError(t, err) + + // PendingForfeitState (not LiveState) proves the coin was reclaimed, + // not relived, and a ForfeitRequest was dispatched to start the + // refresh. + assertState[*PendingForfeitState](h) + assertOutboxContains[*ForfeitRequest](h) + + // Belt-and-suspenders: no Live status update was ever emitted, so the + // coin never re-entered the spendable set. + for _, msg := range h.outboxMessages { + if u, ok := msg.(*VTXOStatusUpdate); ok { + require.NotEqual( + t, VTXOStatusLive, u.NewStatus, + "conflicted coin must never be relived to Live", + ) + } + } +} + // TestUnilateralExitSelfLoopsWhileExiting verifies that truly inert events // received while the exit is in flight (block epochs, resume) leave the VTXO // in UnilateralExitState and emit nothing: the exit is already at the chain diff --git a/waved/rpc_server.go b/waved/rpc_server.go index 338c3007f..94c29346d 100644 --- a/waved/rpc_server.go +++ b/waved/rpc_server.go @@ -5623,12 +5623,14 @@ func unrollJobStatusToProto( case db.UnilateralExitJobStatusCompleted: return waverpc.UnrollJobStatus_UNROLL_JOB_STATUS_COMPLETED - // Both terminal failure flavours surface as FAILED to clients. The - // recoverable variant is an internal distinction used by boot-time - // reconciliation to roll a no-footprint failure back to live; the - // user-visible job still failed. + // All terminal failure flavours surface as FAILED to clients. The + // recoverable and conflicted variants are internal distinctions used by + // boot-time reconciliation (roll a no-footprint failure back to live; + // retire a source-batch conflict out of pending); the user-visible job + // still failed. case db.UnilateralExitJobStatusFailed, - db.UnilateralExitJobStatusFailedRecoverable: + db.UnilateralExitJobStatusFailedRecoverable, + db.UnilateralExitJobStatusFailedConflicted: return waverpc.UnrollJobStatus_UNROLL_JOB_STATUS_FAILED default: diff --git a/waved/server.go b/waved/server.go index bec65f659..dc3b40b96 100644 --- a/waved/server.go +++ b/waved/server.go @@ -4462,6 +4462,15 @@ func resolveExitOutcome(ctx context.Context, ), }), nil + case db.UnilateralExitJobStatusFailedConflicted: + return fn.Some(vtxo.ExitOutcomeResolution{ + Outcome: vtxo.ExitOutcomeConflicted, + Reason: job.LastError, + ExitPolicyKind: actormsg.ExitPolicyKind( + job.ExitPolicyKind, + ), + }), nil + default: return fn.None[vtxo.ExitOutcomeResolution](), nil }