use std::collections::HashSet;
use std::future::Future;
use std::pin::Pin;
use std::str::FromStr;
use chrono::Utc;
use mostro_core::error::{
CantDoReason,
MostroError::{self, MostroCantDo, MostroInternalErr},
ServiceError,
};
use mostro_core::message::{Action, BondResolution, Message, Payload};
use mostro_core::order::{Kind, Order, SmallOrder, Status};
use nostr_sdk::prelude::PublicKey;
use sqlx::{Pool, Sqlite};
use sqlx_crud::Crud;
use tracing::{info, warn};
use uuid::Uuid;
use super::db::{
find_active_bonds_for_order, find_child_slashes_for_parent, find_maker_bond_for_order,
find_range_root_order,
};
use super::flow::{
release_bond, release_bonds_for_order_or_warn, release_taker_bonds_for_order_or_warn,
};
use super::math::compute_node_share;
use super::model::Bond;
use super::types::{BondRole, BondSlashReason, BondState};
use crate::config::settings::Settings;
use crate::config::types::AntiAbuseBondSettings;
use crate::lightning::LndConnector;
use crate::util::enqueue_order_msg;
pub trait SettleLightning {
fn settle_hold_invoice<'a>(
&'a mut self,
preimage: &'a str,
) -> Pin<Box<dyn Future<Output = Result<(), MostroError>> + Send + 'a>>;
}
impl SettleLightning for LndConnector {
fn settle_hold_invoice<'a>(
&'a mut self,
preimage: &'a str,
) -> Pin<Box<dyn Future<Output = Result<(), MostroError>> + Send + 'a>> {
Box::pin(async move {
LndConnector::settle_hold_invoice(self, preimage)
.await
.map(|_| ())
})
}
}
pub(super) fn is_already_settled_error(err: &MostroError) -> bool {
let s = err.to_string().to_lowercase();
s.contains("already settled")
|| s.contains("invoice already settled")
|| s.contains("code=alreadyexists")
}
pub(super) fn is_unique_violation(err: &sqlx::Error) -> bool {
err.as_database_error().is_some_and(|d| {
d.code().as_deref() == Some("2067")
|| d.message()
.to_lowercase()
.contains("unique constraint failed")
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Side {
Seller,
Buyer,
}
pub fn extract_bond_resolution(msg: &Message) -> BondResolution {
match &msg.get_inner_message_kind().payload {
Some(Payload::BondResolution(br)) => br.clone(),
_ => BondResolution {
slash_seller: false,
slash_buyer: false,
},
}
}
pub async fn validate_bond_resolution(
pool: &Pool<Sqlite>,
order: &Order,
resolution: &BondResolution,
) -> Result<(), MostroError> {
if !resolution.slash_seller && !resolution.slash_buyer {
return Ok(());
}
let bonds = find_active_bonds_for_order(pool, order.id).await?;
let is_range = order_has_range_maker_bond(pool, order).await?;
if resolution.slash_seller
&& resolve_slash_target(pool, order, &bonds, Side::Seller, is_range)
.await?
.is_none()
{
return Err(MostroCantDo(CantDoReason::InvalidPayload));
}
if resolution.slash_buyer
&& resolve_slash_target(pool, order, &bonds, Side::Buyer, is_range)
.await?
.is_none()
{
return Err(MostroCantDo(CantDoReason::InvalidPayload));
}
Ok(())
}
fn side_is_maker(order: &Order, side: Side) -> Result<bool, MostroError> {
let kind = order.get_order_kind().map_err(MostroInternalErr)?;
Ok(matches!(
(kind, side),
(Kind::Sell, Side::Seller) | (Kind::Buy, Side::Buyer)
))
}
async fn resolve_slash_target(
pool: &Pool<Sqlite>,
order: &Order,
bonds: &[Bond],
side: Side,
is_range: bool,
) -> Result<Option<Bond>, MostroError> {
if let Some(b) = resolve_locked_bond(order, bonds, side) {
return Ok(Some(b.clone()));
}
if is_range && side_is_maker(order, side)? {
let locked = BondState::Locked.to_string();
if let Some(b) = find_maker_bond_for_order(pool, order).await? {
if b.state == locked {
return Ok(Some(b));
}
}
}
Ok(None)
}
pub async fn apply_bond_resolution<L: SettleLightning + Send>(
pool: &Pool<Sqlite>,
ln_client: &mut L,
order: &Order,
resolution: &BondResolution,
reason: BondSlashReason,
) -> Result<(), MostroError> {
let active = find_active_bonds_for_order(pool, order.id).await?;
let node_share_pct = Settings::get_bond().map_or(0.0, |c| c.slash_node_share_pct);
let is_range = order_has_range_maker_bond(pool, order).await?;
let mut slashed_ids: HashSet<Uuid> = HashSet::new();
for (flag, side) in [
(resolution.slash_seller, Side::Seller),
(resolution.slash_buyer, Side::Buyer),
] {
if !flag {
continue;
}
let Some(target) = resolve_slash_target(pool, order, &active, side, is_range).await? else {
continue;
};
if is_range && side_is_maker(order, side)? {
let root = find_range_root_order(pool, order.clone()).await?;
record_maker_slice_slash(pool, order, &root, &target, reason, node_share_pct).await?;
} else {
slash_one(pool, ln_client, &target, reason, node_share_pct).await;
slashed_ids.insert(target.id);
}
}
let maker_role = BondRole::Maker.to_string();
for bond in active.iter() {
if slashed_ids.contains(&bond.id) {
continue;
}
if is_range && bond.role == maker_role {
continue;
}
if let Err(e) = release_bond(pool, bond).await {
warn!(
bond_id = %bond.id,
order_id = %order.id,
"apply_bond_resolution: release_bond failed: {}", e
);
}
}
Ok(())
}
async fn order_has_range_maker_bond(
pool: &Pool<Sqlite>,
order: &Order,
) -> Result<bool, MostroError> {
let root = find_range_root_order(pool, order.clone()).await?;
Ok(root.max_amount.is_some())
}
fn order_republishes_on_timeout(order: &Order) -> bool {
matches!(
(order.get_order_status(), order.get_order_kind()),
(Ok(Status::WaitingBuyerInvoice), Ok(Kind::Sell))
| (Ok(Status::WaitingPayment), Ok(Kind::Buy))
)
}
async fn release_on_timeout(pool: &Pool<Sqlite>, order_id: Uuid, republishes: bool) {
if republishes {
release_taker_bonds_for_order_or_warn(pool, order_id, "scheduler_timeout").await;
} else {
release_bonds_for_order_or_warn(pool, order_id, "scheduler_timeout").await;
}
}
pub async fn slash_or_release_on_timeout<L: SettleLightning + Send>(
pool: &Pool<Sqlite>,
ln_client: &mut L,
order: &Order,
bond_cfg: Option<&AntiAbuseBondSettings>,
) -> Result<Option<Bond>, MostroError> {
let side = match order.get_order_status() {
Ok(Status::WaitingBuyerInvoice) => Side::Buyer,
Ok(Status::WaitingPayment) => Side::Seller,
_ => {
release_bonds_for_order_or_warn(pool, order.id, "scheduler_timeout").await;
return Ok(None);
}
};
let republishes = order_republishes_on_timeout(order);
let responsible_is_maker = side_is_maker(order, side)?;
let slash_armed = bond_cfg.is_some_and(|c| {
c.enabled
&& c.slash_on_waiting_timeout
&& if responsible_is_maker {
c.apply_to.applies_to_maker()
} else {
c.apply_to.applies_to_taker()
}
});
if !slash_armed {
release_on_timeout(pool, order.id, republishes).await;
return Ok(None);
}
let bonds = find_active_bonds_for_order(pool, order.id).await?;
let is_range = order_has_range_maker_bond(pool, order).await?;
let Some(responsible) = resolve_slash_target(pool, order, &bonds, side, is_range).await? else {
release_on_timeout(pool, order.id, republishes).await;
return Ok(None);
};
let node_share_pct = Settings::get_bond().map_or(0.0, |c| c.slash_node_share_pct);
let slashed_row: Option<Bond> = if responsible_is_maker && is_range {
let root = find_range_root_order(pool, order.clone()).await?;
let inserted = record_maker_slice_slash(
pool,
order,
&root,
&responsible,
BondSlashReason::Timeout,
node_share_pct,
)
.await?;
if inserted {
find_slice_slash_child(pool, responsible.id, order.id).await?
} else {
None
}
} else {
slash_one(
pool,
ln_client,
&responsible,
BondSlashReason::Timeout,
node_share_pct,
)
.await;
if timeout_slash_confirmed(pool, responsible.id).await? {
Some(responsible.clone())
} else {
None
}
};
let maker = BondRole::Maker.to_string();
for bond in bonds.iter() {
if bond.id == responsible.id {
continue;
}
if bond.role == maker && (republishes || is_range) {
continue;
}
if let Err(e) = release_bond(pool, bond).await {
warn!(
bond_id = %bond.id,
order_id = %order.id,
"scheduler_timeout: release_bond failed: {}", e
);
}
}
match slashed_row {
Some(slashed) => {
info!(
bond_id = %slashed.id,
order_id = %order.id,
role = %slashed.role,
"Bond slashed on waiting-state timeout"
);
Ok(Some(slashed))
}
None => {
warn!(
bond_id = %responsible.id,
order_id = %order.id,
"timeout slash did not land (still Locked / already slashed); no forfeiture notice sent"
);
Ok(None)
}
}
}
async fn find_slice_slash_child(
pool: &Pool<Sqlite>,
parent_bond_id: Uuid,
slice_order_id: Uuid,
) -> Result<Option<Bond>, MostroError> {
sqlx::query_as::<_, Bond>("SELECT * FROM bonds WHERE parent_bond_id = ? AND child_order_id = ?")
.bind(parent_bond_id)
.bind(slice_order_id)
.fetch_optional(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))
}
async fn timeout_slash_confirmed(pool: &Pool<Sqlite>, bond_id: Uuid) -> Result<bool, MostroError> {
let row: Option<(Option<String>,)> =
sqlx::query_as("SELECT slashed_reason FROM bonds WHERE id = ?")
.bind(bond_id)
.fetch_optional(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
let timeout = BondSlashReason::Timeout.to_string();
Ok(row.and_then(|(reason,)| reason).as_deref() == Some(timeout.as_str()))
}
pub async fn notify_bond_slashed(order: &Order, slashed: &Bond) {
let recipient = match PublicKey::from_str(&slashed.pubkey) {
Ok(pk) => pk,
Err(e) => {
warn!(
bond_id = %slashed.id,
order_id = %order.id,
"bond slash: unparseable bonded pubkey ({e}); skipping BondSlashed notice"
);
return;
}
};
let order_kind = match order.get_order_kind() {
Ok(k) => k,
Err(e) => {
warn!(
order_id = %order.id,
"bond slash: cannot resolve order kind ({e:?}); skipping BondSlashed notice"
);
return;
}
};
let small = SmallOrder::new(
Some(order.id),
Some(order_kind),
None,
slashed.amount_sats,
order.fiat_code.clone(),
order.min_amount,
order.max_amount,
order.fiat_amount,
order.payment_method.clone(),
order.premium,
None,
None,
None,
None,
None,
);
enqueue_order_msg(
None,
Some(order.id),
Action::BondSlashed,
Some(Payload::Order(small)),
recipient,
None,
)
.await;
}
async fn slash_one<L: SettleLightning + Send>(
pool: &Pool<Sqlite>,
ln_client: &mut L,
bond: &Bond,
reason: BondSlashReason,
node_share_pct: f64,
) {
let preimage = match bond.preimage.as_deref() {
Some(p) => p,
None => {
warn!(
bond_id = %bond.id,
order_id = %bond.order_id,
"slash: bond has no preimage — cannot settle HTLC; leaving Locked for operator review"
);
return;
}
};
if let Err(e) = ln_client.settle_hold_invoice(preimage).await {
if is_already_settled_error(&e) {
info!(
bond_id = %bond.id,
order_id = %bond.order_id,
"slash: HTLC already settled (idempotent retry); proceeding to CAS"
);
} else {
warn!(
bond_id = %bond.id,
order_id = %bond.order_id,
"slash: settle_hold_invoice failed: {e} — leaving bond Locked for admin retry"
);
return;
}
}
let node_share_sats = compute_node_share(bond.amount_sats, node_share_pct);
let now = Utc::now().timestamp();
let result = sqlx::query(
"UPDATE bonds \
SET state = ?, slashed_reason = ?, slashed_at = ?, node_share_sats = ? \
WHERE id = ? AND state = ?",
)
.bind(BondState::PendingPayout.to_string())
.bind(reason.to_string())
.bind(now)
.bind(node_share_sats)
.bind(bond.id)
.bind(BondState::Locked.to_string())
.execute(pool)
.await;
match result {
Ok(r) if r.rows_affected() == 1 => {
info!(
bond_id = %bond.id,
order_id = %bond.order_id,
reason = %reason,
node_share_sats,
counterparty_share_sats = bond.amount_sats - node_share_sats,
"Bond HTLC settled and row transitioned to PendingPayout"
);
}
Ok(_) => {
warn!(
bond_id = %bond.id,
order_id = %bond.order_id,
current_state = %bond.state,
"slash CAS no-op (bond state changed concurrently); HTLC was settled"
);
}
Err(e) => {
warn!(
bond_id = %bond.id,
order_id = %bond.order_id,
"slash CAS DB error: {} (HTLC was settled)", e
);
}
}
}
async fn record_maker_slice_slash(
pool: &Pool<Sqlite>,
slice: &Order,
root: &Order,
parent_bond: &Bond,
reason: BondSlashReason,
node_share_pct: f64,
) -> Result<bool, MostroError> {
let kind = slice.get_order_kind().map_err(MostroInternalErr)?;
let maker_slice_pubkey = match kind {
Kind::Sell => slice.seller_pubkey.as_deref(),
Kind::Buy => slice.buyer_pubkey.as_deref(),
};
let Some(maker_slice_pubkey) = maker_slice_pubkey else {
warn!(
bond_id = %parent_bond.id,
slice_order_id = %slice.id,
"record_maker_slice_slash: slice has no maker-side pubkey; skipping"
);
return Ok(false);
};
let Some(max_fiat) = root.max_amount.filter(|m| *m > 0) else {
warn!(
bond_id = %parent_bond.id,
root_order_id = %root.id,
"record_maker_slice_slash: range root missing positive max_amount; skipping"
);
return Ok(false);
};
let raw = (parent_bond.amount_sats as f64 * slice.fiat_amount as f64 / max_fiat as f64).round()
as i64;
let remaining = (parent_bond.amount_sats - parent_bond.slashed_share_sats).max(0);
let slash_amount = raw.clamp(0, remaining);
if slash_amount <= 0 {
warn!(
bond_id = %parent_bond.id,
slice_order_id = %slice.id,
raw, remaining,
"record_maker_slice_slash: computed non-positive / over-allocated share; skipping"
);
return Ok(false);
}
let now = Utc::now().timestamp();
let node_share = compute_node_share(slash_amount, node_share_pct);
let insert = sqlx::query(
"INSERT INTO bonds \
(id, order_id, parent_bond_id, child_order_id, pubkey, role, \
amount_sats, state, slashed_reason, node_share_sats, slashed_at, created_at) \
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? \
WHERE NOT EXISTS ( \
SELECT 1 FROM bonds WHERE parent_bond_id = ? AND child_order_id = ?) \
AND EXISTS ( \
SELECT 1 FROM bonds WHERE id = ? AND state = ?)",
)
.bind(Uuid::new_v4())
.bind(slice.id)
.bind(parent_bond.id)
.bind(slice.id)
.bind(maker_slice_pubkey)
.bind(BondRole::Maker.to_string())
.bind(slash_amount)
.bind(BondState::PendingPayout.to_string())
.bind(reason.to_string())
.bind(node_share)
.bind(now)
.bind(now)
.bind(parent_bond.id)
.bind(slice.id)
.bind(parent_bond.id)
.bind(BondState::Locked.to_string())
.execute(pool)
.await;
let inserted = match insert {
Ok(r) => r,
Err(e) if is_unique_violation(&e) => {
info!(
bond_id = %parent_bond.id,
slice_order_id = %slice.id,
"record_maker_slice_slash: slice already slashed (unique index); skipping duplicate"
);
return Ok(false);
}
Err(e) => {
return Err(MostroInternalErr(ServiceError::DbAccessError(
e.to_string(),
)))
}
};
if inserted.rows_affected() == 0 {
info!(
bond_id = %parent_bond.id,
slice_order_id = %slice.id,
"record_maker_slice_slash: slice already slashed or parent no longer Locked; skipping"
);
return Ok(false);
}
sqlx::query(
"UPDATE bonds SET slashed_share_sats = \
(SELECT COALESCE(SUM(amount_sats), 0) FROM bonds \
WHERE parent_bond_id = ? AND child_order_id IS NOT NULL) \
WHERE id = ? AND state = ?",
)
.bind(parent_bond.id)
.bind(parent_bond.id)
.bind(BondState::Locked.to_string())
.execute(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
info!(
bond_id = %parent_bond.id,
slice_order_id = %slice.id,
reason = %reason,
slash_amount,
"Phase 6: recorded proportional maker slice slash (parent HTLC stays Locked)"
);
Ok(true)
}
pub async fn resolve_range_maker_bond_at_close<L: SettleLightning + Send>(
pool: &Pool<Sqlite>,
ln_client: &mut L,
order: &Order,
) -> Result<(), MostroError> {
let Some(parent) = find_maker_bond_for_order(pool, order).await? else {
return Ok(());
};
if parent.state != BondState::Locked.to_string() {
return Ok(());
}
let root = find_range_root_order(pool, order.clone()).await?;
let slice_children: Vec<Bond> = if root.max_amount.is_some() {
find_child_slashes_for_parent(pool, parent.id)
.await?
.into_iter()
.filter(|c| c.child_order_id.is_some())
.collect()
} else {
Vec::new()
};
let snapshot_slashed: i64 = slice_children.iter().map(|c| c.amount_sats).sum();
if snapshot_slashed == 0 {
return release_bond(pool, &parent).await;
}
let Some(preimage) = parent.preimage.as_deref() else {
warn!(
bond_id = %parent.id,
"range close: parent bond has no preimage; cannot settle — left Locked"
);
return Ok(());
};
if let Err(e) = ln_client.settle_hold_invoice(preimage).await {
if is_already_settled_error(&e) {
info!(
bond_id = %parent.id,
"range close: parent HTLC already settled (idempotent); proceeding"
);
} else {
warn!(
bond_id = %parent.id,
"range close: settle_hold_invoice failed: {e} — leaving Locked for retry"
);
return Ok(());
}
}
let now = Utc::now().timestamp();
let mut tx = pool
.begin()
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
let cas = sqlx::query("UPDATE bonds SET state = ? WHERE id = ? AND state = ?")
.bind(BondState::Slashed.to_string())
.bind(parent.id)
.bind(BondState::Locked.to_string())
.execute(&mut *tx)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
if cas.rows_affected() != 1 {
let _ = tx.rollback().await;
info!(
bond_id = %parent.id,
"range close: parent already closed concurrently; skipping refund row"
);
return Ok(());
}
let total_slashed: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COALESCE(SUM(amount_sats), 0) FROM bonds \
WHERE parent_bond_id = ? AND child_order_id IS NOT NULL",
)
.bind(parent.id)
.fetch_one(&mut *tx)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
let refund_amount = (parent.amount_sats - total_slashed).max(0);
if !slice_children.is_empty() {
sqlx::query(
"UPDATE bonds SET slashed_at = ? \
WHERE parent_bond_id = ? AND child_order_id IS NOT NULL AND state = ?",
)
.bind(now)
.bind(parent.id)
.bind(BondState::PendingPayout.to_string())
.execute(&mut *tx)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
}
if refund_amount > 0 {
let reason = slice_children
.first()
.and_then(|c| c.slashed_reason.clone())
.unwrap_or_else(|| BondSlashReason::LostDispute.to_string());
sqlx::query(
"INSERT INTO bonds \
(id, order_id, parent_bond_id, child_order_id, pubkey, role, \
amount_sats, state, slashed_reason, node_share_sats, slashed_at, created_at) \
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(Uuid::new_v4())
.bind(root.id)
.bind(parent.id)
.bind(parent.pubkey.clone())
.bind(BondRole::Maker.to_string())
.bind(refund_amount)
.bind(BondState::PendingPayout.to_string())
.bind(reason)
.bind(0_i64) .bind(now)
.bind(now)
.execute(&mut *tx)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
}
tx.commit()
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?;
info!(
bond_id = %parent.id,
order_id = %root.id,
amount_sats = parent.amount_sats,
total_slashed,
refund_amount,
children = slice_children.len(),
"Phase 6: range maker bond settled at close; distributing child shares + maker refund"
);
Ok(())
}
pub async fn resolve_range_maker_bond_at_close_or_warn(
pool: &Pool<Sqlite>,
order: &Order,
context: &'static str,
) {
let locked = BondState::Locked.to_string();
match find_maker_bond_for_order(pool, order).await {
Ok(Some(b)) if b.state == locked => {}
Ok(_) => return,
Err(e) => {
warn!("{context}: maker bond lookup failed for {}: {e}", order.id);
return;
}
}
let mut ln = match LndConnector::new().await {
Ok(l) => l,
Err(e) => {
warn!("{context}: cannot connect to LND to resolve maker bond at close: {e}");
return;
}
};
if let Err(e) = resolve_range_maker_bond_at_close(pool, &mut ln, order).await {
warn!(
order_id = %order.id,
"{context}: resolve_range_maker_bond_at_close failed: {e}"
);
}
}
async fn collect_stranded_range_maker_roots(
pool: &Pool<Sqlite>,
) -> Result<Vec<Order>, MostroError> {
let mut stranded = Vec::new();
for bond in super::db::find_locked_maker_parent_bonds(pool).await? {
let root = match Order::by_id(pool, bond.order_id).await {
Ok(Some(root)) => root,
Ok(None) => continue, Err(e) => {
warn!(
order_id = %bond.order_id,
error = %e,
"reconcile_sweep: range-root order lookup failed; skipping this root"
);
continue;
}
};
match super::db::range_tree_fully_terminal(pool, bond.order_id).await {
Ok(true) => stranded.push(root),
Ok(false) => {}
Err(e) => {
warn!(
order_id = %bond.order_id,
error = %e,
"reconcile_sweep: range-tree terminal check failed; skipping this root"
);
continue;
}
}
}
Ok(stranded)
}
pub(crate) async fn reconcile_stranded_range_maker_bonds_with<L: SettleLightning + Send>(
pool: &Pool<Sqlite>,
ln_client: &mut L,
) -> usize {
let roots = match collect_stranded_range_maker_roots(pool).await {
Ok(r) => r,
Err(e) => {
warn!("reconcile_sweep: scan for stranded maker bonds failed: {e}");
return 0;
}
};
let mut resolved = 0;
for order in &roots {
match resolve_range_maker_bond_at_close(pool, ln_client, order).await {
Ok(()) => resolved += 1,
Err(e) => warn!(
order_id = %order.id,
"reconcile_sweep: resolve_range_maker_bond_at_close failed: {e}"
),
}
}
resolved
}
pub async fn reconcile_stranded_range_maker_bonds(pool: &Pool<Sqlite>) {
match collect_stranded_range_maker_roots(pool).await {
Ok(roots) if roots.is_empty() => return,
Ok(_) => {}
Err(e) => {
warn!("reconcile_sweep: scan for stranded maker bonds failed: {e}");
return;
}
}
let mut ln = match LndConnector::new().await {
Ok(l) => l,
Err(e) => {
warn!("reconcile_sweep: cannot connect to LND to retry stranded maker bonds: {e}");
return;
}
};
let resolved = reconcile_stranded_range_maker_bonds_with(pool, &mut ln).await;
if resolved > 0 {
info!(
resolved,
"reconcile_sweep: retried stranded range maker bond(s) at close"
);
}
}
fn resolve_locked_bond<'a>(order: &Order, bonds: &'a [Bond], side: Side) -> Option<&'a Bond> {
let target_pubkey = match side {
Side::Seller => order.seller_pubkey.as_deref()?,
Side::Buyer => order.buyer_pubkey.as_deref()?,
};
let locked = BondState::Locked.to_string();
bonds
.iter()
.find(|b| b.pubkey == target_pubkey && b.state == locked)
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use mostro_core::error::{MostroError, ServiceError};
use mostro_core::message::{Action, Message, Payload};
use mostro_core::order::{Kind, Order, Status};
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::{Pool, Sqlite};
use super::*;
use crate::app::bond::model::Bond;
use crate::app::bond::types::{BondRole, BondSlashReason, BondState};
#[derive(Default)]
struct StubSettle {
calls: Mutex<Vec<String>>,
fail_with: Mutex<Option<String>>,
}
impl StubSettle {
fn new() -> Arc<Self> {
Arc::new(Self::default())
}
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
fn fail_next_with(&self, msg: &str) {
*self.fail_with.lock().unwrap() = Some(msg.to_string());
}
}
impl SettleLightning for Arc<StubSettle> {
fn settle_hold_invoice<'a>(
&'a mut self,
preimage: &'a str,
) -> Pin<Box<dyn Future<Output = Result<(), MostroError>> + Send + 'a>> {
Box::pin(async move {
self.calls.lock().unwrap().push(preimage.to_string());
if let Some(msg) = self.fail_with.lock().unwrap().take() {
return Err(MostroError::MostroInternalErr(ServiceError::LnNodeError(
msg,
)));
}
Ok(())
})
}
}
async fn setup_pool() -> Pool<Sqlite> {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect(":memory:")
.await
.expect("open in-memory sqlite");
sqlx::query(include_str!(
"../../../migrations/20221222153301_orders.sql"
))
.execute(&pool)
.await
.expect("orders migration");
for stmt in include_str!("../../../migrations/20251126120000_dev_fee.sql")
.split(';')
.map(str::trim)
.filter(|s| !s.is_empty() && !s.lines().all(|l| l.trim_start().starts_with("--")))
{
sqlx::query(stmt)
.execute(&pool)
.await
.expect("dev_fee migration");
}
sqlx::query(include_str!(
"../../../migrations/20260423120000_anti_abuse_bond.sql"
))
.execute(&pool)
.await
.expect("bonds migration");
sqlx::query(include_str!(
"../../../migrations/20260518120000_bond_payout_payment_hash.sql"
))
.execute(&pool)
.await
.expect("bond_payout_payment_hash migration");
for stmt in include_str!("../../../migrations/20260530120000_cashu_escrow_fields.sql")
.split(';')
.map(str::trim)
.filter(|s| !s.is_empty() && !s.lines().all(|l| l.trim_start().starts_with("--")))
{
sqlx::query(stmt)
.execute(&pool)
.await
.expect("cashu_escrow_fields migration");
}
sqlx::query(include_str!(
"../../../migrations/20260611120000_bond_slice_slash_unique.sql"
))
.execute(&pool)
.await
.expect("bond_slice_slash_unique migration");
pool
}
async fn insert_order_row(pool: &Pool<Sqlite>, order: &Order) {
sqlx::query(
r#"INSERT INTO orders (
id, kind, event_id, status, premium, payment_method,
amount, fiat_code, fiat_amount, created_at, expires_at,
seller_pubkey, buyer_pubkey
) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?)"#,
)
.bind(order.id)
.bind(&order.kind)
.bind(order.id.simple().to_string())
.bind(&order.status)
.bind(&order.payment_method)
.bind(order.amount)
.bind(&order.fiat_code)
.bind(order.fiat_amount)
.bind(order.created_at)
.bind(order.expires_at)
.bind(order.seller_pubkey.as_deref())
.bind(order.buyer_pubkey.as_deref())
.execute(pool)
.await
.expect("insert order");
}
fn fixture_order(kind: Kind, seller_pk: &str, buyer_pk: &str) -> Order {
Order {
id: Uuid::new_v4(),
kind: kind.to_string(),
status: Status::Dispute.to_string(),
seller_pubkey: Some(seller_pk.to_string()),
buyer_pubkey: Some(buyer_pk.to_string()),
amount: 100_000,
fiat_code: "USD".to_string(),
fiat_amount: 10,
payment_method: "lightning".to_string(),
created_at: Utc::now().timestamp(),
expires_at: Utc::now().timestamp() + 3600,
..Order::default()
}
}
fn stub_preimage() -> String {
"00".repeat(32)
}
async fn insert_bond(
pool: &Pool<Sqlite>,
order_id: Uuid,
pubkey: &str,
state: BondState,
) -> Bond {
insert_bond_with_role(pool, order_id, pubkey, BondRole::Taker, state).await
}
async fn insert_bond_with_role(
pool: &Pool<Sqlite>,
order_id: Uuid,
pubkey: &str,
role: BondRole,
state: BondState,
) -> Bond {
let mut b = Bond::new_requested(order_id, pubkey.to_string(), role, 10_000);
b.state = state.to_string();
b.preimage = Some(stub_preimage());
b.hash = None;
sqlx_crud::Crud::create(b.clone(), pool).await.unwrap();
b
}
fn taker_pk() -> &'static str {
"1111111111111111111111111111111111111111111111111111111111111111"
}
fn maker_pk() -> &'static str {
"2222222222222222222222222222222222222222222222222222222222222222"
}
fn order_msg_with(payload: Option<Payload>) -> Message {
Message::new_order(
Some(Uuid::new_v4()),
None,
None,
Action::AdminSettle,
payload,
)
}
#[test]
fn extract_returns_default_when_payload_absent() {
let msg = order_msg_with(None);
let br = extract_bond_resolution(&msg);
assert!(!br.slash_seller);
assert!(!br.slash_buyer);
}
#[test]
fn extract_returns_default_for_unrelated_payload_shapes() {
let msg = order_msg_with(Some(Payload::TextMessage("hi".into())));
let br = extract_bond_resolution(&msg);
assert!(!br.slash_seller);
assert!(!br.slash_buyer);
}
#[test]
fn extract_returns_payload_when_present() {
let payload = Payload::BondResolution(BondResolution {
slash_seller: true,
slash_buyer: false,
});
let msg = order_msg_with(Some(payload));
let br = extract_bond_resolution(&msg);
assert!(br.slash_seller);
assert!(!br.slash_buyer);
}
#[tokio::test]
async fn validate_null_payload_passes_with_no_bonds() {
let pool = setup_pool().await;
let order = fixture_order(Kind::Sell, maker_pk(), taker_pk());
insert_order_row(&pool, &order).await;
let res = BondResolution {
slash_seller: false,
slash_buyer: false,
};
validate_bond_resolution(&pool, &order, &res).await.unwrap();
}
#[tokio::test]
async fn validate_slash_buyer_passes_when_buyer_has_locked_bond() {
let pool = setup_pool().await;
let order = fixture_order(Kind::Sell, maker_pk(), taker_pk());
insert_order_row(&pool, &order).await;
insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let res = BondResolution {
slash_seller: false,
slash_buyer: true,
};
validate_bond_resolution(&pool, &order, &res).await.unwrap();
}
#[tokio::test]
async fn validate_slash_seller_on_sell_apply_to_take_rejects() {
let pool = setup_pool().await;
let order = fixture_order(Kind::Sell, maker_pk(), taker_pk());
insert_order_row(&pool, &order).await;
insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let res = BondResolution {
slash_seller: true,
slash_buyer: false,
};
let err = validate_bond_resolution(&pool, &order, &res)
.await
.unwrap_err();
assert!(
matches!(err, MostroCantDo(CantDoReason::InvalidPayload)),
"expected CantDo(InvalidPayload), got {err:?}"
);
}
#[tokio::test]
async fn validate_rejects_when_bond_table_is_empty() {
let pool = setup_pool().await;
let order = fixture_order(Kind::Sell, maker_pk(), taker_pk());
insert_order_row(&pool, &order).await;
let res = BondResolution {
slash_seller: false,
slash_buyer: true,
};
let err = validate_bond_resolution(&pool, &order, &res)
.await
.unwrap_err();
assert!(matches!(err, MostroCantDo(CantDoReason::InvalidPayload)));
}
#[tokio::test]
async fn apply_null_payload_releases_all_active_bonds() {
let pool = setup_pool().await;
let order = fixture_order(Kind::Sell, maker_pk(), taker_pk());
insert_order_row(&pool, &order).await;
let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let res = BondResolution {
slash_seller: false,
slash_buyer: false,
};
apply_bond_resolution(
&pool,
&mut StubSettle::new(),
&order,
&res,
BondSlashReason::LostDispute,
)
.await
.unwrap();
let row: (String,) = sqlx::query_as("SELECT state FROM bonds WHERE id = ?")
.bind(bond.id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
row.0,
BondState::Released.to_string(),
"null payload must release, not slash"
);
}
#[tokio::test]
async fn apply_slash_buyer_on_sell_order_transitions_taker_bond() {
let pool = setup_pool().await;
let order = fixture_order(Kind::Sell, maker_pk(), taker_pk());
insert_order_row(&pool, &order).await;
let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let res = BondResolution {
slash_seller: false,
slash_buyer: true,
};
apply_bond_resolution(
&pool,
&mut StubSettle::new(),
&order,
&res,
BondSlashReason::LostDispute,
)
.await
.unwrap();
let row: (String, Option<String>, Option<i64>, Option<i64>) = sqlx::query_as(
"SELECT state, slashed_reason, slashed_at, node_share_sats \
FROM bonds WHERE id = ?",
)
.bind(bond.id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row.0, BondState::PendingPayout.to_string());
assert_eq!(row.1.as_deref(), Some("lost-dispute"));
assert!(row.2.unwrap() > 0, "slashed_at must be set");
assert_eq!(row.3, Some(0));
}
#[tokio::test]
async fn apply_slash_seller_on_sell_order_transitions_maker_bond() {
let pool = setup_pool().await;
let order = fixture_order(Kind::Sell, maker_pk(), taker_pk());
insert_order_row(&pool, &order).await;
let maker_bond = insert_bond_with_role(
&pool,
order.id,
maker_pk(),
BondRole::Maker,
BondState::Locked,
)
.await;
let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let res = BondResolution {
slash_seller: true,
slash_buyer: false,
};
validate_bond_resolution(&pool, &order, &res).await.unwrap();
apply_bond_resolution(
&pool,
&mut StubSettle::new(),
&order,
&res,
BondSlashReason::LostDispute,
)
.await
.unwrap();
let maker_row: (String, Option<String>) =
sqlx::query_as("SELECT state, slashed_reason FROM bonds WHERE id = ?")
.bind(maker_bond.id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
maker_row.0,
BondState::PendingPayout.to_string(),
"maker bond must be slashed on slash_seller for a sell order"
);
assert_eq!(maker_row.1.as_deref(), Some("lost-dispute"));
let taker_state: String = sqlx::query_scalar("SELECT state FROM bonds WHERE id = ?")
.bind(taker_bond.id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
taker_state,
BondState::Released.to_string(),
"the non-slashed taker bond must be released, not settled"
);
}
#[tokio::test]
async fn apply_slash_buyer_on_buy_order_transitions_maker_bond() {
let pool = setup_pool().await;
let order = fixture_order(Kind::Buy, taker_pk(), maker_pk());
insert_order_row(&pool, &order).await;
let maker_bond = insert_bond_with_role(
&pool,
order.id,
maker_pk(),
BondRole::Maker,
BondState::Locked,
)
.await;
let res = BondResolution {
slash_seller: false,
slash_buyer: true,
};
validate_bond_resolution(&pool, &order, &res).await.unwrap();
apply_bond_resolution(
&pool,
&mut StubSettle::new(),
&order,
&res,
BondSlashReason::LostDispute,
)
.await
.unwrap();
let row: (String, Option<String>) =
sqlx::query_as("SELECT state, slashed_reason FROM bonds WHERE id = ?")
.bind(maker_bond.id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
row.0,
BondState::PendingPayout.to_string(),
"maker bond must be slashed on slash_buyer for a buy order"
);
assert_eq!(row.1.as_deref(), Some("lost-dispute"));
}
#[tokio::test]
async fn apply_is_idempotent_on_already_pending_payout() {
let pool = setup_pool().await;
let order = fixture_order(Kind::Sell, maker_pk(), taker_pk());
insert_order_row(&pool, &order).await;
let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let res = BondResolution {
slash_seller: false,
slash_buyer: true,
};
apply_bond_resolution(
&pool,
&mut StubSettle::new(),
&order,
&res,
BondSlashReason::LostDispute,
)
.await
.unwrap();
let first: (String, Option<i64>, Option<i64>) =
sqlx::query_as("SELECT state, slashed_at, node_share_sats FROM bonds WHERE id = ?")
.bind(bond.id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(first.0, BondState::PendingPayout.to_string());
std::thread::sleep(std::time::Duration::from_secs(1));
apply_bond_resolution(
&pool,
&mut StubSettle::new(),
&order,
&res,
BondSlashReason::LostDispute,
)
.await
.unwrap();
let second: (String, Option<i64>, Option<i64>) =
sqlx::query_as("SELECT state, slashed_at, node_share_sats FROM bonds WHERE id = ?")
.bind(bond.id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
second.0,
BondState::PendingPayout.to_string(),
"second apply must not transition the bond out of PendingPayout"
);
assert_eq!(
first, second,
"second apply must not rebump state / slashed_at / node_share_sats"
);
}
#[tokio::test]
async fn apply_with_no_bond_rows_is_noop() {
let pool = setup_pool().await;
let order = fixture_order(Kind::Sell, maker_pk(), taker_pk());
insert_order_row(&pool, &order).await;
let res = BondResolution {
slash_seller: false,
slash_buyer: false,
};
apply_bond_resolution(
&pool,
&mut StubSettle::new(),
&order,
&res,
BondSlashReason::LostDispute,
)
.await
.unwrap();
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM bonds")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(count.0, 0);
}
#[tokio::test]
async fn slash_one_settles_exactly_one_htlc() {
let pool = setup_pool().await;
let order = fixture_order(Kind::Sell, maker_pk(), taker_pk());
insert_order_row(&pool, &order).await;
let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
let res = BondResolution {
slash_seller: false,
slash_buyer: true,
};
apply_bond_resolution(&pool, &mut ln, &order, &res, BondSlashReason::LostDispute)
.await
.unwrap();
assert_eq!(
ln.calls(),
vec![stub_preimage()],
"slash path must settle exactly the slashed bond's HTLC"
);
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());
}
#[tokio::test]
async fn slash_both_settles_both_htlcs() {
let pool = setup_pool().await;
let order = fixture_order(Kind::Sell, maker_pk(), taker_pk());
insert_order_row(&pool, &order).await;
let buyer_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let seller_bond = insert_bond(&pool, order.id, maker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
let res = BondResolution {
slash_seller: true,
slash_buyer: true,
};
apply_bond_resolution(&pool, &mut ln, &order, &res, BondSlashReason::LostDispute)
.await
.unwrap();
assert_eq!(
ln.calls().len(),
2,
"both slashed bonds must have their HTLCs settled immediately"
);
for b in [&buyer_bond, &seller_bond] {
let state: (String,) = sqlx::query_as("SELECT state FROM bonds WHERE id = ?")
.bind(b.id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(state.0, BondState::PendingPayout.to_string());
}
}
#[tokio::test]
async fn non_slashed_bond_is_released_not_settled() {
let pool = setup_pool().await;
let order = fixture_order(Kind::Sell, maker_pk(), taker_pk());
insert_order_row(&pool, &order).await;
let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
let res = BondResolution {
slash_seller: false,
slash_buyer: false,
};
apply_bond_resolution(&pool, &mut ln, &order, &res, BondSlashReason::LostDispute)
.await
.unwrap();
assert!(
ln.calls().is_empty(),
"non-slashed (released) bonds must not invoke settle_hold_invoice"
);
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::Released.to_string());
}
#[tokio::test]
async fn slash_treats_already_settled_as_success() {
let pool = setup_pool().await;
let order = fixture_order(Kind::Sell, maker_pk(), taker_pk());
insert_order_row(&pool, &order).await;
let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
ln.fail_next_with("code=AlreadyExists: invoice already settled");
let res = BondResolution {
slash_seller: false,
slash_buyer: true,
};
apply_bond_resolution(&pool, &mut ln, &order, &res, BondSlashReason::LostDispute)
.await
.unwrap();
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(),
"already-settled error must not block the CAS"
);
}
#[tokio::test]
async fn slash_settle_transport_failure_leaves_bond_locked() {
let pool = setup_pool().await;
let order = fixture_order(Kind::Sell, maker_pk(), taker_pk());
insert_order_row(&pool, &order).await;
let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
ln.fail_next_with("code=Unavailable: connection refused");
let res = BondResolution {
slash_seller: false,
slash_buyer: true,
};
apply_bond_resolution(&pool, &mut ln, &order, &res, BondSlashReason::LostDispute)
.await
.unwrap();
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::Locked.to_string(),
"transient settle failure must leave the bond Locked for admin retry"
);
}
use crate::config::types::{AntiAbuseBondSettings, BondApplyTo};
fn timeout_cfg(
enabled: bool,
slash_on_waiting_timeout: bool,
apply_to: BondApplyTo,
) -> AntiAbuseBondSettings {
AntiAbuseBondSettings {
enabled,
slash_on_waiting_timeout,
apply_to,
..AntiAbuseBondSettings::default()
}
}
fn waiting_order(kind: Kind, seller_pk: &str, buyer_pk: &str, status: Status) -> Order {
let mut o = fixture_order(kind, seller_pk, buyer_pk);
o.status = status.to_string();
o
}
async fn read_bond_state(pool: &Pool<Sqlite>, id: Uuid) -> String {
let row: (String,) = sqlx::query_as("SELECT state FROM bonds WHERE id = ?")
.bind(id)
.fetch_one(pool)
.await
.unwrap();
row.0
}
#[tokio::test]
async fn timeout_slash_disabled_releases_without_slashing() {
let pool = setup_pool().await;
let order = waiting_order(
Kind::Sell,
maker_pk(),
taker_pk(),
Status::WaitingBuyerInvoice,
);
insert_order_row(&pool, &order).await;
let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
let cfg = timeout_cfg(true, false, BondApplyTo::Take);
let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg))
.await
.unwrap();
assert!(
result.is_none(),
"disabled timeout slash must not report a slash"
);
assert!(
ln.calls().is_empty(),
"release path must not settle the HTLC"
);
assert_eq!(
read_bond_state(&pool, bond.id).await,
BondState::Released.to_string()
);
}
#[tokio::test]
async fn timeout_slash_sell_buyer_silent_slashes_taker_bond() {
let pool = setup_pool().await;
let order = waiting_order(
Kind::Sell,
maker_pk(),
taker_pk(),
Status::WaitingBuyerInvoice,
);
insert_order_row(&pool, &order).await;
let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
let cfg = timeout_cfg(true, true, BondApplyTo::Take);
let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg))
.await
.unwrap();
assert_eq!(
result.map(|b| b.id),
Some(bond.id),
"must report the slashed bond for notification"
);
assert_eq!(
ln.calls(),
vec![stub_preimage()],
"slash settles exactly the responsible bond's HTLC"
);
let row: (String, Option<String>, Option<i64>) =
sqlx::query_as("SELECT state, slashed_reason, slashed_at FROM bonds WHERE id = ?")
.bind(bond.id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row.0, BondState::PendingPayout.to_string());
assert_eq!(row.1.as_deref(), Some("timeout"));
assert!(row.2.unwrap() > 0, "slashed_at must be set");
}
#[tokio::test]
async fn timeout_republish_retains_maker_bond_sell_order() {
let pool = setup_pool().await;
let order = waiting_order(
Kind::Sell,
maker_pk(),
taker_pk(),
Status::WaitingBuyerInvoice,
);
insert_order_row(&pool, &order).await;
let maker_bond = insert_bond_with_role(
&pool,
order.id,
maker_pk(),
BondRole::Maker,
BondState::Locked,
)
.await;
let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
let cfg = timeout_cfg(true, true, BondApplyTo::Both);
let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg))
.await
.unwrap();
assert_eq!(
result.map(|b| b.id),
Some(taker_bond.id),
"the abandoning taker's bond is the one slashed"
);
assert_eq!(
read_bond_state(&pool, taker_bond.id).await,
BondState::PendingPayout.to_string(),
"taker bond is slashed on the republish path"
);
assert_eq!(
read_bond_state(&pool, maker_bond.id).await,
BondState::Locked.to_string(),
"maker bond must stay Locked when the order is republished"
);
assert_eq!(
ln.calls(),
vec![stub_preimage()],
"only the slashed taker HTLC is settled; the maker HTLC is untouched"
);
}
#[tokio::test]
async fn timeout_republish_with_no_slash_still_retains_maker_bond() {
let pool = setup_pool().await;
let order = waiting_order(
Kind::Sell,
maker_pk(),
taker_pk(),
Status::WaitingBuyerInvoice,
);
insert_order_row(&pool, &order).await;
let maker_bond = insert_bond_with_role(
&pool,
order.id,
maker_pk(),
BondRole::Maker,
BondState::Locked,
)
.await;
let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
let cfg = timeout_cfg(true, false, BondApplyTo::Both);
let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg))
.await
.unwrap();
assert!(result.is_none(), "gate closed → no slash reported");
assert!(ln.calls().is_empty(), "release path never settles an HTLC");
assert_eq!(
read_bond_state(&pool, taker_bond.id).await,
BondState::Released.to_string(),
"taker bond is released when the slash gate is closed"
);
assert_eq!(
read_bond_state(&pool, maker_bond.id).await,
BondState::Locked.to_string(),
"maker bond is retained on republish even when no slash happens"
);
}
#[tokio::test]
async fn timeout_cancel_releases_maker_bond_sell_order() {
let pool = setup_pool().await;
let order = waiting_order(Kind::Sell, maker_pk(), taker_pk(), Status::WaitingPayment);
insert_order_row(&pool, &order).await;
let maker_bond = insert_bond_with_role(
&pool,
order.id,
maker_pk(),
BondRole::Maker,
BondState::Locked,
)
.await;
let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
let cfg = timeout_cfg(true, false, BondApplyTo::Both);
let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg))
.await
.unwrap();
assert!(result.is_none());
assert!(ln.calls().is_empty());
assert_eq!(
read_bond_state(&pool, maker_bond.id).await,
BondState::Released.to_string(),
"maker bond is released when the order is cancelled (terminal)"
);
assert_eq!(
read_bond_state(&pool, taker_bond.id).await,
BondState::Released.to_string(),
"taker bond is released too on a terminal cancel"
);
}
#[tokio::test]
async fn timeout_slash_buy_seller_silent_slashes_taker_bond() {
let pool = setup_pool().await;
let order = waiting_order(Kind::Buy, taker_pk(), maker_pk(), Status::WaitingPayment);
insert_order_row(&pool, &order).await;
let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
let cfg = timeout_cfg(true, true, BondApplyTo::Take);
let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg))
.await
.unwrap();
assert_eq!(result.map(|b| b.id), Some(bond.id));
assert_eq!(ln.calls(), vec![stub_preimage()]);
assert_eq!(
read_bond_state(&pool, bond.id).await,
BondState::PendingPayout.to_string()
);
}
#[tokio::test]
async fn timeout_no_slash_when_responsible_party_is_maker_sell_order() {
let pool = setup_pool().await;
let order = waiting_order(Kind::Sell, maker_pk(), taker_pk(), Status::WaitingPayment);
insert_order_row(&pool, &order).await;
let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
let cfg = timeout_cfg(true, true, BondApplyTo::Take);
let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg))
.await
.unwrap();
assert!(
result.is_none(),
"maker-responsible row must not slash the taker's bond"
);
assert!(ln.calls().is_empty());
assert_eq!(
read_bond_state(&pool, bond.id).await,
BondState::Released.to_string()
);
}
#[tokio::test]
async fn timeout_no_slash_when_responsible_party_is_maker_buy_order() {
let pool = setup_pool().await;
let order = waiting_order(
Kind::Buy,
taker_pk(),
maker_pk(),
Status::WaitingBuyerInvoice,
);
insert_order_row(&pool, &order).await;
let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
let cfg = timeout_cfg(true, true, BondApplyTo::Take);
let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg))
.await
.unwrap();
assert!(result.is_none());
assert!(ln.calls().is_empty());
assert_eq!(
read_bond_state(&pool, bond.id).await,
BondState::Released.to_string()
);
}
#[tokio::test]
async fn timeout_slash_skipped_when_apply_to_make_only() {
let pool = setup_pool().await;
let order = waiting_order(
Kind::Sell,
maker_pk(),
taker_pk(),
Status::WaitingBuyerInvoice,
);
insert_order_row(&pool, &order).await;
let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
let cfg = timeout_cfg(true, true, BondApplyTo::Make);
let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg))
.await
.unwrap();
assert!(result.is_none());
assert!(ln.calls().is_empty());
assert_eq!(
read_bond_state(&pool, bond.id).await,
BondState::Released.to_string()
);
}
#[tokio::test]
async fn timeout_maker_responsible_slashes_maker_bond_sell_order() {
let pool = setup_pool().await;
let order = waiting_order(Kind::Sell, maker_pk(), taker_pk(), Status::WaitingPayment);
insert_order_row(&pool, &order).await;
let maker_bond = insert_bond_with_role(
&pool,
order.id,
maker_pk(),
BondRole::Maker,
BondState::Locked,
)
.await;
let mut ln = StubSettle::new();
let cfg = timeout_cfg(true, true, BondApplyTo::Make);
let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg))
.await
.unwrap();
assert_eq!(
result.map(|b| b.id),
Some(maker_bond.id),
"must report the slashed maker bond for notification"
);
assert_eq!(
ln.calls(),
vec![stub_preimage()],
"the non-range maker slash settles the HTLC inline"
);
let row: (String, Option<String>) =
sqlx::query_as("SELECT state, slashed_reason FROM bonds WHERE id = ?")
.bind(maker_bond.id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row.0, BondState::PendingPayout.to_string());
assert_eq!(row.1.as_deref(), Some("timeout"));
}
#[tokio::test]
async fn timeout_maker_responsible_buy_order_slashes_maker_and_releases_taker() {
let pool = setup_pool().await;
let order = waiting_order(
Kind::Buy,
taker_pk(),
maker_pk(),
Status::WaitingBuyerInvoice,
);
insert_order_row(&pool, &order).await;
let maker_bond = insert_bond_with_role(
&pool,
order.id,
maker_pk(),
BondRole::Maker,
BondState::Locked,
)
.await;
let taker_bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
let cfg = timeout_cfg(true, true, BondApplyTo::Both);
let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg))
.await
.unwrap();
assert_eq!(result.map(|b| b.id), Some(maker_bond.id));
assert_eq!(
read_bond_state(&pool, maker_bond.id).await,
BondState::PendingPayout.to_string(),
"maker bond slashed on maker-responsible timeout"
);
assert_eq!(
read_bond_state(&pool, taker_bond.id).await,
BondState::Released.to_string(),
"the non-responsible taker's bond is released on the terminal cancel"
);
assert_eq!(
ln.calls(),
vec![stub_preimage()],
"only the slashed maker HTLC is settled"
);
}
#[tokio::test]
async fn timeout_maker_slash_skipped_when_apply_to_take_only() {
let pool = setup_pool().await;
let order = waiting_order(Kind::Sell, maker_pk(), taker_pk(), Status::WaitingPayment);
insert_order_row(&pool, &order).await;
let maker_bond = insert_bond_with_role(
&pool,
order.id,
maker_pk(),
BondRole::Maker,
BondState::Locked,
)
.await;
let mut ln = StubSettle::new();
let cfg = timeout_cfg(true, true, BondApplyTo::Take);
let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg))
.await
.unwrap();
assert!(result.is_none());
assert!(ln.calls().is_empty());
assert_eq!(
read_bond_state(&pool, maker_bond.id).await,
BondState::Released.to_string()
);
}
#[tokio::test]
async fn timeout_range_maker_slash_records_child_and_keeps_parent_locked() {
let pool = setup_pool().await;
let mut root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100);
root.status = Status::WaitingPayment.to_string();
insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
let taker_bond = insert_bond(&pool, root.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
let cfg = timeout_cfg(true, true, BondApplyTo::Both);
let result = slash_or_release_on_timeout(&pool, &mut ln, &root, Some(&cfg))
.await
.unwrap();
let child = result.expect("range maker timeout must report the child slash row");
assert_eq!(child.parent_bond_id, Some(parent.id));
assert_eq!(child.child_order_id, Some(root.id));
assert_eq!(child.amount_sats, 400, "proportional slice share");
assert_eq!(child.state, BondState::PendingPayout.to_string());
assert_eq!(child.slashed_reason.as_deref(), Some("timeout"));
assert_eq!(child.pubkey, maker_pk(), "the maker is the slashed party");
assert!(
ln.calls().is_empty(),
"the parent HTLC must NOT be settled mid-range (settle-at-close)"
);
let parent_row = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap();
assert_eq!(parent_row.state, BondState::Locked.to_string());
assert_eq!(parent_row.slashed_share_sats, 400);
assert_eq!(
read_bond_state(&pool, taker_bond.id).await,
BondState::Released.to_string(),
"taker bond released on the terminal cancel"
);
}
#[tokio::test]
async fn timeout_range_maker_slash_resolves_root_bond_from_descendant_slice() {
let pool = setup_pool().await;
let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 0, 10, 100);
insert_range_order_row(&pool, &root).await;
let mut slice = range_slice(Kind::Sell, maker_pk(), taker_pk(), 25, 10, 100);
slice.status = Status::WaitingPayment.to_string();
slice.range_parent_id = Some(root.id);
insert_range_order_row(&pool, &slice).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
let mut ln = StubSettle::new();
let cfg = timeout_cfg(true, true, BondApplyTo::Make);
let result = slash_or_release_on_timeout(&pool, &mut ln, &slice, Some(&cfg))
.await
.unwrap();
let child = result.expect("slice timeout must resolve the root's maker bond");
assert_eq!(child.parent_bond_id, Some(parent.id));
assert_eq!(child.child_order_id, Some(slice.id));
assert_eq!(child.amount_sats, 250, "25/100 of the 1000-sat bond");
assert!(ln.calls().is_empty());
assert_eq!(
read_bond_state(&pool, parent.id).await,
BondState::Locked.to_string()
);
}
#[tokio::test]
async fn timeout_range_maker_slash_is_idempotent_per_slice() {
let pool = setup_pool().await;
let mut root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100);
root.status = Status::WaitingPayment.to_string();
insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
let inserted = record_maker_slice_slash(
&pool,
&root,
&root,
&parent,
BondSlashReason::LostDispute,
0.0,
)
.await
.unwrap();
assert!(inserted, "fixture insert must land");
let mut ln = StubSettle::new();
let cfg = timeout_cfg(true, true, BondApplyTo::Make);
let result = slash_or_release_on_timeout(&pool, &mut ln, &root, Some(&cfg))
.await
.unwrap();
assert!(
result.is_none(),
"an already-slashed slice must not re-notify"
);
assert!(ln.calls().is_empty());
let children = find_child_slashes_for_parent(&pool, parent.id)
.await
.unwrap();
assert_eq!(children.len(), 1, "no duplicate child row");
assert_eq!(
children[0].slashed_reason.as_deref(),
Some("lost-dispute"),
"the original slash row is untouched"
);
}
#[tokio::test]
async fn timeout_no_config_releases_bond() {
let pool = setup_pool().await;
let order = waiting_order(
Kind::Sell,
maker_pk(),
taker_pk(),
Status::WaitingBuyerInvoice,
);
insert_order_row(&pool, &order).await;
let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
let result = slash_or_release_on_timeout(&pool, &mut ln, &order, None)
.await
.unwrap();
assert!(result.is_none());
assert!(ln.calls().is_empty());
assert_eq!(
read_bond_state(&pool, bond.id).await,
BondState::Released.to_string()
);
}
#[tokio::test]
async fn timeout_slash_transient_settle_failure_leaves_bond_locked_and_no_notice() {
let pool = setup_pool().await;
let order = waiting_order(
Kind::Sell,
maker_pk(),
taker_pk(),
Status::WaitingBuyerInvoice,
);
insert_order_row(&pool, &order).await;
let bond = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
let mut ln = StubSettle::new();
ln.fail_next_with("code=Unavailable: connection refused");
let cfg = timeout_cfg(true, true, BondApplyTo::Take);
let result = slash_or_release_on_timeout(&pool, &mut ln, &order, Some(&cfg))
.await
.unwrap();
assert!(
result.is_none(),
"an unconfirmed slash must not be reported to the caller"
);
assert_eq!(
read_bond_state(&pool, bond.id).await,
BondState::Locked.to_string(),
"transient settle failure must leave the bond Locked for retry"
);
}
#[tokio::test]
async fn timeout_slash_confirmed_survives_concurrent_payout_progression() {
let pool = setup_pool().await;
let order = waiting_order(
Kind::Sell,
maker_pk(),
taker_pk(),
Status::WaitingBuyerInvoice,
);
insert_order_row(&pool, &order).await;
for state in [
BondState::PendingPayout,
BondState::Slashed,
BondState::Forfeited,
BondState::Failed,
] {
let bond = insert_bond(&pool, order.id, taker_pk(), state).await;
sqlx::query("UPDATE bonds SET slashed_reason = ? WHERE id = ?")
.bind(BondSlashReason::Timeout.to_string())
.bind(bond.id)
.execute(&pool)
.await
.unwrap();
assert!(
timeout_slash_confirmed(&pool, bond.id).await.unwrap(),
"post-slash state {state:?} with slashed_reason=Timeout must confirm the slash"
);
}
let locked = insert_bond(&pool, order.id, taker_pk(), BondState::Locked).await;
assert!(
!timeout_slash_confirmed(&pool, locked.id).await.unwrap(),
"a Locked bond with no slash metadata must not confirm"
);
let dispute = insert_bond(&pool, order.id, taker_pk(), BondState::PendingPayout).await;
sqlx::query("UPDATE bonds SET slashed_reason = ? WHERE id = ?")
.bind(BondSlashReason::LostDispute.to_string())
.bind(dispute.id)
.execute(&pool)
.await
.unwrap();
assert!(
!timeout_slash_confirmed(&pool, dispute.id).await.unwrap(),
"a LostDispute slash must not confirm as a timeout slash"
);
}
#[tokio::test]
async fn notify_bond_slashed_targets_the_slashed_user() {
use crate::config::MESSAGE_QUEUES;
let pool = setup_pool().await;
let order = waiting_order(
Kind::Sell,
maker_pk(),
taker_pk(),
Status::WaitingBuyerInvoice,
);
insert_order_row(&pool, &order).await;
let bond = insert_bond(&pool, order.id, taker_pk(), BondState::PendingPayout).await;
notify_bond_slashed(&order, &bond).await;
let recipients: Vec<String> = MESSAGE_QUEUES
.queue_order_msg
.read()
.await
.iter()
.filter(|(m, _)| {
let k = m.get_inner_message_kind();
k.id == Some(order.id) && k.action == Action::BondSlashed
})
.map(|(_, pk)| pk.to_string())
.collect();
assert_eq!(
recipients,
vec![taker_pk().to_string()],
"BondSlashed must be enqueued to the slashed taker only"
);
}
use crate::app::bond::db::{
find_bond_by_id, find_child_slashes_for_parent, find_maker_bond_for_order,
find_range_root_order,
};
async fn insert_range_order_row(pool: &Pool<Sqlite>, order: &Order) {
sqlx::query(
r#"INSERT INTO orders (
id, kind, event_id, status, premium, payment_method,
amount, fiat_code, fiat_amount, min_amount, max_amount,
range_parent_id, created_at, expires_at, seller_pubkey, buyer_pubkey
) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
)
.bind(order.id)
.bind(&order.kind)
.bind(order.id.simple().to_string())
.bind(&order.status)
.bind(&order.payment_method)
.bind(order.amount)
.bind(&order.fiat_code)
.bind(order.fiat_amount)
.bind(order.min_amount)
.bind(order.max_amount)
.bind(order.range_parent_id)
.bind(order.created_at)
.bind(order.expires_at)
.bind(order.seller_pubkey.as_deref())
.bind(order.buyer_pubkey.as_deref())
.execute(pool)
.await
.expect("insert range order");
}
fn range_slice(
kind: Kind,
seller_pk: &str,
buyer_pk: &str,
fiat_amount: i64,
min: i64,
max: i64,
) -> Order {
let mut o = fixture_order(kind, seller_pk, buyer_pk);
o.amount = 0;
o.fiat_amount = fiat_amount;
o.min_amount = Some(min);
o.max_amount = Some(max);
o
}
async fn insert_parent_maker_bond(
pool: &Pool<Sqlite>,
order_id: Uuid,
pubkey: &str,
amount_sats: i64,
) -> Bond {
let mut b = Bond::new_requested(order_id, pubkey.to_string(), BondRole::Maker, amount_sats);
b.state = BondState::Locked.to_string();
b.preimage = Some(stub_preimage());
b.hash = None;
sqlx_crud::Crud::create(b.clone(), pool).await.unwrap();
b
}
#[tokio::test]
async fn record_maker_slice_slash_is_proportional() {
let pool = setup_pool().await;
let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100);
insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
record_maker_slice_slash(
&pool,
&root,
&root,
&parent,
BondSlashReason::LostDispute,
0.5,
)
.await
.unwrap();
let children = find_child_slashes_for_parent(&pool, parent.id)
.await
.unwrap();
assert_eq!(children.len(), 1);
let c = &children[0];
assert_eq!(c.amount_sats, 400, "proportional slice share");
assert_eq!(c.node_share_sats, Some(200));
assert_eq!(c.state, BondState::PendingPayout.to_string());
assert_eq!(c.parent_bond_id, Some(parent.id));
assert_eq!(c.child_order_id, Some(root.id));
assert_eq!(c.order_id, root.id);
assert_eq!(c.pubkey, maker_pk());
assert!(c.slashed_at.is_some());
assert!(c.preimage.is_none(), "child shares the parent HTLC");
let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap();
assert_eq!(p.slashed_share_sats, 400);
assert_eq!(p.state, BondState::Locked.to_string());
}
#[tokio::test]
async fn record_maker_slice_slash_clamps_cumulative_to_bond() {
let pool = setup_pool().await;
let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 80, 10, 100);
insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
record_maker_slice_slash(
&pool,
&root,
&root,
&parent,
BondSlashReason::LostDispute,
0.0,
)
.await
.unwrap();
let parent = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap();
let slice2 = range_slice(Kind::Sell, maker_pk(), taker_pk(), 80, 10, 100);
insert_range_order_row(&pool, &slice2).await;
record_maker_slice_slash(
&pool,
&slice2,
&root,
&parent,
BondSlashReason::LostDispute,
0.0,
)
.await
.unwrap();
let children = find_child_slashes_for_parent(&pool, parent.id)
.await
.unwrap();
let total: i64 = children.iter().map(|c| c.amount_sats).sum();
assert_eq!(total, 1000, "cumulative slash clamped to the bond amount");
}
#[tokio::test]
async fn range_close_no_slashes_releases_maker_bond() {
let pool = setup_pool().await;
let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100);
insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
let stub = StubSettle::new();
resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root)
.await
.unwrap();
assert!(
stub.calls().is_empty(),
"no slice was ever slashed → the HTLC must be cancelled, not settled"
);
let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap();
assert_eq!(p.state, BondState::Released.to_string());
assert!(find_child_slashes_for_parent(&pool, parent.id)
.await
.unwrap()
.is_empty());
}
#[tokio::test]
async fn range_close_with_one_slash_settles_once_and_refunds_remainder() {
let pool = setup_pool().await;
let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100);
insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
record_maker_slice_slash(
&pool,
&root,
&root,
&parent,
BondSlashReason::LostDispute,
0.5,
)
.await
.unwrap();
let stub = StubSettle::new();
resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root)
.await
.unwrap();
assert_eq!(stub.calls(), vec![stub_preimage()]);
let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap();
assert_eq!(p.state, BondState::Slashed.to_string());
let children = find_child_slashes_for_parent(&pool, parent.id)
.await
.unwrap();
assert_eq!(children.len(), 2, "slice slash + maker refund row");
let refund = children
.iter()
.find(|c| c.child_order_id.is_none())
.expect("a maker-refund row");
assert_eq!(refund.amount_sats, 600, "1000 bond - 400 slashed");
assert_eq!(refund.node_share_sats, Some(0), "full refund to the maker");
assert_eq!(refund.pubkey, maker_pk());
assert_eq!(refund.order_id, root.id);
assert_eq!(refund.state, BondState::PendingPayout.to_string());
resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root)
.await
.unwrap();
assert_eq!(stub.calls().len(), 1, "no second settle");
assert_eq!(
find_child_slashes_for_parent(&pool, parent.id)
.await
.unwrap()
.len(),
2,
"no duplicate refund row"
);
}
#[tokio::test]
async fn range_close_crash_after_settle_is_resumed() {
let pool = setup_pool().await;
let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100);
insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
record_maker_slice_slash(
&pool,
&root,
&root,
&parent,
BondSlashReason::LostDispute,
0.5,
)
.await
.unwrap();
let slice_before = find_child_slashes_for_parent(&pool, parent.id)
.await
.unwrap()
.into_iter()
.find(|c| c.child_order_id.is_some())
.expect("slice child");
let original_slashed_at = slice_before.slashed_at;
sqlx::query(
"CREATE TRIGGER bond_refund_fail BEFORE INSERT ON bonds \
WHEN NEW.parent_bond_id IS NOT NULL AND NEW.child_order_id IS NULL \
BEGIN SELECT RAISE(ABORT, 'injected refund insert failure'); END",
)
.execute(&pool)
.await
.unwrap();
let stub = StubSettle::new();
let err = resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root).await;
assert!(
err.is_err(),
"a failed close transaction must surface as Err"
);
assert_eq!(stub.calls(), vec![stub_preimage()], "settle attempted once");
let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap();
assert_eq!(
p.state,
BondState::Locked.to_string(),
"parent must remain Locked so the sweep retries it"
);
let children = find_child_slashes_for_parent(&pool, parent.id)
.await
.unwrap();
assert_eq!(children.len(), 1, "no refund row was written");
assert!(children.iter().all(|c| c.child_order_id.is_some()));
let slice_mid = children
.iter()
.find(|c| c.child_order_id.is_some())
.unwrap();
assert_eq!(
slice_mid.slashed_at, original_slashed_at,
"the slice claim window must not re-anchor until the close commits"
);
sqlx::query("DROP TRIGGER bond_refund_fail")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"UPDATE bonds SET slashed_at = 1000000 \
WHERE parent_bond_id = ? AND child_order_id IS NOT NULL",
)
.bind(parent.id)
.execute(&pool)
.await
.unwrap();
let before_close = Utc::now().timestamp();
stub.fail_next_with("invoice already settled");
resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root)
.await
.unwrap();
assert_eq!(stub.calls().len(), 2, "settle re-attempted on retry");
let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap();
assert_eq!(p.state, BondState::Slashed.to_string());
let children = find_child_slashes_for_parent(&pool, parent.id)
.await
.unwrap();
assert_eq!(children.len(), 2, "exactly one refund row after the retry");
let refund = children
.iter()
.find(|c| c.child_order_id.is_none())
.expect("maker refund row");
assert_eq!(refund.amount_sats, 600);
assert_eq!(refund.node_share_sats, Some(0));
let slice_after = children
.iter()
.find(|c| c.child_order_id.is_some())
.unwrap();
assert!(
slice_after.slashed_at.unwrap() >= before_close,
"slice claim window re-anchored at close time once committed (got {:?})",
slice_after.slashed_at
);
}
#[tokio::test]
async fn settle_already_settled_is_treated_as_success() {
let pool = setup_pool().await;
let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100);
insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
record_maker_slice_slash(
&pool,
&root,
&root,
&parent,
BondSlashReason::LostDispute,
0.5,
)
.await
.unwrap();
let stub = StubSettle::new();
stub.fail_next_with("invoice already settled");
resolve_range_maker_bond_at_close(&pool, &mut stub.clone(), &root)
.await
.unwrap();
assert_eq!(stub.calls(), vec![stub_preimage()], "settle attempted once");
let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap();
assert_eq!(p.state, BondState::Slashed.to_string());
let refund = find_child_slashes_for_parent(&pool, parent.id)
.await
.unwrap()
.into_iter()
.find(|c| c.child_order_id.is_none())
.expect("maker refund row");
assert_eq!(refund.amount_sats, 600);
}
#[tokio::test]
async fn apply_range_maker_slash_records_child_without_settling() {
let pool = setup_pool().await;
let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100);
insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
let res = BondResolution {
slash_seller: true,
slash_buyer: false,
};
let stub = StubSettle::new();
apply_bond_resolution(
&pool,
&mut stub.clone(),
&root,
&res,
BondSlashReason::LostDispute,
)
.await
.unwrap();
assert!(
stub.calls().is_empty(),
"a range maker slash records a child row but must NOT settle inline"
);
let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap();
assert_eq!(p.state, BondState::Locked.to_string());
let children = find_child_slashes_for_parent(&pool, parent.id)
.await
.unwrap();
assert_eq!(children.len(), 1);
assert_eq!(children[0].amount_sats, 400);
assert_eq!(children[0].child_order_id, Some(root.id));
}
#[tokio::test]
async fn maker_bond_resolves_from_descendant_slice() {
let pool = setup_pool().await;
let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 30, 10, 100);
insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
let mut c1 = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 70);
c1.range_parent_id = Some(root.id);
insert_range_order_row(&pool, &c1).await;
let found = find_maker_bond_for_order(&pool, &c1)
.await
.unwrap()
.expect("maker bond resolved via the range root");
assert_eq!(found.id, parent.id);
let resolved_root = find_range_root_order(&pool, c1.clone()).await.unwrap();
assert_eq!(resolved_root.id, root.id);
}
#[tokio::test]
async fn range_close_reanchors_slice_child_claim_window() {
let pool = setup_pool().await;
let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100);
insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
record_maker_slice_slash(
&pool,
&root,
&root,
&parent,
BondSlashReason::LostDispute,
0.5,
)
.await
.unwrap();
sqlx::query(
"UPDATE bonds SET slashed_at = ? WHERE parent_bond_id = ? AND child_order_id IS NOT NULL",
)
.bind(1_000_000i64)
.bind(parent.id)
.execute(&pool)
.await
.unwrap();
let before_close = Utc::now().timestamp();
resolve_range_maker_bond_at_close(&pool, &mut StubSettle::new(), &root)
.await
.unwrap();
let children = find_child_slashes_for_parent(&pool, parent.id)
.await
.unwrap();
let slice = children
.iter()
.find(|c| c.child_order_id.is_some())
.expect("slice child");
assert!(
slice.slashed_at.unwrap() >= before_close,
"slice child claim window must re-anchor at close time, got {:?}",
slice.slashed_at
);
}
#[tokio::test]
async fn record_maker_slice_slash_is_idempotent_per_slice() {
let pool = setup_pool().await;
let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100);
insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
record_maker_slice_slash(
&pool,
&root,
&root,
&parent,
BondSlashReason::LostDispute,
0.5,
)
.await
.unwrap();
let parent = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap();
record_maker_slice_slash(
&pool,
&root,
&root,
&parent,
BondSlashReason::LostDispute,
0.5,
)
.await
.unwrap();
let children = find_child_slashes_for_parent(&pool, parent.id)
.await
.unwrap();
assert_eq!(children.len(), 1, "the slice must be slashed exactly once");
assert_eq!(children[0].amount_sats, 400);
let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap();
assert_eq!(p.slashed_share_sats, 400, "share must not double on replay");
}
#[tokio::test]
async fn record_maker_slice_slash_skipped_once_parent_left_locked() {
let pool = setup_pool().await;
let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100);
insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
sqlx::query("UPDATE bonds SET state = ? WHERE id = ?")
.bind(BondState::Slashed.to_string())
.bind(parent.id)
.execute(&pool)
.await
.unwrap();
record_maker_slice_slash(
&pool,
&root,
&root,
&parent,
BondSlashReason::LostDispute,
0.5,
)
.await
.unwrap();
let children = find_child_slashes_for_parent(&pool, parent.id)
.await
.unwrap();
assert!(
children.is_empty(),
"no child row may be inserted once the parent has left Locked"
);
}
#[tokio::test]
async fn slice_slash_unique_index_rejects_forced_duplicate() {
let pool = setup_pool().await;
let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100);
insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
record_maker_slice_slash(
&pool,
&root,
&root,
&parent,
BondSlashReason::LostDispute,
0.5,
)
.await
.unwrap();
let now = Utc::now().timestamp();
let forced = sqlx::query(
"INSERT INTO bonds \
(id, order_id, parent_bond_id, child_order_id, pubkey, role, \
amount_sats, state, created_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(Uuid::new_v4())
.bind(root.id)
.bind(parent.id)
.bind(root.id)
.bind(maker_pk())
.bind(BondRole::Maker.to_string())
.bind(123)
.bind(BondState::PendingPayout.to_string())
.bind(now)
.execute(&pool)
.await;
let err = forced.expect_err("duplicate child slash must violate the unique index");
assert!(
is_unique_violation(&err),
"expected a unique-constraint violation, got {err:?}"
);
let refund = sqlx::query(
"INSERT INTO bonds \
(id, order_id, parent_bond_id, child_order_id, pubkey, role, \
amount_sats, state, created_at) \
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?)",
)
.bind(Uuid::new_v4())
.bind(root.id)
.bind(parent.id)
.bind(maker_pk())
.bind(BondRole::Maker.to_string())
.bind(456)
.bind(BondState::PendingPayout.to_string())
.bind(now)
.execute(&pool)
.await;
assert!(
refund.is_ok(),
"a maker-refund row (child_order_id NULL) must be unconstrained: {refund:?}"
);
}
#[tokio::test]
async fn reconcile_sweep_retries_stranded_locked_parent_after_failed_close() {
let pool = setup_pool().await;
let mut root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100);
root.status = Status::CooperativelyCanceled.to_string();
insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
record_maker_slice_slash(
&pool,
&root,
&root,
&parent,
BondSlashReason::LostDispute,
0.5,
)
.await
.unwrap();
let failing = StubSettle::new();
failing.fail_next_with("transient lnd transport error");
resolve_range_maker_bond_at_close(&pool, &mut failing.clone(), &root)
.await
.unwrap();
let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap();
assert_eq!(
p.state,
BondState::Locked.to_string(),
"a failed settle must leave the parent retryably Locked"
);
let healthy = StubSettle::new();
let resolved = reconcile_stranded_range_maker_bonds_with(&pool, &mut healthy.clone()).await;
assert_eq!(resolved, 1, "exactly one stranded parent resolved");
assert_eq!(healthy.calls(), vec![stub_preimage()], "HTLC settled once");
let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap();
assert_eq!(
p.state,
BondState::Slashed.to_string(),
"the sweep drives the parent to Slashed"
);
let children = find_child_slashes_for_parent(&pool, parent.id)
.await
.unwrap();
assert_eq!(children.len(), 2, "slice slash + maker refund row");
assert!(children
.iter()
.all(|c| c.state == BondState::PendingPayout.to_string()));
}
#[tokio::test]
async fn reconcile_sweep_skips_open_range() {
let pool = setup_pool().await;
let mut root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100);
root.status = Status::Active.to_string(); insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
let stub = StubSettle::new();
let resolved = reconcile_stranded_range_maker_bonds_with(&pool, &mut stub.clone()).await;
assert_eq!(resolved, 0, "an open range must not be swept");
assert!(stub.calls().is_empty(), "no HTLC touched");
let p = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap();
assert_eq!(p.state, BondState::Locked.to_string());
}
#[tokio::test]
async fn reconcile_sweep_isolates_a_bad_root_and_processes_the_rest() {
let pool = setup_pool().await;
let orphan_order_id = Uuid::new_v4();
sqlx::query("PRAGMA foreign_keys = OFF")
.execute(&pool)
.await
.unwrap();
let bad = insert_parent_maker_bond(&pool, orphan_order_id, maker_pk(), 1000).await;
sqlx::query("PRAGMA foreign_keys = ON")
.execute(&pool)
.await
.unwrap();
let mut good = range_slice(Kind::Sell, maker_pk(), taker_pk(), 40, 10, 100);
good.status = Status::CooperativelyCanceled.to_string();
insert_range_order_row(&pool, &good).await;
let good_parent = insert_parent_maker_bond(&pool, good.id, maker_pk(), 1000).await;
record_maker_slice_slash(
&pool,
&good,
&good,
&good_parent,
BondSlashReason::LostDispute,
0.5,
)
.await
.unwrap();
let stub = StubSettle::new();
let resolved = reconcile_stranded_range_maker_bonds_with(&pool, &mut stub.clone()).await;
assert_eq!(resolved, 1, "the valid stranded root must still resolve");
let gp = find_bond_by_id(&pool, good_parent.id)
.await
.unwrap()
.unwrap();
assert_eq!(gp.state, BondState::Slashed.to_string());
let bp = find_bond_by_id(&pool, bad.id).await.unwrap().unwrap();
assert_eq!(bp.state, BondState::Locked.to_string());
}
#[tokio::test]
async fn range_close_conserves_sats_across_two_slices() {
let pool = setup_pool().await;
let root = range_slice(Kind::Sell, maker_pk(), taker_pk(), 2, 1, 7);
insert_range_order_row(&pool, &root).await;
let parent = insert_parent_maker_bond(&pool, root.id, maker_pk(), 1000).await;
let node_pct = 0.3;
record_maker_slice_slash(
&pool,
&root,
&root,
&parent,
BondSlashReason::LostDispute,
node_pct,
)
.await
.unwrap();
let parent = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap();
let slice2 = range_slice(Kind::Sell, maker_pk(), taker_pk(), 3, 1, 7);
insert_range_order_row(&pool, &slice2).await;
record_maker_slice_slash(
&pool,
&slice2,
&root,
&parent,
BondSlashReason::LostDispute,
node_pct,
)
.await
.unwrap();
resolve_range_maker_bond_at_close(&pool, &mut StubSettle::new(), &root)
.await
.unwrap();
let parent = find_bond_by_id(&pool, parent.id).await.unwrap().unwrap();
let children = find_child_slashes_for_parent(&pool, parent.id)
.await
.unwrap();
assert_eq!(children.len(), 3, "two slices + maker refund");
let total_child_sats: i64 = children.iter().map(|c| c.amount_sats).sum();
assert_eq!(
total_child_sats, parent.amount_sats,
"Σ child amount_sats (slices + refund) must equal the parent bond exactly"
);
let node_retention: i64 = children
.iter()
.map(|c| c.node_share_sats.unwrap_or(0))
.sum();
let counterparty_total: i64 = children
.iter()
.map(|c| c.amount_sats - c.node_share_sats.unwrap_or(0))
.sum();
assert_eq!(
node_retention + counterparty_total,
parent.amount_sats,
"no sat created or lost: node retention + counterparty payouts == bond"
);
let refund = children
.iter()
.find(|c| c.child_order_id.is_none())
.expect("maker refund row");
assert_eq!(refund.node_share_sats, Some(0));
let slice_slash_total: i64 = children
.iter()
.filter(|c| c.child_order_id.is_some())
.map(|c| c.amount_sats)
.sum();
assert_eq!(
refund.amount_sats,
parent.amount_sats - slice_slash_total,
"refund = bond - Σ slice slashes (absorbs the rounding remainder)"
);
}
}