Skip to content

feat(bond): Phase 2 — solver-directed dispute slash#737

Merged
grunch merged 3 commits into
mainfrom
feat/bond-phase-2-slash
May 14, 2026
Merged

feat(bond): Phase 2 — solver-directed dispute slash#737
grunch merged 3 commits into
mainfrom
feat/bond-phase-2-slash

Conversation

@grunch
Copy link
Copy Markdown
Member

@grunch grunch commented May 13, 2026

Summary

  • Implements Phase 2 of the anti-abuse-bond spec (docs/ANTI_ABUSE_BOND.md §7): admin handlers consume the new BondResolution payload so a solver can slash seller / buyer bonds independently of the settle-vs-cancel trade outcome.
  • New src/app/bond/slash.rs module: extract_bond_resolution, validate_bond_resolution, apply_bond_resolution. Validation runs after the existing status guards (cooperatively-cancelled, in-dispute) and before any trade-side mutation — slash_*=true for a side without a Locked bond row returns CantDo(InvalidPayload) and the trade does not run (§7.3 step 3).
  • On valid payload: trade resolution first, then each slashed bond CAS-transitions Locked → PendingPayout with slashed_reason, slashed_at, and node_share_sats snapshotted in the same UPDATE. Non-slashed bonds release as in Phase 1.
  • bond::math::compute_node_share (floor + clamp) freezes the split at transition time; Phase 3 will read the persisted node_share_sats, never the live config.
  • Absent / null payload preserves Phase 1 behaviour bit-for-bit.
  • Bumps mostro-core to 0.11.1 for the new CantDoReason::InvalidPayload variant named by the spec.

