From f6032c48ba856c9b73d9457c66da4d9129cfea76 Mon Sep 17 00:00:00 2001 From: grunch Date: Wed, 2 Sep 2026 01:09:18 -0300 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20admcancelpending=20=E2=80=94=20oper?= =?UTF-8?q?ator=20cancel=20of=20a=20pending=20order=20over=20the=20admin?= =?UTF-8?q?=20gRPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `admcancel` sends AdminCancel over Nostr signed with ADMIN_NSEC, which is the solver's dispute resolution. The daemon (MostroP2P/mostro#939) now lets the daemon key cancel a still-`pending` / `waiting-taker-bond` order through the `CancelOrder` gRPC, releasing the maker's bond at once so the maintenance drain does not wait for `max_expiration_days`. Add `admcancelpending -o `, routed like `admsetmaintenance` through `run_rpc` (needs MOSTRO_RPC_URL / MOSTRO_RPC_TOKEN, not ADMIN_NSEC), with the `CancelOrderRequest` / `CancelOrderResponse` prost types and an `AdminRpcClient::cancel_order` method. Wire encoding pinned by a test. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PWN1jHfoZxfjusDVB9n3GW --- README.md | 13 ++++++++-- docs/commands.md | 6 +++++ src/cli.rs | 21 ++++++++++++++-- src/cli/maintenance.rs | 54 +++++++++++++++++++++++++++++++++++++++--- src/rpc.rs | 46 +++++++++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 057b340..7be4012 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ 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_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 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. | | `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. | @@ -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. They must run on the daemon's host or through a tunnel to it: `mostrod` only accepts `SetMaintenanceMode` from loopback peers. ```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. + ### 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..483928c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -192,6 +192,12 @@ 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; maker notified, bonds released. Needs `MOSTRO_RPC_URL` / `MOSTRO_RPC_TOKEN`, not `ADMIN_NSEC`. + - **Args**: + - `--order-id `: 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..9a60b95 100644 --- a/src/cli/maintenance.rs +++ b/src/cli/maintenance.rs @@ -1,6 +1,7 @@ -//! `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, @@ -42,6 +43,45 @@ 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. 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`. +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?; + let resp = client.cancel_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?; @@ -198,6 +238,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..94c1965 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -29,6 +29,22 @@ 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, +} + +#[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 SetMaintenanceModeRequest { #[prost(bool, tag = "1")] @@ -241,6 +257,21 @@ impl AdminRpcClient { .await } + /// `CancelOrder` from the daemon key. For a `pending` / + /// `waiting-taker-bond` order this is the operator cancel (bonds + /// released, maker notified); for a dispute the daemon has taken it is + /// the solver resolution. + pub async fn cancel_order(&mut self, order_id: &str) -> Result { + self.unary( + "CancelOrder", + CancelOrderRequest { + order_id: order_id.to_owned(), + request_id: None, + }, + ) + .await + } + pub async fn get_maintenance_status(&mut self) -> Result { self.unary( "GetMaintenanceStatus", @@ -312,6 +343,21 @@ mod tests { assert!(bearer_header(Some("bad\nvalue")).is_err()); } + /// `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, + } + .encode_to_vec(); + // field 1 len-delimited = 0x0a 0x02 'a' 'b'; no field 2 + assert_eq!(bytes, vec![0x0a, 0x02, b'a', b'b']); + 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] From 9773af6488816ed9a68e1d3bddb603aa2befc16d Mon Sep 17 00:00:00 2001 From: grunch Date: Wed, 2 Sep 2026 01:10:00 -0300 Subject: [PATCH 2/5] docs: admcancelpending flag is --orderid --- docs/commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/commands.md b/docs/commands.md index 483928c..7d41bb7 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -195,7 +195,7 @@ settable via the matching env var): - **`admcancelpending`** *(operator only, admin gRPC)* - **Description**: Cancel a still-`pending` / `waiting-taker-bond` order through the daemon's `CancelOrder` RPC; maker notified, bonds released. Needs `MOSTRO_RPC_URL` / `MOSTRO_RPC_TOKEN`, not `ADMIN_NSEC`. - **Args**: - - `--order-id `: Order identifier. + - `-o, --orderid `: Order identifier. - **Handler**: `execute_cancel_pending(order_id)` in `src/cli/maintenance.rs`. - **`admsettle`** *(admin only)* From c03afacaae381dcb5798cfb515f66019efb72d53 Mon Sep 17 00:00:00 2001 From: grunch Date: Wed, 2 Sep 2026 09:08:18 -0300 Subject: [PATCH 3/5] chore: label pending_bond_payouts as in-flight bond payouts (mostro#943) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PWN1jHfoZxfjusDVB9n3GW --- src/cli/maintenance.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/maintenance.rs b/src/cli/maintenance.rs index 9a60b95..33c3545 100644 --- a/src/cli/maintenance.rs +++ b/src/cli/maintenance.rs @@ -134,7 +134,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( From 1ca6da6c0347a8beffcbb13c03c2a254edb0a0e4 Mon Sep 17 00:00:00 2001 From: grunch Date: Wed, 2 Sep 2026 09:43:00 -0300 Subject: [PATCH 4/5] fix: admcancelpending sets pretrade_only so it can never resolve a dispute; doc fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review: `CancelOrder` is generic — with a mistyped id belonging to a dispute the daemon has taken, `admcancelpending` would resolve that dispute and print a "pending order cancelled" success. The daemon now takes `CancelOrderRequest.pretrade_only` (MostroP2P/mostro#944) and refuses anything that is not `pending` / `waiting-taker-bond`; the CLI sets it (`AdminRpcClient::cancel_pending_order`). Wire test pinned. CodeRabbit: token requirement stated conditionally in commands.md; README env table says "three commands"; loopback restriction scoped to SetMaintenanceMode only. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PWN1jHfoZxfjusDVB9n3GW --- README.md | 6 +++--- docs/commands.md | 2 +- src/cli/maintenance.rs | 12 +++++++----- src/rpc.rs | 23 ++++++++++++++++------- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 7be4012..26cd9d5 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ The mnemonic-based user and the admin key are completely independent. You can ru | `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` / `admcancelpending`. | -| `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_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 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 @@ -599,7 +599,7 @@ Every command supports `-h, --help`. The list below is a one-line summary; run ` ### 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. +- `admcancelpending -o ` — cancel a still-pending order, releasing its bonds; the daemon refuses any other status. ### 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 7d41bb7..c95a739 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -193,7 +193,7 @@ settable via the matching env var): - **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; maker notified, bonds released. Needs `MOSTRO_RPC_URL` / `MOSTRO_RPC_TOKEN`, not `ADMIN_NSEC`. + - **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`. - **Args**: - `-o, --orderid `: Order identifier. - **Handler**: `execute_cancel_pending(order_id)` in `src/cli/maintenance.rs`. diff --git a/src/cli/maintenance.rs b/src/cli/maintenance.rs index 33c3545..39befe6 100644 --- a/src/cli/maintenance.rs +++ b/src/cli/maintenance.rs @@ -44,10 +44,12 @@ pub async fn execute_set_maintenance(enabled: bool, reason: Option) -> R } /// Operator cancel of a pre-trade order through the daemon's `CancelOrder` -/// gRPC. 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`. +/// 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. pub async fn execute_cancel_pending(order_id: uuid::Uuid) -> Result<()> { let config = RpcConfig::from_env(); println!("👑 Admin Cancel Pending Order"); @@ -63,7 +65,7 @@ pub async fn execute_cancel_pending(order_id: uuid::Uuid) -> Result<()> { println!("{table}"); let mut client = AdminRpcClient::connect(&config).await?; - let resp = client.cancel_order(&order_id.to_string()).await?; + let resp = client.cancel_pending_order(&order_id.to_string()).await?; if !resp.success { return Err(anyhow!( "daemon refused the cancel: {}", diff --git a/src/rpc.rs b/src/rpc.rs index 94c1965..073d589 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -35,6 +35,11 @@ pub struct CancelOrderRequest { 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)] @@ -257,16 +262,18 @@ impl AdminRpcClient { .await } - /// `CancelOrder` from the daemon key. For a `pending` / - /// `waiting-taker-bond` order this is the operator cancel (bonds - /// released, maker notified); for a dispute the daemon has taken it is - /// the solver resolution. - pub async fn cancel_order(&mut self, order_id: &str) -> Result { + /// `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 @@ -349,10 +356,12 @@ mod tests { 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 - assert_eq!(bytes, vec![0x0a, 0x02, b'a', b'b']); + // 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")); From 4dd31135de18dcd4c46d55a0cf3d3f0c0fec6b67 Mon Sep 17 00:00:00 2001 From: grunch Date: Wed, 2 Sep 2026 09:56:18 -0300 Subject: [PATCH 5/5] fix: admcancelpending refuses daemons that do not enforce pretrade_only CodeRabbit: proto3 drops unknown fields, so against a mostrod older than MostroP2P/mostro#944 the `pretrade_only` flag is silently ignored and `CancelOrder` could still resolve a dispute the daemon has taken. Gate the command on `GetVersion` before any RPC that could touch an order: `ensure_pretrade_only_enforced` requires mostrod >= 0.18.7 (the first release with #944; `MIN_DAEMON_FOR_PRETRADE_ONLY`) and refuses older or unparseable versions with an explicit "upgrade mostrod" error. Adds the `GetVersion` request/response types and client method. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PWN1jHfoZxfjusDVB9n3GW --- README.md | 2 +- docs/commands.md | 1 + src/cli/maintenance.rs | 13 +++++-- src/rpc.rs | 82 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 26cd9d5..d678540 100644 --- a/README.md +++ b/README.md @@ -599,7 +599,7 @@ Every command supports `-h, --help`. The list below is a one-line summary; run ` ### 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. +- `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 c95a739..e9a3df5 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -194,6 +194,7 @@ settable via the matching env var): - **`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`. diff --git a/src/cli/maintenance.rs b/src/cli/maintenance.rs index 39befe6..093288c 100644 --- a/src/cli/maintenance.rs +++ b/src/cli/maintenance.rs @@ -6,7 +6,10 @@ 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<()> { @@ -49,7 +52,9 @@ pub async fn execute_set_maintenance(enabled: bool, reason: Option) -> R /// `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. +/// 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"); @@ -65,6 +70,10 @@ pub async fn execute_cancel_pending(order_id: uuid::Uuid) -> Result<()> { 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!( diff --git a/src/rpc.rs b/src/rpc.rs index 073d589..a283171 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -50,6 +50,53 @@ pub struct CancelOrderResponse { 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")] @@ -279,6 +326,10 @@ impl AdminRpcClient { .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", @@ -350,6 +401,37 @@ 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() {