cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 48900011c281245c96d11c2acff1253be9dc12b3
parent ae793686fc4136d3432ca0b496c24f7cdf035e49
Author: triesap <tyson@radroots.org>
Date:   Tue,  5 May 2026 17:02:42 +0000

cli: fix post-pricing review drift

- classify revision commands as relay-backed network operations
- build signed order economics from exact replica listing values
- remove unused production warning surfaces from the cli runtime
- add target tests for revision posture and exact economics

Diffstat:
Msrc/main.rs | 11++++++++++-
Msrc/runtime/accounts.rs | 14--------------
Msrc/runtime/config.rs | 16+++++-----------
Msrc/runtime/mod.rs | 1+
Msrc/runtime/order.rs | 113++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mtests/target_cli.rs | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 265 insertions(+), 56 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -389,7 +389,10 @@ fn validate_network_contract( Ok(()) } OperationNetworkMode::Online => { - if external && !request.context().dry_run && config.relay.urls.is_empty() { + if external + && (!request.context().dry_run || dry_run_requires_network(request.operation_id())) + && config.relay.urls.is_empty() + { return Err(OperationAdapterError::NetworkUnavailable { operation_id: spec.operation_id.to_owned(), message: format!( @@ -409,6 +412,9 @@ fn dry_run_requires_network(operation_id: &str) -> bool { "order.accept" | "order.decline" | "order.cancel" + | "order.revision.propose" + | "order.revision.accept" + | "order.revision.decline" | "order.fulfillment.update" | "order.receipt.record" ) @@ -428,6 +434,9 @@ fn external_network_operation(operation_id: &str) -> bool { | "order.accept" | "order.decline" | "order.cancel" + | "order.revision.propose" + | "order.revision.accept" + | "order.revision.decline" | "order.fulfillment.update" | "order.receipt.record" | "order.status.get" diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs @@ -36,8 +36,6 @@ pub struct AccountRecordView { #[derive(Debug, Clone)] pub struct AccountSecretBackendStatus { - pub configured_primary: String, - pub configured_fallback: Option<String>, pub state: String, pub active_backend: Option<String>, pub used_fallback: bool, @@ -380,32 +378,20 @@ pub fn unresolved_account_reason(config: &RuntimeConfig) -> Result<String, Runti } pub fn secret_backend_status(config: &RuntimeConfig) -> AccountSecretBackendStatus { - let configured_primary = config.account.secret_backend.kind().to_string(); - let configured_fallback = config - .account - .secret_fallback - .map(|backend| backend.kind().to_string()); - match resolve_secret_backend(config) { Ok(resolved) => AccountSecretBackendStatus { - configured_primary, - configured_fallback, state: "ready".to_owned(), active_backend: Some(resolved.backend.kind().to_string()), used_fallback: resolved.used_fallback, reason: None, }, Err(SecretBackendResolutionError::Unavailable(reason)) => AccountSecretBackendStatus { - configured_primary, - configured_fallback, state: "unavailable".to_owned(), active_backend: None, used_fallback: false, reason: Some(reason), }, Err(SecretBackendResolutionError::Invalid(reason)) => AccountSecretBackendStatus { - configured_primary, - configured_fallback, state: "error".to_owned(), active_backend: None, used_fallback: false, diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -247,6 +247,7 @@ pub enum CapabilityBindingTargetKind { ExplicitEndpoint, } +#[cfg(test)] impl CapabilityBindingTargetKind { pub fn as_str(self) -> &'static str { match self { @@ -262,6 +263,7 @@ pub enum CapabilityBindingSource { WorkspaceConfig, } +#[cfg(test)] impl CapabilityBindingSource { pub fn as_str(self) -> &'static str { match self { @@ -283,6 +285,7 @@ pub struct CapabilityBindingConfig { pub source: CapabilityBindingSource, } +#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CapabilityBindingInspectionState { Configured, @@ -290,16 +293,7 @@ pub enum CapabilityBindingInspectionState { Disabled, } -impl CapabilityBindingInspectionState { - pub fn as_str(self) -> &'static str { - match self { - Self::Configured => "configured", - Self::NotConfigured => "not_configured", - Self::Disabled => "disabled", - } - } -} - +#[cfg(test)] #[derive(Debug, Clone, PartialEq, Eq)] pub struct CapabilityBindingInspection { pub capability_id: String, @@ -408,7 +402,6 @@ struct CapabilityBindingSpec { } pub(crate) const SIGNER_REMOTE_NIP46_CAPABILITY: &str = "signer.remote_nip46"; -pub(crate) const WORKFLOW_TRADE_CAPABILITY: &str = "workflow.trade"; pub(crate) const INFERENCE_HYF_STDIO_CAPABILITY: &str = "inference.hyf_stdio"; const CAPABILITY_BINDING_SPECS: &[CapabilityBindingSpec] = &[ @@ -618,6 +611,7 @@ impl RuntimeConfig { }) } + #[cfg(test)] pub fn inspect_capability_bindings(&self) -> Vec<CapabilityBindingInspection> { CAPABILITY_BINDING_SPECS .iter() diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -11,6 +11,7 @@ pub mod logging; pub mod network; pub mod order; pub mod paths; +#[cfg(test)] pub mod provider; pub mod signer; pub mod sync; diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -164,11 +164,11 @@ struct ResolvedOrderListing { #[derive(Debug, Clone)] struct ResolvedOrderEconomicsProduct { - qty_amt: i64, + qty_amt_exact: Option<String>, qty_unit: String, - price_amt: f64, + price_amt_exact: Option<String>, price_currency: String, - price_qty_amt: u32, + price_qty_amt_exact: Option<String>, price_qty_unit: String, primary_bin_id: Option<String>, notes: Option<String>, @@ -177,11 +177,11 @@ struct ResolvedOrderEconomicsProduct { impl ResolvedOrderEconomicsProduct { fn from_summary(row: &ReplicaTradeProductSummaryRow) -> Self { Self { - qty_amt: row.qty_amt, + qty_amt_exact: row.qty_amt_exact.clone(), qty_unit: row.qty_unit.clone(), - price_amt: row.price_amt, + price_amt_exact: row.price_amt_exact.clone(), price_currency: row.price_currency.clone(), - price_qty_amt: row.price_qty_amt, + price_qty_amt_exact: row.price_qty_amt_exact.clone(), price_qty_unit: row.price_qty_unit.clone(), primary_bin_id: row.primary_bin_id.clone(), notes: row.notes.clone(), @@ -190,11 +190,11 @@ impl ResolvedOrderEconomicsProduct { fn from_product(row: TradeProduct) -> Self { Self { - qty_amt: row.qty_amt, + qty_amt_exact: row.qty_amt_exact, qty_unit: row.qty_unit, - price_amt: row.price_amt, + price_amt_exact: row.price_amt_exact, price_currency: row.price_currency, - price_qty_amt: row.price_qty_amt, + price_qty_amt_exact: row.price_qty_amt_exact, price_qty_unit: row.price_qty_unit, primary_bin_id: row.primary_bin_id, notes: row.notes, @@ -1608,6 +1608,7 @@ struct OrderStatusContext<'a> { selected_account_pubkey: Option<&'a str>, } +#[cfg(test)] fn order_status_from_receipt(order_id: &str, receipt: DirectRelayFetchReceipt) -> OrderStatusView { order_status_from_receipt_with_context( OrderStatusContext { @@ -6813,12 +6814,15 @@ fn trade_product_listing_addr_filter(listing_addr: &str) -> ITradeProductFieldsF profile: None, year: None, qty_amt: None, + qty_amt_exact: None, qty_unit: None, qty_label: None, qty_avail: None, price_amt: None, + price_amt_exact: None, price_currency: None, price_qty_amt: None, + price_qty_amt_exact: None, price_qty_unit: None, listing_addr: Some(listing_addr.to_owned()), primary_bin_id: None, @@ -6850,16 +6854,15 @@ fn order_economics_from_resolved_listing( } let currency = parse_economics_currency(product.price_currency.as_str(), "price_currency")?; - let quantity_amount = decimal_from_non_negative_i64(product.qty_amt, "qty_amt")?; + let quantity_amount = + exact_non_negative_decimal(product.qty_amt_exact.as_deref(), "qty_amt_exact")?; let quantity_unit = parse_economics_unit(product.qty_unit.as_str(), "qty_unit")?; - let price_amount = decimal_from_non_negative_f64(product.price_amt, "price_amt")?; - let price_quantity_amount = if product.price_qty_amt == 0 { - return Err(RuntimeError::Config( - "listing price_qty_amt must be greater than zero".to_owned(), - )); - } else { - RadrootsCoreDecimal::from(product.price_qty_amt) - }; + let price_amount = + exact_non_negative_decimal(product.price_amt_exact.as_deref(), "price_amt_exact")?; + let price_quantity_amount = exact_positive_decimal( + product.price_qty_amt_exact.as_deref(), + "price_qty_amt_exact", + )?; let price_unit = parse_economics_unit(product.price_qty_unit.as_str(), "price_qty_unit")?; let quantity_unit_in_price_units = convert_unit_decimal(RadrootsCoreDecimal::ONE, quantity_unit, price_unit).map_err( @@ -7063,29 +7066,39 @@ fn parse_economics_unit(value: &str, field: &str) -> Result<RadrootsCoreUnit, Ru .map_err(|error| RuntimeError::Config(format!("listing {field} is invalid: {error}"))) } -fn decimal_from_non_negative_i64( - value: i64, +fn exact_non_negative_decimal( + value: Option<&str>, field: &str, ) -> Result<RadrootsCoreDecimal, RuntimeError> { - if value < 0 { + let parsed = exact_decimal(value, field)?; + if parsed.is_sign_negative() { return Err(RuntimeError::Config(format!( "listing {field} must be non-negative" ))); } - Ok(RadrootsCoreDecimal::from(value)) + Ok(parsed) } -fn decimal_from_non_negative_f64( - value: f64, +fn exact_positive_decimal( + value: Option<&str>, field: &str, ) -> Result<RadrootsCoreDecimal, RuntimeError> { - if !value.is_finite() || value < 0.0 { + let parsed = exact_non_negative_decimal(value, field)?; + if parsed.is_zero() { return Err(RuntimeError::Config(format!( - "listing {field} must be a finite non-negative decimal" + "listing {field} must be greater than zero" ))); } + Ok(parsed) +} + +fn exact_decimal(value: Option<&str>, field: &str) -> Result<RadrootsCoreDecimal, RuntimeError> { + let Some(value) = value.and_then(non_empty_ref) else { + return Err(RuntimeError::Config(format!( + "listing {field} exact source is missing" + ))); + }; value - .to_string() .parse::<RadrootsCoreDecimal>() .map_err(|error| RuntimeError::Config(format!("listing {field} is invalid: {error}"))) } @@ -8704,11 +8717,11 @@ mod tests { listing_event_id: "1".repeat(64), seller_pubkey: "seller".to_owned(), economics_product: Some(ResolvedOrderEconomicsProduct { - qty_amt: 1, + qty_amt_exact: Some("1".to_owned()), qty_unit: "each".to_owned(), - price_amt: 10.0, + price_amt_exact: Some("10".to_owned()), price_currency: "USD".to_owned(), - price_qty_amt: 1, + price_qty_amt_exact: Some("1".to_owned()), price_qty_unit: "each".to_owned(), primary_bin_id: Some("bin-1".to_owned()), notes: Some( @@ -8772,6 +8785,46 @@ mod tests { } #[test] + fn order_economics_uses_exact_listing_values_over_display_projection() { + let listing = ResolvedOrderListing { + listing_addr: "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".to_owned(), + listing_event_id: "1".repeat(64), + seller_pubkey: "seller".to_owned(), + economics_product: Some(ResolvedOrderEconomicsProduct { + qty_amt_exact: Some("0.5".to_owned()), + qty_unit: "each".to_owned(), + price_amt_exact: Some("10.25".to_owned()), + price_currency: "USD".to_owned(), + price_qty_amt_exact: Some("1".to_owned()), + price_qty_unit: "each".to_owned(), + primary_bin_id: Some("bin-1".to_owned()), + notes: None, + }), + }; + let items = vec![OrderDraftItem { + bin_id: "bin-1".to_owned(), + bin_count: 2, + }]; + + let economics = order_economics_from_resolved_listing( + "ord_AAAAAAAAAAAAAAAAAAAAAg", + Some(&listing), + items.as_slice(), + &[], + ) + .expect("economics") + .expect("economics present"); + + assert_eq!( + economics.subtotal, + RadrootsCoreMoney::new( + "10.25".parse::<RadrootsCoreDecimal>().unwrap(), + RadrootsCoreCurrency::USD + ) + ); + } + + #[test] fn order_draft_requires_listing_event_id_for_submit_readiness() { let document = OrderDraftDocument { version: 1, diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -519,6 +519,63 @@ fn offline_forbids_external_network_operations() { .as_slice(), ), ( + "order.revision.propose", + [ + "--format", + "json", + "--offline", + "--approval-token", + "approve", + "order", + "revision", + "propose", + "ord_offline_revision", + "--reason", + "update count", + "--bin-id", + "bin-1", + "--bin-count", + "2", + ] + .as_slice(), + ), + ( + "order.revision.accept", + [ + "--format", + "json", + "--offline", + "--approval-token", + "approve", + "order", + "revision", + "accept", + "ord_offline_revision", + "--revision-id", + "revision_1", + ] + .as_slice(), + ), + ( + "order.revision.decline", + [ + "--format", + "json", + "--offline", + "--approval-token", + "approve", + "order", + "revision", + "decline", + "ord_offline_revision", + "--revision-id", + "revision_1", + "--reason", + "keep original", + ] + .as_slice(), + ), + ( "order.fulfillment.update", [ "--format", @@ -631,6 +688,60 @@ fn offline_rejects_order_decision_dry_run() { .as_slice(), ), ( + "order.revision.propose", + [ + "--format", + "json", + "--offline", + "--dry-run", + "order", + "revision", + "propose", + "ord_offline_revision", + "--reason", + "update count", + "--bin-id", + "bin-1", + "--bin-count", + "2", + ] + .as_slice(), + ), + ( + "order.revision.accept", + [ + "--format", + "json", + "--offline", + "--dry-run", + "order", + "revision", + "accept", + "ord_offline_revision", + "--revision-id", + "revision_1", + ] + .as_slice(), + ), + ( + "order.revision.decline", + [ + "--format", + "json", + "--offline", + "--dry-run", + "order", + "revision", + "decline", + "ord_offline_revision", + "--revision-id", + "revision_1", + "--reason", + "keep original", + ] + .as_slice(), + ), + ( "order.fulfillment.update", [ "--format", @@ -776,6 +887,61 @@ fn online_requires_relay_for_external_network_operations() { .as_slice(), ), ( + "order.revision.propose", + [ + "--format", + "json", + "--online", + "--approval-token", + "approve", + "order", + "revision", + "propose", + "ord_missing", + "--reason", + "update count", + "--bin-id", + "bin-1", + "--bin-count", + "2", + ] + .as_slice(), + ), + ( + "order.revision.accept", + [ + "--format", + "json", + "--online", + "--dry-run", + "order", + "revision", + "accept", + "ord_missing", + "--revision-id", + "revision_1", + ] + .as_slice(), + ), + ( + "order.revision.decline", + [ + "--format", + "json", + "--online", + "--dry-run", + "order", + "revision", + "decline", + "ord_missing", + "--revision-id", + "revision_1", + "--reason", + "keep original", + ] + .as_slice(), + ), + ( "order.fulfillment.update", [ "--format",