Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `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
Expand Down Expand Up @@ -448,7 +448,7 @@ mostro-cli sendadmindmattach -p <user-pubkey> -o <order-id> -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
Expand All @@ -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.
Comment thread
grunch marked this conversation as resolved.
mostro-cli admcancelpending -o <order-id>

# Reopen the book
mostro-cli admsetmaintenance --enabled false
```
Expand Down Expand Up @@ -592,6 +596,11 @@ Every command supports `-h, --help`. The list below is a one-line summary; run `
- `admsenddm -p <pubkey> -m <msg>`
- `getadmindm [--since <min>] [--from-user]`

### Operator (admin gRPC: `MOSTRO_RPC_URL` / `MOSTRO_RPC_TOKEN`, no `ADMIN_NSEC`)
- `admsetmaintenance --enabled <true|false> [--reason <text>]`
- `admmaintenancestatus`
- `admcancelpending -o <id>` — 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 <pubkey> -o <id> -f <file>` — send an encrypted file attachment (uploaded to a Blossom server) over the order's trade key.

Expand Down
7 changes: 7 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,13 @@ settable via the matching env var):
- `--dispute-id <UUID>`: 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 <UUID>`: Order identifier.
- **Handler**: `execute_cancel_pending(order_id)` in `src/cli/maintenance.rs`.

- **`admsettle`** *(admin only)*
- **Description**: Settle a dispute.
- **Args**:
Expand Down
21 changes: 19 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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")
}

Expand Down
69 changes: 64 additions & 5 deletions src/cli/maintenance.rs
Original file line number Diff line number Diff line change
@@ -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<String>) -> Result<()> {
Expand Down Expand Up @@ -42,6 +46,53 @@ pub async fn execute_set_maintenance(enabled: bool, reason: Option<String>) -> 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?;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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());
Expand Down
137 changes: 137 additions & 0 deletions src/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<bool>,
}

#[derive(Clone, PartialEq, prost::Message)]
pub struct CancelOrderResponse {
#[prost(bool, tag = "1")]
pub success: bool,
#[prost(string, optional, tag = "2")]
pub error_message: Option<String>,
}

#[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::<u64>().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")]
Expand Down Expand Up @@ -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<CancelOrderResponse> {
self.unary(
"CancelOrder",
Comment thread
grunch marked this conversation as resolved.
CancelOrderRequest {
order_id: order_id.to_owned(),
request_id: None,
pretrade_only: Some(true),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
)
.await
}

pub async fn get_version(&mut self) -> Result<GetVersionResponse> {
self.unary("GetVersion", GetVersionRequest {}).await
}

pub async fn get_maintenance_status(&mut self) -> Result<GetMaintenanceStatusResponse> {
self.unary(
"GetMaintenanceStatus",
Expand Down Expand Up @@ -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]
Expand Down
Loading