Skip to content

feat(bond): Phase 4 — timeout slash for the taker bond#744

Open
grunch wants to merge 2 commits into
mainfrom
feat/bond-phase-4-timeout-slash
Open

feat(bond): Phase 4 — timeout slash for the taker bond#744
grunch wants to merge 2 commits into
mainfrom
feat/bond-phase-4-timeout-slash

Conversation

@grunch
Copy link
Copy Markdown
Member

@grunch grunch commented May 22, 2026

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 a Locked bond, it reuses the Phase 2 apply_bond_resolution primitive with BondSlashReason::Timeout — settling the bond HTLC + CAS to PendingPayout and 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.
  • The §3.1 buyer/seller → bond-row mapping is the one already baked into the reused primitive; bond.pubkey equals the order's buyer_pubkey/seller_pubkey, so under apply_to = take only the taker side ever resolves a bond and the maker-responsible §9.2 rows fall through to release.
  • The bond config is passed in (the scheduler hands it Settings::get_bond()) instead of read from the global OnceLock, so the gate is unit-testable.
  • Wired 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 resolution matches the bonded party.
  • bond::notify_bond_slashed sends the slashed user an Action::BondSlashed (mostro-core 0.11.5) forfeiture notice, carrying Payload::Order (amount = slashed bond amount). Best-effort, and sent only after the slash is confirmed to have landed — confirmed via the durable slashed_reason = Timeout metadata (written atomically with the slash CAS and never cleared by the concurrent Phase 3 payout job), not a transient state = PendingPayout check the scheduler could invalidate. A transient settle_hold_invoice failure leaves the bond Locked with no slash metadata, so it never produces a false "your bond was slashed" message.

