Skip to content

feat(bond): Phase 7 — maker timeout slash#775

Merged
grunch merged 3 commits into
mainfrom
bond-phase-7-maker-timeout-slash
Jun 15, 2026
Merged

feat(bond): Phase 7 — maker timeout slash#775
grunch merged 3 commits into
mainfrom
bond-phase-7-maker-timeout-slash

Conversation

@grunch

@grunch grunch commented Jun 12, 2026

Copy link
Copy Markdown
Member

Anti-Abuse Bond — Phase 7: maker timeout slash

Implements spec §12 (issue #711): timeout slash for the maker bond. Symmetric to Phase 4 — no new mechanism, the job_cancel_orders dispatch already exists; the apply_to gate widens to the maker side and the bond lookup becomes range-aware.

Gate: enabled && slash_on_waiting_timeout && apply_to ∈ { make, both } (checked against the responsible party's posting role, so a leftover maker bond under apply_to = take still releases, never slashes).

Daemon-only: no mostro-core change, no migration, no new protocol variant (reuses Action::BondSlashed from Phase 4 and the Phase 6 child-slash schema). enabled = false keeps today's behaviour bit-for-bit.

What changed

src/app/bond/slash.rsslash_or_release_on_timeout

  • The slash gate is now per-role: the responsible side (from the §9.2 waiting-state table) maps to maker/taker via the §3.1 order-kind mapping, and apply_to must cover that role.
  • The responsible bond resolves through resolve_slash_target (the Phase 2/6 resolver), which adds the range-root fallback: a range order's maker bond lives on the range root, found by walking range_parent_id.
  • Non-range maker (and the existing taker path): HTLC settled inline via the Phase 2 slash_one primitive, confirmed through the durable slashed_reason = Timeout witness.
  • Range maker: goes through the Phase 6 partial-slash path — record_maker_slice_slash inserts a proportional child row (PendingPayout, reason = Timeout) and the parent HTLC stays Locked; the single settle happens at range close. The reported bond is the child row, so the BondSlashed notice carries the slice's slashed amount, not the whole parent bond.
  • record_maker_slice_slash now returns whether it actually inserted the child. The timeout path keys the one-shot BondSlashed notice on that, so a scheduler retry (order-persist failed, next tick re-runs) never re-notifies the maker — the same no-duplicate-notice guarantee Phase 4 gets from the Locked re-check, which a range parent (deliberately still Locked) can't provide.
  • The post-slash release loop retains a range maker parent bond alongside the existing republish carve-out — it is resolved only at range close.

src/scheduler.rsjob_cancel_orders

  • After the terminal Canceled status persists, the cancel branch now runs resolve_range_maker_bond_at_close_or_warn: a maker-responsible timeout terminates the range (no remainder is spawned on a cancel), so the parent settles and distributes (per-slice counterparty shares + maker refund) promptly instead of waiting for the 5-minute reconciliation sweep — which remains the backstop on transient failure. The close helper is idempotent and a cheap no-op for non-range / already-resolved bonds. The republish branch never closes: the order returns to the book with the maker still committed.

Phase 4 invariants preserved

  • Cancels before the timeout never slash (this dispatch only runs from the scheduler's waiting-state timeout).
  • The notice is only sent when the slash durably landed (inline-settle confirmation for non-range; the atomically-guarded child insert for range).
  • Gate-closed / no-config paths still drain leftover bonds via release, and a republish still retains the maker bond.

How to test

Automated

cargo test  --bin mostrod          # full suite (432 passing, incl. 6 new Phase 7 tests)
cargo test  --bin mostrod bond     # bond module only
cargo clippy --all-targets --all-features
cargo fmt --check

Key new unit tests (src/app/bond/slash.rs, mirroring Phase 4 from the maker side):

  • timeout_maker_responsible_slashes_maker_bond_sell_order — sell / WaitingPayment (seller = maker silent): maker bond → pending-payout, reason = timeout, HTLC settled inline exactly once.
  • timeout_maker_responsible_buy_order_slashes_maker_and_releases_taker — buy / WaitingBuyerInvoice mirror under apply_to = both: maker slashed, the non-responsible taker's bond released, only one settle call.
  • timeout_maker_slash_skipped_when_apply_to_take_only — per-role gate: a leftover Locked maker bond under apply_to = take releases, never slashes.
  • timeout_range_maker_slash_records_child_and_keeps_parent_locked — range: proportional child recorded (40/100 × 1000 = 400, reason = timeout), parent stays Locked with slashed_share_sats = 400, no settle call, taker bond released.
  • timeout_range_maker_slash_resolves_root_bond_from_descendant_slice — the range_parent_id walk finds the root's maker bond from a remainder slice; child recorded against the slice's own order id.
  • timeout_range_maker_slash_is_idempotent_per_slice — an already-slashed slice reports None: no duplicate child row, no re-notification.

Manual (regtest / Polar)

Enable the feature in settings.toml — the maker side must be bonded and the timeout slash armed:

[anti_abuse_bond]
enabled = true
apply_to = "make"               # or "both" to also bond takers
amount_pct = 0.01
base_amount_sats = 1000
slash_on_waiting_timeout = true # ← the Phase 7 gate
slash_node_share_pct = 0.5
payout_claim_window_days = 15

Tip: lower [mostro] expiration_seconds (the waiting-state window the scheduler enforces) so you don't wait long for the timeout to elapse. The cancel job ticks every 60 s.

Handy inspection queries:

SELECT id, status, kind, range_parent_id, fiat_amount, max_amount FROM orders;
SELECT id, role, state, amount_sats, parent_bond_id, child_order_id,
       slashed_share_sats, node_share_sats, slashed_reason
  FROM bonds ORDER BY created_at;
  1. Non-range maker timeout slash (sell order). Maker creates a fixed-amount sell order and pays the bond (Action::PayBondInvoice → order publishes). A taker takes it; the order enters waiting-payment and the maker (seller) never pays the trade hold invoice. When expiration_seconds elapses: the order is cancelled outright (not republished), the maker bond flips to state = pending-payout, slashed_reason = timeout (HTLC settled — check Mostro's wallet), the maker receives Action::BondSlashed (amount = the bond) plus the order's Action::Canceled, and the taker (winner) receives Action::AddBondInvoice for the counterparty share. Reply with a bolt11 → BondInvoiceAccepted then BondPayoutCompleted.
  2. Buy-order mirror. Maker creates a buy order, bond locked, taker takes and pays the trade hold invoice; the order enters waiting-buyer-invoice and the maker (buyer) never adds the invoice. Same outcome as step 1: cancel + maker bond slashed with timeout.
  3. Taker-responsible timeout does NOT touch the maker bond. On a sell order with apply_to = "make", the taker (buyer) goes silent in waiting-buyer-invoice. The order is republished to the book and the maker bond stays locked (it follows the order, not the failed take). Nothing is slashed — under apply_to = make the taker posted no bond. With apply_to = "both" the same scenario slashes the taker's bond (Phase 4) and still retains the maker's.
  4. Cancel before timeout always releases (invariant). Repeat step 1 but have either party cancel (cooperative/unilateral) before expiration_seconds elapses → the maker bond is released, never slashed.
  5. Range maker timeout → proportional child, parent stays Locked, close settles once. Maker creates a range sell order (e.g. min 10 / max 50 USD), bond locked (sized against max_amount). A taker takes a slice (e.g. 20 USD) and the maker goes silent in waiting-payment. On timeout, in one tick:
    • a child bonds row appears: parent_bond_id set, child_order_id = the slice, amount_sats = round(bond × slice_fiat / max_fiat), state = pending-payout, slashed_reason = timeout;
    • the maker receives Action::BondSlashed with the child's (proportional) amount, not the whole bond;
    • the order is cancelled, terminating the range, and the close runs immediately (no 5-minute wait): parent state = slashed (HTLC settled once), a refund row appears (child_order_id NULL, recipient = the maker);
    • the slice winner (taker) gets Action::AddBondInvoice for their counterparty share and the maker gets a separate Action::AddBondInvoice for the unslashed remainder.
  6. Wallet accounting for step 5. With bond 1000, slice 20/50 → child 400 (node 200 / winner 200), maker refund 600. Mostro's wallet nets exactly the node share: settle(1000) − winner 200 − refund 600 = 200. Child + refund rows sum to the parent amount_sats.
  7. No duplicate notice on retry. While reproducing step 5, restart the daemon (or watch a tick where the order persist races): the child row is recorded at most once (UNIQUE (parent_bond_id, child_order_id)) and Action::BondSlashed is sent exactly once.
  8. Per-role gate. Set apply_to = "take" and repeat step 1 (the maker now posts no bond): the timeout cancels the order with nothing slashed — old behaviour. Conversely slash_on_waiting_timeout = false releases every bond on timeout even with apply_to = "make".
  9. Disabled unchanged. enabled = false → no bond messages, timeouts cancel/republish exactly as before this PR.

Note: based on #774 (fix for the cargo test compile breakage on main from the #753/#770 merge skew). Merge that first; this PR's first commit is that fix and the diff will collapse to the Phase 7 change after a rebase.

🤖 Generated with Claude Code

grunch and others added 2 commits June 12, 2026 10:46
…skew

PR #753 removed the BITCOIN_PRICES static when it migrated price reads
to the PriceManager, while PR #770 (merged independently) added
BitcoinPriceManager::set_price_for_test writing to that static — the
combination doesn't compile under cargo test on main.

Re-point the test seam at a small cfg(test) override map consulted by
price::get_bitcoin_price before the global manager, so unit tests keep
seeding deterministic prices without installing the global PriceManager
(whose OnceLock would leak one test's configuration into the rest of
the binary).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fill the maker-responsible rows of the §9.2 responsibility table: a
waiting-state timeout now slashes the maker's bond when the maker is
the responsible party, gated per posting role (apply_to must cover the
responsible side — taker since Phase 4, maker since Phase 7).

- slash_or_release_on_timeout resolves the responsible bond through the
  range-aware resolve_slash_target (the maker bond of a range order
  lives on the range root), and gates the slash on applies_to_maker()
  / applies_to_taker() per the responsible side.
- A maker-responsible timeout on a range order goes through the Phase 6
  partial-slash path: record_maker_slice_slash inserts a proportional
  child row with reason=Timeout and the parent HTLC stays Locked; the
  single settle happens at range close. The helper now returns whether
  it inserted, so the BondSlashed notice fires exactly once (a
  scheduler retry that finds the child already recorded reports None).
- The scheduler's terminal cancel branch now runs
  resolve_range_maker_bond_at_close_or_warn after the Canceled status
  persists, so a slashed range settles and distributes promptly instead
  of waiting for the 5-minute reconciliation sweep (which remains the
  backstop). The republish branch never closes — the maker stays
  committed there.
- The timeout release loop retains a range maker parent bond (resolved
  only at range close), alongside the existing republish carve-out.

Tests mirror Phase 4 from the maker side: non-range sell/buy slashes,
the per-role apply_to gate, and the range path (proportional child,
parent stays Locked, root resolution from a descendant slice,
per-slice idempotency without re-notification).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@grunch, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 23 minutes and 51 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d366018d-1858-4f7b-8edc-5a6ff0ebcf24

📥 Commits

Reviewing files that changed from the base of the PR and between af8f05d and 23abaf9.

📒 Files selected for processing (6)
  • docs/ANTI_ABUSE_BOND.md
  • src/app/bond/slash.rs
  • src/bitcoin_price.rs
  • src/price/mod.rs
  • src/scheduler.rs
  • src/util.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bond-phase-7-maker-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.

…ase 4.5 row

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b634a5cead

ℹ️ 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/scheduler.rs
@arkanoider

Copy link
Copy Markdown
Collaborator

Tested :

  1. Pass
  2. Pass
  3. Pass
  4. Pass
  5. Pass
  6. Pass
  7. Pass
  8. Pass
  9. Pass

Tested all 9 points manually and everything worked fine for me!

@arkanoider arkanoider left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

tACK

@Catrya Catrya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

tACK

@grunch grunch merged commit be1bd5a into main Jun 15, 2026
8 checks passed
@grunch grunch deleted the bond-phase-7-maker-timeout-slash branch June 15, 2026 18:05
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.

3 participants