feat(bond): Phase 3 — payout flow for slashed bonds#738
Conversation
Drives bonds left in `PendingPayout` by Phase 2 through the counterparty payout: ask for an invoice, settle the bond HTLC into Mostro's wallet, send_payment the counterparty share, and transition to `Slashed` (or `Forfeited` if the claim window elapses, or `Failed` after `payout_max_retries` send_payment failures). The split snapshot is read from `bond.node_share_sats` so daemon restarts and config changes can never rebalance an in-flight payout. Ships `Action::AddBondInvoice` (mostro-core 0.11.2) as the counterparty-direction dual of Phase 1.5's `Action::PayBondInvoice`, so the bond-payout reply is routed by action type rather than by ambient DB state. The spec is updated to match: §8.1 drops the Nostr audit event (slash observability stays tracing-only via §14.5) and the AddInvoice references switch to AddBondInvoice. Drive-by fix to `config::util::make_settings` test helper for the `anti_abuse_bond` field added in Phase 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds Phase‑3 bond payout runtime and scheduler (run_bond_payout_cycle, payout state transitions, AddBondInvoice handler, tests), wires it into app and scheduler, bumps mostro-core to 0.11.2, fixes a test helper, and updates Phase 3 documentation and info-tags. ChangesBond payout runtime and scheduler
Anti‑Abuse Bond Phase 3 Documentation and tags
Sequence Diagram(s)sequenceDiagram
participant Scheduler
participant BondPayout as run_bond_payout_cycle
participant DB
participant LND as LndConnector
participant Recipient
Scheduler->>BondPayout: periodic tick -> enumerate PendingPayout
BondPayout->>DB: query bond rows (PendingPayout)
alt missing payout_invoice
BondPayout->>Recipient: enqueue Action::AddBondInvoice (via MessageQueues)
Recipient-->>BondPayout: AddBondInvoice reply (bolt11 invoice)
BondPayout->>DB: CAS-update payout_invoice
end
BondPayout->>LND: settle hold invoice (preimage)
BondPayout->>LND: send_payment for counterparty share
LND-->>BondPayout: payment status (success/failure)
BondPayout->>DB: CAS-transition to Slashed/Failed/Forfeited
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related issues
Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. 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. Comment |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 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.
Inline comments:
In `@docs/ANTI_ABUSE_BOND.md`:
- Around line 995-1001: The doc currently instructs sending an
Action::AddBondInvoice asking for counterparty_share_sats -
estimated_routing_fee, which conflicts with the handler that validates the
invoice principal against the full counterparty_share_sats and will cause
InvalidInvoice errors; change the text so the DM (Action::AddBondInvoice with a
Payload::TextMessage body and the forfeit deadline) requests the full
counterparty_share_sats (you can mention estimated routing fee as guidance only,
not as a subtraction), and keep the instruction to bump invoice_request_attempts
so the behavior matches the handler's validation.
In `@src/app/bond/payout.rs`:
- Around line 152-153: The code currently defaults bond.node_share_sats to zero
with unwrap_or(0) in PendingPayout flows which hides missing data; instead make
node_share_sats mandatory by replacing unwrap_or(0) with an explicit
missing-value error path (e.g., use bond.node_share_sats.ok_or_else(||
PayoutError::MissingNodeShareSats)? or .expect with a clear message) so the
function returns an error rather than silently treating node share as 0 before
computing counterparty_share; apply the same change for the other occurrences of
node_share_sats handling (lines referenced 312-313, 401-402, 633-634) so all
PendingPayout code paths validate bond.node_share_sats before computing
counterparty_share.
- Around line 176-190: The code allows terminal transitions and progressing to
send_payment when bond.preimage is None; update the payout logic to
"fail-closed": before any terminal transition or before calling
ln_client.settle_hold_invoice or send_payment, assert the presence of
bond.preimage (use bond.preimage.as_deref()), and if missing return an Err (do
not mark PendingPayout as Forfeited nor proceed to send_payment). Specifically,
in the forfeit/settle branches around ln_client.settle_hold_invoice and in the
send_payment path, replace the current else-paths that log and continue with an
immediate error return (include contextual logging via bond.id and
bond.order_id) so accounting and wallet spending cannot proceed without a valid
preimage; apply the same change to the other similar blocks noted (the other two
places handling preimage at the payout transitions).
- Line 140: The code currently uses bond.slashed_at.unwrap_or(now) which
silently resets the forfeiture anchor; instead, guard on the Option and do not
default to now: replace that line with logic that checks bond.slashed_at and, if
None, either skip/return from processing this PendingPayout (or mark it as
already forfeited) rather than assigning now—use bond.slashed_at.unwrap() only
when Some and handle the None case explicitly (log a warning and bail/continue
or set forfeiture handling) in the function that computes slashed_at.
In `@src/scheduler.rs`:
- Around line 604-608: The bond payout worker currently returns early when
LndConnector::new() fails (the block that assigns ln_client), so transient LND
startup failures permanently stop draining PendingPayout bonds; change this to a
resilient retry loop: repeatedly attempt LndConnector::new() (reference
LndConnector::new()) with a capped backoff (e.g., tokio::time::sleep between
attempts), log each failure with context (use process/error logging instead of
return error!), and only proceed once you have a successful ln_client; keep the
rest of the bond payout job logic unchanged so PendingPayout processing resumes
when LND becomes available.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: dfb09e2b-016f-4066-86b9-149537bf9fc3
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (7)
Cargo.tomldocs/ANTI_ABUSE_BOND.mdsrc/app.rssrc/app/bond/mod.rssrc/app/bond/payout.rssrc/config/util.rssrc/scheduler.rs
- Document AddBondInvoice DM as requesting full counterparty_share_sats: the handler validates against the full amount with fee=0, so the prior "minus estimated routing fee" wording would cause InvalidInvoice errors. Routing fee comes from Mostro's wallet, not the principal. - Make node_share_sats mandatory in PendingPayout flows: replace unwrap_or(0) with a counterparty_share_sats helper that errors on None. Phase 2's slash CAS writes the column atomically, so a missing value is an invariant violation that must not be silently treated as "pay the full bond to the counterparty." - Fail closed on missing preimage in forfeit_bond, settle_node_only, and settle_and_pay: a PendingPayout row without a preimage cannot have its HTLC settled, so the state machine must not advance to a terminal state nor attempt send_payment from sats it does not hold. Returns Err instead of warn-and-continue. - Stop defaulting slashed_at to now in process_one_bond and request_payout_invoice: synthetic anchors would silently defer the forfeit transition. Error out instead. - Make the bond payout worker resilient to LND startup failure: retry LndConnector::new() with capped exponential backoff (2s -> 60s) inside the spawned task rather than returning on first failure. A transient LND outage no longer permanently halts PendingPayout draining. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
docs/ANTI_ABUSE_BOND.md (1)
189-189: 💤 Low valueUse consistent action naming format.
The phase summary uses
add-bond-invoice(lowercase, hyphenated), but the detailed sections reference this action asAction::AddBondInvoice(lines 995, 1041, 1075, 1479) orAddBondInvoicewithout the prefix (lines 1037, 1063). For searchability and consistency, consider usingAddBondInvoicehere as well.📝 Suggested consistency fix
-| 3 | Payout flow: `add-bond-invoice` to winner, routing-fee estimation, retries | 2 | pending | +| 3 | Payout flow: `AddBondInvoice` to winner, routing-fee estimation, retries | 2 | pending |🤖 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` at line 189, The phase summary line uses a lowercase hyphenated action name; update it to match the rest of the document by replacing `add-bond-invoice` with the canonical action name `AddBondInvoice` (consistent with references like `Action::AddBondInvoice` and `AddBondInvoice` elsewhere) so searchability and naming are consistent across the file.
🤖 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.
Nitpick comments:
In `@docs/ANTI_ABUSE_BOND.md`:
- Line 189: The phase summary line uses a lowercase hyphenated action name;
update it to match the rest of the document by replacing `add-bond-invoice` with
the canonical action name `AddBondInvoice` (consistent with references like
`Action::AddBondInvoice` and `AddBondInvoice` elsewhere) so searchability and
naming are consistent across the file.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: e0b90150-5d28-4d09-9741-9009e610ef8e
📒 Files selected for processing (3)
docs/ANTI_ABUSE_BOND.mdsrc/app/bond/payout.rssrc/scheduler.rs
🚧 Files skipped from review as they are similar to previous changes (2)
- src/scheduler.rs
- src/app/bond/payout.rs
Update add-bond-invoice with with the canonical action name Action::AddBondInvoice in spec so searchability and naming are consistent across the file.
…d payout-deadline message Move the bond-payout forfeit deadline out of the daemon's wire payload and onto the info event, so clients can render it in the user's locale. `Action::AddBondInvoice` now carries only the structured request — the hardcoded English "claim by <ISO> or your share will be forfeited" DM is gone. `info_to_tags` now emits the bond-policy snapshot the spec §13.1 designated for Phase 8, brought forward to Phase 3 because the payout flow is what makes them load-bearing: - bond_enabled (always, including when disabled — disambiguates "feature off" from "older daemon that doesn't speak bond") - bond_apply_to, bond_slash_on_waiting_timeout - bond_amount_pct, bond_base_amount_sats - bond_slash_node_share_pct - bond_payout_claim_window_days (clients add this to slashed_at to render the forfeit deadline themselves) All emitted under snake_case to match the rest of `info_to_tags`. The six policy tags are present iff `bond_enabled = true`. Also: rename all comment/prose references to "DM" → "message" across the bond code and docs (and a handful of unrelated call sites) — the wire is generic Nostr messages, not direct messages specifically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 5
♻️ Duplicate comments (1)
docs/ANTI_ABUSE_BOND.md (1)
1129-1132:⚠️ Potential issue | 🟠 Major | ⚡ Quick winAcceptance text still describes an incorrect payout-invoice principal.
This criterion says the winner is asked for
floor(amount/2) − routing_fee, which contradicts the updated Phase 3 contract that requests the full counterparty share and treats routing fee separately.Suggested doc patch
-- is asked for a bolt11 sized at `floor(amount/2) − routing_fee`, +- is asked for a bolt11 sized at `floor(amount/2)`,🤖 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 1129 - 1132, Update the acceptance text that describes the payout-invoice principal to match the Phase 3 contract: replace the line that says the winner is asked for "floor(amount/2) − routing_fee" with wording that the winner is asked for the full counterparty share (based on slash_node_share_pct) and that routing_fee is treated and deducted separately; ensure references to slash_node_share_pct and "payout-invoice principal" are used so the text clearly distinguishes the counterparty share calculation from routing fee handling.
🧹 Nitpick comments (1)
src/nip33.rs (1)
847-878: ⚡ Quick winAdd an enabled-path info-tag test to lock down the new mapping.
The new test only verifies
bond_enabled=falsebehavior. Please add a companion test with bond enabled that assertsbond_apply_toand the other bond policy tags are present with expected values.🤖 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 `@src/nip33.rs` around lines 847 - 878, Add a companion test that mirrors info_to_tags_emits_bond_disabled_marker_when_bond_off but sets up settings/ln_status with the anti_abuse_bond feature enabled, calls info_to_tags, asserts get_tag_value(&tags, "bond_enabled") == Some("true"), and verifies each bond policy tag ("bond_amount_pct","bond_base_amount_sats","bond_apply_to","bond_slash_on_waiting_timeout","bond_slash_node_share_pct","bond_payout_claim_window_days") is present and equals the expected value derived from the ln_status/bond settings (use make_ln_status to build an LN status containing the bond policy and get_tag_value to compare values); name the test similarly (e.g., info_to_tags_emits_bond_enabled_marker_when_bond_on) and reuse init_test_settings/make_ln_status/get_tag_value to locate code paths.
🤖 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.
Inline comments:
In `@docs/ANTI_ABUSE_BOND.md`:
- Around line 266-274: Update the legacy Action::AddInvoice references to the
new Action::AddBondInvoice throughout the Phase 3 documentation: replace
occurrences of "Action::AddInvoice" and "AddInvoice" in the explanatory text
surrounding the invoice_request_attempts column and the payout invoice timestamp
(which drives payout_invoice_window_seconds cadence) with
"Action::AddBondInvoice"; also make the same replacements for the other
occurrences noted (the ones around the payout-flow spec mentioned as also
applies to 1136-1137) so the doc consistently uses AddBondInvoice.
In `@src/app/bond/payout.rs`:
- Around line 214-220: The two branches that call ln_client.settle_hold_invoice
(the block starting with "if let Err(e) =
ln_client.settle_hold_invoice(preimage).await" and the similar branch at lines
250-256) must treat LND's idempotent "already settled" response as success:
instead of returning Err(e) unconditionally, detect the condition using
is_already_settled_error(e) and, when true, proceed as if settle succeeded
(i.e., continue to the CAS transition/path that updates SQL), otherwise log and
return the error; keep the existing warn log context including bond.id and
bond.order_id.
- Around line 223-227: The forfeiture CAS currently updates state to
BondState::Forfeited without proving an invoice is still missing; modify the
UPDATE in the payout logic (the sqlx::query that binds BondState::Forfeited,
bond.id, BondState::PendingPayout) to include "AND payout_invoice IS NULL" in
the WHERE clause, and after execute() treat rows_affected() == 0 as a lost
race/no-op (i.e., do not treat it as an error) to avoid discarding an invoice
that was inserted by add_bond_invoice_action between the scheduler snapshot and
this update.
- Around line 464-481: The receive loop on rx.recv() can block forever; wrap the
receive operations in a bounded timeout (e.g. tokio::time::timeout) and treat
both timeout and EOF (None) as payment failures routed through
on_send_payment_failure. Specifically, in the loop that checks
PaymentStatus::try_from(msg.payment.status) (around the rx.recv() usage),
replace the direct await with a timeout'd receive, and on Err(timeout) or
Ok(None) call on_send_payment_failure with a clear timeout/EOF message and set
failure (or return) as the existing PaymentStatus::Failed branch does; keep the
existing handling for Succeeded and Failed so succeeded and failure variables
are set the same way. Ensure the timeout duration is configurable/constant and
that the call to on_send_payment_failure uses the same signature/context as the
current code.
- Around line 182-189: The helper counterparty_share_sats currently subtracts
node_share_sats from bond.amount_sats without bounds checks which can produce a
negative value; update counterparty_share_sats to validate that
bond.node_share_sats is Some and lies within 0..=bond.amount_sats, returning a
MostroError (matching the existing
MostroInternalErr(ServiceError::UnexpectedError(...)) pattern) if the value is
out of range or missing, otherwise return the computed amount_sats -
node_share_sats; reference the Bond struct's node_share_sats field and the
counterparty_share_sats function when making the change.
---
Duplicate comments:
In `@docs/ANTI_ABUSE_BOND.md`:
- Around line 1129-1132: Update the acceptance text that describes the
payout-invoice principal to match the Phase 3 contract: replace the line that
says the winner is asked for "floor(amount/2) − routing_fee" with wording that
the winner is asked for the full counterparty share (based on
slash_node_share_pct) and that routing_fee is treated and deducted separately;
ensure references to slash_node_share_pct and "payout-invoice principal" are
used so the text clearly distinguishes the counterparty share calculation from
routing fee handling.
---
Nitpick comments:
In `@src/nip33.rs`:
- Around line 847-878: Add a companion test that mirrors
info_to_tags_emits_bond_disabled_marker_when_bond_off but sets up
settings/ln_status with the anti_abuse_bond feature enabled, calls info_to_tags,
asserts get_tag_value(&tags, "bond_enabled") == Some("true"), and verifies each
bond policy tag
("bond_amount_pct","bond_base_amount_sats","bond_apply_to","bond_slash_on_waiting_timeout","bond_slash_node_share_pct","bond_payout_claim_window_days")
is present and equals the expected value derived from the ln_status/bond
settings (use make_ln_status to build an LN status containing the bond policy
and get_tag_value to compare values); name the test similarly (e.g.,
info_to_tags_emits_bond_enabled_marker_when_bond_on) and reuse
init_test_settings/make_ln_status/get_tag_value to locate code paths.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 19c00ca9-3bb5-46f7-b421-9e66516d5496
📒 Files selected for processing (12)
docs/ANTI_ABUSE_BOND.mddocs/STARTUP_AND_CONFIG.mdsrc/app/bond/flow.rssrc/app/bond/model.rssrc/app/bond/payout.rssrc/app/bond/slash.rssrc/app/cancel.rssrc/app/last_trade_index.rssrc/app/take_buy.rssrc/app/take_sell.rssrc/nip33.rssrc/util.rs
✅ Files skipped from review due to trivial changes (9)
- src/util.rs
- src/app/take_sell.rs
- src/app/last_trade_index.rs
- src/app/bond/slash.rs
- src/app/take_buy.rs
- src/app/cancel.rs
- src/app/bond/model.rs
- docs/STARTUP_AND_CONFIG.md
- src/app/bond/flow.rs
… `make` Payout-flow correctness (src/app/bond/payout.rs): - forfeit_bond and settle_node_only now treat LND's idempotent "already settled" response as success (via is_already_settled_error) instead of returning Err and retrying next tick. - Forfeit CAS gains `AND payout_invoice IS NULL` so a late add_bond_invoice_action insert cannot be silently discarded. - send_payment status-stream recv() is now bounded by PAYMENT_STATUS_RECV_TIMEOUT (120s); both timeout and EOF route through on_send_payment_failure. - counterparty_share_sats validates node_share_sats ∈ [0, amount_sats] as a belt-and-braces guard against a corrupted row. Info-event tags (src/nip33.rs): - Extract bond_policy_tags(Option<&AntiAbuseBondSettings>) as a pure helper so the enabled branch is testable without mutating the MOSTRO_CONFIG OnceLock. Added a companion test that asserts every policy tag is emitted byte-for-byte against the source settings. Docs (docs/ANTI_ABUSE_BOND.md): - Phase 3 column comments and §8.3 acceptance examples now say Action::AddBondInvoice (not the legacy buyer-flow Action::AddInvoice). - §8.3 acceptance clarifies the payout-invoice principal carries the full counterparty share; routing fee is paid separately from Mostro's wallet and recorded into payout_routing_fee_sats. Naming (cross-cutting): - Rename BondApplyTo::Create → BondApplyTo::Make (and the wire/TOML value via serde rename_all = "lowercase"). Matches the standard exchange-system make/take terminology. Settings template, info-event match arm, predicate tests, and all spec references updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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.
Inline comments:
In `@docs/ANTI_ABUSE_BOND.md`:
- Around line 1109-1111: The doc statement that "winner is kept informed via
periodic messages" conflicts with §8.1 step 1 because Action::AddBondInvoice is
only scheduled when payout_invoice IS NULL; if the invoice already exists there
is no path shown to notify the winner on retries. Update the text to either (A)
explicitly reference and document the notification mechanism used when
payout_invoice IS NOT NULL (name the action or function that emits periodic
notifications to the winner), or (B) remove or reword the claim about periodic
messages so it only applies to the branch where Action::AddBondInvoice is
scheduled; ensure you reference Action::AddBondInvoice and the payout_invoice IS
NULL condition so readers can locate the relevant decision point.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 6b2624b7-8a1f-48af-8309-412954172251
📒 Files selected for processing (6)
docs/ANTI_ABUSE_BOND.mdsettings.tpl.tomlsrc/app/bond/model.rssrc/app/bond/payout.rssrc/config/types.rssrc/nip33.rs
✅ Files skipped from review due to trivial changes (1)
- settings.tpl.toml
🚧 Files skipped from review as they are similar to previous changes (3)
- src/app/bond/model.rs
- src/nip33.rs
- src/app/bond/payout.rs
…t once invoice is submitted The previous wording claimed "winner is kept informed via periodic messages" in the partial-success branch, but periodic `Action::AddBondInvoice` messages are only scheduled by §8.1 step 1 when `payout_invoice IS NULL`. Once the winner has submitted a bolt11, the retry loop runs silently (tracing logs only). Reword to reference `Action::AddBondInvoice` and its `payout_invoice IS NULL` precondition so the "operators can manually pay the winner from logs" fallback is self-consistent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…drives only the counterparty payout The slashed bond's HTLC is now claimed inside `apply_bond_resolution` (Phase 2) via `settle_hold_invoice(preimage)` before the row CAS to `PendingPayout`, instead of waiting for the Phase 3 scheduler. By the time the scheduler picks up a `PendingPayout` row the sats are already in Mostro's wallet; Phase 3 only requests the recipient bolt11, runs `send_payment`, retries, and handles forfeiture. Architecture preserved: `PendingPayout` state name kept (semantics docstring updated); Phase 3 still owns the asynchronous counterparty payout machinery; non-slashed bonds still flow through `release_bond` (cancel, not settle); trade finalization is unblocked from the recipient leg as before. Implementation: - New `SettleLightning` trait in `bond::slash` (mirrors `CancelLightning`) so tests can stub LND. `LndConnector` impls it. - `apply_bond_resolution` + `slash_one` are now generic over `SettleLightning + Send` and call `settle_hold_invoice` before the Locked → PendingPayout CAS. "Already settled" (admin retry) is classified as success; transient failures leave the row Locked for retry. - `is_already_settled_error` moved from `bond::payout` to `bond::slash` (its only remaining caller). - Phase 3 scheduler: removed all `settle_hold_invoice` calls. `forfeit_bond` and `settle_node_only` are now pure SQL CAS transitions (renamed the latter to `finalize_node_only`). `settle_and_pay` → `pay_counterparty`. State-machine docstring updated; `process_one_bond` no longer requires the preimage helper. - `admin_settle_action` / `admin_cancel_action` pass `ln_client` through to `apply_bond_resolution`. Tests (306/306 passing, +6 new): - slash one bond → exactly one `settle_hold_invoice` call. - slash both bonds → two calls. - non-slashed (released) bond → zero calls. - "already settled" admin retry → CAS still succeeds. - transient settle failure → row stays Locked. - forfeit transition is pure SQL CAS, with the `AND payout_invoice IS NULL` race guard verified end-to-end. - `finalize_node_only` is pure SQL CAS. Spec (docs/ANTI_ABUSE_BOND.md): - §1 overview, §7.3 slash steps, §8 lead-in, §8.1 scheduler (forfeit branch is now pure SQL, normal payout step renumbered, both-slashed branch settles per-bond at slash time), §8.2 failure modes (rewritten around the immediate-settle invariant), §8.3 acceptance (added slash-time-settle / slash-both / non-slashed-no-settle test items, updated forfeit-window test), §9.3 Phase 4 timeout slash (reuses Phase 2's settle primitive), §15.4 (rationale references slash-time settle). - `BondState::PendingPayout` docstring updated to reflect "HTLC already settled". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ndPayoutRequest
Adopt mostro-core 0.11.3, which introduces the dedicated
`Payload::BondPayoutRequest { order: SmallOrder, slashed_at: i64 }`
variant for the outbound side of `Action::AddBondInvoice`. Mostro
now emits the slash anchor on every cadence retry of the request,
so a recipient that's offline (or whose relay is offline) for days
still computes the correct forfeit deadline as
`slashed_at + bond_payout_claim_window_days * 86_400` — instead of
deriving it from message receipt time, which would silently drift
into the future.
Daemon (src/app/bond/payout.rs):
- `request_payout_invoice` re-validates `bond.slashed_at` at every
emission point (not just in `process_one_bond`) so the invariant
holds for each retry.
- The enqueued message body is now
`Payload::BondPayoutRequest { order, slashed_at }` instead of
`Payload::Order(order)`. The reply direction (counterparty →
Mostro) keeps using `Payload::PaymentRequest` with the bolt11.
- `bond payout: requesting invoice from counterparty` log line
includes `slashed_at` so operators can correlate the wire
emission with the slash CAS.
Spec (docs/ANTI_ABUSE_BOND.md):
- §8.1 opens with the new variant contract: struct shape, verify
rules, motivation (offline-recipient case), serde-additive caveat
for 0.11.2 clients.
- §8.1 step 1 rewritten to describe the wire body as
`Payload::BondPayoutRequest { order, slashed_at }` and the client
rule for computing the forfeit deadline locally.
- §14.3 changelog entry for the variant, with explicit note that
daemon 0.11.3 and client 0.11.3 must adopt together (a 0.11.2
client rejects `BondPayoutRequest` on `AddBondInvoice`).
Cargo.toml: mostro-core 0.11.2 → 0.11.3 (published on crates.io).
Tests: 306/306 pass (no new daemon-side tests needed — the variant
emission is a wire-format change, covered by the existing
`request_payout_invoice_respects_cadence_window` CAS test). The
variant itself ships with two `MessageKind::verify` tests in
mostro-core (verify accepts on `AddBondInvoice`, rejects elsewhere).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
I did a strict pass on the current PR head and I can't approve this yet.
There is one conceptual mismatch between the current spec and the implementation that looks important enough to block:
- The slash path still does not settle the slashed bond HTLC immediately.
Right now src/app/bond/slash.rs transitions slashed bonds from Locked -> PendingPayout and snapshots slashed_reason, slashed_at, and node_share_sats, but it never calls settle_hold_invoice there. The actual settle still happens later in the Phase 3 scheduler (src/app/bond/payout.rs) inside forfeit_bond, settle_node_only, or settle_and_pay.
That matches the current wording in the Phase 3 section, but it means the slashed user's hold invoice remains merely parked until a later scheduler tick. If the intended behavior is that a slash should immediately claim the slashed HTLC, then both the spec and the code still need another change: the settle must move into the slash path itself (and if both sides are slashed, both HTLCs must be settled there).
So before this can be approved, I think we need one of two things, but they must be made consistent:
- either explicitly keep the current delayed-settle design and document/defend it as the intended final behavior,
- or change the implementation so the slash path settles the slashed hold invoice(s) immediately and Phase 3 only handles the asynchronous payout / retry / forfeiture part afterwards.
As written today, the PR still implements the delayed-settle model.
There was a problem hiding this comment.
Re-reviewed the latest PR head (c65d8e12b190ade234d0bc9da678d14d0ac18b4c).
My previous blocking point is resolved in this head.
This commit stack now updates both the spec and the implementation so that the slashed bond HTLC is settled immediately in the slash path:
src/app/bond/slash.rsnow performssettle_hold_invoice(preimage)before theLocked -> PendingPayoutCAS,- the spec (
docs/ANTI_ABUSE_BOND.md) now explicitly documents that ordering, - and Phase 3 is reframed as handling only the asynchronous counterparty payout / retry / forfeiture flow after Mostro already holds the sats.
So the earlier objection about delayed settle no longer applies. Sorry, that earlier review was based on an older head and was stale.
There was a problem hiding this comment.
I re-reviewed the latest PR head (c65d8e12b190ade234d0bc9da678d14d0ac18b4c) and found one remaining issue.
The implementation now correctly settles the slashed HTLC at slash time, and the spec was updated accordingly. However, one user-visible/log-facing string in src/app/bond/payout.rs is now stale and misleading:
add_bond_invoice_actionstill logs"bond payout: invoice accepted; awaiting scheduler tick for settle+pay"
That was accurate under the old delayed-settle model, but it is no longer true after commit 28929e4: by the time the user submits Action::AddBondInvoice, the slashed HTLC has already been settled in the slash path. The scheduler is now only waiting to do the counterparty payout leg.
So this should be reworded to avoid documenting the old behavior in logs/comments. Something like awaiting scheduler tick for payout would match the new model.
Slashed HTLC is now settled at slash time, so the scheduler tick only drives the counterparty payout. The old "settle+pay" wording documented the prior delayed-settle model. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Catrya
left a comment
There was a problem hiding this comment.
When the payment to the user's invoice fails, the bond ends up as Failed in the DB and mostrod doesn't send another add-bond-invoice to the user.
If the user submits a new invoice anyway, mostrod responds with cant-do: not_allowed_by_status, so there's no way to recover from the user side
There was a problem hiding this comment.
Hermes Agent Review
Changes requested. I found one blocking issue in the payout request flow.
Blocking
enqueue_order_msg()is called before the database write that persistsinvoice_request_attemptsandlast_invoice_request_at. If the process crashes, the DB write fails, or the worker restarts after the message has already been queued, the durable state still says no request was sent. The next scheduler tick will emit the sameAction::AddBondInvoiceagain.- That breaks the pacing guarantee and can produce duplicate payout requests / duplicate DMs.
Fix
- Make the durable state the source of truth: either persist the request first and then enqueue, or use a transactional outbox pattern so the DB update and the outgoing message are coupled.
Everything else in the reviewed bond payout path looks internally consistent with the current spec.
…laim window Previously, once payout_max_retries was exhausted the bond went to Failed and the recipient had no path forward — a resubmitted AddBondInvoice was rejected with NotAllowedByStatus, and mostrod never re-prompted. Operators had to pay the winner manually from logs (§8.2 "known limitation"). This change makes Failed user-recoverable while still inside payout_claim_window_days. A fresh AddBondInvoice from the recipient flips the row Failed → PendingPayout, overwrites the stale payout_invoice, and resets payout_attempts and invoice_request_attempts to 0 so the scheduler gets a fresh retry budget against the new bolt11 on its next tick. Past the claim window, Failed remains operator territory. Race safety — every transition is a single guarded CAS on the state column: - Scheduler only enumerates PendingPayout, so it never overlaps a Failed row mid-resurrection. - Two concurrent resurrections both pass the Rust claim-window check, but only one matches `WHERE state = 'failed'`; the loser sees rows_affected=0 and is rejected uniformly. - on_send_payment_failure and a concurrent user submission against a PendingPayout row are mutually exclusive via the existing `payout_invoice IS NULL` predicate. - Claim-window boundary is `>=` so exact-boundary submissions belong to operator territory. is_terminal() unchanged — Failed is still terminal for the release/cancel paths in flow.rs (which correctly skip it because the HTLC was settled at slash time). The resurrection path works directly on the DB row. Spec §8.1 step 5, §8.2 partial-failure bullet, and §8.3 acceptance updated with three new tests (resurrection inside window, rejection past window, concurrent race). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…voice Previously, request_payout_invoice enqueued the outbound Action::AddBondInvoice *before* the DB UPDATE that bumps invoice_request_attempts and last_invoice_request_at. If the process crashed (or the UPDATE itself failed) between enqueue and UPDATE, the durable state still read "no nudge sent" while the recipient (or relays, after publisher flush) had already seen one. The next scheduler tick would then emit a duplicate Action::AddBondInvoice, defeating the cadence guard. Swap the order: UPDATE first under a guarded `WHERE id = ? AND state = 'pending-payout'` CAS, abort with rows_affected == 0 short-circuit before any enqueue, then enqueue. The DB is now the source of truth: the worst-case crash mode loses one nudge (re-prompted on the next tick once payout_invoice_window_seconds elapses) instead of producing a duplicate. The state predicate also catches the case where the row has moved on (Forfeited via the scheduler, Slashed, Failed via the resurrection path) between the scheduler's snapshot and this write — the UPDATE matches zero rows and the enqueue is skipped entirely. Tests: two new in payout module. The happy-path test verifies that both the durable counters advance and exactly one Action::AddBondInvoice lands in MESSAGE_QUEUES (filtered by order_id for determinism under concurrent test execution). The state-moved-off test simulates a row flipped to Forfeited under us and verifies zero messages are enqueued — the exact case that would have leaked a duplicate before this change. Spec §8.1 step 1 documents the persist-first ordering invariant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Implements Phase 3 of the Anti-Abuse Bond rollout (issue #711, spec
docs/ANTI_ABUSE_BOND.md§8). Picks up where #737 left off: bonds thatPhase 2 parked in
BondState::PendingPayoutnow get drained throughto either
Slashed,Forfeited, orFailed.job_process_bond_payouts(mirror of the dev-feejob) that for each
PendingPayoutbond:Forfeitedoncepayout_claim_window_dayselapses with no counterparty invoice (long-stop forfeiture, §15.4);
Action::AddBondInvoice(cadence-gated onpayout_invoice_window_seconds, with the forfeit deadline shippedas a
Payload::TextMessageDM alongside);send_payments thecounterparty share (
amount_sats − node_share_sats), capped byMostroSettings::max_routing_fee;send_paymentfailures up topayout_max_retries, thentransitions to
Failedwith a louderror!for operator review;slash_node_share_pct = 1.0(settle →
Slashed, no DMs, nosend_payment).add_bond_invoice_actionconsumingAction::AddBondInvoicereplies. CAS-guarded persistence (state = PendingPayout AND payout_invoice IS NULL) closes the late-invoicerace against a concurrent
Forfeitedtransition.docs/ANTI_ABUSE_BOND.md:observability stays tracing-only via §14.5, which is left
untouched);
Action::AddBondInvoiceinstead ofAddInvoice— keeps thebond-payout invoice flow disjoint from the buyer-flow at the
routing layer rather than relying on ambient DB lookups;
mostro-core0.11.2 bump that shipsAction::AddBondInvoice.Cargo.toml: bumpedmostro-corefrom 0.11.1 to 0.11.2.anti_abuse_bond: Noneto theconfig::utiltest helpermake_settings, which had failed tocompile in the test profile since Phase 0 landed the new field on
Settings.The split snapshot (
node_share_sats) frozen by Phase 2 at theLocked → PendingPayouttransition is the sole source of truth forthe scheduler;
slash_node_share_pctis never re-read from config.Daemon restarts and operator config changes mid-payout cannot
rebalance an in-flight bond.
Test plan
cargo test --bin mostrod— 298 tests pass (10 new tests undersrc/app/bond/payout.rs::tests).cargo clippy --all-targets --all-features -- -D warnings.cargo fmt --all.SQLX_OFFLINE=true cargo build --bin mostrod(no new compile-timequery!macros, so the offline cache is unchanged).How to test (manual regtest walkthrough)
These scenarios exercise Phase 3 end-to-end against polar/regtest LND.
The cast:
S7, a buy-order in S6.
apply_to = "take").mostro.solvers. Resolves disputes viaadmin-settle/admin-cancel.Common setup before every scenario:
settings.tomlenable the bond on the taker side:RUST_LOG=info,mostro=debug. Look for thebond payout:prefix — every transition logs through it.sqlite3 mostro.db "select id, state, slashed_reason, node_share_sats, payout_attempts, invoice_request_attempts, last_invoice_request_at, payout_invoice from bonds;"is the source of truth for state per row.
S1 — Happy path: dispute slashes buyer, seller paid out
Tests the
LostDispute → settle (at slash time) → AddBondInvoice → send_payment → Slashedflow.WaitingTakerBond→ bondbolt11 (
Action::PayBondInvoice) is DMed to B. Confirm in the DBthe bond row is
requested, thenlockedonce B pays thebond invoice.
WaitingPayment/Active. B(buyer) pays the hold invoice. Then A signals fiat-sent /
release won't happen; one party opens a dispute.
admin-take-dispute) and resolveswith settle + slash buyer:
{ "order": { "version": 1, "id": "<order-id>", "action": "admin-settle", "payload": { "bond_resolution": { "slash_seller": false, "slash_buyer": true } } } }state = pending-payout, slashed_reason = lost-dispute, node_share_sats = 500(50% of a 1 000 sat bond at the specdefaults).
slashed_atis set;payout_invoiceisNULL.Also confirm in the log the line
Bond HTLC settled and row transitioned to PendingPayout— the slash path settles thebond hold invoice immediately (Phase 2 §7.3), so Mostro's
wallet is already up by 1 000 sats at this point. Phase 3
only drives the recipient payout from sats already on hand.
seller — the recipient under §3.1 mapping for a sell-order) must
receive an
Action::AddBondInvoicemessage with the ordercontext (the amount field = 500 sats, the counterparty share).
The message carries only the structured payload — no
human-readable deadline text. Clients render the forfeit
warning locally from
slashed_at + bond_payout_claim_window_days × 86 400, withbond_payout_claim_window_dayspublished on thekind-38385 info event (§13.1). DB-wise:
invoice_request_attempts = 1,last_invoice_request_atisnow-ish.
Action::AddBondInvoicecarrying a bolt11 for500 sats. Verify the DB now shows
payout_invoice = lnbc…andinvoice_request_attemptsreset to 0.send_paymentto A's bolt11 for 500 sats, capped bymax_routing_fee(the sats are drawn from the slash-timesettle in step 4 — Phase 3 never calls
settle_hold_invoice);state = slashed.bond row is
slashed, andpayout_attemptsis still 0 (noretries needed).
S2 — Forfeit window: counterparty never claims
Tests the long-stop
Forfeitedpath (§15.4).sats from the slash-time settle.
pending-payoutand A received the firstAddBondInvoicemessage. A does nothing — no reply.payout_claim_window_days = 1insettings.tomlbeforestarting the run (the scheduler reads the config on every tick,
only the snapshot inside
node_share_satsis frozen — soshrinking the window mid-flight also works).
payout_claim_window_days × 86 400seconds, the nextscheduler tick must:
state = forfeited— a pure SQLtransition with the
AND payout_invoice IS NULLrace guard;no LND call (the HTLC was settled at slash time);
bond forfeited: claim window elapsed, node retains full amount.payment. Mostro's wallet is still up by 1 000 sats (no change
from step 1 — the forfeit transition just records that the
counterparty share is also retained by the node now).
bondsrow is
forfeited;payout_invoiceis stillNULL.S3 — Late invoice on a forfeited bond is rejected
Companion to S2; covers the CAS-guarded
add_bond_invoice_action.forfeited), A wakes up andsends an
Action::AddBondInvoicereply with a valid bolt11.CantDo(NotAllowedByStatus)to A and does not touch thebondsrow. The row staysstate = forfeited, payout_invoice IS NULL.S4 —
slash_node_share_pct = 1.0: full node retentionTests the no-counterparty-leg shortcut.
settings.tomltoslash_node_share_pct = 1.0and restart mostrod.node_share_sats = 1000(i.e. equal toamount_sats).already up by 1 000 sats by the end of step 2 (look for the
Bond HTLC settled and row transitioned to PendingPayoutlogline). On the next scheduler tick:
state = slashed— pure SQL,no LND call;
AddBondInvoicemessage is sent to A, and nosend_paymentruns.wallet is up by 1 000 sats.
S5 —
send_paymentretries →FailedTests the technical-failure path that produces an operator-attention
state.
payout_max_retries = 3insettings.toml.easiest way on polar: pay from a node with no channel path to A,
or have A advertise an invoice on a node Mostro can't reach
(force-close all channels between them before submission).
send_payment, fail;bond payout: send_payment failure (...); will retry on next tick;payout_attemptsby 1.payout_attempts = 3), the bond musttransition
state = failedand the log line must be the louderror!bond payout: send_payment exhausted retries — transitioning to Failed; node share retained, counterparty share stranded (operator review required).slash-time settle in step 2 deposited them; only the
counterparty
send_paymentleg never landed). This is theknown-limitation case the spec §8.2 calls out — operators
manually pay A from logs.
S6 — Buy-order mirror of S1 (recipient resolution sanity)
Same flow, different §3.1 mapping. Confirms recipient resolution
isn't hard-coded for sell-orders.
seller).
slash seller:
{ "order": { "version": 1, "id": "<order-id>", "action": "admin-cancel", "payload": { "bond_resolution": { "slash_seller": true, "slash_buyer": false } } } }side on a buy-order). Confirm A — not B — receives the
AddBondInvoicemessage, and the rest of S1 plays outidentically.
S7 — Daemon restart mid-payout preserves the split snapshot
Tests the "config changes can't rebalance an in-flight bond"
invariant.
pending-payout,node_share_sats = 500).slash_node_share_pct = 0.9insettings.toml, restart.AddBondInvoicemessage for 500 sats (not 100 sats — the 0.9 snapshot is
ignored).
send_paymentruns for500 sats (the bond HTLC was already settled at slash time
in S1 step 4 of the original run, before the restart). Confirm
the row's
node_share_satsis still 500 and the bond ends instate = slashed.Quick sanity grep before merging
sqlite3 mostro.db "select state, count(*) from bonds group by state;"After running S1, S2, S4, S5, S6, S7 the row should be:
slashed | 3(S1, S4, S6),forfeited | 1(S2),failed | 1(S5),slashed | 1(S7) — no row stuck inpending-payoutother than thelate
S3case (which isforfeited).Refs: #711.
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Tests
Chores