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:
| M | src/runtime/order.rs | | | 85 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- |
| M | tests/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());