Skip to content
Merged
59 changes: 46 additions & 13 deletions docs/ANTI_ABUSE_BOND.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,24 +79,24 @@ The issue proposes three phases. We split them further so each PR is small
enough to review without a marathon session. Data-model and payout plumbing
come early (Phase 0 & 3) and are reused by every subsequent slash path.

| Phase | PR scope | Depends on |
|------:|----------|------------|
| 0 | Foundation: config schema, `bonds` table, pure helpers, types | — |
| 1 | Taker bond lifecycle: **lock + always release** (no slashing yet) | 0 |
| 2 | Taker bond: slash on **lost dispute** | 1 |
| 3 | Payout flow: `add-invoice` to winner, routing-fee estimation, retries, audit event | 2 |
| 4 | Taker bond: slash on **timeout** (apply_to=take, slash_on_waiting_timeout) | 3 |
| 5 | Maker bond (non-range): lock + dispute slash reusing phase 3 payout | 3 |
| 6 | Maker bond for **range orders** with proportional slashes | 5 |
| 7 | Maker bond: slash on **timeout** | 5 |
| 8 | Public config exposure (Mostro info event) + operator docs polish | 7 |
| Phase | PR scope | Depends on | Status |
|------:|----------|------------|--------|
| 0 | Foundation: config schema, `bonds` table, pure helpers, types | — | ✅ shipped (PR #712) |
| 1 | Taker bond lifecycle: **lock + always release** (no slashing yet) | 0 | ✅ shipped |
| 2 | Taker bond: slash on **lost dispute** | 1 | pending |
| 3 | Payout flow: `add-invoice` to winner, routing-fee estimation, retries, audit event | 2 | pending |
| 4 | Taker bond: slash on **timeout** (apply_to=take, slash_on_waiting_timeout) | 3 | pending |
| 5 | Maker bond (non-range): lock + dispute slash reusing phase 3 payout | 3 | pending |
| 6 | Maker bond for **range orders** with proportional slashes | 5 | pending |
| 7 | Maker bond: slash on **timeout** | 5 | pending |
| 8 | Public config exposure (Mostro info event) + operator docs polish | 7 | pending |

Phases 4, 5, 6, 7 can partially overlap in time but must land in this order on
`main` to keep review scope honest.

---

## 5. Phase 0 — Foundation
## 5. Phase 0 — Foundation ✅ Completed

Purely additive. Touches no trade flow.

Expand Down Expand Up @@ -183,12 +183,45 @@ Purely additive. Touches no trade flow.

---

## 6. Phase 1 — Taker bond: lock + always release
## 6. Phase 1 — Taker bond: lock + always release ✅ Completed

Wire the bond into the take flow but **never slash**. This lets operators
turn the feature on in staging to exercise hold-invoice custody with zero risk
to users.

**Implementation notes (as shipped):**

- The phase took the §6.2 "Alternative" path: orders stay in `Status::Pending`
while the bond is outstanding, and the bond bolt11 is delivered to the
taker via the existing `Action::PayInvoice` (the bond's payment hash
uniquely distinguishes it from the trade hold invoice that follows). The
dedicated `Status::WaitingTakerBond` / `Action::AddBondInvoice` will be
introduced in the matching `mostro-core` release alongside a later
phase, at which point this can be migrated transparently.
- Bond release is wired into every Phase 1 exit:
`release_action`, `cancel_action` (cooperative + unilateral, taker- and
maker-side, including pending-order maker cancels), `admin_settle_action`,
`admin_cancel_action`, and `scheduler::job_cancel_orders`. Slashing
hooks are intentionally absent and land in Phase 2+.
- `take_buy_action` / `take_sell_action` call
`bond::supersede_prior_taker_bonds` before persisting the new take.
A still-`Requested` prior bond is released (its hold invoice
cancelled) so a malicious taker can't keep an order in `Pending`
by abandoning the bond invoice — anyone may re-take and the first
bond to lock wins. A `Locked` prior bond is treated as committed
and the new take is rejected with `PendingOrderExists`.
- `cancel_action` recognises a bonded taker as authorised to cancel a
still-`Pending` order: when `event.sender` matches the `pubkey` of an
active bond on the order, the cancel routes through the existing
`cancel_order_by_taker` flow (release the bond, clear the taker
fields, republish the order). This lets a taker who took the order
but no longer wants to proceed back out cleanly instead of getting
`IsNotYourOrder`.
- On daemon startup, `bond::resubscribe_active_bonds` re-attaches LND
invoice subscribers for any bond rows still in `Requested` / `Locked`,
so a restart never strands a taker who paid the bond just before the
daemon went down.

### 6.1 Scope

- Gate `enabled && apply_to ∈ { "take", "both" }`. Otherwise existing code
Expand Down
5 changes: 5 additions & 0 deletions src/app/admin_cancel.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::borrow::Cow;
use std::str::FromStr;

use crate::app::bond;
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 @@ -199,5 +200,9 @@ 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;

Ok(())
}
5 changes: 5 additions & 0 deletions src/app/admin_settle.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::app::bond;
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 @@ -188,6 +189,10 @@ 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;

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

