feat(bond): Phase 2 — solver-directed dispute slash#737
Conversation
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>
|
Warning Rate limit exceeded
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 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 configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
WalkthroughThis PR implements Phase 2 of solver-directed bond slashing for dispute resolution. It adds ChangesBond Slash Phase 2 Feature
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)
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: 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".
`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>
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/app/bond/slash.rs (1)
553-591: ⚡ Quick winIdempotency test should also assert
stateremainsPendingPayout.The test only checks
slashed_at/node_share_satsare stable across the secondapply_bond_resolution. A regression where the second call inadvertently routes the already-slashed bond throughrelease_bondand flips it toReleasedwould still pass this test (the slash columns wouldn't be touched byrelease_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
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (6)
Cargo.tomlsrc/app/admin_cancel.rssrc/app/admin_settle.rssrc/app/bond/math.rssrc/app/bond/mod.rssrc/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>
Summary
docs/ANTI_ABUSE_BOND.md§7): admin handlers consume the newBondResolutionpayload so a solver can slash seller / buyer bonds independently of the settle-vs-cancel trade outcome.src/app/bond/slash.rsmodule: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_*=truefor a side without aLockedbond row returnsCantDo(InvalidPayload)and the trade does not run (§7.3 step 3).Locked → PendingPayoutwithslashed_reason,slashed_at, andnode_share_satssnapshotted 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 persistednode_share_sats, never the live config.mostro-coreto 0.11.1 for the newCantDoReason::InvalidPayloadvariant named by the spec.Automated test plan
cargo buildcleancargo fmt --checkcleancargo clippy --all-targets -- -D warningscleanbond::slashunit tests pass:apply_to=takerejects (the spec's §7.5 Request list of pending orders from mostro #2 test); empty bond table rejects any slashPendingPayoutwithslashed_reason,slashed_at,node_share_satspersisted; idempotent on duplicate admin call (slashed_atandnode_share_satsare not rebumped); no-op when bond table is emptycompute_node_sharemath tests coverpct=0.0,0.5,1.0, the floor / no-rounding-leak invariant (§8.1), and out-of-range pct clampingManual test plan for reviewers
Roles
BondResolutionpayload)Setup (once)
docker-compose.ymlis fine). Make sure A, B, and S each have a wallet and channels.settings.tomlunder[anti_abuse_bond]set:mostrodon this branch. Register S as a solver and assign them as the dispute solver for the test orders below.Scenario 1 — Happy path: settle dispute + slash buyer (Alice scenario, §15.1 / §7.6)
Locked.Active./dispute).admin-take-dispute, then sendsadmin-settlewith payload:{ "bond_resolution": { "slash_seller": false, "slash_buyer": true } }PendingPayoutwithslashed_reason = 'lost-dispute',slashed_atset, andnode_share_sats = floor(amount_sats * 0.5).AddInvoiceDM is sent (that's Phase 3's job).Scenario 2 — Null payload preserves Phase 1 (legacy admin client)
admin-settlewith nobond_resolution(i.e.payload: null).Released(notPendingPayout). 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, soslash_seller=trueis illegal.admin-cancelwith payload:{ "bond_resolution": { "slash_seller": true, "slash_buyer": false } }CantDo(InvalidPayload).Dispute, escrow hold invoice is NOT cancelled, bond row staysLocked.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.Locked), A pays trade escrow.cancelflow. Bonds are released.admin-settlewithbond_resolution: { slash_buyer: true }against the same order id.CooperativeCancelAccepted(the prior idempotent response), notInvalidPayload.Scenario 5 — Non-dispute order rejected by status guard
Locked), A pays trade escrow. Order isActive, no dispute opened.admin-settlewithbond_resolution: { slash_buyer: true }.CantDo(NotAllowedByStatus), notInvalidPayload.Locked, order staysActive.Scenario 6 — Admin-cancel + slash on a buy order (seller side has the bond)
Verifies the mirror of Scenario 1 against
admin-canceland against theseller/buyermapping on a buy order.admin-cancelwith payload:{ "bond_resolution": { "slash_seller": true, "slash_buyer": false } }PendingPayoutwithslashed_reason = 'lost-dispute'.Scenario 7 — Non-admin sender is rejected before reaching slash code
admin-settlemessage with aBondResolutionpayload and sends it.NotAuthorized/IsNotYourDisputefrom the existing admin gating, never reachvalidate_bond_resolution. Bond row staysLocked.Cleanup: between scenarios, either point
mostrodat a freshmostro.dbor manually delete the relevantorders/bonds/disputesrows.🤖 Generated with Claude Code