multi: reclaim exits whose source batch was swept - #1065
Conversation
|
@claude please review this PR. It closes #1050: a unilateral exit could sit in MATERIALIZING forever This touches the VTXO-manager FSM and the unroll terminal/durability path,
|
|
Claude finished @ellemouton's task in 5m 23s —— View job Review: unroll — fail and retire exits whose source batch was sweptI read the full diff plus the surrounding FSM, registry, manager-reconcile, and proof-assembler code. This is a careful, well-documented change and the four correctness pillars all hold up. Findings organized by the areas you flagged, then minor notes. ✅ 1. The "don't relive" invariant — correctThe precedence is right in all three places:
✅ 2. Restart durability — correct, including the crash window
✅ 3. Source-watch classification — correctThe case split in
I confirmed the batch commitment tx is not a proof node (that's exactly why ✅ 4. Detection completeness — correct
Minor notes (non-blocking)a) Neutrino pkScript matching depends on an index alignment that isn't asserted. b) c) Reorg after a confirmed conflict. A foreign spend drives a terminal d) Nice touches worth calling out: the TLV-23 "omit when false → byte-identical old checkpoints" encoding, the Verdict: The four invariants you asked me to scrutinise are all correctly handled, the durability crash-window is covered, and test coverage is strong across the FSM, registry, manager (both actor and no-actor paths), codec round-trip, and recovery accessor. My only substantive suggestion is note (a): give neutrino source-conflict detection some explicit coverage/guarding, since it silently degrades to outpoint-only if the batch-output index assumption ever breaks. Everything else is polish. I was unable to run |
00dbbb0 to
c86a4e3
Compare
|
Thanks for the thorough review — addressed note (a) (the neutrino source-conflict coverage gap) in the latest push:
On the index-alignment observation: agreed it holds today because Notes (b) reorg / (c) |
|
after it fails, can it then do the recently added refresh flow? |
|
@sputn1ck - good catch. trying that now |
c86a4e3 to
2dcdeba
Compare
|
Good catch — you're right, and it changes the approach. I'd been treating a swept source batch as "funds gone," but the operator can only sweep that batch commitment output past batch expiry, so a swept source means the VTXO is expired, not lost — and #1000 already reclaims expired VTXOs through the normal refresh/forfeit flow with no sweep-confirmation gate ( Reworked accordingly (force-pushed, rebased onto current
PR description and the lead commit are retitled to match. Tests updated to assert the Expired routing + the recovery-only/non-exiting guards. |
|
@claude please review this PR |
|
Claude finished @ellemouton's task in 3m 34s —— View job Re-review: unroll — reclaim exits whose source batch was sweptI focused this pass on the reworked reclaim path ( The rework is clean and the plumbing is faithful. One concern is worth a definitive answer before merge because it re-expresses the exact "don't relive" invariant you flagged — now routed through
|
2dcdeba to
5613b36
Compare
|
Addressed the pre-merge concern (the Confirmed: the rollback branch is unreachable for a conflicted coin. The chain is:
On the trust boundary you rightly flagged: Changes:
|
|
@claude please re-review — the pre-merge concern from the last round is addressed above. |
|
Claude finished @ellemouton's task in 1m 52s —— View job Re-review: the
|
RootExternalInputs returns the outpoints consumed by a proof's root transactions that are not themselves produced by any node in the graph -- the external funding inputs the whole recovery tree 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) can consume out from under an exit, so an upcoming unroll change watches them to fail a doomed exit terminally instead of materializing forever. The result is deduplicated and sorted so two proofs built from the same node set yield identical output.
Add a distinct terminal job status for an exit defeated by a confirmed conflicting spend -- the operator swept a source batch commitment output the recovery tree depends on, so the exit can never complete (wavelength#1050). It is separate from the plain Failed and the FailedRecoverable statuses because boot-time reconciliation must treat it differently: retire the target VTXO out of unilateral-exit (clearing it from pending balance) rather than leave it pending forever, and -- unlike a recoverable failure -- never roll it back to live, since the coin is provably gone. The enum is append-only (value 8) so existing rows' numeric meaning never shifts, and it is added to the non-terminal-jobs query's exclusion list so a terminal conflict is not restored on restart.
make sqlc output for the non-terminal unilateral-exit job query, now excluding the appended FailedConflicted status (8) alongside the other terminal statuses.
A unilateral-exit job could sit in EXIT_JOB_STATUS_MATERIALIZING forever after the Ark operator swept a source batch commitment output. The confirmed sweep double-spends the recovery-tree root, so the exit can never complete, but txconfirm never gives up on a no-mempool tx, so the job never failed. The VTXO stayed EXIT / PENDING and its amount stayed in pending_out_sat with phase_detail implying progress. Detect the conflict by arming a spend watch on each root external funding input (the batch commitment outpoints; one per contributing commitment for OOR/fan-in VTXOs), supplying the batch pkScript from the descriptor ancestry for neutrino filter matching. A confirmed foreign spend routes the exit job to a terminal ExitOutcomeConflicted outcome; our own root spending the same output reads as a benign parent confirmation, so the watch is safe in both directions. The operator can only sweep that batch output past batch expiry, so the coin is expired, not lost: its value is still recoverable through the ordinary refresh path (wavelength#1000). So instead of retiring the VTXO to a terminal Failed state, the manager routes it to the non-terminal ExpiredState -- quarantined from coin selection because its lineage is dead, but reclaimed by the next block epoch through a cooperative forfeit. The unroll job still terminates as conflicted; only the coin's fate differs. A recovery-only target (a non-standard exit policy such as a vHTLC refund) is held in exit instead, since a swap-contract output must not be reclaimed as spendable wallet liquidity.
5613b36 to
2bccda4
Compare
Closes #1050.
Problem
A unilateral-exit job could sit in
EXIT_JOB_STATUS_MATERIALIZINGforever after the Ark operator swept a source batch commitment output.
The confirmed sweep double-spends the recovery-tree root, so the exit can
never complete — but
txconfirmnever gives up on a no-mempool tx, sothe job never failed. The VTXO stayed
EXIT / PENDING, its amount stayedin
pending_out_sat, andphase_detailkept implying progress. Nothingwatched the batch commitment output the tree root spends, so the conflict
was invisible to the client.
Fix
Detect (
recovery+unroll) —Proof.RootExternalInputs()returnsthe roots' external funding inputs (the batch commitment outpoints; one
per contributing commitment for OOR/fan-in VTXOs). The unroll actor arms
a spend watch on each, supplying the batch output's pkScript from the
descriptor ancestry for neutrino BIP-158 filter matching. A confirmed
foreign spend routes the exit job to a terminal
ExitOutcomeConflicted;our own root spending the same output reads as a benign parent
confirmation, so the watch is safe both ways. Arming is best-effort and
never blocks materialization.
Reclaim (
unroll→vtxo) — the operator can only sweep the batchcommitment output past batch expiry, so a swept source means the VTXO
is expired, not lost: its value is still recoverable through the
ordinary refresh path (#1000). So rather than retiring the coin to a
terminal
Failedstate, the VTXO manager routes it to the non-terminalExpiredState— quarantined from coin selection because its lineageis dead, but reclaimed by the next block epoch through a cooperative
forfeit, exactly like a naturally-expired VTXO. The unroll job still
terminates as conflicted (the unilateral exit genuinely died); only the
coin's fate differs. A recovery-only target (a non-standard exit policy
such as a vHTLC refund) is held in exit instead, since a swap-contract
output must not be reclaimed as spendable wallet liquidity.
Why not terminal
Failed?An earlier revision of this PR retired the coin to
Failed, on theassumption that a swept source meant the funds were gone. That strands
recoverable value: post-#1000 the client reclaims expired VTXOs through
the normal refresh protocol with no sweep-confirmation gate
(
TestSweepIntegrationReclaimWithoutObservingSweep), so a swept sourcebatch is precisely the condition that reclaim handles. Thanks to @sputn1ck
for catching this.
Testing
vtxo:TestUnilateralExitConflicts(routes toExpiredState, emits noterminated notification),
TestHandleExitOutcomeConflictedDrivesActorToExpired,and recovery-only / non-exiting guard tests on the manager fallback path.
recovery:RootExternalInputsreturns/dedups/sorts root external inputs.vtxo+waved+unrollsuites green;make fmt-changed+make lint-changed-localclean.🤖 Generated with Claude Code