diff --git a/docs/LIGHTNING_OPS.md b/docs/LIGHTNING_OPS.md index 04c2b9db..0e700f20 100644 --- a/docs/LIGHTNING_OPS.md +++ b/docs/LIGHTNING_OPS.md @@ -361,8 +361,12 @@ The daemon ships a **maintenance (drain) mode** for this. Full design in 127.0.0.1:50051 mostro.admin.v1.AdminService/GetMaintenanceStatus ``` - Meanwhile: pending orders expire on their own (or ask makers to cancel); - close long-running disputes with `AdminSettle` / `AdminCancel`; keep the + Meanwhile: pending orders expire on their own (or ask makers to cancel). + To shorten the drain, cancel them yourself — `CancelOrder` accepts a + `pending` order from the operator (`mostro-cli admcancel -o `) and + releases the maker's range bond at once; announce it first, it is the + user's order. Close long-running disputes with `AdminSettle` / + `AdminCancel`; keep the **old node online the whole time** — it also has to finish in-flight payouts and dev fees. 4. **Stop `mostrod`** and back up `mostro.db`. diff --git a/docs/MAINTENANCE_MODE_LN_MIGRATION.md b/docs/MAINTENANCE_MODE_LN_MIGRATION.md index f485a63d..71eef778 100644 --- a/docs/MAINTENANCE_MODE_LN_MIGRATION.md +++ b/docs/MAINTENANCE_MODE_LN_MIGRATION.md @@ -353,6 +353,7 @@ does not ask for it. | Client sends `NewOrder` / `TakeBuy` / `TakeSell` | `CantDo(MaintenanceMode)`; nothing persisted | | Client releases / cancels an escrowed order | unchanged | | Pending order reaches `expiration` | `job_expire_pending_older_orders` expires it as today | +| Operator sends `CancelOrder` for a `pending` / `waiting-taker-bond` order | `canceled-by-admin`; maker and bonded takers get `AdminCanceled`; taker bonds released, maker bond resolved at range close (`admin_cancel::admin_cancel_pending_order`) | | `waiting-payment` seller never pays | `job_cancel_orders` cancels the hold as today | | Dispute opened / resolved | unchanged; solver uses `AdminSettle`/`AdminCancel` | | Maker bond outstanding (`waiting-maker-bond`) | maker can still pay it; on payment the order is published as `pending` and simply cannot be taken | @@ -522,7 +523,9 @@ orders work and the guard rejects a switch back with open escrow. Verify the info event now shows `maintenance_mode = true`. 3. Poll `GetMaintenanceStatus` until `drained == true`. Meanwhile: - pending orders expire on their own, or the operator asks makers to - cancel; + cancel; to shorten the drain, cancel them yourself with `CancelOrder` + (`mostro-cli admcancel -o `), which releases the maker's range + bond at once — announce it first, it is the user's order; - long‑running disputes are closed with `AdminSettle` / `AdminCancel`; - keep the **old** node online the entire time — it also has to finish in‑flight payouts (B/E) and dev fees (C). @@ -591,9 +594,10 @@ needed it. ## 7. Open questions 1. Should `SetMaintenanceMode` optionally expire all `pending` orders in one - call (`expire_pending: bool`) to shorten the drain? Proposed answer: no - for v1 — `job_expire_pending_older_orders` and makers cancelling cover - it, and an explicit bulk expiry is easy to add later. + call (`expire_pending: bool`) to shorten the drain? Answered: no bulk + switch; instead `CancelOrder` accepts `pending` orders from the daemon + key (operator via gRPC), one order per call, so the operator decides + which orders to close and users get an `AdminCanceled` notice each. 2. Should the `maintenance_reason` be published in the info event? Proposed answer: no — free text from the operator in a public event is a footgun; clients can show a generic message. diff --git a/docs/RPC.md b/docs/RPC.md index 5bd60bb5..340ac884 100644 --- a/docs/RPC.md +++ b/docs/RPC.md @@ -30,7 +30,26 @@ The RPC interface supports the following admin operations: ### 1. Cancel Order -Cancel an order as an admin. +Cancel an order as an admin. Two cases are accepted: + +- An order in `dispute`: the assigned solver's resolution. The escrow hold + invoice is cancelled (funds return to the seller), the dispute is closed as + `seller-refunded`, both parties are notified, and the optional + `BondResolution` is applied. +- An order still pre-trade — `pending`, or `waiting-taker-bond` while a + taker is mid-bond — with no escrow: an **operator** cancel. + The row flips to `canceled-by-admin`, the maker and any bonded prospective + taker receive `AdminCanceled`, taker bonds are released and the maker bond + is resolved at range close (released, or settled once if a slice was + slashed). Only the daemon key is allowed — i.e. this RPC; a solver sending + `AdminCancel` over Nostr for a pending order is refused with + `NotAuthorized`. Use it to drain open range-order maker bonds ahead of a + Lightning node migration instead of waiting for `max_expiration_days` + (see `docs/MAINTENANCE_MODE_LN_MIGRATION.md` §5). A take that commits + concurrently wins: the cancel then fails with `NotAllowedByStatus` and the + order continues. + +Any other status is refused with `NotAllowedByStatus`. **Request:** diff --git a/src/app/admin_cancel.rs b/src/app/admin_cancel.rs index 7cf8e8be..a5f46b4b 100644 --- a/src/app/admin_cancel.rs +++ b/src/app/admin_cancel.rs @@ -17,7 +17,10 @@ use tracing::{error, info}; /// Admin-initiated order cancellation. /// /// Allows authorized dispute solvers or admins to cancel an order and refund -/// any held Lightning invoice back to the seller. +/// any held Lightning invoice back to the seller. A still-`Pending` order +/// (no taker, no escrow) may instead be cancelled by the operator through +/// the daemon key — the gRPC `CancelOrder` path — see +/// [`admin_cancel_pending_order`]. /// /// # Parameters /// @@ -40,6 +43,8 @@ use tracing::{error, info}; /// /// Returns `MostroError` if: /// - Solver is not assigned to the dispute +/// - A pre-trade (`Pending` / `WaitingTakerBond`) order is cancelled by +/// anything but the daemon key /// - Order/dispute not found /// - Lightning invoice cancellation fails /// - Database update fails @@ -56,6 +61,26 @@ pub async fn admin_cancel_action( let request_id = msg.get_inner_message_kind().request_id; // Get order let order = get_order(&msg, pool).await?; + + // Operator cancel of a pre-trade order (`Pending`, or parked at + // `WaitingTakerBond` while a taker is mid-bond — the same window the + // maker's own cancel covers, see `cancel::cancel_action_generic`). There + // is no dispute to be assigned to and no escrow to refund, so the solver + // gates below do not apply; instead only the daemon key may do this — + // which is exactly what the gRPC `CancelOrder` path synthesises as + // `identity`. A solver acting over Nostr never carries the daemon key and + // is refused. Used to drain the book (open range-order maker bonds) + // ahead of a Lightning node migration instead of waiting for + // `max_expiration_days`. + if order.check_status(Status::Pending).is_ok() + || order.check_status(Status::WaitingTakerBond).is_ok() + { + if event.identity != my_keys.public_key() { + return Err(MostroCantDo(CantDoReason::NotAuthorized)); + } + return admin_cancel_pending_order(pool, &order, my_keys, ln_client).await; + } + // Check if the solver is assigned to the order match is_assigned_solver(pool, &event.identity.to_string(), order.id).await { Ok(false) => { @@ -251,6 +276,89 @@ pub async fn admin_cancel_action( Ok(()) } +/// Operator cancel of a pre-trade (`Pending` / `WaitingTakerBond`) order +/// (see the gate in [`admin_cancel_action`]). Mirrors the maker's own pending cancel +/// (`cancel::cancel_pending_order_from_maker`): publish the replaceable +/// event with `CanceledByAdmin`, persist it with a pre-trade CAS so a take +/// that commits concurrently wins, then notify the maker and any bonded +/// prospective taker, release the taker bonds and resolve the maker bond at +/// range close. +async fn admin_cancel_pending_order( + pool: &sqlx::SqlitePool, + order: &Order, + my_keys: &Keys, + ln_client: &mut LndConnector, +) -> Result<(), MostroError> { + let order_updated = update_order_event(my_keys, Status::CanceledByAdmin, order) + .await + .map_err(|e| MostroInternalErr(ServiceError::DbAccessError(e.to_string())))?; + let won = crate::db::cas_pretrade_order_status( + pool, + order_updated.id, + Status::CanceledByAdmin, + &order_updated.event_id, + ) + .await?; + if !won { + // A take committed while we were publishing: the order has left the + // pre-trade window and now carries escrow. Put the winning state back + // on Nostr and tell the operator to look again. + crate::util::republish_winning_state_after_cas_miss(pool, my_keys, order_updated.id).await; + return Err(MostroCantDo(CantDoReason::NotAllowedByStatus)); + } + info!("Order Id {}: pending order cancelled by operator", order.id); + + let maker = order.get_creator_pubkey().map_err(MostroInternalErr)?; + enqueue_order_msg( + None, + Some(order.id), + Action::AdminCanceled, + None, + maker, + None, + ) + .await; + + // Prospective takers with a bond in flight must not keep waiting on an + // order that will never be taken. A lookup failure is logged, not + // propagated: the release below still runs. + match bond::db::find_active_bonds_for_order(pool, order.id).await { + Ok(active_bonds) => { + for active in active_bonds.iter() { + if let Ok(taker_pk) = PublicKey::from_str(&active.pubkey) { + if taker_pk != maker { + enqueue_order_msg( + None, + Some(order.id), + Action::AdminCanceled, + None, + taker_pk, + None, + ) + .await; + } + } + } + } + Err(err) => { + tracing::warn!( + order_id = %order.id, + "admin_cancel_pending: failed to look up active bonds for taker notification: {}", + err + ); + } + } + bond::release_taker_bonds_for_order_or_warn(pool, order.id, "admin_cancel_pending").await; + if let Err(e) = bond::resolve_range_maker_bond_at_close(pool, ln_client, order).await { + tracing::warn!( + order_id = %order.id, + "admin_cancel_pending: maker bond close failed: {}", e + ); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -626,4 +734,223 @@ mod tests { DisputeStatus::SellerRefunded.to_string() ); } + + // ── Operator cancel of a still-Pending order ───────────────────────── + + fn pending_sell_order(maker: PublicKey) -> Order { + Order { + id: uuid::Uuid::new_v4(), + status: Status::Pending.to_string(), + kind: mostro_core::order::Kind::Sell.to_string(), + fiat_code: "USD".to_string(), + creator_pubkey: maker.to_string(), + seller_pubkey: Some(maker.to_string()), + buyer_pubkey: None, + amount: 21_000, + fee: 210, + ..Default::default() + } + } + + /// The daemon key (what the gRPC `CancelOrder` path synthesises as + /// `identity`) may cancel a `Pending` order: no dispute, no escrow, + /// no counterparty. The row flips to `CanceledByAdmin` and the maker + /// is told via `AdminCanceled`. + #[tokio::test] + async fn daemon_key_cancels_pending_order_and_notifies_maker() { + let pool = setup_pool().await; + let ctx = build_ctx(pool.clone()); + let mut ln = dead_lnd().await; + let daemon = Keys::generate(); + let maker = Keys::generate().public_key(); + + let order = pending_sell_order(maker).create(ctx.pool()).await.unwrap(); + + admin_cancel_action( + &ctx, + cancel_msg(order.id), + &admin_event(daemon.public_key()), + &daemon, + &mut ln, + ) + .await + .expect("operator cancel of a pending order succeeds"); + + let stored = Order::by_id(ctx.pool(), order.id).await.unwrap().unwrap(); + assert_eq!(stored.status, Status::CanceledByAdmin.to_string()); + assert!( + queued_actions_for(maker) + .await + .contains(&Action::AdminCanceled), + "maker must be told their order was cancelled by the operator" + ); + } + + /// A solver (any identity other than the daemon key) has no business + /// cancelling a Pending order: there is no dispute to be assigned to. + /// Refused with `NotAuthorized`, and the row is untouched. + #[tokio::test] + async fn pending_cancel_refuses_non_daemon_identity() { + let pool = setup_pool().await; + let ctx = build_ctx(pool.clone()); + let mut ln = dead_lnd().await; + let daemon = Keys::generate(); + let solver = Keys::generate(); + let maker = Keys::generate().public_key(); + + let order = pending_sell_order(maker).create(ctx.pool()).await.unwrap(); + + let result = admin_cancel_action( + &ctx, + cancel_msg(order.id), + &admin_event(solver.public_key()), + &daemon, + &mut ln, + ) + .await; + + assert!(matches!( + result, + Err(MostroCantDo(CantDoReason::NotAuthorized)) + )); + let stored = Order::by_id(ctx.pool(), order.id).await.unwrap().unwrap(); + assert_eq!(stored.status, Status::Pending.to_string()); + assert!(queued_actions_for(maker).await.is_empty()); + } + + /// A prospective taker who has a bond in flight on the Pending order + /// is released and notified, so no HTLC is left waiting on an order + /// that will never be taken. + #[tokio::test] + async fn pending_cancel_releases_and_notifies_bonded_taker() { + use crate::app::bond::{db::create_bond, model::Bond, BondRole, BondState}; + + let pool = setup_pool().await; + let ctx = build_ctx(pool.clone()); + let mut ln = dead_lnd().await; + let daemon = Keys::generate(); + let maker = Keys::generate().public_key(); + let taker = Keys::generate().public_key(); + + let order = pending_sell_order(maker).create(ctx.pool()).await.unwrap(); + let bond = create_bond( + ctx.pool(), + Bond { + id: uuid::Uuid::new_v4(), + order_id: order.id, + pubkey: taker.to_string(), + role: BondRole::Taker.to_string(), + amount_sats: 1_000, + state: BondState::Requested.to_string(), + ..Default::default() + }, + ) + .await + .unwrap(); + + admin_cancel_action( + &ctx, + cancel_msg(order.id), + &admin_event(daemon.public_key()), + &daemon, + &mut ln, + ) + .await + .expect("operator cancel of a pending order succeeds"); + + let stored_bond = crate::app::bond::db::find_bond_by_id(ctx.pool(), bond.id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored_bond.state, BondState::Released.to_string()); + assert!(queued_actions_for(taker) + .await + .contains(&Action::AdminCanceled)); + } + + /// `WaitingTakerBond` is the same pre-trade window as `Pending` (a + /// taker is mid-bond, still no escrow): the operator can cancel it too, + /// and the in-flight taker bond is released. + #[tokio::test] + async fn daemon_key_cancels_waiting_taker_bond_order_and_releases_bond() { + use crate::app::bond::{db::create_bond, model::Bond, BondRole, BondState}; + + let pool = setup_pool().await; + let ctx = build_ctx(pool.clone()); + let mut ln = dead_lnd().await; + let daemon = Keys::generate(); + let maker = Keys::generate().public_key(); + let taker = Keys::generate().public_key(); + + let mut order = pending_sell_order(maker); + order.status = Status::WaitingTakerBond.to_string(); + let order = order.create(ctx.pool()).await.unwrap(); + let bond = create_bond( + ctx.pool(), + Bond { + id: uuid::Uuid::new_v4(), + order_id: order.id, + pubkey: taker.to_string(), + role: BondRole::Taker.to_string(), + amount_sats: 1_000, + state: BondState::Locked.to_string(), + ..Default::default() + }, + ) + .await + .unwrap(); + + admin_cancel_action( + &ctx, + cancel_msg(order.id), + &admin_event(daemon.public_key()), + &daemon, + &mut ln, + ) + .await + .expect("operator cancel during the taker bond window succeeds"); + + let stored = Order::by_id(ctx.pool(), order.id).await.unwrap().unwrap(); + assert_eq!(stored.status, Status::CanceledByAdmin.to_string()); + let stored_bond = crate::app::bond::db::find_bond_by_id(ctx.pool(), bond.id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored_bond.state, BondState::Released.to_string()); + assert!(queued_actions_for(taker) + .await + .contains(&Action::AdminCanceled)); + } + + /// A take that commits between the operator's read and the CAS wins: + /// the cancel reports `NotAllowedByStatus`, the escrowed row keeps its + /// status and nobody is told the order was cancelled. + #[tokio::test] + async fn pending_cancel_loses_cas_to_a_concurrent_take() { + let pool = setup_pool().await; + let ctx = build_ctx(pool.clone()); + let mut ln = dead_lnd().await; + let daemon = Keys::generate(); + let maker = Keys::generate().public_key(); + + // The operator's snapshot says Pending, but the row has already + // moved on to `waiting-payment` (a take committed). + let snapshot = pending_sell_order(maker).create(ctx.pool()).await.unwrap(); + let mut taken = snapshot.clone(); + taken.status = Status::WaitingPayment.to_string(); + taken.update(ctx.pool()).await.unwrap(); + + let result = admin_cancel_pending_order(ctx.pool(), &snapshot, &daemon, &mut ln).await; + + assert!(matches!( + result, + Err(MostroCantDo(CantDoReason::NotAllowedByStatus)) + )); + let stored = Order::by_id(ctx.pool(), snapshot.id) + .await + .unwrap() + .unwrap(); + assert_eq!(stored.status, Status::WaitingPayment.to_string()); + assert!(queued_actions_for(maker).await.is_empty()); + } } diff --git a/src/app/bond/flow.rs b/src/app/bond/flow.rs index 53b70497..e06f684b 100644 --- a/src/app/bond/flow.rs +++ b/src/app/bond/flow.rs @@ -307,6 +307,12 @@ pub async fn request_taker_bond( ); } } + } else { + // Lost the CAS. If it was to a cancel/expiry rather than to a + // sibling take or a fast lock, this bond will never be promoted: + // release it now instead of stranding the hold invoice (and the + // taker) until CLTV expiry. + release_bond_if_order_closed(pool, &bond).await; } Ok(bond) @@ -1059,6 +1065,61 @@ async fn notify_loser(bond: &Bond) { } } +/// Called when the `Pending → WaitingTakerBond` CAS in +/// [`request_taker_bond`] is lost. Decides whether the bond just created +/// still backs anything. A sibling take or a fast `Accepted` callback moved +/// the order forward: the bond stays in play. A cancel (maker, operator, +/// cooperative) or an expiry landed in the window between the take +/// handler's status read and the bond insert: nobody will ever promote this +/// bond, so release it now and tell the taker instead of leaving a +/// `Requested` hold invoice the taker could still pay open until CLTV +/// expiry — which also pins the maintenance drain's `open_bonds` counter. +/// Returns `true` when the order was found closed (release attempted, +/// taker notified). +pub async fn release_bond_if_order_closed(pool: &Pool, bond: &Bond) -> bool { + let order = match Order::by_id(pool, bond.order_id).await { + Ok(Some(order)) => order, + Ok(None) => { + warn!( + bond_id = %bond.id, + order_id = %bond.order_id, + "release_bond_if_order_closed: order row missing; leaving bond for the exit paths" + ); + return false; + } + Err(e) => { + warn!( + bond_id = %bond.id, + order_id = %bond.order_id, + "release_bond_if_order_closed: order lookup failed ({}); leaving bond for the exit paths", + e + ); + return false; + } + }; + if !matches!( + order.get_order_status(), + Ok(Status::Canceled + | Status::CooperativelyCanceled + | Status::CanceledByAdmin + | Status::Expired) + ) { + return false; + } + info!( + "Bond {} requested on order {} that closed ({}) under the take — releasing and notifying taker", + bond.id, order.id, order.status + ); + if let Err(e) = release_bond(pool, bond).await { + warn!( + bond_id = %bond.id, + "release_bond on closed order failed ({}); the next exit path retries", e + ); + } + notify_loser(bond).await; + true +} + /// Copy the winning bond's deferred taker context onto the order row. /// /// Called from `on_bond_invoice_accepted` once a bond wins the @@ -3283,4 +3344,74 @@ mod tests { "event_id must be persisted after a successful republish" ); } + + // ── release_bond_if_order_closed (CAS lost to a cancel) ───────────── + + async fn set_status(pool: &Pool, order_id: Uuid, status: Status) { + sqlx::query("UPDATE orders SET status = ? WHERE id = ?") + .bind(status.to_string()) + .bind(order_id) + .execute(pool) + .await + .unwrap(); + } + + async fn queued_actions_for(pubkey: &str) -> Vec { + let pk = PublicKey::from_str(pubkey).unwrap(); + crate::config::MESSAGE_QUEUES + .queue_order_msg + .read() + .await + .iter() + .filter(|(_, dest)| *dest == pk) + .map(|(m, _)| m.get_inner_message_kind().action.clone()) + .collect() + } + + /// The operator (or the maker) cancelled between the take handler's + /// status read and the bond insert: the `Requested` bond is released + /// and the taker told, instead of an open hold invoice lingering until + /// CLTV expiry. + #[tokio::test] + async fn cas_lost_to_admin_cancel_releases_requested_bond_and_notifies_taker() { + let pool = setup_pool().await; + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id).await; + let taker = Keys::generate().public_key().to_string(); + let mut bond = Bond::new_requested(order_id, taker.clone(), BondRole::Taker, 1_500); + bond.hash = None; // no LND in unit tests; release is a DB flip + let bond = bond.create(&pool).await.unwrap(); + set_status(&pool, order_id, Status::CanceledByAdmin).await; + + let closed = release_bond_if_order_closed(&pool, &bond).await; + + assert!(closed); + let stored = Bond::by_id(&pool, bond.id).await.unwrap().unwrap(); + assert_eq!(stored.state, BondState::Released.to_string()); + assert!(queued_actions_for(&taker).await.contains(&Action::Canceled)); + } + + /// Lost to a sibling take (order parked at `WaitingTakerBond`) or to a + /// fast lock (`WaitingPayment`): the bond is still a live candidate and + /// must be left alone. + #[tokio::test] + async fn cas_lost_to_a_live_transition_keeps_the_bond() { + let pool = setup_pool().await; + for status in [Status::WaitingTakerBond, Status::WaitingPayment] { + let order_id = Uuid::new_v4(); + insert_order(&pool, order_id).await; + let taker = Keys::generate().public_key().to_string(); + let mut bond = Bond::new_requested(order_id, taker.clone(), BondRole::Taker, 1_500); + bond.hash = None; + let bond = bond.create(&pool).await.unwrap(); + set_status(&pool, order_id, status).await; + + let closed = release_bond_if_order_closed(&pool, &bond).await; + + assert!(!closed, "{status}: order is live, bond must stay"); + let stored = Bond::by_id(&pool, bond.id).await.unwrap().unwrap(); + assert_eq!(stored.state, BondState::Requested.to_string()); + assert!(queued_actions_for(&taker).await.is_empty()); + } + } }