Safety invariants

  • §9.1 honoured: only the elapsed-timeout scheduler path can slash. Cooperative / unilateral / admin cancels still go through the Phase 1 release_bonds_for_order_or_warn helpers, untouched (verified: single non-test call site).
  • No mis-slash: a counterparty going silent never costs the other party their bond — the responsible-side resolution is keyed on the waiting state, and the maker-responsible rows release rather than slash (covered by tests).
  • No false forfeiture notice: the notice is gated on a re-read of the durable slashed_reason = Timeout metadata, so a transient settle failure (bond stays Locked) 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 additive Action::BondSlashed (released upstream in mostro-core#149). Serde-additive: clients that don't know the variant ignore it and fall back to the Action::Canceled they 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-features clean, cargo fmt clean. 10 new unit tests in bond::slash:

  • all four §9.2 worked rows (sell+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;
  • no [anti_abuse_bond] config → release;
  • transient settle failure → bond stays Locked, no notice;
  • timeout-slash confirmation survives the concurrent payout-job progression (all post-slash states with slashed_reason = timeout confirm; Locked/no-metadata and LostDispute do not);
  • Action::BondSlashed targets the slashed taker only.

How to test (manual regtest walkthrough)

These scenarios exercise Phase 4 end-to-end against polar/regtest LND.
The cast:

  • User A — the maker. Posts a sell-order in S1, S3–S6; a buy-order in S2.
  • User B — the taker. Posts the anti-abuse bond (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:

  1. In settings.toml, shrink the waiting-state timeout and enable the
    bond on the taker side:
    [mostro]
    # Waiting-state deadline (default 900 = 15 min). Shrink it so the
    # scheduler fires in ~1–2 min instead of 15.
    expiration_seconds = 60
    
    [anti_abuse_bond]
    enabled = true
    apply_to = "take"
    slash_on_waiting_timeout = true      # the Phase 4 gate
    slash_node_share_pct = 0.5
    payout_invoice_window_seconds = 60   # Phase 3 payout; shrink for faster runs
    payout_max_retries = 3
    payout_claim_window_days = 1
  2. Start mostrod with RUST_LOG=info,mostro=debug. Watch for
    Bond slashed on waiting-state timeout (the Phase 4 slash) and the
    bond payout: prefix (the Phase 3 drain).
  3. 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_seconds has passed, and job_cancel_orders ticks
every 60 s — so allow up to expiration_seconds + 60 s after the order
parks in its waiting state.

S1 — Buyer silent past WaitingBuyerInvoice (sell-order): slash taker, republish, pay seller

The main happy-path slash + Phase 3 payout. §9.2 row
sell / WaitingBuyerInvoice → buyer = taker → slash.

  1. A posts a sell-order for, say, 100 000 sats.
  2. B takes it without including a payout invoice. The bond bolt11
    arrives as Action::PayBondInvoice; B pays it. Confirm the bond row
    goes requested → locked and the order moves to waiting-buyer-invoice.
  3. B goes silent — never sends the payout invoice. Wait
    expiration_seconds + up to one 60 s tick.
  4. On the tick, confirm the log line Bond slashed on waiting-state timeout. DB: bond row state = pending-payout, slashed_reason = timeout, node_share_sats = 500 (50 % of a 1 000 sat bond at these
    defaults), slashed_at set. The bond HTLC is settled at slash
    time
    , so Mostro's wallet is already up by the bond amount.
  5. B (the slashed buyer/taker) receives two messages:
    Action::Canceled (the order) and Action::BondSlashed (the
    forfeiture notice; amount = the slashed bond amount). The order is
    republished to A as Action::NewOrder and returns to pending
    (still takeable by others).
  6. Phase 3 takes over (reuse): the non-slashed counterparty for
    WaitingBuyerInvoice is the seller = A. Within ~60 s A
    receives an Action::AddBondInvoice for 500 sats. A replies with a
    bolt11 → send_payment → bond row state = slashed. Confirm A
    actually received 500 sats.

S2 — Seller silent past WaitingPayment (buy-order): slash taker, pay buyer

§9.2 row buy / WaitingPayment → seller = taker → slash. Confirms
recipient resolution lands on the buyer, not hard-coded for sell-orders.

  1. A posts a buy-order (maker = buyer; taker will be seller).
  2. B takes it. B pays the bond (Action::PayBondInvoice) → bond
    locks → order moves to waiting-payment and B receives the trade
    hold invoice (Action::PayInvoice). B pays the bond but not
    the trade hold invoice.
  3. B goes silent. Wait for the timeout + tick.
  4. Confirm bond → pending-payout / timeout; B receives
    Action::Canceled + Action::BondSlashed; the order republishes to
    pending.
  5. Phase 3: the recipient for WaitingPayment is the buyer = A.
    Confirm A — not B — receives Action::AddBondInvoice, and the
    payout completes as in S1.

S3 — Cancel before the timeout never slashes (attack invariant, §9.1)

  1. A posts a sell-order; B takes it (no invoice), pays the bond →
    waiting-buyer-invoice.
  2. Before expiration_seconds elapses, one party cancels (e.g. B
    initiates a cooperative cancel and A co-signs).
  3. Confirm: the bond row → released (HTLC cancelled, funds back to B).
    No slashed_reason, no Action::BondSlashed, no
    Action::AddBondInvoice. This is the anti-theft invariant — a
    counterparty 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.

  1. A posts a sell-order. B takes it with a payout invoice, so
    the order skips waiting-buyer-invoice and parks at waiting-payment,
    awaiting the seller = A = maker to pay the trade hold invoice. B
    pays the bond → locks.
  2. A (the seller/maker) never pays the hold invoice. Wait for the
    timeout + tick.
  3. Responsible party = seller = maker, who holds no bond under
    apply_to = take. Confirm: no slash — B's bond is released,
    the order is canceled ((WaitingPayment, Sell)Canceled), and
    B receives no Action::BondSlashed.
    • Buy-order mirror: a buy-order stuck in waiting-buyer-invoice
      (buyer = maker responsible) behaves identically — no slash, order
      canceled.

S5 — slash_on_waiting_timeout = false → no slash

  1. Set slash_on_waiting_timeout = false; restart mostrod.
  2. Repeat S1 steps 1–3 (B silent at waiting-buyer-invoice).
  3. On the timeout tick, confirm the bond is released, not slashed:
    no slashed_reason, no Action::BondSlashed, no
    Action::AddBondInvoice. The gate is off, so Phase 4 falls back to
    the 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 → slashed within the confirmation
window, so the notice must key off the durable slashed_reason = timeout
metadata, not the transient state.

  1. Set slash_node_share_pct = 1.0; restart.
  2. Run S1 steps 1–4 (B silent → slash). Bond → pending-payout / timeout, node_share_sats = the full bond amount.
  3. The next Phase 3 tick flips the row directly to slashed
    (finalize_node_only), with no Action::AddBondInvoice and no
    send_payment (the node keeps everything).
  4. Confirm B still receives Action::BondSlashed, even though the
    row may already read slashed by the time you inspect it. (Before the
    fix, 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 released with NULL slashed_reason. No timeout-slashed
row should be stuck in pending-payout once Phase 3 has drained it.

Refs: #711.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Orders stuck in waiting state now trigger bond slashing upon timeout when anti-abuse bond settings are enabled
    • Added new notification to alert users when their bonds are slashed with the forfeited amount
  • Dependencies

    • Updated mostro-core dependency to version 0.11.5
  • Documentation

    • Updated anti-abuse bond documentation to reflect the new timeout-based bond slashing behavior

Review Change Stack

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>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 22, 2026

Walkthrough

This PR implements Phase 4 of the anti-abuse bond timeout-slash feature, bumping mostro-core to 0.11.5 for the new Action::BondSlashed notification, adding a dispatcher that conditionally slashes or releases bonds based on waiting-state timeout and config, confirming slashes via durable database state, and integrating the flow into the scheduler's order-cancellation path.

Changes

Phase 4 Timeout-Slash Implementation

Layer / File(s) Summary
Dependency bump and Phase 4 specification
Cargo.toml, docs/ANTI_ABUSE_BOND.md
mostro-core is updated to 0.11.5 to enable Action::BondSlashed. Documentation marks Phase 4 as shipped, describes the dispatcher integration point, clarifies durable confirmation required before forfeiture notification, and documents the wire protocol change for the new action tag.
Phase 4 dispatcher, confirmation, and notification
src/app/bond/slash.rs
slash_or_release_on_timeout derives responsible side from waiting status and conditionally slashes the Locked bond via apply_bond_resolution if config gates are satisfied (enabled, slash_on_waiting_timeout, apply_to covering taker); timeout_slash_confirmed verifies the slash persisted to the database; notify_bond_slashed parses the bond pubkey, resolves order kind, and enqueues Action::BondSlashed only after confirmation. Comprehensive tests cover enabled/disabled config, seller/buyer waiting states, apply-to gating, no-config fallback, transient settle failures (no notification), and confirmation under concurrent progression.
Module export and scheduler integration
src/app/bond/mod.rs, src/scheduler.rs
New functions are exported from the bond module. Scheduler's job_cancel_orders now calls slash_or_release_on_timeout after persisting the canceled order, branches on slashed vs. no-op vs. error outcomes, enqueues slashed notification when slash is confirmed, and logs warnings on failure.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • MostroP2P/mostro#711: This PR implements the Phase 4 anti-abuse bond timeout-slash dispatch and confirmation logic, directly realizing the phased bond behavior described in the feature proposal.

Possibly related PRs

  • MostroP2P/mostro#725: Implements and wires Phase 4 waiting-state timeout slashing with overlapping spec/semantics for timeout slash responsibility.
  • MostroP2P/mostro#737: Extends src/app/bond/slash.rs with Phase 4 timeout-slash logic (slash_or_release_on_timeout, notify_bond_slashed) building on Phase 2 slashing primitives.
  • MostroP2P/mostro#719: Scheduler integration in job_cancel_orders supersedes the earlier Phase 1 bond-release logic introduced in this related PR.

Suggested reviewers

  • arkanoider
  • AndreaDiazCorreia
  • Catrya

Poem

🐰 A bond slashed when orders wait too long,
With Phase 4 we right the wrong.
Confirmed through durable database state,
The timeout slash won't hesitate!
Notifications bloom when confirmed and true,
A fuzzy feature, tried and through! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: implementing Phase 4 timeout-slash functionality for the taker bond in the anti-abuse bond system.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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-timeout-slash

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: 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".

Comment thread src/app/bond/slash.rs Outdated
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>
@grunch
Copy link
Copy Markdown
Member Author

grunch commented May 22, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 22, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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