Skip to content

multi: fail a cooperative send when the operator can't fund the round - #915

Merged
Roasbeef merged 9 commits into
mainfrom
operator-fund-failure-handling
Jul 10, 2026
Merged

multi: fail a cooperative send when the operator can't fund the round#915
Roasbeef merged 9 commits into
mainfrom
operator-fund-failure-handling

Conversation

@Roasbeef

@Roasbeef Roasbeef commented Jul 10, 2026

Copy link
Copy Markdown
Member

In this PR, we fix the client half of #889: a send --onchain --sweep-all (or any cooperative consumption) that hangs forever as "pending" while the balance comes back, and re-tries into the same wall on every restart.

The root cause turned out to be operator-side. On signet and testnet the operator's lnd wallet was empty, so arkd's FundPsbt failed for every commitment tx. But the client couldn't tell that apart from a transient hiccup. It got an opaque reason string, treated the failure as recoverable, released its forfeit (so the VTXO went back to LIVE, which is the "balance returned" the reporter saw), but left the persisted send intent in place. On the next restart the intent replayer re-submitted it into the same broke operator, and the activity entry sat pending indefinitely. This is not the vHTLC/unroll path from #909: that repro was an OOR coin, this is a plain cooperative round.

The typed failure code

We add a native round.RoundFailureCode enum, carried on BoardingFailed and ClientFailedState, and mapped to and from the wire roundpb.RoundFailureCode only at the RPC boundary (FromProto/ToProto) so the FSM never traffics in proto types. The shared ClientRoundFailedResp proto grows a failure_code field. An older server, or a failure we don't classify, leaves it ROUND_FAILURE_UNKNOWN, and the client falls back to the reason string exactly as before.

Retiring the dead send job

The forfeit-release chokepoint does the work. releaseForfeitsOnFailure already runs for the pre-signing states (where an operator-funding failure lands), already detects the transition into ClientFailedState, and already holds the forfeit set, which is exactly the originating job's pending-intent anchors. When the failure code is terminal-for-job, it emits a TerminalJobFailedNotification carrying those outpoints alongside the existing forfeit release, so the job is failed in the same breath its inputs return to the live set. The round actor hands that to the store's FailForfeitIntents.

On the db side we mark the intent failed rather than delete it (migration 000011 adds a status, reason, and typed code to the pending-intents header). Marking both stops the replay, since the replay query now selects only pending rows, and leaves a durable, anchor-correlatable record. A generic or unknown failure is untouched: it releases the forfeit and leaves the intent eligible for replay, which is right when the operator is only briefly down.

Since failed rows are retained while successful ones are deleted on adoption, the outbox accumulates only failures over time, so 000011 also adds a composite (kind, status) index to keep the replayer's startup scan off that pile.

Re-arming a retried send

NewPendingIntentID is deterministic over the consumed inputs and payload, so a user retrying the exact same failed send reuses the same intent_id and upserts straight onto the retained failed row. UpsertPendingIntentHeader now resets status/failure_reason/failure_code on conflict, so the retry re-arms as pending. Without that reset, a retry persisted but not yet adopted by a round would be silently dropped by the new pending-only replay filter if the daemon crashed in that window, which is the same "hangs forever" failure mode one hop removed. Thanks to the Codex and Gemini review passes for catching this and the index.

Follow-up

Surfacing the activity entry as FAILED, rather than dropping it back to pending, is deliberately left to the C2 activity-correlation work. The cooperative-leave EXIT is a runtime-local row that only the SDK runtime can project with fidelity, and it has no daemon read for a failed cooperative send yet. The durable failure record this PR lands is what that follow-up will read.

Testing

Unit coverage for the code mapping in both directions (including an unrecognized newer-server code degrading to unknown), the release chokepoint emitting the terminal drop only for a classified failure, the store marking the intent failed and excluding it from replay while keeping the record, and a regression that fails an intent, re-persists the identical one, and asserts it replays again with the failure fields cleared. The server-side end-to-end regression lives in the darepo PR.

See each commit message for the detailed reasoning w.r.t the incremental changes.

Roasbeef added 2 commits July 9, 2026 19:10
In this commit, we give the operator a way to tell a client *why* a
round it had already joined failed, beyond the opaque reason string it
gets today. We add a RoundFailureCode enum and a failure_code field to
ClientRoundFailedResp.

