Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ reqwest = { version = "0.12.1", default-features = false, features = [
"json",
"rustls-tls",
] }
mostro-core = { version = "0.11.0", features = ["sqlx"] }
mostro-core = { version = "0.11.1", features = ["sqlx"] }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
clap = { version = "4.5.45", features = ["derive"] }
Expand Down
31 changes: 27 additions & 4 deletions src/app/admin_cancel.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::borrow::Cow;
use std::str::FromStr;

use crate::app::bond;
use crate::app::bond::{self, BondSlashReason};
use crate::app::context::AppContext;
use crate::db::{
find_dispute_by_order_id, is_assigned_solver, is_dispute_taken_by_admin,
Expand Down Expand Up @@ -99,6 +99,18 @@ pub async fn admin_cancel_action(
return Err(MostroCantDo(CantDoReason::NotAllowedByStatus));
}

// Phase 2: extract and validate the optional `BondResolution` payload
// here — after the status guards above (which are non-destructive
// early returns, so an admin retry against an already-cooperatively-
// cancelled or out-of-dispute order still gets the prior status-
// driven response) and before the LND `cancel_hold_invoice` on the
// escrow below, which would otherwise be irreversible. On a
// `slash_*=true` for a side with no `Locked` bond row we return
// `CantDo(InvalidPayload)` and the trade does not cancel; the solver
// resends a corrected directive. See `docs/ANTI_ABUSE_BOND.md` §7.3.
let bond_resolution = bond::extract_bond_resolution(&msg);
bond::validate_bond_resolution(pool, &order, &bond_resolution).await?;

if order.hash.is_some() {
// We return funds to seller
if let Some(hash) = order.hash.as_ref() {
Expand Down Expand Up @@ -200,9 +212,20 @@ pub async fn admin_cancel_action(
.await
.map_err(|e| MostroInternalErr(ServiceError::NostrError(e.to_string())))?;

// Phase 1: admin cancellation always releases any taker bond. The
// dispute slash path lands in Phase 2.
bond::release_bonds_for_order_or_warn(pool, order.id, "admin_cancel").await;
// Phase 2: apply the solver's `BondResolution` to the bond rows
// (release-by-default when absent). The buyer/seller pubkeys on
// the order row are immutable through the dispute cycle, so the
// original `order` snapshot is the right context for resolving
// sides to bonds. The Lightning payout side is Phase 3.
if let Err(e) =
bond::apply_bond_resolution(pool, &order, &bond_resolution, BondSlashReason::LostDispute)
.await
{
tracing::warn!(
order_id = %order.id,
"admin_cancel: bond resolution apply failed: {}", e
);
}

Ok(())
}
37 changes: 33 additions & 4 deletions src/app/admin_settle.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::app::bond;
use crate::app::bond::{self, BondSlashReason};
use crate::app::context::AppContext;
use crate::db::{
find_dispute_by_order_id, is_assigned_solver, is_dispute_taken_by_admin,
Expand Down Expand Up @@ -72,6 +72,21 @@ pub async fn admin_settle_action(
if let Err(cause) = order.check_status(Status::Dispute) {
return Err(MostroCantDo(cause));
}

// Phase 2: extract and validate the optional `BondResolution` payload
// here — after the status guards above (which are non-destructive
// early returns, so an admin retry against an already-cooperatively-
// cancelled or out-of-dispute order still gets the prior status-
// driven response) and before any trade-side mutation
// (`settle_seller_hold_invoice` / `update_order_event` below). On a
// `slash_*=true` for a side with no `Locked` bond row we return
// `CantDo(InvalidPayload)` and the trade does not settle; the solver
// resends a corrected directive. Absent payload ≡
// `BondResolution { false, false }` ≡ Phase 1 behaviour (release all
// active bonds, slash none). See `docs/ANTI_ABUSE_BOND.md` §7.3.
let bond_resolution = bond::extract_bond_resolution(&msg);
bond::validate_bond_resolution(pool, &order, &bond_resolution).await?;

// Settle seller hold invoice
settle_seller_hold_invoice(event, ln_client, Action::AdminSettled, true, &order)
.await
Expand Down Expand Up @@ -189,9 +204,23 @@ pub async fn admin_settle_action(
)
.await;
}
// Phase 1: admin-settled disputes always release any taker bond.
// Slashing on lost dispute lands in Phase 2.
bond::release_bonds_for_order_or_warn(pool, order_updated.id, "admin_settle").await;
// Phase 2: apply the solver's `BondResolution` (release-by-default
// when absent, otherwise slash the flagged sides). The actual
// Lightning payout to the wronged counterparty is Phase 3's job;
// here we only transition the bond rows.
if let Err(e) = bond::apply_bond_resolution(
pool,
&order_updated,
&bond_resolution,
BondSlashReason::LostDispute,
)
.await
{
tracing::warn!(
order_id = %order_updated.id,
"admin_settle: bond resolution apply failed: {}", e
);
}

let _ = do_payment(ctx, order_updated, request_id).await;

Expand Down
73 changes: 73 additions & 0 deletions src/app/bond/math.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,38 @@ pub fn compute_bond_amount(order_amount_sats: i64, cfg: &AntiAbuseBondSettings)
pct_sats.max(base)
}

/// Compute the node's share of a slashed bond, in sats.
///
/// `pct` is the operator's `slash_node_share_pct`, already validated by
/// the config deserializer to be in `[0.0, 1.0]`. The counterparty share
/// is always derived as `amount_sats - node_share_sats` by the caller,
/// so the two cannot drift and always sum exactly to `amount_sats` (the
/// spec's "no rounding leaks" invariant in §8.1).
///
/// Frozen at the moment the bond enters `PendingPayout`: Phase 2 writes
/// the result to the `node_share_sats` column in the same DB update
/// that flips the bond state, so a later config change or daemon restart
/// can never rebalance the split.
pub fn compute_node_share(amount_sats: i64, pct: f64) -> i64 {
if amount_sats <= 0 {
return 0;
}
// The config deserializer rejects values outside [0.0, 1.0], but be
// defensive against a future call site that builds settings in code.
let pct = pct.clamp(0.0, 1.0);
let raw = (amount_sats as f64) * pct;
// `floor` so the counterparty share, computed as `amount_sats - share`,
// is never negative even at pct=1.0 and never strands a sat at pct<1.0.
let floored = raw.floor();
if floored <= 0.0 {
0
} else if floored >= amount_sats as f64 {
amount_sats
} else {
floored as i64
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -119,4 +151,45 @@ mod tests {
// the guard prevents a panic.
assert_eq!(compute_bond_amount(i64::MAX, &cfg), i64::MAX);
}

#[test]
fn node_share_half_default() {
// 10_000 sats at 50% → 5_000 node / 5_000 counterparty.
assert_eq!(compute_node_share(10_000, 0.5), 5_000);
}

#[test]
fn node_share_zero_pct_goes_to_counterparty() {
// Legacy winner-takes-all: pct=0 → node keeps nothing.
assert_eq!(compute_node_share(10_000, 0.0), 0);
}

#[test]
fn node_share_one_pct_keeps_all() {
// pct=1.0 → counterparty leg is empty; full amount stays with node.
assert_eq!(compute_node_share(10_000, 1.0), 10_000);
}

#[test]
fn node_share_floors_no_rounding_leak() {
// 333 * 0.5 = 166.5 → floor 166. Counterparty gets 333 - 166 = 167.
// The two sum exactly to amount_sats (spec §8.1 invariant).
let share = compute_node_share(333, 0.5);
assert_eq!(share, 166);
assert_eq!(333 - share, 167);
}

#[test]
fn node_share_zero_or_negative_amount() {
assert_eq!(compute_node_share(0, 0.5), 0);
assert_eq!(compute_node_share(-100, 0.5), 0);
}

#[test]
fn node_share_clamps_out_of_range_pct() {
// Config deserializer rejects out-of-range pct; clamp defensively
// anyway so a future programmatic caller can't underflow.
assert_eq!(compute_node_share(10_000, -0.1), 0);
assert_eq!(compute_node_share(10_000, 1.5), 10_000);
}
}
4 changes: 3 additions & 1 deletion src/app/bond/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ pub mod db;
pub mod flow;
pub mod math;
pub mod model;
pub mod slash;
pub mod types;

pub use flow::{
release_bond, release_bonds_for_order, release_bonds_for_order_or_warn, request_taker_bond,
resubscribe_active_bonds, taker_bond_required, TakerContext,
};
pub use math::compute_bond_amount;
pub use math::{compute_bond_amount, compute_node_share};
pub use model::Bond;
pub use slash::{apply_bond_resolution, extract_bond_resolution, validate_bond_resolution};
pub use types::{BondRole, BondSlashReason, BondState};
Loading