cli

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

commit ecbd9d0c8e3a323402d62dc178aafabaef97857f
parent 3f6650d85331170574479ecd20abb650f6d7ba1f
Author: triesap <tyson@radroots.org>
Date:   Sat, 25 Apr 2026 04:57:48 +0000

order: resolve listing lookups through market data

- resolve --listing from the local replica when listing_addr is omitted
- fail before draft creation for unsafe lookup outcomes
- derive seller pubkey from the resolved listing address
- cover success missing addressless invalid and ambiguous lookups

Diffstat:
Msrc/runtime/order.rs | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mtests/order.rs | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 249 insertions(+), 11 deletions(-)

diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -10,11 +10,13 @@ use radroots_events::trade::{ }; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::trade::RadrootsTradeListingAddress; +use radroots_replica_db::ReplicaSql; use radroots_runtime::BackoffConfig; use radroots_runtime_paths::{ RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, RadrootsRuntimeNamespace, }; use radroots_sdk::config::RadrootsSdkConfig; +use radroots_sql_core::SqliteExecutor; use rhi::features::trade_listing::state::{TradeListingRuntime, TradeListingRuntimeConfig}; use rhi::identity_storage::load_service_identity; use rhi::rhi::{Rhi, start_subscriber}; @@ -144,9 +146,23 @@ impl WorkflowResolutionError { } } +#[derive(Debug, Clone)] +struct ResolvedOrderListing { + listing_addr: String, + seller_pubkey: String, +} + pub fn scaffold(config: &RuntimeConfig, args: &OrderNewArgs) -> Result<OrderNewView, RuntimeError> { validate_scaffold_args(args)?; + let listing_lookup = normalize_optional(args.listing.as_deref()); + let explicit_listing_addr = normalize_optional(args.listing_addr.as_deref()); + let resolved_listing = resolve_order_listing( + config, + listing_lookup.as_deref(), + explicit_listing_addr.as_deref(), + )?; + let selected_account = accounts::resolve_account(config)?; let buyer_account_id = selected_account .as_ref() @@ -156,10 +172,11 @@ pub fn scaffold(config: &RuntimeConfig, args: &OrderNewArgs) -> Result<OrderNewV .map(|account| account.record.public_identity.public_key_hex.clone()) .unwrap_or_default(); - let listing_lookup = normalize_optional(args.listing.as_deref()); - let listing_addr = normalize_optional(args.listing_addr.as_deref()).unwrap_or_default(); - let parsed_listing_addr = parse_listing_addr(listing_addr.as_str()); - let seller_pubkey = parsed_listing_addr + let listing_addr = resolved_listing + .as_ref() + .map(|listing| listing.listing_addr.clone()) + .unwrap_or_default(); + let seller_pubkey = resolved_listing .as_ref() .map(|listing| listing.seller_pubkey.clone()) .unwrap_or_default(); @@ -876,6 +893,66 @@ fn validate_scaffold_args(args: &OrderNewArgs) -> Result<(), RuntimeError> { } } +fn resolve_order_listing( + config: &RuntimeConfig, + listing_lookup: Option<&str>, + explicit_listing_addr: Option<&str>, +) -> Result<Option<ResolvedOrderListing>, RuntimeError> { + if let Some(listing_addr) = explicit_listing_addr { + let seller_pubkey = parse_listing_addr(listing_addr) + .map(|listing| listing.seller_pubkey) + .unwrap_or_default(); + return Ok(Some(ResolvedOrderListing { + listing_addr: listing_addr.to_owned(), + seller_pubkey, + })); + } + + let Some(listing_lookup) = listing_lookup else { + return Ok(None); + }; + + if !config.local.replica_db_path.exists() { + return Err(RuntimeError::Config(format!( + "order listing lookup `{listing_lookup}` requires local market data; run `radroots local init` and `radroots market update` before creating an order from a listing" + ))); + } + + let db = ReplicaSql::new(SqliteExecutor::open(&config.local.replica_db_path)?); + let rows = db.trade_product_lookup(listing_lookup)?; + match rows.len() { + 0 => Err(RuntimeError::Config(format!( + "listing `{listing_lookup}` is not available in the local replica; run `radroots market update` or pass `--listing-addr`" + ))), + 1 => { + let row = rows.into_iter().next().expect("one row"); + let listing_addr = normalize_optional(row.listing_addr.as_deref()).ok_or_else(|| { + RuntimeError::Config(format!( + "listing `{listing_lookup}` is missing a canonical listing address; run `radroots market update` or pass `--listing-addr`" + )) + })?; + let parsed = parse_listing_addr(listing_addr.as_str()).map_err(|error| { + RuntimeError::Config(format!( + "listing `{listing_lookup}` has invalid listing_addr: {error}; run `radroots market update` or pass `--listing-addr`" + )) + })?; + if parsed.kind != KIND_LISTING { + return Err(RuntimeError::Config(format!( + "listing `{listing_lookup}` listing_addr must reference a public NIP-99 listing; run `radroots market update` or pass `--listing-addr`" + ))); + } + + Ok(Some(ResolvedOrderListing { + listing_addr, + seller_pubkey: parsed.seller_pubkey, + })) + } + count => Err(RuntimeError::Config(format!( + "listing lookup `{listing_lookup}` matched {count} local listings; use a unique product key or pass `--listing-addr`" + ))), + } +} + fn view_from_loaded( config: &RuntimeConfig, loaded: LoadedOrderDraft, diff --git a/tests/order.rs b/tests/order.rs @@ -10,9 +10,17 @@ use std::thread::{self, JoinHandle}; use std::time::Duration; use assert_cmd::prelude::*; +use radroots_sql_core::{SqlExecutor, SqliteExecutor}; use serde_json::{Value, json}; use tempfile::tempdir; +const ORDER_SELLER_PUBKEY: &str = + "1111111111111111111111111111111111111111111111111111111111111111"; +const ORDER_LISTING_ADDR: &str = + "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; +const ORDER_DRAFT_LISTING_ADDR: &str = + "30403:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; + fn data_root(workdir: &Path) -> std::path::PathBuf { if cfg!(windows) { workdir.join("local").join("Radroots").join("data") @@ -61,6 +69,90 @@ fn write_workspace_config(workdir: &Path, contents: &str) { fs::write(config_dir.join("config.toml"), contents).expect("write workspace config"); } +fn init_local_replica(workdir: &Path) { + let init = order_command_in(workdir) + .args(["local", "init"]) + .output() + .expect("run local init"); + assert!(init.status.success()); +} + +fn seed_trade_product(workdir: &Path, product_id: &str, key: &str, listing_addr: Option<&str>) { + let replica_db = data_root(workdir).join("apps/cli/replica/replica.sqlite"); + let executor = SqliteExecutor::open(&replica_db).expect("open replica db"); + let now = "2026-04-07T00:00:00.000Z"; + executor + .exec( + "INSERT INTO trade_product (id, created_at, updated_at, key, listing_addr, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + json!([ + product_id, + now, + now, + key, + listing_addr, + "produce", + "Pasture Eggs", + "Fresh pasture-raised eggs", + "fresh", + "lot-a", + "standard", + 2026, + 36, + "each", + "dozen", + 18, + 4.5, + "USD", + 1, + "each", + Value::Null + ]) + .to_string() + .as_str(), + ) + .expect("insert trade product"); +} + +fn assert_no_order_drafts(workdir: &Path) { + let drafts_dir = data_root(workdir).join("apps/cli/orders/drafts"); + if !drafts_dir.exists() { + return; + } + assert!( + fs::read_dir(&drafts_dir) + .expect("read drafts dir") + .next() + .is_none() + ); +} + +fn run_order_lookup_failure(seed: impl FnOnce(&Path), expected_stderr: &str) { + let dir = tempdir().expect("tempdir"); + seed(dir.path()); + + let output = order_command_in(dir.path()) + .args([ + "--json", + "order", + "new", + "--listing", + "pasture-eggs", + "--bin", + "bin-1", + "--qty", + "1", + ]) + .output() + .expect("run order new failure"); + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8(output.stderr).expect("stderr utf8"); + assert!( + stderr.contains(expected_stderr), + "stderr did not contain `{expected_stderr}`: {stderr}" + ); + assert_no_order_drafts(dir.path()); +} + fn workspace_config_with_write_plane(extra: &str, url: &str) -> String { let mut rendered = String::new(); if !extra.trim().is_empty() { @@ -369,6 +461,14 @@ fn order_new_creates_a_local_draft_with_selected_account_defaults() { .as_str() .expect("buyer pubkey"); + init_local_replica(dir.path()); + seed_trade_product( + dir.path(), + "00000000-0000-0000-0000-000000000901", + "pasture-eggs", + Some(ORDER_LISTING_ADDR), + ); + let output = order_command_in(dir.path()) .args([ "--json", @@ -376,8 +476,6 @@ fn order_new_creates_a_local_draft_with_selected_account_defaults() { "new", "--listing", "pasture-eggs", - "--listing-addr", - "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", "--bin", "bin-1", "--qty", @@ -390,10 +488,8 @@ fn order_new_creates_a_local_draft_with_selected_account_defaults() { assert_eq!(json["state"], "draft_created"); assert_eq!(json["buyer_account_id"], account_id); assert_eq!(json["buyer_pubkey"], buyer_pubkey); - assert_eq!( - json["seller_pubkey"], - "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" - ); + assert_eq!(json["listing_addr"], ORDER_LISTING_ADDR); + assert_eq!(json["seller_pubkey"], ORDER_SELLER_PUBKEY); assert_eq!(json["ready_for_submit"], true); assert_eq!(json["items"][0]["bin_id"], "bin-1"); assert_eq!(json["items"][0]["bin_count"], 2); @@ -403,10 +499,67 @@ fn order_new_creates_a_local_draft_with_selected_account_defaults() { let contents = fs::read_to_string(file).expect("read order draft"); assert!(contents.contains("kind = \"order_draft_v1\"")); assert!(contents.contains("listing_lookup = \"pasture-eggs\"")); + assert!(contents.contains(&format!("listing_addr = \"{ORDER_LISTING_ADDR}\""))); + assert!(contents.contains(&format!("seller_pubkey = \"{ORDER_SELLER_PUBKEY}\""))); assert!(contents.contains(&format!("buyer_account_id = \"{account_id}\""))); } #[test] +fn order_new_listing_lookup_failures_do_not_create_drafts() { + let _guard = order_test_guard(); + + run_order_lookup_failure(|_| {}, "requires local market data"); + run_order_lookup_failure( + |workdir| { + init_local_replica(workdir); + }, + "is not available in the local replica", + ); + run_order_lookup_failure( + |workdir| { + init_local_replica(workdir); + seed_trade_product( + workdir, + "00000000-0000-0000-0000-000000000902", + "pasture-eggs", + None, + ); + }, + "is missing a canonical listing address", + ); + run_order_lookup_failure( + |workdir| { + init_local_replica(workdir); + seed_trade_product( + workdir, + "00000000-0000-0000-0000-000000000903", + "pasture-eggs", + Some(ORDER_DRAFT_LISTING_ADDR), + ); + }, + "must reference a public NIP-99 listing", + ); + run_order_lookup_failure( + |workdir| { + init_local_replica(workdir); + seed_trade_product( + workdir, + "00000000-0000-0000-0000-000000000904", + "pasture-eggs", + Some(ORDER_LISTING_ADDR), + ); + seed_trade_product( + workdir, + "00000000-0000-0000-0000-000000000905", + "pasture-eggs", + Some(ORDER_LISTING_ADDR), + ); + }, + "matched 2 local listings", + ); +} + +#[test] fn order_get_and_ls_read_local_drafts_and_report_missing() { let _guard = order_test_guard(); let dir = tempdir().expect("tempdir"); @@ -435,7 +588,15 @@ fn order_get_and_ls_read_local_drafts_and_report_missing() { let first_order_id = first_json["order_id"].as_str().expect("first order id"); let second = order_command_in(dir.path()) - .args(["--json", "order", "new", "--listing", "carrots"]) + .args([ + "--json", + "order", + "new", + "--listing", + "carrots", + "--listing-addr", + ORDER_LISTING_ADDR, + ]) .output() .expect("run second order new"); assert!(second.status.success());