Skip to content

[v0.1.x-branch] Backport #992: round: release forfeit reservations via a status reconcile on round death - #1002

Merged
Roasbeef merged 4 commits into
v0.1.x-branchfrom
backport-992-to-v0.1.x-branch
Jul 20, 2026
Merged

[v0.1.x-branch] Backport #992: round: release forfeit reservations via a status reconcile on round death#1002
Roasbeef merged 4 commits into
v0.1.x-branchfrom
backport-992-to-v0.1.x-branch

Conversation

@github-actions

Copy link
Copy Markdown

Backport of #992


In this PR, we fix #844: a refresh or leave round that fails after the client has already sent its per-VTXO forfeit signatures no longer strands the VTXO in Forfeiting for the life of the batch. The strand needed no crash to trigger; a single lost SubmitVTXOForfeitSigs on the wire was enough, and the coin stayed wedged until the #823 startup sweep or batch expiry rescued it.

The reason this was never a one-line table edit is the double-spend hazard the issue lays out: once the forfeit signatures leave the box, the operator may hold fully-signed forfeit txs for the coin, so a blind runtime release would mark the VTXO spendable while a signed forfeit for it exists. The releaseForfeitsOnFailure wrapper (the #653 fix) deliberately stops at PartialSigsSentState for exactly this reason. The saving grace is that a forfeit tx spends a connector output of the round's commitment tx, and (since the lumos#631 fix) the operator persists a finalized round atomically with its VTXOs before the commitment is ever broadcast. So "the operator has no record of this round" is a proof that the commitment can never confirm, and that proof is what makes the release safe.

The status reconcile

The fix turns that proof into protocol. A BoardingFailed that lands in InputSigSentState with forfeits at stake now parks in the state while a new QueryRoundStatus probe asks the operator for the round's authoritative lifecycle status (in-flight, broadcast, confirmed, or dead). Only a dead answer fails the round through releaseForfeitsOnFailure, returning the inputs to LiveState and retiring the originating job on a terminal-for-job code. Any other answer holds the reservations and keeps waiting, since the commitment may still confirm.

The same probe covers the lumos#618 silence door, where a crashed operator never sends a failure notification at all: a status-reconcile timeout is armed when the forfeit signatures are emitted (and re-armed on restart reload), fires in the silence, and drives the identical query. The timeout alone never releases anything; only the operator's answer does. Boarding-only rounds keep the old immediate-failure behavior, and a non-positive StatusReconcileTimeout opts out entirely, restoring the pre-fix behavior.

The server half (answering the query from the live FSM first, then the durable round store) lands in lightninglabs/lumos alongside a submodule bump; the two halves are wire-compatible either way — a client probing an old server just retries on its reconcile timeout, which is today's strand behavior, no worse.

Verification

Beyond the unit tests in round/status_reconcile_test.go (park-and-probe, dead-releases, non-dead-holds, mismatched-report guard, re-probe loop, terminal-job retirement), the fix was verified end to end under the darepo DST harness (systest/dst on lumos PR #81), which is what found #844 in the first place: both deterministic catches — the timeout door and the #618 server-crash door — now drive the real client and server actors through the reconcile and watch the coin return to Live on both stores. The full DST suite and a 500-seed workload soak are green with the strand allowlist deleted; the ops that used to classify the known bug now assert the recovery instead.

See each commit message for the incremental breakdown.

Review hardening

An adversarial review pass over the combined client+server diff (plus the codex bot, which flagged the same hole) caught a defect that silently defeated the fix across a client restart: the forfeit set only ever lived in memory. A reloaded round looked boarding-only, so the restart re-arm guard never fired, and even a hand-armed timer would have released nothing since the dead-answer path releases the (empty) in-memory set. The new db: commit closes this by rebuilding Intents.Forfeits from the Forfeiting VTXO rows (each row already carries the binding forfeit_round_id) in both reload paths, and TestRoundStoreReloadRebuildsForfeitSet pins it against a real store. Everything the rebuild returns is a standard wallet forfeit, which is exactly right: custom caller-supplied forfeit inputs never enter the wallet store and their signing contexts die with the process.

The pass also hardened the probe loop: reconcile probes now back off exponentially (capped at 16x the base window) so an operator predating the status RPC sees a bounded cadence instead of a fixed-rate loop forever, and the timer only arms when forfeits are actually at stake. The dead-answer release now spells out its trust boundary in the code: the proof of death is the operator's own self-report, sound against an honest-but-faulty operator (the failure mode this reconcile exists for), while the commitment confirmation watch stays registered so a fraudulent later broadcast still surfaces as a detected conflict rather than passing silently.

@github-actions

Copy link
Copy Markdown
Author

Please cherry-pick the changes locally and resolve any conflicts.

git fetch origin backport-992-to-v0.1.x-branch
git worktree add --checkout .worktree/backport-992-to-v0.1.x-branch backport-992-to-v0.1.x-branch
cd .worktree/backport-992-to-v0.1.x-branch
git reset --hard HEAD^
git cherry-pick -x ff13cd57bfac9513aec001cb0c698ceb65979da7
git push --force-with-lease

Roasbeef added 4 commits July 20, 2026 16:32
In this commit, we add the wire surface for the wavelength#844
status reconcile: a client->server QueryRoundStatus RPC and the
ClientRoundStatusReport push event that answers it, carrying a
RoundLifecycleStatus classification (in-flight, broadcast,
confirmed, or dead).

A client that has already sent its per-VTXO forfeit signatures
cannot release the forfeit reservations on a round-failure
notification alone: the operator may hold fully-signed forfeit
txs, and a blind release risks a double-spend if the round's
commitment later confirms. The saving grace is that a forfeit tx
spends a connector output of the commitment, and the operator
persists a finalized round atomically with its VTXOs before the
commitment is ever broadcast. A ROUND_STATUS_DEAD answer (no live
FSM, no durable row) therefore proves the commitment can never
confirm, which is exactly the proof-of-death that makes the
release safe. The generated code is regenerated via make rpc.

(cherry picked from commit f346cce)
In this commit, we fix wavelength#844: a round failure arriving
after the client's forfeit signatures have left the box no longer
strands the VTXO in Forfeiting for the life of the batch. The
releaseForfeitsOnFailure wrapper (the #653 fix) deliberately stops
at PartialSigsSentState, because past that point the operator may
hold fully-signed forfeit txs and a blind release risks a
double-spend. The result was that InputSigSentState had no release
path at all: the coin sat stranded until the #823 startup sweep or
batch expiry rescued it.

The fix is a status reconcile rather than a table edit. A
BoardingFailed that lands in InputSigSentState with forfeits at
stake now parks in the state (PendingFailure) while a
QueryRoundStatusOutbox probes the operator for the round's
authoritative lifecycle status. Only a dead answer, meaning the
round never finalized so its commitment can never confirm, fails
the round through releaseForfeitsOnFailure, which returns the
inputs to LiveState and retires the originating job on a
terminal-for-job code. Any other answer holds the reservations.

The same probe covers the lumos#618 silence door, where a crashed
operator never sends a failure at all: a status-reconcile timeout
(armed when the forfeit signatures are emitted, re-armed per
probe, re-armed on restart reload) fires in the silence and drives
the identical query. The timeout alone never releases; only the
operator's answer does. Boarding-only rounds keep the old
immediate-failure behavior, and a non-positive
StatusReconcileTimeout opts out entirely.

The timer only arms when forfeits are actually at stake, matching
the gate every consumer applies, and repeated unanswered probes
back off exponentially (capped at 16x the base window) so an
operator that predates the status RPC sees a bounded probe cadence
rather than a fixed-rate loop forever. The dead-answer release
also spells out its trust boundary in the code: the proof of death
is the operator's own self-report, sound against an
honest-but-faulty operator (the failure mode this reconcile exists
for), while the commitment confirmation watch stays registered so
a fraudulent later broadcast still surfaces as a detected conflict.

(cherry picked from commit 9148dcd)
In this commit, we register the inbound dispatch route for
ClientRoundStatusReport, so the operator's answer to a
QueryRoundStatus probe reaches the round FSM as a
RoundStatusReported event through the same push-event path the
other round messages use. Without the route the daemon would drop
the report on the floor and the reconcile would spin on its retry
timeout forever.

(cherry picked from commit 8fcbf8f)
In this commit, we close the restart gap in the status-reconcile
release. The reconcile keys every decision on Intents.Forfeits, but
that set only ever lived in memory: dbRoundToDomainRound and
reconstructInputSigSentState rebuilt boarding intents alone, so after
a restart a forfeit-bearing round looked boarding-only. The re-arm
guard in the actor's reload loop never fired, and even a hand-armed
timer would have released nothing, since the dead-answer path releases
the (empty) in-memory forfeit list. The strand the reconcile exists to
fix simply reopened across every restart.

The durable ground truth was already there: MarkVTXOForfeiting stamps
each Forfeiting VTXO row with the binding forfeit_round_id. We add a
ListForfeitingVTXOsByRound query over those rows and rebuild the
forfeit set in both reload paths. The rebuilt requests carry the
outpoint and amount with no custom spend paths, which is exactly
right: custom (caller-supplied) forfeit inputs never enter the wallet
store and their signing contexts die with the process, so everything
the query returns is a standard wallet forfeit for the release path.

The new TestRoundStoreReloadRebuildsForfeitSet pins the behavior
against a real store: commit an InputSigSent round, mark two VTXOs
Forfeiting against it, and require both reload paths to surface the
pair (and only the pair) as standard forfeits.
@Roasbeef
Roasbeef force-pushed the backport-992-to-v0.1.x-branch branch from 91b3c87 to 7a0ccaf Compare July 20, 2026 21:37
@Roasbeef
Roasbeef marked this pull request as ready for review July 20, 2026 22:24
@Roasbeef
Roasbeef merged commit 533f81e into v0.1.x-branch Jul 20, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant