diff --git a/README.md b/README.md index 057b340..d678540 100644 --- a/README.md +++ b/README.md @@ -134,8 +134,8 @@ The mnemonic-based user and the admin key are completely independent. You can ru | `SECRET` | `-s, --secret` | Use secret/anonymous mode for the inner event tuple (advanced, hides trade index from gift-wrap inner). | | `TRANSPORT` | `-t, --transport` | Wire transport: `gift-wrap` (protocol v1) or `nip44` (protocol v2). Leave unset to auto-detect from the instance's info event. | | `ADMIN_NSEC` | — | Admin/solver private key in `nsec1...` or hex format. Only read when an `adm*` command is invoked. | -| `MOSTRO_RPC_URL` | `http://127.0.0.1:50051` | `mostrod` admin gRPC endpoint (`[rpc]` in the daemon's settings). Only used by `admsetmaintenance` / `admmaintenancestatus`. | -| `MOSTRO_RPC_TOKEN` | — | Bearer token for the admin gRPC, required when the daemon sets `[rpc].auth_token`. Only used by the two commands above. Sent in cleartext only to a loopback URL (direct or through an SSH tunnel); any other `http://` host is refused, use `https://` via a TLS proxy instead. | +| `MOSTRO_RPC_URL` | `http://127.0.0.1:50051` | `mostrod` admin gRPC endpoint (`[rpc]` in the daemon's settings). Only used by `admsetmaintenance` / `admmaintenancestatus` / `admcancelpending`. | +| `MOSTRO_RPC_TOKEN` | — | Bearer token for the admin gRPC, required when the daemon sets `[rpc].auth_token`. Only used by the three commands above. Sent in cleartext only to a loopback URL (direct or through an SSH tunnel); any other `http://` host is refused, use `https://` via a TLS proxy instead. | | `RUST_LOG` | `-v, --verbose` | **Not actually configurable.** The logger is initialised only when `-v` is passed, and `-v` overwrites `RUST_LOG` with `info` first. So `RUST_LOG` alone produces no output, and `RUST_LOG=debug -v` still logs at `info`. `-v` is the only available level. | ### Choosing a Mostro instance @@ -448,7 +448,7 @@ mostro-cli sendadmindmattach -p -o -f /path/to/evidence ### Operator commands: maintenance mode (Lightning node migration) -These two commands talk to the daemon's admin gRPC directly instead of Nostr, so they need `MOSTRO_RPC_URL` (and `MOSTRO_RPC_TOKEN` if the daemon requires it) but **not** `ADMIN_NSEC`, relays or a mnemonic. They must run on the daemon's host or through a tunnel to it: `mostrod` only accepts `SetMaintenanceMode` from loopback peers. +These commands talk to the daemon's admin gRPC directly instead of Nostr, so they need `MOSTRO_RPC_URL` (and `MOSTRO_RPC_TOKEN` if the daemon requires it) but **not** `ADMIN_NSEC`, relays or a mnemonic. `admsetmaintenance` must run on the daemon's host or through a tunnel to it: `mostrod` accepts `SetMaintenanceMode` from loopback peers only. `admmaintenancestatus` (read-only) and `admcancelpending` (bearer token when configured) have no peer restriction, so they also work against a remote `https://` endpoint behind a TLS proxy. ```bash # Close the book: new orders and takes are rejected, open trades keep working @@ -457,6 +457,10 @@ mostro-cli admsetmaintenance --enabled true --reason "LN node migration" # Watch the drain; switch the Lightning node only once drained = true mostro-cli admmaintenancestatus +# Shorten the drain: cancel a still-pending order yourself (maker notified, +# its bond released at once). Announce it first — it is the user's order. +mostro-cli admcancelpending -o + # Reopen the book mostro-cli admsetmaintenance --enabled false ``` @@ -592,6 +596,11 @@ Every command supports `-h, --help`. The list below is a one-line summary; run ` - `admsenddm -p -m ` - `getadmindm [--since ] [--from-user]` +### Operator (admin gRPC: `MOSTRO_RPC_URL` / `MOSTRO_RPC_TOKEN`, no `ADMIN_NSEC`) +- `admsetmaintenance --enabled [--reason ]` +- `admmaintenancestatus` +- `admcancelpending -o ` — cancel a still-pending order, releasing its bonds; the daemon refuses any other status. Needs `mostrod` ≥ 0.18.7 (checked via `GetVersion` first; older daemons are refused). + ### Solver tooling (no `ADMIN_NSEC` needed) - `sendadmindmattach -p -o -f ` — send an encrypted file attachment (uploaded to a Blossom server) over the order's trade key. diff --git a/docs/commands.md b/docs/commands.md index 4316c82..e9a3df5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -192,6 +192,13 @@ settable via the matching env var): - `--dispute-id `: Dispute identifier. - **Handler**: `execute_admin_cancel_dispute(order_id, ctx)` in `src/cli/take_dispute.rs`. +- **`admcancelpending`** *(operator only, admin gRPC)* + - **Description**: Cancel a still-`pending` / `waiting-taker-bond` order through the daemon's `CancelOrder` RPC with `pretrade_only` set (any other status, a dispute included, is refused); maker notified, bonds released. Needs `MOSTRO_RPC_URL`, plus `MOSTRO_RPC_TOKEN` only when the daemon configures `[rpc].auth_token`; not `ADMIN_NSEC`. + - **Requires**: `mostrod` ≥ 0.18.7 (the first release enforcing `pretrade_only`); the CLI calls `GetVersion` first and refuses older daemons. + - **Args**: + - `-o, --orderid `: Order identifier. + - **Handler**: `execute_cancel_pending(order_id)` in `src/cli/maintenance.rs`. + - **`admsettle`** *(admin only)* - **Description**: Settle a dispute. - **Args**: diff --git a/src/cli.rs b/src/cli.rs index 1c6b573..926258d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -31,7 +31,9 @@ use crate::cli::last_trade_index::{ }; use crate::cli::list_disputes::execute_list_disputes; use crate::cli::list_orders::execute_list_orders; -use crate::cli::maintenance::{execute_maintenance_status, execute_set_maintenance}; +use crate::cli::maintenance::{ + execute_cancel_pending, execute_maintenance_status, execute_set_maintenance, +}; use crate::cli::new_order::execute_new_order; use crate::cli::orders_info::execute_orders_info; use crate::cli::rate_user::execute_rate_user; @@ -320,6 +322,16 @@ pub enum Commands { /// Show the maintenance flag and what is still bound to the daemon's /// Lightning node; poll until `drained = true` before switching nodes AdmMaintenanceStatus {}, + /// Cancel a still-pending (or waiting-taker-bond) order as the operator + /// over the admin gRPC: the maker is notified and every bond on it is + /// released at once. Shortens the maintenance drain. Only operator; + /// needs MOSTRO_RPC_URL / MOSTRO_RPC_TOKEN, not ADMIN_NSEC. Unlike + /// `admcancel` this is not a dispute resolution. + AdmCancelPending { + /// Order id + #[arg(short, long)] + order_id: Uuid, + }, /// Add a new dispute's solver (only admin) AdmAddSolver { /// npubkey @@ -598,6 +610,9 @@ impl Commands { Some(execute_set_maintenance(*enabled, reason.clone()).await) } Commands::AdmMaintenanceStatus {} => Some(execute_maintenance_status().await), + Commands::AdmCancelPending { order_id } => { + Some(execute_cancel_pending(*order_id).await) + } _ => None, } } @@ -728,7 +743,9 @@ impl Commands { slash_buyer, } => execute_admin_cancel_dispute(order_id, *slash_seller, *slash_buyer, ctx).await, Commands::AdmTakeDispute { dispute_id } => execute_take_dispute(dispute_id, ctx).await, - Commands::AdmSetMaintenance { .. } | Commands::AdmMaintenanceStatus {} => { + Commands::AdmSetMaintenance { .. } + | Commands::AdmMaintenanceStatus {} + | Commands::AdmCancelPending { .. } => { unreachable!("handled by run_rpc before a Context is built") } diff --git a/src/cli/maintenance.rs b/src/cli/maintenance.rs index 63354cd..093288c 100644 --- a/src/cli/maintenance.rs +++ b/src/cli/maintenance.rs @@ -1,11 +1,15 @@ -//! `admsetmaintenance` / `admmaintenancestatus`: drive `mostrod`'s -//! maintenance (drain) mode over the admin gRPC. These talk to the daemon -//! directly, not over Nostr, so they need neither relays nor `ADMIN_NSEC`. +//! `admsetmaintenance` / `admmaintenancestatus` / `admcancelpending`: drive +//! `mostrod`'s maintenance (drain) mode over the admin gRPC. These talk to +//! the daemon directly, not over Nostr, so they need neither relays nor +//! `ADMIN_NSEC`. use crate::parser::common::{ create_emoji_field_row, create_field_value_header, create_standard_table, }; -use crate::rpc::{AdminRpcClient, GetMaintenanceStatusResponse, RpcConfig, RPC_URL_ENV}; +use crate::rpc::{ + ensure_pretrade_only_enforced, AdminRpcClient, GetMaintenanceStatusResponse, RpcConfig, + RPC_URL_ENV, +}; use anyhow::{anyhow, Result}; pub async fn execute_set_maintenance(enabled: bool, reason: Option) -> Result<()> { @@ -42,6 +46,53 @@ pub async fn execute_set_maintenance(enabled: bool, reason: Option) -> R Ok(()) } +/// Operator cancel of a pre-trade order through the daemon's `CancelOrder` +/// gRPC with `pretrade_only` set. Unlike `admcancel` (Nostr, `ADMIN_NSEC`, +/// disputes) this reaches the daemon-key path that accepts `pending` / +/// `waiting-taker-bond` orders, releasing the maker's bond at once — the +/// way to shorten the maintenance drain instead of waiting for +/// `max_expiration_days`. The daemon refuses any other status (a dispute +/// included), so a mistyped id can never resolve a trade. Because an older +/// daemon would silently ignore the flag, the daemon version is checked +/// first (`ensure_pretrade_only_enforced`). +pub async fn execute_cancel_pending(order_id: uuid::Uuid) -> Result<()> { + let config = RpcConfig::from_env(); + println!("👑 Admin Cancel Pending Order"); + println!("═══════════════════════════════════════"); + let mut table = create_standard_table(); + table.set_header(create_field_value_header()); + table.add_row(create_emoji_field_row("🔌 ", RPC_URL_ENV, &config.url)); + table.add_row(create_emoji_field_row( + "📋 ", + "Order id", + &order_id.to_string(), + )); + println!("{table}"); + + let mut client = AdminRpcClient::connect(&config).await?; + // Capability gate before anything that could touch an order: an older + // daemon ignores `pretrade_only` and would resolve a dispute instead. + let daemon = client.get_version().await?.version; + ensure_pretrade_only_enforced(&daemon)?; + let resp = client.cancel_pending_order(&order_id.to_string()).await?; + if !resp.success { + return Err(anyhow!( + "daemon refused the cancel: {}", + resp.error_message.unwrap_or_else(|| "unknown error".into()) + )); + } + println!("{}", cancel_pending_ok(order_id)); + Ok(()) +} + +/// Pure success line, testable without a daemon. +pub fn cancel_pending_ok(order_id: uuid::Uuid) -> String { + format!( + "✅ Order {order_id} cancelled by the operator: maker notified, bonds released.\n\ + 💡 Run `mostro-cli admmaintenancestatus` to watch open_bonds drop." + ) +} + pub async fn execute_maintenance_status() -> Result<()> { let config = RpcConfig::from_env(); let mut client = AdminRpcClient::connect(&config).await?; @@ -94,7 +145,7 @@ pub fn render_status(s: &GetMaintenanceStatusResponse) -> String { )); table.add_row(create_emoji_field_row( "🪢 ", - "Pending bond payouts", + "In-flight bond payouts", &c.pending_bond_payouts.to_string(), )); table.add_row(create_emoji_field_row( @@ -198,6 +249,14 @@ mod tests { ); } + #[test] + fn cancel_pending_ok_names_the_order_and_next_step() { + let id = uuid::Uuid::new_v4(); + let out = cancel_pending_ok(id); + assert!(out.contains(&id.to_string())); + assert!(out.contains("admmaintenancestatus")); + } + #[test] fn render_tolerates_missing_optionals() { let out = render_status(&GetMaintenanceStatusResponse::default()); diff --git a/src/rpc.rs b/src/rpc.rs index c9d40a7..a283171 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -29,6 +29,74 @@ pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); /// A server that accepts the connection but never answers. pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +#[derive(Clone, PartialEq, prost::Message)] +pub struct CancelOrderRequest { + #[prost(string, tag = "1")] + pub order_id: String, + #[prost(string, optional, tag = "2")] + pub request_id: Option, + /// Refuse anything that is not still `pending` / `waiting-taker-bond` + /// instead of falling through to the dispute-resolution cancel + /// (MostroP2P/mostro#944). + #[prost(bool, optional, tag = "3")] + pub pretrade_only: Option, +} + +#[derive(Clone, PartialEq, prost::Message)] +pub struct CancelOrderResponse { + #[prost(bool, tag = "1")] + pub success: bool, + #[prost(string, optional, tag = "2")] + pub error_message: Option, +} + +#[derive(Clone, PartialEq, prost::Message)] +pub struct GetVersionRequest {} + +#[derive(Clone, PartialEq, prost::Message)] +pub struct GetVersionResponse { + #[prost(string, tag = "1")] + pub version: String, +} + +/// First `mostrod` release whose `CancelOrder` enforces +/// `CancelOrderRequest.pretrade_only` (MostroP2P/mostro#944). An older +/// daemon silently ignores the unknown field and would fall through to the +/// dispute-resolution cancel, so `admcancelpending` refuses to run against +/// it. +pub const MIN_DAEMON_FOR_PRETRADE_ONLY: (u64, u64, u64) = (0, 18, 7); + +/// Parse `major.minor.patch` from a daemon version string, tolerating a +/// leading `v` and any pre-release / build suffix (`0.19.0-rc.1+abc`). +pub fn parse_version(s: &str) -> Option<(u64, u64, u64)> { + let core = s.trim().trim_start_matches('v').split(['-', '+']).next()?; + let mut it = core.split('.').map(|p| p.parse::().ok()); + let (a, b, c) = (it.next()??, it.next()??, it.next()??); + if it.next().is_some() { + return None; + } + Some((a, b, c)) +} + +/// Capability gate for `admcancelpending`: the daemon must be recent enough +/// to enforce `pretrade_only`, otherwise the command is refused before any +/// RPC that could touch an order. +pub fn ensure_pretrade_only_enforced(daemon_version: &str) -> Result<()> { + let (a, b, c) = MIN_DAEMON_FOR_PRETRADE_ONLY; + match parse_version(daemon_version) { + Some(v) if v >= (a, b, c) => Ok(()), + Some(_) => Err(anyhow!( + "mostrod {daemon_version} does not enforce pretrade_only (needs >= {a}.{b}.{c}); \ + refusing admcancelpending — on this daemon CancelOrder could resolve a dispute \ + instead of refusing it. Upgrade mostrod." + )), + None => Err(anyhow!( + "cannot parse mostrod version {daemon_version:?}; refusing admcancelpending \ + (needs >= {a}.{b}.{c} to enforce pretrade_only)" + )), + } +} + #[derive(Clone, PartialEq, prost::Message)] pub struct SetMaintenanceModeRequest { #[prost(bool, tag = "1")] @@ -241,6 +309,27 @@ impl AdminRpcClient { .await } + /// `CancelOrder` from the daemon key, restricted to a still-pending + /// (`pending` / `waiting-taker-bond`) order: bonds released, maker + /// notified. `pretrade_only` makes the daemon refuse anything else — + /// in particular a dispute the daemon has taken, which the same RPC + /// would otherwise resolve as the solver (cancel escrow, refund seller). + pub async fn cancel_pending_order(&mut self, order_id: &str) -> Result { + self.unary( + "CancelOrder", + CancelOrderRequest { + order_id: order_id.to_owned(), + request_id: None, + pretrade_only: Some(true), + }, + ) + .await + } + + pub async fn get_version(&mut self) -> Result { + self.unary("GetVersion", GetVersionRequest {}).await + } + pub async fn get_maintenance_status(&mut self) -> Result { self.unary( "GetMaintenanceStatus", @@ -312,6 +401,54 @@ mod tests { assert!(bearer_header(Some("bad\nvalue")).is_err()); } + #[test] + fn parse_version_accepts_common_shapes() { + assert_eq!(parse_version("0.18.6"), Some((0, 18, 6))); + assert_eq!(parse_version("v0.19.0"), Some((0, 19, 0))); + assert_eq!(parse_version("0.19.0-rc.1+abc"), Some((0, 19, 0))); + assert_eq!(parse_version(" 1.2.3\n"), Some((1, 2, 3))); + assert_eq!(parse_version("0.18"), None); + assert_eq!(parse_version("0.18.6.1"), None); + assert_eq!(parse_version("garbage"), None); + } + + /// The gate is what keeps `admcancelpending` off a daemon that would + /// silently fall through to the dispute cancel. + #[test] + fn pretrade_only_gate_refuses_old_or_unparseable_daemons() { + assert!(ensure_pretrade_only_enforced("0.18.7").is_ok()); + assert!(ensure_pretrade_only_enforced("0.19.0").is_ok()); + assert!(ensure_pretrade_only_enforced("1.0.0").is_ok()); + let old = ensure_pretrade_only_enforced("0.18.6") + .unwrap_err() + .to_string(); + assert!( + old.contains("0.18.6") && old.contains("Upgrade mostrod"), + "{old}" + ); + let bad = ensure_pretrade_only_enforced("dev") + .unwrap_err() + .to_string(); + assert!(bad.contains("cannot parse"), "{bad}"); + } + + /// `CancelOrderRequest` field numbers match `proto/admin.proto`. + #[test] + fn cancel_request_encodes_with_proto_field_numbers() { + let bytes = CancelOrderRequest { + order_id: "ab".into(), + request_id: None, + pretrade_only: Some(true), + } + .encode_to_vec(); + // field 1 len-delimited = 0x0a 0x02 'a' 'b'; no field 2; field 3 + // varint = 0x18 0x01 + assert_eq!(bytes, vec![0x0a, 0x02, b'a', b'b', 0x18, 0x01]); + let resp = CancelOrderResponse::decode(&[0x08, 0x00, 0x12, 0x01, b'e'][..]).unwrap(); + assert!(!resp.success); + assert_eq!(resp.error_message.as_deref(), Some("e")); + } + /// Field numbers are the wire contract with `proto/admin.proto`; pin the /// encoding so a renumbering here cannot silently talk past the daemon. #[test]