Skip to content

feat(bond): Phase 4.5 — re-prompt winner for payout invoice on payment failure#755

Open
grunch wants to merge 2 commits into
mainfrom
feat/bond-phase-4.5-reprompt-payout
Open

feat(bond): Phase 4.5 — re-prompt winner for payout invoice on payment failure#755
grunch wants to merge 2 commits into
mainfrom
feat/bond-phase-4.5-reprompt-payout

Conversation

@grunch
Copy link
Copy Markdown
Member

@grunch grunch commented May 29, 2026

Summary

Implements Phase 4.5 of the anti-abuse bond rollout (spec docs/ANTI_ABUSE_BOND.md §9.5), fixing the payout failure mode reported in #750.

When a slashed bond's counterparty payout exhausted payout_max_retries against a submitted invoice, on_send_payment_failure transitioned the bond straight to Failed and the winner was never asked for a new invoice again:

  1. The scheduler (run_bond_payout_cycle) only enumerates PendingPayout bonds — a Failed bond is invisible to it, so Action::AddBondInvoice is never re-sent.
  2. During the retry phase the row keeps its original payout_invoice, so all retries hit the same unroutable bolt11 — Mostro never asks for a fresh one.

A recovery path existed (the "Failed resurrection" branch in apply_payout_invoice), but it only fires if the client spontaneously resends. Mostro never prompts, so in practice the counterparty share stranded and required manual operator intervention.

What changed

