Skip to content

round: release forfeit reservations via a status reconcile on round death - #992

Merged
Roasbeef merged 4 commits into
mainfrom
forfeit-release-reconcile
Jul 18, 2026
Merged

round: release forfeit reservations via a status reconcile on round death#992
Roasbeef merged 4 commits into
mainfrom
forfeit-release-reconcile

Conversation

@Roasbeef

@Roasbeef Roasbeef commented Jul 17, 2026

Copy link
Copy Markdown
Member

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.

@Roasbeef Roasbeef added the dst Found by the deterministic simulation tester (systest/dst) label Jul 17, 2026
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cd50c7216c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread round/actor.go
Comment on lines +1416 to +1417
if len(round.Intents.Forfeits) > 0 &&
a.env.StatusReconcileTimeout > 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Re-arm reconcile for persisted forfeit rounds

When the daemon restarts with an active refresh round, the production DB reconstruction does not populate Round.Intents.Forfeits (it only restores boarding intents in dbRoundToDomainRound, and the reconstructed InputSigSentState only gets Boarding/VTXOs). That makes this condition false for exactly the reloaded forfeit-bearing rounds described in the comment, so no status-reconcile timer is armed after restart; if the operator failure was missed during the crash window, the client never sends QueryRoundStatus and the pending-forfeit reservation can remain stranded.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and it ran deeper than the guard: even with the timer armed, the dead-answer release would have been a no-op after a restart, since it releases the in-memory forfeit set that the reload never repopulated. Fixed in the new db: rebuild reloaded round forfeit sets from vtxo rows commit: both reload paths (dbRoundToDomainRound and reconstructInputSigSentState) now rebuild Intents.Forfeits from the Forfeiting VTXO rows via the persisted forfeit_round_id, pinned by TestRoundStoreReloadRebuildsForfeitSet against a real store.

@Roasbeef
Roasbeef force-pushed the forfeit-release-reconcile branch from cd50c72 to 4425f8a Compare July 18, 2026 00:14
@Roasbeef

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Jul 18, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 3m 30s —— View job


Review: forfeit release via status reconcile

I read the full diff (origin/main...HEAD) across all four commits, focusing on the handwritten round FSM, actor, DB rebuild, and wiring changes (proto is generated, skimmed only).

  • Read the diff across all commits
  • Review round actor / transitions / status reconcile logic
  • Review the DB forfeit-set rebuild commit
  • Review proto / wiring changes
  • Post review feedback

Overall this is a careful, well-reasoned fix. The double-spend hazard is handled correctly (release gated on an authoritative dead answer, not on the timeout or notification), the exponential backoff is bounded, the RoundID mismatch guard is present, the confirmation watch stays registered as a fraud backstop, and the DB rebuild closes the real cross-restart hole the codex bot flagged. Tests cover the important branches. A few things worth addressing before merge:

1. Stale doc comment on releaseForfeitsOnFailure now contradicts the new caller (should fix)

releaseForfeitsOnFailure is now invoked from InputSigSentState on the dead-answer path (round/transitions.go:4478), but its doc comment still asserts the opposite:

round/transitions.go:106-108"Callers therefore wire this only into the pre-signing states (PendingRoundAssembly through ForfeitSignaturesCollectingState); the post-signing states (InputSigSentState onward) deliberately do not release."

and again:

round/transitions.go:186-187"This runs only in the pre-signing states this wrapper guards, where returning the inputs to LiveState cannot double-spend."