The first code we classify is ROUND_FAILURE_INSUFFICIENT_OPERATOR_FUNDS:
the operator could not fund the round's commitment transaction from its
own on-chain wallet. A client that sees this knows the round is dead on
arrival, so it can terminally fail the originating job (a cooperative
on-chain send, say) instead of returning its inputs to the live set and
replaying them into an operator that has no coins to spend.

Older servers, and failures we do not specifically classify, leave the
field at ROUND_FAILURE_UNKNOWN, which a client treats as a generic
recoverable failure and falls back to the reason string.
Regenerated via `make rpc` after adding the RoundFailureCode enum and
the failure_code field to round.proto. No hand edits.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces a terminal-failure status to the pending-intent outbox to prevent infinite replays of failed intents (e.g., when the operator cannot fund a commitment transaction). It updates the database schema, SQL queries, and state transitions to track and handle these terminal failures, ensuring that failed intents are retired while retaining their anchors for correlation. The review feedback suggests database optimizations, including adding a CHECK constraint and a composite index on (kind, status) to prevent full table scans as failed intents accumulate, along with dropping this index in the down migration. Additionally, a defensive nil check is recommended in the state transition logic to prevent potential nil-pointer dereferences.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread db/sqlc/migrations/000011_pending_intent_status.up.sql
Comment thread db/sqlc/migrations/000011_pending_intent_status.down.sql
Comment thread round/transitions.go

@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: 275e08253c

ℹ️ 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 db/sqlc/queries/pending_intents.sql
@Roasbeef

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 2m 23s —— View job


Review: PR #915 — fail a cooperative send when the operator can't fund the round

  • Read the diff across all changed files
  • Review DB migration / sqlc changes
  • Review round FSM / failure-code changes
  • Review proto changes
  • Assess existing bot review comments
  • Post review