src/app/bond/payout.rson_send_payment_failure now decides what to do after the retry budget for a given invoice is exhausted, based on the forfeit window anchored on slashed_at:

  • Inside the claim window → discard the unroutable invoice and re-arm the invoice-request sub-phase: clear payout_invoice, payout_routing_fee_sats, payout_payment_hash, last_invoice_request_at; reset payout_attempts = 0; keep state = PendingPayout. The next scheduler tick sees payout_invoice IS NULL and re-prompts the winner via request_payout_invoice. slashed_at is never touched, so the forfeit deadline does not move and the re-prompt/retry cycle is bounded by payout_claim_window_days (→ Forfeited). invoice_request_attempts is preserved (it's bounded by the forfeit window, not the retry budget).
  • Past the claim window → transition to Failed as before (terminal technical failure, operator review). No point re-prompting past the deadline.

claim_window_seconds is threaded from process_one_bond through pay_counterparty into on_send_payment_failure (consistent with how max_retries is already passed), keeping the function unit-testable.

This is daemon-only: it reuses Action::AddBondInvoice (Phase 3) and Action::BondInvoiceAccepted (Phase 3.5). No mostro-core bump, no schema migration.

The spec was also updated: §9.5 added, the phase-overview table now marks Phases 0–4 as merged on main (PRs #712, #719, #736, #737, #738, #743, #744), and §8.1/§8.2/§14.2/§14.3 carry forward-references and corrected release status.

How to test

Automated

cargo test payout      # 30 tests, incl. the two new ones below
cargo test bond        # full bond suite (115 tests)
cargo clippy --all-targets --all-features

New/updated unit tests in src/app/bond/payout.rs:

  • send_payment_failure_within_window_reprompts_winner — after max_retries failures with an in-window slashed_at, the row re-arms: state stays PendingPayout, payout_invoice/payout_routing_fee_sats/payout_payment_hash/last_invoice_request_at cleared, payout_attempts = 0, slashed_at unchanged, invoice_request_attempts preserved.
  • send_payment_failure_past_window_flips_to_failed — same exhaustion but with an out-of-window slashed_at still terminates in Failed.
  • apply_payout_invoice_resurrects_after_re_failure — updated to force the out-of-window branch where it needs a Failed row.

Manual (regtest / Polar)

  1. Enable bonds with slash_node_share_pct between 0 and 1 and a short payout_invoice_window_seconds so re-prompts are quick to observe.
  2. Run a trade, open a dispute, and have the solver slash a bond (BondResolution) so a bond enters PendingPayout.
  3. When Mostro sends Action::AddBondInvoice, reply from the winner with a bolt11 that cannot be routed (e.g. an invoice from a node with no path / no inbound liquidity).
  4. Watch the scheduler logs: send_payment fails up to payout_max_retries, then logs re-requesting a fresh invoice from the winner (still inside claim window, deadline unchanged) and the bond stays PendingPayout with its invoice cleared.
  5. On the next tick a fresh Action::AddBondInvoice is delivered to the winner. Reply with a routable invoice → the payout succeeds, the bond reaches Slashed, and the winner gets Action::BondInvoiceAccepted then Action::BondPayoutCompleted.
  6. Confirm the forfeit deadline (slashed_at + payout_claim_window_days) the client renders does not shift across re-prompts.
  7. Optional: keep submitting unroutable invoices past payout_claim_window_days and confirm the bond eventually Forfeited (no invoice in flight) — i.e. the loop is bounded.

Closes #750

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Phase 4.5: improved in-window payout recovery — on terminal retry exhaustion the system clears stale invoice info and re-prompts winners for a fresh invoice; out-of-window failures still move to operator review. Indeterminate failures retain in-flight invoice for reconciliation.
  • Documentation

    • Expanded anti-abuse bond rollout doc with Phase 1.5–4.5 headings, Phase 4.5 specification, compatibility notes, and core pinned to 0.11.5.
  • Tests

    • Added coverage for in-window re-arming and indeterminate-failure reconciliation; adjusted end-to-end test paths.

Review Change Stack

…t failure

When a slashed bond's counterparty payout exhausted `payout_max_retries`
against a submitted invoice, the bond went straight to `Failed` and the
winner was never asked for a new invoice: the scheduler only enumerates
`PendingPayout` rows, and the retry loop hammered the *same* unroutable
bolt11. The §8.2 "Failed resurrection" recovery existed but only fired
if the client spontaneously resubmitted — Mostro never prompted — so in
practice the counterparty share stranded and needed operator
intervention (issue #750).

Phase 4.5 changes the retry-exhaustion transition in
`on_send_payment_failure`:

- **Inside the claim window**: discard the unroutable invoice and re-arm
  the invoice-request sub-phase (`payout_invoice`,
  `payout_routing_fee_sats`, `payout_payment_hash`,
  `last_invoice_request_at` cleared; `payout_attempts` reset to 0; state
  stays `PendingPayout`). The next scheduler tick re-prompts the winner
  via `request_payout_invoice`. `slashed_at` is never touched, so the
  forfeit deadline does not move and the re-prompt/retry cycle is bounded
  by `payout_claim_window_days` (→ `Forfeited`).
- **Past the claim window**: transition to `Failed` as before (terminal
  technical failure, operator review).

Daemon-only: reuses `Action::AddBondInvoice` (Phase 3) and
`Action::BondInvoiceAccepted` (Phase 3.5). No mostro-core bump, no
migration. `claim_window_seconds` is threaded through `pay_counterparty`
into `on_send_payment_failure` for unit-testability.

Spec §9.5 added; phase overview, §8.1/§8.2 cross-refs, and §14.2/§14.3
release status updated to reflect Phases 0–4 merged on main.

Closes #750

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 29, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c6de3fe8-edfc-4b43-92ec-dc3046492679

📥 Commits

Reviewing files that changed from the base of the PR and between 983043c and 08b0b69.

📒 Files selected for processing (2)
  • docs/ANTI_ABUSE_BOND.md
  • src/app/bond/payout.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/ANTI_ABUSE_BOND.md

Walkthrough

This PR adds Phase 4.5 documentation and implements claim-window-aware payout retry exhaustion: indeterminate failures retain the invoice for reconciliation, terminal failures inside the claim window clear stale invoice fields and re-arm the payout loop, and terminal failures outside the window transition the bond to Failed.

Changes

Phase 4.5 payout recovery mechanism

Layer / File(s) Summary
Phase 4.5 specification and compatibility reference
docs/ANTI_ABUSE_BOND.md
Phase overview table and status block updated; inserted missing Phase headings; pinned mostro-core to 0.11.5; Phase 4.5 fully specified with state-machine, in-window re-arm, indeterminate vs terminal semantics, scheduler-driven re-prompting, and compatibility notes (daemon-only, no upstream schema changes).
Claim-window-aware state-machine and retry exhaustion logic
src/app/bond/payout.rs
process_one_bond documents and computes claim_window_seconds and calls pay_counterparty with it; pay_counterparty and send_payment failure paths now classify failures via PaymentFailureKind and call on_send_payment_failure with (kind, cause, claim_window_seconds); on_send_payment_failure now: retains invoice on indeterminate exhaustion, re-arms (clears invoice/payment fields, resets attempts, preserves slashed_at) on terminal exhaustion inside claim window, and transitions to Failed for terminal exhaustion outside the window.
Test updates for new signature and re-arm behavior
src/app/bond/payout.rs (tests)
Updated tests to call the new on_send_payment_failure signature with CLAIM_WINDOW_SECONDS; added coverage for in-window re-arm and indeterminate retention behaviors; adjusted resurrection E2E test to force out-of-window exhaustion by setting claim_window_seconds = 0.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • MostroP2P/mostro#728: Adds payout claim-window configuration that feeds claim-window semantics used here.
  • MostroP2P/mostro#742: Adds/specifies Phase 3.5 winner payout confirmations referenced and reused by Phase 4.5 re-prompt cycles.

Suggested reviewers

  • AndreaDiazCorreia
  • arkanoider
  • BraCR10

Poem

🐰
I nibble at retries beneath the moon,
When payments fail, I hum a kinder tune.
Within the window I nudge for a new bill,
Outside it folds — the deadlines stay still.
Hop, scheduler, hop — let invoices spill.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: implementing Phase 4.5 to re-prompt winners for payout invoices on payment failure, which directly addresses the core objective.
Linked Issues check ✅ Passed The PR fully implements the requirements from #750: re-prompts winners for invoices within the claim window, preserves the claim-window deadline anchored at slashed_at, and prevents repeated prompting after claim window expires.
Out of Scope Changes check ✅ Passed All changes are directly scoped to Phase 4.5 implementation: documentation updates, payout state machine logic, and claim-window-aware retry behavior. No unrelated modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/bond-phase-4.5-reprompt-payout

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

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

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: 983043c359

ℹ️ 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 src/app/bond/payout.rs
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/ANTI_ABUSE_BOND.md (1)

194-217: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Mark Phase 4.5 as implemented here.

This PR adds the Phase 4.5 daemon logic and tests, but the overview still says Phase 4.5 is pending and "not yet implemented." If merged as-is, the spec will immediately contradict the code and mislead follow-up rollout work.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/ANTI_ABUSE_BOND.md` around lines 194 - 217, Update the doc to reflect
that Phase 4.5 is implemented: change the table row for "4.5" from "pending" to
"implemented" (and optionally add the PR/commit reference), and update the
paragraph that currently says "Phase 4.5 and Phases 5–8 are not yet
implemented." to say that Phase 4.5 is merged/implemented while Phases 5–8
remain unimplemented; ensure the Status section still cites the `mostro-core`
pin in Cargo.toml (0.11.5) and any relevant PR numbers for Phase 4.5 so the spec
matches the code.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@docs/ANTI_ABUSE_BOND.md`:
- Around line 194-217: Update the doc to reflect that Phase 4.5 is implemented:
change the table row for "4.5" from "pending" to "implemented" (and optionally
add the PR/commit reference), and update the paragraph that currently says
"Phase 4.5 and Phases 5–8 are not yet implemented." to say that Phase 4.5 is
merged/implemented while Phases 5–8 remain unimplemented; ensure the Status
section still cites the `mostro-core` pin in Cargo.toml (0.11.5) and any
relevant PR numbers for Phase 4.5 so the spec matches the code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 447cc29c-d0b5-4417-84e2-879f4323b9f5

📥 Commits

Reviewing files that changed from the base of the PR and between 82336e3 and 983043c.

📒 Files selected for processing (2)
  • docs/ANTI_ABUSE_BOND.md
  • src/app/bond/payout.rs

…lures

Codex review on #755 flagged a P1: the Phase 4.5 re-arm path cleared
`payout_payment_hash` on every exhausted retry, including non-terminal
failures (status-stream timeout, stream EOF, send_payment RPC error)
where the original payment may still be InFlight in LND. Clearing the
hash disables the reconciliation branch in `pay_counterparty`, so a
freshly-prompted invoice (different hash) could be paid while the
original payment later settles — a double payout.

Introduce `PaymentFailureKind { Terminal, Indeterminate }` and thread it
into `on_send_payment_failure`:

- Terminal (LND-confirmed Failed, or structurally unusable invoice): no
  payment is/will be in flight, so the invoice may be abandoned —
  re-arm in-window, or Failed out-of-window (unchanged Phase 4.5
  behaviour).
- Indeterminate (timeout / EOF / send RPC error): keep `payout_invoice`
  and `payout_payment_hash` so reconciliation polls LND to a definitive
  Succeeded/Failed before anything new is paid. Never re-arms, never
  flips to Failed. `payout_attempts` saturates at `payout_max_retries`
  so a long LND outage can't grow it unbounded.

The send-status stream now tracks whether it ended on an explicit
`PaymentStatus::Failed` (terminal) vs timeout/EOF (indeterminate); the
reconciliation-Failed and decode-failure paths are terminal; the
send_payment RPC error is indeterminate.

Adds `send_payment_indeterminate_failure_keeps_invoice` test; updates the
existing exhaustion tests to pass the failure kind. Spec §9.5 documents
the double-payout guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@mostronatorcoder mostronatorcoder Bot left a comment

Choose a reason for hiding this comment

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

Looks good to me. I reviewed the current head commit 08b0b6961a932d17ba5c4cc3df41c8cb8b26d895.

This hardens the Phase 4.5 payout path in the right places:

  • it keeps indeterminate send_payment failures on the current invoice plus payout_payment_hash, so reconciliation can continue without risking a double payout,
  • it only re-arms on terminal failures while still inside the claim window,
  • it clears the stale payout hash on resurrection so the row always describes the current invoice,
  • and it preserves the fixed slashed_at anchor instead of extending the winner's deadline.

I also checked the important races around forfeit_bond, request_payout_invoice, apply_payout_invoice, and the success-path Slashed CAS. The invariants are documented well and the guarded transitions are consistent with the intended state machine.

Checks run:

  • cargo test payout -- --nocapture
  • cargo clippy --all-targets --all-features -- -D warnings

Both passed.

Copy link
Copy Markdown

@ermeme ermeme Bot left a comment

Choose a reason for hiding this comment

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

Code Review Summary

Verdict: Approve

Reviewed current head 08b0b6961a932d17ba5c4cc3df41c8cb8b26d895.

I checked the payout retry / re-arm logic and the new tests cover the important branches:

  • terminal failure past the claim window → Failed
  • terminal failure within the claim window → re-arm and re-prompt
  • indeterminate failure → keep invoice/hash for reconciliation

Local verification:

  • cargo test payout -- --nocapture
  • cargo test bond -- --nocapture

Both 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.

Bond payout: winner is never re-prompted for an invoice after payout payment fails

1 participant