Both statements are now false — this is exactly the post-signing state the PR teaches to release. A future reader auditing double-spend safety will trust the comment and conclude InputSigSentState never releases, which is the wrong mental model. The comment should be updated to document the new post-signing caller and why it is safe there (release is gated on the operator's authoritative dead verdict, which proves the commitment can never confirm — the same invariant the inline comment at transitions.go:4451-4470 already spells out). Fix this →

2. Silence-door dead path never retires the originating job (confirm intended)

In the dead-answer handler, when PendingFailure == nil (the lumos#618 pure-silence door, where no BoardingFailed ever arrived), the synthesized failure is built with a zero FailureCode:

// round/transitions.go:4462
failure = &BoardingFailed{
        RoundID:     fn.Some(s.RoundID),
        Reason:      reason,
        Recoverable: true,
}

RoundFailureUnknown.IsTerminalForJob() is false (round/events.go:378), so releaseForfeitsOnFailure releases the reservation but emits no TerminalJobFailedNotification. The reservation returns to Live, but the originating job's persisted pending intent is left for recoverable replay. That's likely the intended semantics for a recoverable failure (the job retries on a fresh round and re-reserves), and it's consistent with the parked-failure path when the real failure code isn't terminal-for-job — but it's worth confirming the released VTXO + surviving pending intent don't interact badly on the next assembly (e.g. a replay immediately re-reserving the just-released input). If it is intended, a one-line comment on the synthesized failure noting the job stays replayable would save the next reader the trace.

3. Minor notes (no action required)

  • RoundStatusReported.FromProto (round/from_proto.go:685) copies pb.Status without validating it against the known enum set. This is safe by construction — any unrecognized value is != roundStatusDead, so it takes the hold branch — but that safety is implicit. Fine as-is; the round-id length check is the important one and it's there.
  • Test coverage: the FSM reconcile (status_reconcile_test.go) and the store rebuild (TestRoundStoreReloadRebuildsForfeitSet) are both well-pinned, but the actor's restart re-arm in Start() (round/actor.go:1409) — the seam between them — is exercised only by the DST harness, not a Go unit test. A focused actor test (reload a forfeit-bearing round → assert a StartTimeoutReq with TimeoutPhaseStatusReconcile is dispatched) would guard that guard against regression, since it's the exact spot the codex P1 was about.

Things I verified as correct

  • Backoff math: park emits probe with probes=0 (base re-arm) and stores ReconcileProbes=1; each timeout uses the pre-increment count, so cadence is base, base·2, base·4 … capped at base·16. StartTimeoutReq replaces rather than stacks. ✓
  • Non-dead answer self-loops without cancelling the already-armed timeout, so probing continues. ✓
  • BoardingConfirmed and the dead path both cancel the reconcile timeout; confirmation cancel is gated on the same forfeit-count/enabled predicate as the arm. ✓
  • The DB rebuild is scoped to status = 2 (Forfeiting) AND forfeit_round_id, so pre-signing reloaded rounds (VTXOs not yet Forfeiting) correctly get an empty set, and the boarding-only case stays boarding-only. ✓
  • Transition-table duplicate BoardingFailed entries are documentation-only (TransitionTable is introspection/test validation, not compiler-enforced per baselib/protofsm/CLAUDE.md), so listing both the park and immediate-fail outcomes is fine. ✓
    · forfeit-release-reconcile

@Roasbeef
Roasbeef force-pushed the forfeit-release-reconcile branch from 4425f8a to 2972194 Compare July 18, 2026 00:24
@Roasbeef

Copy link
Copy Markdown
Member Author

Addressed the review in the latest push (tip 2972194):

  1. The stale releaseForfeitsOnFailure doc is rewritten in both spots: it now documents the single post-signing caller (InputSigSentState's dead-answer path) and why the release is safe there, instead of asserting post-signing states never release.
  2. The silence-door semantics are intended, and now say so in code: a comment on the synthesized failure notes the zero FailureCode is deliberate, keeping the persisted pending intent in recoverable replay so the job retries on a fresh round and re-reserves the just-released inputs.

On the restart re-arm seam: it is exercised end to end by the DST crash-door catch on lumos#81 (server crash, silence, probe from the re-armed timer, release), which reruns green against this tip. A focused Go unit test for the actor reload loop is a fair follow-up.

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.
@Roasbeef
Roasbeef force-pushed the forfeit-release-reconcile branch from 2972194 to bb02e71 Compare July 18, 2026 01:41
Roasbeef added 3 commits July 17, 2026 20:46
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.
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.
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 forfeit-release-reconcile branch from bb02e71 to ff13cd5 Compare July 18, 2026 01:46
@Roasbeef
Roasbeef merged commit 87f228f into main Jul 18, 2026
18 checks passed
@Roasbeef Roasbeef added the backport-v0.1.x-branch Backport this merged PR to v0.1.x-branch label Jul 20, 2026
@github-actions

Copy link
Copy Markdown

Created backport PR for v0.1.x-branch:

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 a commit that referenced this pull request Jul 20, 2026
…anch

[v0.1.x-branch] Backport #992: round: release forfeit reservations via a status reconcile on round death
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-v0.1.x-branch Backport this merged PR to v0.1.x-branch dst Found by the deterministic simulation tester (systest/dst)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

round: round failure after forfeit sigs are sent strands VTXO in Forfeiting (no runtime release)

1 participant