Nicely scoped change. The typed-code-at-the-RPC-boundary approach keeps proto types out of the FSM cleanly, and routing the terminal drop through the existing releaseForfeitsOnFailure chokepoint (where the forfeit set == the originating job's anchors) is the right seam. The mark-don't-delete decision, with ListPending*Intents now filtering status = 'pending', is coherent and well-commented. Comments throughout explain why, per the guidelines.

Below are the things worth addressing before merge, then my read on the existing bot comments.

1. (confirming Codex P2) Re-persisting the same failed send silently drops the retry

wallet.NewPendingIntentID (wallet/pending_intent.go:251) derives the ID deterministically from kind + sorted anchors + payload digest. After a terminal failure the VTXOs return to LIVE, so a user retrying the identical send --onchain reselects the same outpoints → identical intent_id. UpsertPendingIntentHeader (db/sqlc/queries/pending_intents.sql:1) only updates requested_at_unix on conflict:

ON CONFLICT (intent_id) DO UPDATE
SET requested_at_unix = excluded.requested_at_unix;

so the retained row stays status = 'failed'. On the happy path this is harmless — the round adopts the intent and CommitState/clearForfeitIntentAnchors deletes the row. But in the crash window after the retry is persisted and before adoption, ListPendingSendIntents now excludes it (status = 'pending' filter), so the retry is silently lost on restart — exactly the "hangs pending forever" failure mode this PR set out to kill, just one hop removed.

Fix is small: reset the status/failure fields on re-persist so a reused ID re-arms as pending.

ON CONFLICT (intent_id) DO UPDATE
SET requested_at_unix = excluded.requested_at_unix,
    status = 'pending',
    failure_reason = NULL,
    failure_code = 0;

Fix this → — remember to make sqlc and add a regression test.

2. (partly agreeing with Gemini) index on the retained rows

Since failed rows are retained indefinitely while successful ones are deleted, pending_intents accumulates only failed rows over time, and both list queries filter kind = ? AND status = 'pending'. This is an outbox table so absolute volume is modest, but a composite index on (kind, status) is cheap insurance against a growing scan and is worth adding (with the matching DROP INDEX in 000011_...down.sql, as Gemini notes). Low priority.

The CHECK (status IN ('pending','failed')) constraint Gemini also suggested is a reasonable defensive add, though status is only ever written by generated queries with literal values, so it's optional.

3. (disagreeing with Gemini) the nil-transition check at transitions.go:123 is a false positive

The suggested if transition == nil guard is already satisfied: releaseForfeitsOnFailure synthesizes a non-nil transition at round/transitions.go:107-116 before it ever reaches the transition.NextState.(*ClientFailedState) type assertion, so the dereference at line 120 cannot panic. No change needed here. (The withFailureCode helper also guards nil independently, so both paths are covered.)

Minor notes

  • handleTerminalJobFailure (round/actor.go) deliberately logs-and-continues on a store error, degrading to the pre-existing replay behavior rather than wedging the actor — good call, and clearly documented.
  • Scope check: FailForfeitIntents / MarkPendingSendIntentFailedByOutpoint only touch kind = 'send_onchain', which is correct — board intents carry no forfeits, so the forfeit-anchor path never reaches them. Worth a one-liner acknowledging board intents are intentionally out of scope for terminal-failure marking.
  • Test coverage on the mapping (both directions + unknown-code degrade) and the store mark/exclude is solid. Adding the retry-reset regression from lib: start adding lib helpers #1 would close the last gap.

Overall: one real correctness gap (#1) worth fixing before merge; the rest is polish.
· operator-fund-failure-handling

In this commit, we teach the pending-intent outbox to record that an
intent is dead. Until now an intent had exactly two fates: a round
adopts it (the row is cleared in the same transaction that checkpoints
the round), or it replays on the next restart. A round that fails
terminally, because the operator cannot fund its commitment tx, had no
third path, so the send replayed forever into the same wall and the
user's activity entry sat pending indefinitely.

Migration 000011 adds a status column (defaulting to 'pending') plus a
failure_reason and a typed failure_code to the kind-agnostic
pending_intents header. The send and board replay queries now select
only status = 'pending' rows, so a failed intent is never re-submitted.
MarkPendingSendIntentFailedByOutpoint flips the intent anchored to a
forfeited outpoint to 'failed', recording the reason and code, and is
idempotent under the status guard so a second forfeit outpoint of the
same intent is a no-op. We keep the anchors on a failed intent rather
than clearing them, so the record stays correlatable by its consumed
outpoint for the activity projection that will later surface it.

We bump LatestMigrationVersion to 11 so the daemon's downgrade guard
knows about the new migration.
@Roasbeef
Roasbeef force-pushed the operator-fund-failure-handling branch 2 times, most recently from 9668fce to 9e1574f Compare July 10, 2026 03:01
Roasbeef added 4 commits July 9, 2026 20:25
Regenerated via `make sqlc` after migration 000011 and the new
pending-intent queries. No hand edits.
In this commit, we consume the operator's typed round-failure code on
the client and use it to stop a doomed cooperative send from replaying
forever. This is the client half of the #889 fix: on signet and testnet
an empty operator wallet failed every send at the operator's FundPsbt,
the client could not tell that apart from a transient hiccup, so it
released its inputs, left the intent pending, and re-submitted it into
the same broke operator on every restart.

We add a native round.RoundFailureCode enum, mapped to and from the wire
roundpb value only at the RPC boundary (FromProto / ToProto) so the FSM
never traffics in proto types. BoardingFailed and ClientFailedState both
carry it, and every pre-signing BoardingFailed handler stamps it onto
the failed state.

The release chokepoint does the rest. releaseForfeitsOnFailure already
runs for the pre-signing states, already detects the transition into
ClientFailedState, and already holds the forfeit set, which is exactly
the originating job's pending-intent anchors. When the failure code is
terminal-for-job, it emits a TerminalJobFailedNotification carrying
those outpoints alongside the existing forfeit release, so the job is
failed in the same breath its inputs return to the live set. The round
actor handles that notification by asking the store to fail the intent;
the call is best-effort, so a store hiccup degrades to the prior replay
behavior rather than wedging the actor.

A generic or unknown failure is untouched: it releases the forfeit and
leaves the intent eligible for replay, which is the right thing when the
operator is only briefly unavailable.
In this commit, we wire the store side of the terminal send-failure
path. FailForfeitIntents is the failure counterpart to the anchor clear
CommitState performs when a round succeeds: given the forfeited
outpoints of a round that died terminally, it marks each anchored send
intent 'failed' with the reason and typed code, in one write
transaction.

We mark rather than delete deliberately. Marking both stops the replay,
since the replay query now skips non-pending rows, and leaves a durable,
anchor-correlatable record the activity projection can later surface as
failed instead of leaving the entry stuck pending. To share the
per-outpoint anchor walk with the success path, the existing
clearForfeitIntentAnchors is refactored to run over a plain outpoint
slice.
In this commit, we test the client half of the fix. The round tests
pin the wire-to-native failure-code mapping (including an unrecognized
newer-server code degrading to unknown), assert BoardingFailed decodes
the code, and check that releaseForfeitsOnFailure emits the terminal
drop for a classified failure with the right forfeited outpoints while a
generic failure only releases the forfeit and leaves the intent to
replay.

The db test proves FailForfeitIntents marks the send intent failed and
excludes it from the replay list, that its header and anchors are
retained (not deleted) so the record stays correlatable, and that a
repeat call is a no-op that never overwrites the original reason.
@Roasbeef
Roasbeef force-pushed the operator-fund-failure-handling branch from 9e1574f to 57181f9 Compare July 10, 2026 03:25
@Roasbeef

Copy link
Copy Markdown
Member Author

Thanks @gemini-code-assist and @chatgpt-codex-connector for the passes, and the Claude review too. Round 1 is resolved in-thread; here's the roundup plus a second, deeper review round.

Round 1 (bot threads, resolved)

  • Codex (re-persist gap): real one. UpsertPendingIntentHeader now resets status/failure_reason/failure_code on conflict, so a deterministic re-persist of a retried send re-arms as pending instead of getting stuck on the retained failed row. Added TestRepersistFailedSendIntentRearmsReplay.
  • Gemini (index): added the composite (kind, status) index in the up migration, with the matching DROP INDEX in down.
  • Gemini (nil guard): false positive; releaseForfeitsOnFailure synthesizes a non-nil transition before the type assertion, so it can't panic there.

Round 2 (Fable advisor review)

Ran an independent Fable review over both branches. It caught a real one:

  • Finding 1 (High), fixed. The release-idempotency guard in releaseForfeitsOnFailure early-returned before the terminal-drop block, so the two states that build their own failure outbox (IntentSentState, the reachable Unable to cooperatively exit with send --onchain --sweep-all #889 path, and CommitmentTxReceivedState) stamped the failure code but never emitted TerminalJobFailedNotification. The send would have replayed forever, i.e. Unable to cooperatively exit with send --onchain --sweep-all #889 surviving on its own fix path. Decoupled the drop from the release-idempotency guard, and added TestPreSigningTerminalFailureRetiresJob across every pre-signing state.
  • Finding 3 (durability), fixed. The orphan sweep deleted a failed record once its released coin was reused by a later send. It now spares status = 'failed' rows so the durable record survives for the activity projection.
  • Finding 2 (retry race), assessed as a non-issue. The mark runs in the same processOutbox turn that enqueues the release Tell, before the VTXO manager moves the coin back to LIVE, so a retry can't race it. Documented the ordering invariant at the mark site.
  • Doc caveats: the transient-unconfirmed-funds classify-as-terminal note, the WalletController.FundPsbt sentinel-wrap contract, and the Recoverable-vs-FailureCode axes.

New end-to-end coverage

Added TestSendOnChainInsufficientOperatorFundsRetiresJob: the fake operator admits the registration then pushes the typed failure, and we restart the daemon and assert no second JoinRound ever fires. That restart check is what actually catches a swallowed terminal drop (Finding 1), not just the VTXO release. It runs in the systest lane.

Both this and darepo#663 are clean locally on build/test/lint/commitmsg. See each commit for the incremental detail.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@gemini-code-assist

Copy link
Copy Markdown

Warning

Gemini encountered an error creating the issue-comment-reply. You can try again by commenting /gemini issue-comment-reply.

In this commit, we add the end-to-end reproduction of #889 on the
cooperative send path. A send --onchain --sweep-all reserves the VTXO
and registers a round, the fake operator admits it and then pushes a
typed insufficient-operator-funds ClientRoundFailedResp, and we assert
the client classifies it, returns the VTXO to LIVE, and durably retires
the send intent.

The teeth of the test are the restart-and-assert-no-replay check. A send
that only released its VTXO but stayed pending would replay on the next
daemon start and fire a second JoinRound into the same broke operator,
which is the #889 "hangs forever" loop. So after the failure we restart
the daemon and assert no second JoinRound is ever sent. This is the same
path the release chokepoint used to swallow the terminal drop on: the
client sits in IntentSentState, whose handler builds its own failure
outbox, so the test would have caught that regression.

To drive it, the fake mailbox server grows a setFailRoundOnJoin lever
that pushes the ClientRoundFailedResp back as a KIND_EVENT round-failed
event on every JoinRound.
@Roasbeef
Roasbeef force-pushed the operator-fund-failure-handling branch from e58a238 to fbc921c Compare July 10, 2026 03:50
In this commit, we close a gap the operator fund-failure review
surfaced. QuoteReceivedState, CommitmentTxValidatedState, and
NoncesAggregatedState had no BoardingFailed case, so a server-pushed
round failure in any of them self-looped. Both the forfeit release,
which returns the reserved inputs to LiveState, and the pending-intent
retirement on a terminal-for-job code were silently dropped, stranding
the VTXOs in pending-forfeit until a higher-level timeout and, for a
future terminal code that could fire that early, replaying the job
forever.

All three states already wrap their handler in releaseForfeitsOnFailure,
so the fix is just to give them a BoardingFailed case that fails into
ClientFailedState carrying the typed code; the wrapper releases the
forfeits and retires the job. We extend the pre-signing release and
terminal-retire tables to drive a failure through each of the three.

The current insufficient-operator-funds code does not reach these states
(it fires while the client sits in RoundJoinedState, which was already
handled), so this hardens against recoverable mid-quote or mid-signing
failures and any future terminal code, not a live #889 regression.
@Roasbeef
Roasbeef force-pushed the operator-fund-failure-handling branch from 7f7ff42 to 6dce664 Compare July 10, 2026 04:29
@Roasbeef
Roasbeef merged commit 9f56b6e into main Jul 10, 2026
16 of 18 checks passed
ellemouton added a commit that referenced this pull request Jul 10, 2026
The send-failure tests from #915 verified pending_intents state with raw
QueryRowContext calls using SQLite '?' placeholders. The pgx stdlib
driver does not rewrite '?' to $N, so on postgres "intent_id = ?" parses
'?' as a dangling operator and fails with "syntax error at end of input"
(SQLSTATE 42601): the tests passed on sqlite and failed on test_postgres.

Add GetPendingIntentByID, CountPendingIntentAnchorsByIntentID, and
CountPendingSendIntentsByIntentID to the pending_intents sqlc queries and
route the test assertions through the generated methods, dropping the raw
SQL entirely (repo rule: no raw SQL in Go). sqlc emits the correct
placeholder per dialect, so the verification is portable by construction.
ellemouton added a commit that referenced this pull request Jul 10, 2026
The send-failure tests from #915 verified pending_intents state with raw
QueryRowContext calls using SQLite '?' placeholders. The pgx stdlib
driver does not rewrite '?' to $N, so on postgres "intent_id = ?" parses
'?' as a dangling operator and fails with "syntax error at end of input"
(SQLSTATE 42601): the tests passed on sqlite and failed on test_postgres.

Add GetPendingIntentByID, CountPendingIntentAnchorsByIntentID, and
CountPendingSendIntentsByIntentID to the pending_intents sqlc queries
and route the test assertions through the generated methods, dropping
the raw SQL (repo rule: no raw SQL in Go). sqlc emits the correct
placeholder per dialect, so the verification is portable by
construction.
ellemouton added a commit that referenced this pull request Jul 10, 2026
The send-failure tests from #915 verified pending_intents state with raw
QueryRowContext calls using SQLite '?' placeholders. The pgx stdlib
driver does not rewrite '?' to $N, so on postgres "intent_id = ?" parses
'?' as a dangling operator and fails with "syntax error at end of input"
(SQLSTATE 42601): passed on sqlite, failed on test_postgres.

Add GetPendingIntentByID, CountPendingIntentAnchorsByIntentID, and
CountPendingSendIntentsByIntentID to the pending_intents sqlc queries
and route the test assertions through the generated methods, dropping
the raw SQL (repo rule: no raw SQL in Go). sqlc emits the correct
placeholder per dialect, so the verification is portable by
construction.
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