Ok(())
Expand Down
105 changes: 105 additions & 0 deletions src/app/bond/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,60 @@ pub async fn find_bonds_by_state(
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))
}

/// Look up a bond row by its Lightning payment hash. The hash uniquely
/// identifies the bond hold invoice, so this is what the LND subscriber
/// uses to correlate incoming invoice events back to a `Bond`.
pub async fn find_bond_by_hash(
pool: &Pool<Sqlite>,
hash: &str,
) -> Result<Option<Bond>, mostro_core::error::MostroError> {
sqlx::query_as::<_, Bond>("SELECT * FROM bonds WHERE hash = ? LIMIT 1")
.bind(hash)
.fetch_optional(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))
}

/// List every bond row that still has an outstanding LND HTLC, i.e. is in
/// `Requested` or `Locked`. Used on daemon startup to resubscribe to
/// in-flight bond hold invoices, and as the Phase 1 workhorse for the
/// "always release" exits — we filter further on `order_id` in
/// [`find_active_bonds_for_order`].
pub async fn find_active_bonds(
pool: &Pool<Sqlite>,
) -> Result<Vec<Bond>, mostro_core::error::MostroError> {
let requested = BondState::Requested.to_string();
let locked = BondState::Locked.to_string();
sqlx::query_as::<_, Bond>("SELECT * FROM bonds WHERE state IN (?, ?) ORDER BY created_at ASC")
.bind(requested)
.bind(locked)
.fetch_all(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))
}

/// List the still-outstanding bonds attached to a single order. Phase 1
/// uses this to release every bond on any order exit path (cancel,
/// release, admin actions, scheduler timeouts).
pub async fn find_active_bonds_for_order(
pool: &Pool<Sqlite>,
order_id: Uuid,
) -> Result<Vec<Bond>, mostro_core::error::MostroError> {
let requested = BondState::Requested.to_string();
let locked = BondState::Locked.to_string();
sqlx::query_as::<_, Bond>(
"SELECT * FROM bonds \
WHERE order_id = ? AND state IN (?, ?) \
ORDER BY created_at ASC",
)
.bind(order_id)
.bind(requested)
.bind(locked)
.fetch_all(pool)
.await
.map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))
}

/// Update a bond row by primary key. Returns the persisted `Bond`.
pub async fn update_bond(
pool: &Pool<Sqlite>,
Expand Down Expand Up @@ -176,6 +230,57 @@ mod tests {
assert!(res.is_none());
}

#[tokio::test]
async fn find_by_hash_returns_match() {
let pool = setup_pool().await;
let order_id = Uuid::new_v4();
insert_parent_order(&pool, order_id).await;

let mut bond = dummy_bond(order_id, BondRole::Taker);
bond.hash = Some("c".repeat(64));
let created = create_bond(&pool, bond).await.expect("insert");

let found = find_bond_by_hash(&pool, &"c".repeat(64))
.await
.expect("query")
.expect("row present");
assert_eq!(found.id, created.id);

let missing = find_bond_by_hash(&pool, &"f".repeat(64))
.await
.expect("query");
assert!(missing.is_none());
}

#[tokio::test]
async fn active_bonds_filter_terminal_states() {
let pool = setup_pool().await;
let order_a = Uuid::new_v4();
let order_b = Uuid::new_v4();
insert_parent_order(&pool, order_a).await;
insert_parent_order(&pool, order_b).await;
let bond_a = create_bond(&pool, dummy_bond(order_a, BondRole::Taker))
.await
.unwrap();
let bond_b = create_bond(&pool, dummy_bond(order_b, BondRole::Taker))
.await
.unwrap();

// Bond B → Released (terminal): must drop out of active set.
let mut released = bond_b.clone();
released.state = BondState::Released.to_string();
update_bond(&pool, released).await.unwrap();

let active = find_active_bonds(&pool).await.unwrap();
assert_eq!(active.len(), 1);
assert_eq!(active[0].id, bond_a.id);

let active_a = find_active_bonds_for_order(&pool, order_a).await.unwrap();
assert_eq!(active_a.len(), 1);
let active_b = find_active_bonds_for_order(&pool, order_b).await.unwrap();
assert!(active_b.is_empty());
}

#[tokio::test]
async fn find_by_state_filters() {
let pool = setup_pool().await;
Expand Down
Loading