[v0.1.x-branch] Backport #992: round: release forfeit reservations via a status reconcile on round death - #1002
Merged
Conversation
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 |
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
force-pushed
the
backport-992-to-v0.1.x-branch
branch
from
July 20, 2026 21:37
91b3c87 to
7a0ccaf
Compare
Roasbeef
marked this pull request as ready for review
July 20, 2026 22:24
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Forfeitingfor the life of the batch. The strand needed no crash to trigger; a single lostSubmitVTXOForfeitSigson 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
releaseForfeitsOnFailurewrapper (the #653 fix) deliberately stops atPartialSigsSentStatefor 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
BoardingFailedthat lands inInputSigSentStatewith forfeits at stake now parks in the state while a newQueryRoundStatusprobe asks the operator for the round's authoritative lifecycle status (in-flight, broadcast, confirmed, or dead). Only a dead answer fails the round throughreleaseForfeitsOnFailure, returning the inputs toLiveStateand 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
StatusReconcileTimeoutopts 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/dston 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 toLiveon 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 rebuildingIntents.Forfeitsfrom the Forfeiting VTXO rows (each row already carries the bindingforfeit_round_id) in both reload paths, andTestRoundStoreReloadRebuildsForfeitSetpins 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.