Automated test plan

  • cargo build clean
  • cargo fmt --check clean
  • cargo clippy --all-targets -- -D warnings clean
  • 11/11 bond::slash unit tests pass:
    • extract: default on absent / unrelated / present payload
    • validate: null + no bonds passes; slash_buyer with Locked buyer-bond passes; slash_seller on sell-order with apply_to=take rejects (the spec's §7.5 Request list of pending orders from mostro #2 test); empty bond table rejects any slash
    • apply: null releases all active bonds; slash_buyer on sell-order transitions taker bond to PendingPayout with slashed_reason, slashed_at, node_share_sats persisted; idempotent on duplicate admin call (slashed_at and node_share_sats are not rebumped); no-op when bond table is empty
  • compute_node_share math tests cover pct=0.0, 0.5, 1.0, the floor / no-rounding-leak invariant (§8.1), and out-of-range pct clamping

Manual test plan for reviewers

Roles

  • A — maker (creates the order)
  • B — taker (posts the bond; in Phase 2 this is the only side with a bond)
  • S — solver / admin (resolves the dispute and sends the BondResolution payload)

Setup (once)

  1. Run a regtest LND pair (the repo's docker-compose.yml is fine). Make sure A, B, and S each have a wallet and channels.
  2. In settings.toml under [anti_abuse_bond] set:
    enabled = true
    apply_to = "take"
    slash_node_share_pct = 0.5   # any value in [0.0, 1.0]
  3. Start mostrod on this branch. Register S as a solver and assign them as the dispute solver for the test orders below.
  4. Open a sqlite shell against the daemon's DB for verification:
    sqlite3 mostro.db
    Helper query for each scenario:
    SELECT id, pubkey, state, slashed_reason, slashed_at, node_share_sats, amount_sats
    FROM bonds WHERE order_id = '<order-uuid>';

Scenario 1 — Happy path: settle dispute + slash buyer (Alice scenario, §15.1 / §7.6)

  1. A publishes a sell order via mostro-cli (so taker B will be the buyer and posts the bond).
  2. B takes the order and pays the bond hold invoice. Verify the bond row is Locked.
  3. A pays the trade hold invoice; the order reaches Active.
  4. B opens a dispute (/dispute).
  5. S runs admin-take-dispute, then sends admin-settle with payload:
    { "bond_resolution": { "slash_seller": false, "slash_buyer": true } }
  6. Expected
    • Trade settles to B as normal (A's hold invoice is settled, audit DM goes to both).
    • The bond row transitions to PendingPayout with slashed_reason = 'lost-dispute', slashed_at set, and node_share_sats = floor(amount_sats * 0.5).
    • No AddInvoice DM is sent (that's Phase 3's job).

Scenario 2 — Null payload preserves Phase 1 (legacy admin client)

  1. Repeat steps 1–4 of Scenario 1 with a fresh order.
  2. S sends admin-settle with no bond_resolution (i.e. payload: null).
  3. Expected
    • Trade settles to B as before.
    • B's bond row transitions to Released (not PendingPayout). No slash audit.

Scenario 3 — Reject invalid slash directive on a sell order

This is the §7.5 #2 test: on a sell-order with apply_to=take, the seller is the maker and has no bond, so slash_seller=true is illegal.

  1. Repeat steps 1–4 of Scenario 1 with a fresh order.
  2. S sends admin-cancel with payload:
    { "bond_resolution": { "slash_seller": true, "slash_buyer": false } }
  3. Expected
    • S receives CantDo(InvalidPayload).
    • The trade does NOT cancel: order status stays Dispute, escrow hold invoice is NOT cancelled, bond row stays Locked.
    • S can immediately resend with a corrected payload (e.g. slash_buyer: true) and Scenario 1 outcome applies.

Scenario 4 — Idempotent admin retry on a cooperatively-cancelled order (this PR's fix)

Verifies the fix from commit 677c8dd: status guards run before validation.

  1. A creates a sell order, B takes it (bond Locked), A pays trade escrow.
  2. A and B cooperatively cancel via the normal cancel flow. Bonds are released.
  3. S (perhaps reading a stale DM queue) sends admin-settle with bond_resolution: { slash_buyer: true } against the same order id.
  4. Expected
    • S receives CooperativeCancelAccepted (the prior idempotent response), not InvalidPayload.
    • No DB writes happen.

Scenario 5 — Non-dispute order rejected by status guard

  1. A creates a sell order, B takes it (bond Locked), A pays trade escrow. Order is Active, no dispute opened.
  2. S sends admin-settle with bond_resolution: { slash_buyer: true }.
  3. Expected
    • S receives CantDo(NotAllowedByStatus), not InvalidPayload.
    • Bond row stays Locked, order stays Active.

Scenario 6 — Admin-cancel + slash on a buy order (seller side has the bond)

Verifies the mirror of Scenario 1 against admin-cancel and against the seller/buyer mapping on a buy order.

  1. A publishes a buy order. Now taker B is the seller and posts the bond.
  2. B takes the order and pays the bond hold invoice; A posts the trade hold invoice; A opens a dispute (e.g. claiming non-delivery).
  3. S sends admin-cancel with payload:
    { "bond_resolution": { "slash_seller": true, "slash_buyer": false } }
  4. Expected
    • Trade cancels (escrow returns to B).
    • B's bond row transitions to PendingPayout with slashed_reason = 'lost-dispute'.

Scenario 7 — Non-admin sender is rejected before reaching slash code

  1. A creates a sell order, B takes it, dispute opened (as in Scenario 1).
  2. B (not a solver) crafts an admin-settle message with a BondResolution payload and sends it.
  3. Expected
    • Daemon rejects before any slash logic runs — should surface as NotAuthorized / IsNotYourDispute from the existing admin gating, never reach validate_bond_resolution. Bond row stays Locked.

Cleanup: between scenarios, either point mostrod at a fresh mostro.db or manually delete the relevant orders / bonds / disputes rows.

🤖 Generated with Claude Code

Wire the `BondResolution` payload (mostro-core 0.11.1) through
`admin_settle_action` and `admin_cancel_action` so a solver can slash
seller / buyer bonds independently of the trade-resolution outcome,
per `docs/ANTI_ABUSE_BOND.md` §7.

- `bond::extract_bond_resolution` / `validate_bond_resolution` /
  `apply_bond_resolution` in a new `src/app/bond/slash.rs` module.
- Validation runs before any trade-side mutation: a `slash_*=true` flag
  for a side with no `Locked` bond row returns
  `CantDo(InvalidPayload)` and the trade does not run (§7.3 step 3).
- On valid payload, trade resolution runs first, then each slashed bond
  CAS-transitions `Locked → PendingPayout` with `slashed_reason`,
  `slashed_at`, and `node_share_sats` snapshotted in the same UPDATE.
  Non-slashed bonds release as in Phase 1.
- `bond::math::compute_node_share` (with floor + clamp) splits the
  bond amount once at the moment of transition. Phase 3 will read the
  persisted `node_share_sats`, never the live config.
- Absent / null payload preserves Phase 1 behaviour bit-for-bit
  (release all active bonds, slash none).
- Bumps `mostro-core` to 0.11.1 for the new
  `CantDoReason::InvalidPayload` variant the spec names in §7.3 / §7.5.

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

coderabbitai Bot commented May 13, 2026

Warning

Rate limit exceeded

@grunch has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 47 minutes and 11 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, 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 have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: eabc55d4-e28e-4094-80da-b9f85d9cf027

📥 Commits

Reviewing files that changed from the base of the PR and between 677c8dd and b24dfb9.

📒 Files selected for processing (1)
  • src/app/bond/slash.rs

Walkthrough

This PR implements Phase 2 of solver-directed bond slashing for dispute resolution. It adds compute_node_share math, a new slash module with extract/validate/apply workflow, reorganizes the bond module API, and integrates bond resolution logic into admin_cancel_action and admin_settle_action. mostro-core dependency bumped to 0.11.1.

Changes

Bond Slash Phase 2 Feature

Layer / File(s) Summary
Bond node share calculation
src/app/bond/math.rs
Adds compute_node_share(amount_sats, pct) pure function to calculate node's satoshi share with clamped percentage and floor rounding. Tests validate edge cases (zero, negative, boundary percentages) and rounding invariants.
Bond slash module implementation
src/app/bond/slash.rs
Implements Phase 2 bond-slash workflow: extract_bond_resolution parses optional admin message payloads, validate_bond_resolution pre-checks that requested slash sides have corresponding locked bonds, apply_bond_resolution releases non-slashed and transitions slashed bonds to PendingPayout with snapshot persistence. Includes helpers slash_one (CAS SQL update) and resolve_locked_bond (bond matching), plus comprehensive async and unit tests.
Bond module public API reorganization
src/app/bond/mod.rs
Declares and re-exports new slash and types submodules. Adds compute_node_share and re-exports bond-resolution functions (extract_bond_resolution, validate_bond_resolution, apply_bond_resolution) as public module API.
Admin cancel flow bond resolution integration
src/app/admin_cancel.rs
Imports BondSlashReason. Adds Phase 2 pre-check: extracts and validates optional BondResolution before cancel_hold_invoice. Post-notification, applies resolved bond outcome with bond::apply_bond_resolution(..., BondSlashReason::LostDispute), logging warnings on apply failure without aborting cancel.
Admin settle flow bond resolution integration
src/app/admin_settle.rs
Imports BondSlashReason. Adds Phase 2 pre-check: extracts and validates optional BondResolution after status checks (aborts on invalid). During settlement, replaces unconditional bond release with bond::apply_bond_resolution(..., BondSlashReason::LostDispute), logging warnings on apply failure without failing settle.
Dependency update
Cargo.toml
mostro-core dependency bumped from 0.11.0 to 0.11.1 with sqlx feature retained.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

  • MostroP2P/mostro#711: This PR directly implements the Anti‑Abuse Bond Phase 2 (solver-directed dispute slash) feature, adding BondSlashReason, compute_node_share, extract/validate/apply flow, and integration into admin cancel/settle endpoints.

Possibly related PRs

  • MostroP2P/mostro#712: Foundation PR introducing anti-abuse bond schema and Phase 1 logic; this PR builds on it by extending src/app/bond with the Phase 2 slash module and wiring resolution validation/application into admin actions.
  • MostroP2P/mostro#719: Overlaps in touching admin_cancel_action/admin_settle_action bond lifecycle hooks; Phase 1 adds unconditional release while this PR replaces that with solver-directed extract/validate/apply resolution flow.
  • MostroP2P/mostro#728: Complements this PR by introducing anti_abuse_bond.slash_node_share_pct config to supply the percentage parameter that this PR's compute_node_share consumes and persists as node_share_sats.

Suggested reviewers

  • arkanoider
  • AndreaDiazCorreia
  • Catrya

Poem

🐰 A bond slash flows in phases bright,
With slashes parsed and validated right,
Node shares computed, states transform,
From Locked to Payout, the dispute norm.
Admins resolve with cryptic might—
Phase Two bonds now shine with light! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title clearly and specifically summarizes the main change: implementing Phase 2 of solver-directed dispute bond slashing, which is the primary feature across multiple file changes in this PR.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/bond-phase-2-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: 8bf8de38f7

ℹ️ 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/admin_settle.rs Outdated
`validate_bond_resolution` was running before the `CooperativelyCanceled`
and `Status::Dispute` guards in `admin_settle_action` /
`admin_cancel_action`. For an admin retry against an order already
cooperatively cancelled (or otherwise out of dispute), the bond rows
have already been released, so a `slash_*=true` payload would now
return `CantDo(InvalidPayload)` instead of the prior status-driven
response (`CooperativeCancelAccepted` / `NotAllowedByStatus`),
breaking idempotent admin-retry handling.

Move the extract+validate block to after both status guards and
before the first destructive op (`settle_seller_hold_invoice` /
`cancel_hold_invoice`). Spec §7.3 step 3 only requires validation
before *destructive* mutation; the status guards are non-destructive
early returns and stay first.

Reported by chatgpt-codex-connector on PR #737.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@grunch
Copy link
Copy Markdown
Member Author

grunch commented May 13, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 13, 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.

@MostroP2P MostroP2P deleted a comment from coderabbitai Bot May 13, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/app/bond/slash.rs (1)

553-591: ⚡ Quick win

Idempotency test should also assert state remains PendingPayout.

The test only checks slashed_at / node_share_sats are stable across the second apply_bond_resolution. A regression where the second call inadvertently routes the already-slashed bond through release_bond and flips it to Released would still pass this test (the slash columns wouldn't be touched by release_bond). Adding a state assertion closes that gap and provides direct coverage for the idempotency claim at Lines 131-134.

♻️ Suggested test addition
         let second: (Option<i64>, Option<i64>) =
             sqlx::query_as("SELECT slashed_at, node_share_sats FROM bonds WHERE id = ?")
                 .bind(bond.id)
                 .fetch_one(&pool)
                 .await
                 .unwrap();
         assert_eq!(
             first, second,
             "second apply must not rebump slashed_at / node_share_sats"
         );
+        let state: (String,) = sqlx::query_as("SELECT state FROM bonds WHERE id = ?")
+            .bind(bond.id)
+            .fetch_one(&pool)
+            .await
+            .unwrap();
+        assert_eq!(
+            state.0,
+            BondState::PendingPayout.to_string(),
+            "second apply must leave the slashed bond in PendingPayout"
+        );
🤖 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/app/bond/slash.rs` around lines 553 - 591, The idempotency test
apply_is_idempotent_on_already_pending_payout currently only checks slashed_at
and node_share_sats but must also verify the bond's state didn't transition;
after the second call to apply_bond_resolution(...) fetch the bond's state (e.g.
query bonds.state or use the existing insert_bond/lookup helpers) and assert it
equals BondState::PendingPayout to ensure the second apply did not route the
bond through release_bond or flip it to Released.
🤖 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 `@src/app/bond/slash.rs`:
- Around line 553-591: The idempotency test
apply_is_idempotent_on_already_pending_payout currently only checks slashed_at
and node_share_sats but must also verify the bond's state didn't transition;
after the second call to apply_bond_resolution(...) fetch the bond's state (e.g.
query bonds.state or use the existing insert_bond/lookup helpers) and assert it
equals BondState::PendingPayout to ensure the second apply did not route the
bond through release_bond or flip it to Released.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: adfa2721-06a5-49cc-acdb-eb5e91e8bb40

📥 Commits

Reviewing files that changed from the base of the PR and between 6a3b0ff and 677c8dd.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • Cargo.toml
  • src/app/admin_cancel.rs
  • src/app/admin_settle.rs
  • src/app/bond/math.rs
  • src/app/bond/mod.rs
  • src/app/bond/slash.rs

The idempotency test only checked `slashed_at` and `node_share_sats`. A
regression in `find_active_bonds_for_order`'s state filter (e.g. widening
it to include `PendingPayout`) could route the row through `release_bond`
on a duplicate admin call without this test noticing. Extend the SELECT
to include `state` and assert it stays `PendingPayout` after the second
`apply_bond_resolution`.

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

@Catrya Catrya left a comment

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 42943fd into main May 14, 2026
8 checks passed
@grunch grunch deleted the feat/bond-phase-2-slash branch May 14, 2026 13:42
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.

2 participants