feat(bond): Phase 4 — timeout slash for the taker bond#744
Conversation
Implements Phase 4 of the anti-abuse bond (docs/ANTI_ABUSE_BOND.md §9):
when a waiting-state timeout actually elapses, the responsible party's
bond is slashed instead of always released. Gated by
`enabled && slash_on_waiting_timeout && apply_to ∈ { take, both }`.
- `bond::slash_or_release_on_timeout` (src/app/bond/slash.rs): derives the
responsible side from the waiting state (§9.2: WaitingBuyerInvoice →
buyer, WaitingPayment → seller) and, when armed and the responsible
party holds a Locked bond, reuses the Phase 2 `apply_bond_resolution`
primitive with `BondSlashReason::Timeout` (settle the HTLC + CAS to
PendingPayout, releasing every other bond). Otherwise it falls back to
releasing all bonds, exactly as Phase 1 did — so feature-off and
no-bond paths are unchanged, and stray bonds from a prior enabled
period still drain. The §3.1 buyer/seller → bond mapping lives in the
reused primitive; `bond.pubkey` equals the order's buyer/seller pubkey.
- The bond config is passed in (scheduler hands it `Settings::get_bond()`)
rather than read from the global `OnceLock`, so the gate is
unit-testable without mutating process-wide state.
- Wires the dispatch into `scheduler::job_cancel_orders`, replacing the
unconditional Phase 1 release in the persist-success branch. The
pre-cancel order snapshot (waiting status + trade pubkeys intact) is
passed so the buyer/seller resolution matches the bonded party.
- `bond::notify_bond_slashed` sends the slashed user an
`Action::BondSlashed` (mostro-core 0.11.5) forfeiture notice. It is
best-effort and sent only after the slash is confirmed to have landed
(row re-reads as PendingPayout), so a transient settle failure — which
leaves the bond Locked for retry — never produces a false notice.
§9.1 invariant preserved: only the elapsed-timeout scheduler path can
slash; cooperative / unilateral / admin cancels still go through the
Phase 1 release helpers untouched.
Bumps the mostro-core pin to 0.11.5 (adds `Action::BondSlashed`) and
updates the spec (§9.3, §14.3, phase table) to reflect it.
Tests: 9 new unit tests covering all four §9.2 rows, the
slash_on_waiting_timeout=false gate, apply_to=make, no-config release,
the transient-settle-failure safety case, and the BondSlashed
notification target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WalkthroughThis PR implements Phase 4 of the anti-abuse bond timeout-slash feature, bumping mostro-core to 0.11.5 for the new ChangesPhase 4 Timeout-Slash Implementation
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 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 docstrings
🧪 Generate unit tests (beta)
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 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b71eae20fd
ℹ️ 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".
The forfeiture-notice gate re-read the bond and required state == PendingPayout. But the Phase 3 payout scheduler runs concurrently (every 60s) and can move a just-slashed row off PendingPayout within the confirmation window — e.g. finalize_node_only flips a node-only slash (slash_node_share_pct = 1.0) straight to Slashed. In that race the slash already succeeded, yet the check returned None and Action::BondSlashed was silently dropped. Confirm instead via the durable `slashed_reason = Timeout` metadata, written atomically with the Locked → PendingPayout CAS and never cleared by any later transition (Slashed / Forfeited / Failed, or the Failed → PendingPayout resurrection). A transient settle failure leaves the bond Locked with NULL slash metadata, so a false notice is still impossible; a concurrent dispute slash (LostDispute) is not mistaken for a timeout slash. Adds a regression test covering all post-slash states, the Locked no-metadata case, and the LostDispute distinction. Spec §9.3 updated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
What
Implements Phase 4 of the anti-abuse bond (
docs/ANTI_ABUSE_BOND.md§9, issue #711): when a waiting-state timeout actually elapses, the responsible party's bond is slashed instead of always released.Gate flags
enabled && slash_on_waiting_timeout && apply_to ∈ { take, both }. With any of these off, behaviour is identical to today (bonds released).How
bond::slash_or_release_on_timeout(src/app/bond/slash.rs): derives the responsible side from the waiting state alone (§9.2:WaitingBuyerInvoice → buyer,WaitingPayment → seller). When armed and the responsible party holds aLockedbond, it reuses the Phase 2apply_bond_resolutionprimitive withBondSlashReason::Timeout— settling the bond HTLC + CAS toPendingPayoutand releasing every other bond. Otherwise it falls back to releasing all bonds (Phase 1 behaviour), which also drains stray bonds left from a previously-enabled period.bond.pubkeyequals the order'sbuyer_pubkey/seller_pubkey, so underapply_to = takeonly the taker side ever resolves a bond and the maker-responsible §9.2 rows fall through to release.Settings::get_bond()) instead of read from the globalOnceLock, so the gate is unit-testable.scheduler::job_cancel_orders, replacing the unconditional Phase 1 release in the persist-success branch. The pre-cancel order snapshot (waiting status + trade pubkeys intact) is passed so the resolution matches the bonded party.bond::notify_bond_slashedsends the slashed user anAction::BondSlashed(mostro-core 0.11.5) forfeiture notice, carryingPayload::Order(amount = slashed bond amount). Best-effort, and sent only after the slash is confirmed to have landed — confirmed via the durableslashed_reason = Timeoutmetadata (written atomically with the slash CAS and never cleared by the concurrent Phase 3 payout job), not a transientstate = PendingPayoutcheck the scheduler could invalidate. A transientsettle_hold_invoicefailure leaves the bondLockedwith no slash metadata, so it never produces a false "your bond was slashed" message.Safety invariants
release_bonds_for_order_or_warnhelpers, untouched (verified: single non-test call site).slashed_reason = Timeoutmetadata, so a transient settle failure (bond staysLocked) or a concurrent payout-job transition (PendingPayout → Slashed) can neither suppress a true notice nor fabricate a false one.mostro-core dependency
Bumps the pin
0.11.4 → 0.11.5, which adds the additiveAction::BondSlashed(released upstream in mostro-core#149). Serde-additive: clients that don't know the variant ignore it and fall back to theAction::Canceledthey already receive for the order. The spec (§9.3, §14.3, phase table) is updated to reflect this — earlier drafts assumed Phase 4 was daemon-only.Tests
cargo test(337 passed),cargo clippy --all-targets --all-featuresclean,cargo fmtclean. 10 new unit tests inbond::slash:WaitingBuyerInvoice→slash, buy+WaitingPayment→slash, sell+WaitingPayment→no slash/maker, buy+WaitingBuyerInvoice→no slash/maker);slash_on_waiting_timeout = false→ release, no slash;apply_to = make→ no taker slash;[anti_abuse_bond]config → release;Locked, no notice;slashed_reason = timeoutconfirm;Locked/no-metadata andLostDisputedo not);Action::BondSlashedtargets the slashed taker only.How to test (manual regtest walkthrough)
These scenarios exercise Phase 4 end-to-end against polar/regtest LND.
The cast:
apply_to = "take").No solver is involved: Phase 4 slashes are automatic on timeout, not
solver-directed (those are Phase 2). The recipient payout reuses the
Phase 3 machinery unchanged.
Common setup before every scenario:
settings.toml, shrink the waiting-state timeout and enable thebond on the taker side:
RUST_LOG=info,mostro=debug. Watch forBond slashed on waiting-state timeout(the Phase 4 slash) and thebond payout:prefix (the Phase 3 drain).sqlite3 mostro.db "select id, state, slashed_reason, node_share_sats, payout_invoice from bonds;"is the source of truth per row.
Timing note: an order becomes eligible for the timeout once
taken_at + expiration_secondshas passed, andjob_cancel_ordersticksevery 60 s — so allow up to
expiration_seconds + 60 safter the orderparks in its waiting state.
S1 — Buyer silent past
WaitingBuyerInvoice(sell-order): slash taker, republish, pay sellerThe main happy-path slash + Phase 3 payout. §9.2 row
sell / WaitingBuyerInvoice → buyer = taker → slash.arrives as
Action::PayBondInvoice; B pays it. Confirm the bond rowgoes
requested → lockedand the order moves towaiting-buyer-invoice.expiration_seconds+ up to one 60 s tick.Bond slashed on waiting-state timeout. DB: bond rowstate = pending-payout, slashed_reason = timeout, node_share_sats = 500(50 % of a 1 000 sat bond at thesedefaults),
slashed_atset. The bond HTLC is settled at slashtime, so Mostro's wallet is already up by the bond amount.
Action::Canceled(the order) andAction::BondSlashed(theforfeiture notice;
amount= the slashed bond amount). The order isrepublished to A as
Action::NewOrderand returns topending(still takeable by others).
WaitingBuyerInvoiceis the seller = A. Within ~60 s Areceives an
Action::AddBondInvoicefor 500 sats. A replies with abolt11 →
send_payment→ bond rowstate = slashed. Confirm Aactually received 500 sats.
S2 — Seller silent past
WaitingPayment(buy-order): slash taker, pay buyer§9.2 row
buy / WaitingPayment → seller = taker → slash. Confirmsrecipient resolution lands on the buyer, not hard-coded for sell-orders.
Action::PayBondInvoice) → bondlocks → order moves to
waiting-paymentand B receives the tradehold invoice (
Action::PayInvoice). B pays the bond but notthe trade hold invoice.
pending-payout / timeout; B receivesAction::Canceled+Action::BondSlashed; the order republishes topending.WaitingPaymentis the buyer = A.Confirm A — not B — receives
Action::AddBondInvoice, and thepayout completes as in S1.
S3 — Cancel before the timeout never slashes (attack invariant, §9.1)
waiting-buyer-invoice.expiration_secondselapses, one party cancels (e.g. Binitiates a cooperative cancel and A co-signs).
released(HTLC cancelled, funds back to B).No
slashed_reason, noAction::BondSlashed, noAction::AddBondInvoice. This is the anti-theft invariant — acounterparty cannot cancel at minute N−1 to steal a bond.
S4 — Responsible party is the maker → no slash
§9.2 "no slash" row. The taker holds a bond but is not the responsible
party.
the order skips
waiting-buyer-invoiceand parks atwaiting-payment,awaiting the seller = A = maker to pay the trade hold invoice. B
pays the bond → locks.
timeout + tick.
apply_to = take. Confirm: no slash — B's bond is released,the order is canceled (
(WaitingPayment, Sell)→Canceled), andB receives no
Action::BondSlashed.waiting-buyer-invoice(buyer = maker responsible) behaves identically — no slash, order
canceled.
S5 —
slash_on_waiting_timeout = false→ no slashslash_on_waiting_timeout = false; restart mostrod.waiting-buyer-invoice).no
slashed_reason, noAction::BondSlashed, noAction::AddBondInvoice. The gate is off, so Phase 4 falls back tothe Phase 1 always-release behaviour.
S6 —
slash_node_share_pct = 1.0: node-only slash still notifies (race regression)Targets the concurrency fix: the Phase 3 payout job can flip a node-only
slash straight
pending-payout → slashedwithin the confirmationwindow, so the notice must key off the durable
slashed_reason = timeoutmetadata, not the transient state.
slash_node_share_pct = 1.0; restart.pending-payout / timeout,node_share_sats= the full bond amount.slashed(
finalize_node_only), with noAction::AddBondInvoiceand nosend_payment(the node keeps everything).Action::BondSlashed, even though therow may already read
slashedby the time you inspect it. (Before thefix, the notice would be silently dropped in this race.)
Quick sanity grep
sqlite3 mostro.db "select state, slashed_reason, count(*) from bonds group by state, slashed_reason;"After S1, S2, S6 you should see rows in
slashed | timeout; after S3,S4, S5, rows in
releasedwith NULLslashed_reason. No timeout-slashedrow should be stuck in
pending-payoutonce Phase 3 has drained it.Refs: #711.
🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
Dependencies
mostro-coredependency to version 0.11.5Documentation