commit c5a3e754be55bcbdcb695047071bea119a244a25
parent f8f7d29b8300a0567dfe2ba688b132615c0134e2
Author: triesap <tyson@radroots.org>
Date: Tue, 7 Apr 2026 07:38:21 +0000
land order draft and inspection surfaces
Diffstat:
8 files changed, 1475 insertions(+), 11 deletions(-)
diff --git a/src/cli.rs b/src/cli.rs
@@ -103,7 +103,7 @@ impl Command {
NetCommand::Status => "net status",
},
Self::Order(order) => match order.command {
- OrderCommand::New => "order new",
+ OrderCommand::New(_) => "order new",
OrderCommand::Get(_) => "order get",
OrderCommand::Ls => "order ls",
OrderCommand::Submit => "order submit",
@@ -166,7 +166,7 @@ impl Command {
}) | Self::Listing(ListingArgs {
command: ListingCommand::New(_),
}) | Self::Order(OrderArgs {
- command: OrderCommand::New | OrderCommand::Submit | OrderCommand::Cancel(_),
+ command: OrderCommand::New(_) | OrderCommand::Submit | OrderCommand::Cancel(_),
})
)
}
@@ -397,7 +397,7 @@ pub struct OrderArgs {
#[derive(Debug, Clone, Subcommand)]
pub enum OrderCommand {
- New,
+ New(OrderNewArgs),
Get(RecordKeyArgs),
Ls,
Submit,
@@ -406,6 +406,18 @@ pub enum OrderCommand {
History,
}
+#[derive(Debug, Clone, Args, Default)]
+pub struct OrderNewArgs {
+ #[arg(long)]
+ pub listing: Option<String>,
+ #[arg(long = "listing-addr")]
+ pub listing_addr: Option<String>,
+ #[arg(long = "bin")]
+ pub bin_id: Option<String>,
+ #[arg(long = "qty")]
+ pub bin_count: Option<u32>,
+}
+
#[derive(Debug, Clone, Args)]
pub struct RecordKeyArgs {
pub key: String,
@@ -761,10 +773,48 @@ mod tests {
_ => panic!("unexpected command variant"),
}
- let order = CliArgs::parse_from(["radroots", "order", "history"]);
- match order.command {
+ let order_new = CliArgs::parse_from([
+ "radroots",
+ "order",
+ "new",
+ "--listing",
+ "pasture-eggs",
+ "--listing-addr",
+ "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg",
+ "--bin",
+ "bin-1",
+ "--qty",
+ "2",
+ ]);
+ match order_new.command {
+ Command::Order(args) => match args.command {
+ OrderCommand::New(new) => {
+ assert_eq!(new.listing.as_deref(), Some("pasture-eggs"));
+ assert_eq!(
+ new.listing_addr.as_deref(),
+ Some("30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg")
+ );
+ assert_eq!(new.bin_id.as_deref(), Some("bin-1"));
+ assert_eq!(new.bin_count, Some(2));
+ }
+ _ => panic!("unexpected order subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
+ let order_get = CliArgs::parse_from(["radroots", "order", "get", "ord_demo"]);
+ match order_get.command {
+ Command::Order(args) => match args.command {
+ OrderCommand::Get(key) => assert_eq!(key.key, "ord_demo"),
+ _ => panic!("unexpected order subcommand"),
+ },
+ _ => panic!("unexpected command variant"),
+ }
+
+ let order_ls = CliArgs::parse_from(["radroots", "order", "ls"]);
+ match order_ls.command {
Command::Order(args) => match args.command {
- OrderCommand::History => {}
+ OrderCommand::Ls => {}
_ => panic!("unexpected order subcommand"),
},
_ => panic!("unexpected command variant"),
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
@@ -6,6 +6,7 @@ pub mod listing;
pub mod local;
pub mod myc;
pub mod net;
+pub mod order;
pub mod relay;
pub mod rpc;
pub mod runtime;
@@ -73,9 +74,9 @@ pub fn dispatch(
NetCommand::Status => net::status(config),
},
Command::Order(order) => match &order.command {
- OrderCommand::New => unimplemented_command("order new"),
- OrderCommand::Get(_) => unimplemented_command("order get"),
- OrderCommand::Ls => unimplemented_command("order ls"),
+ OrderCommand::New(args) => order::new(config, args),
+ OrderCommand::Get(args) => order::get(config, args),
+ OrderCommand::Ls => order::list(config),
OrderCommand::Submit => unimplemented_command("order submit"),
OrderCommand::Watch(_) => unimplemented_command("order watch"),
OrderCommand::Cancel(_) => unimplemented_command("order cancel"),
diff --git a/src/commands/order.rs b/src/commands/order.rs
@@ -0,0 +1,52 @@
+use crate::cli::{OrderNewArgs, RecordKeyArgs};
+use crate::domain::runtime::{CommandDisposition, CommandOutput, CommandView};
+use crate::runtime::RuntimeError;
+use crate::runtime::config::RuntimeConfig;
+
+pub fn new(config: &RuntimeConfig, args: &OrderNewArgs) -> Result<CommandOutput, RuntimeError> {
+ let view = crate::runtime::order::scaffold(config, args)?;
+ Ok(match view.disposition() {
+ CommandDisposition::Success => CommandOutput::success(CommandView::OrderNew(view)),
+ CommandDisposition::Unconfigured => {
+ CommandOutput::unconfigured(CommandView::OrderNew(view))
+ }
+ CommandDisposition::ExternalUnavailable => {
+ CommandOutput::external_unavailable(CommandView::OrderNew(view))
+ }
+ CommandDisposition::InternalError => {
+ CommandOutput::internal_error(CommandView::OrderNew(view))
+ }
+ })
+}
+
+pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<CommandOutput, RuntimeError> {
+ let view = crate::runtime::order::get(config, args)?;
+ Ok(match view.disposition() {
+ CommandDisposition::Success => CommandOutput::success(CommandView::OrderGet(view)),
+ CommandDisposition::Unconfigured => {
+ CommandOutput::unconfigured(CommandView::OrderGet(view))
+ }
+ CommandDisposition::ExternalUnavailable => {
+ CommandOutput::external_unavailable(CommandView::OrderGet(view))
+ }
+ CommandDisposition::InternalError => {
+ CommandOutput::internal_error(CommandView::OrderGet(view))
+ }
+ })
+}
+
+pub fn list(config: &RuntimeConfig) -> Result<CommandOutput, RuntimeError> {
+ let view = crate::runtime::order::list(config)?;
+ Ok(match view.disposition() {
+ CommandDisposition::Success => CommandOutput::success(CommandView::OrderList(view)),
+ CommandDisposition::Unconfigured => {
+ CommandOutput::unconfigured(CommandView::OrderList(view))
+ }
+ CommandDisposition::ExternalUnavailable => {
+ CommandOutput::external_unavailable(CommandView::OrderList(view))
+ }
+ CommandDisposition::InternalError => {
+ CommandOutput::internal_error(CommandView::OrderList(view))
+ }
+ })
+}
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -88,6 +88,9 @@ pub enum CommandView {
LocalStatus(LocalStatusView),
MycStatus(MycStatusView),
NetStatus(NetStatusView),
+ OrderGet(OrderGetView),
+ OrderList(OrderListView),
+ OrderNew(OrderNewView),
RpcSessions(RpcSessionsView),
RpcStatus(RpcStatusView),
RelayList(RelayListView),
@@ -459,6 +462,149 @@ pub struct JobWatchFrameView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct OrderNewView {
+ pub state: String,
+ pub source: String,
+ pub order_id: String,
+ pub file: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub listing_lookup: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub listing_addr: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub buyer_account_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub buyer_pubkey: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_pubkey: Option<String>,
+ pub ready_for_submit: bool,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub items: Vec<OrderDraftItemView>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub issues: Vec<OrderIssueView>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl OrderNewView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "error" => CommandDisposition::InternalError,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct OrderGetView {
+ pub state: String,
+ pub source: String,
+ pub lookup: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub order_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub file: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub listing_lookup: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub listing_addr: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub buyer_account_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub buyer_pubkey: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub seller_pubkey: Option<String>,
+ pub ready_for_submit: bool,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub items: Vec<OrderDraftItemView>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub updated_at_unix: Option<u64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub job: Option<OrderJobView>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub issues: Vec<OrderIssueView>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl OrderGetView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "error" => CommandDisposition::InternalError,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct OrderListView {
+ pub state: String,
+ pub source: String,
+ pub count: usize,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub orders: Vec<OrderSummaryView>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl OrderListView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "error" => CommandDisposition::InternalError,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct OrderSummaryView {
+ pub id: String,
+ pub state: String,
+ pub ready_for_submit: bool,
+ pub file: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub listing_lookup: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub listing_addr: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub buyer_account_id: Option<String>,
+ pub item_count: usize,
+ pub updated_at_unix: u64,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub job: Option<OrderJobView>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub issues: Vec<OrderIssueView>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct OrderDraftItemView {
+ pub bin_id: String,
+ pub bin_count: u32,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct OrderIssueView {
+ pub field: String,
+ pub message: String,
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct OrderJobView {
+ pub job_id: String,
+ pub state: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub command: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub event_id: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub event_addr: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct ListingNewView {
pub state: String,
pub source: String,
diff --git a/src/render/mod.rs b/src/render/mod.rs
@@ -4,8 +4,9 @@ use crate::domain::runtime::{
AccountListView, AccountSummaryView, CommandOutput, CommandView, DoctorCheckView, DoctorView,
FindView, JobGetView, JobListView, JobWatchView, ListingGetView, ListingMutationView,
ListingNewView, ListingValidateView, LocalBackupView, LocalExportView, LocalInitView,
- LocalStatusView, NetStatusView, RelayListView, RpcSessionsView, RpcStatusView, SyncActionView,
- SyncStatusView, SyncWatchView,
+ LocalStatusView, NetStatusView, OrderDraftItemView, OrderGetView, OrderJobView, OrderListView,
+ OrderNewView, RelayListView, RpcSessionsView, RpcStatusView, SyncActionView, SyncStatusView,
+ SyncWatchView,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::{OutputConfig, OutputFormat};
@@ -78,6 +79,15 @@ fn render_human_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
CommandView::NetStatus(view) => {
render_net_status(stdout, view)?;
}
+ CommandView::OrderGet(view) => {
+ render_order_get(stdout, view)?;
+ }
+ CommandView::OrderList(view) => {
+ render_order_list(stdout, view)?;
+ }
+ CommandView::OrderNew(view) => {
+ render_order_new(stdout, view)?;
+ }
CommandView::RpcSessions(view) => {
render_rpc_sessions(stdout, view)?;
}
@@ -208,6 +218,18 @@ fn render_json_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<(),
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
}
+ CommandView::OrderGet(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
+ CommandView::OrderList(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
+ CommandView::OrderNew(view) => {
+ serde_json::to_writer_pretty(&mut *stdout, view)?;
+ writeln!(stdout)?;
+ }
CommandView::RpcSessions(view) => {
serde_json::to_writer_pretty(&mut *stdout, view)?;
writeln!(stdout)?;
@@ -342,6 +364,13 @@ fn render_ndjson_to(stdout: &mut dyn Write, output: &CommandOutput) -> Result<()
}
Ok(())
}
+ CommandView::OrderList(view) => {
+ for order in &view.orders {
+ serde_json::to_writer(&mut *stdout, order)?;
+ writeln!(stdout)?;
+ }
+ Ok(())
+ }
CommandView::RpcSessions(view) => {
for session in &view.sessions {
serde_json::to_writer(&mut *stdout, session)?;
@@ -718,6 +747,211 @@ fn render_job_watch(stdout: &mut dyn Write, view: &JobWatchView) -> Result<(), R
Ok(())
}
+fn render_order_new(stdout: &mut dyn Write, view: &OrderNewView) -> Result<(), RuntimeError> {
+ write_context(stdout, "order · draft created")?;
+ let mut rows = vec![
+ ("order id", view.order_id.as_str()),
+ ("file", view.file.as_str()),
+ ("ready for submit", yes_no(view.ready_for_submit)),
+ ];
+ if let Some(listing_lookup) = &view.listing_lookup {
+ rows.push(("listing", listing_lookup.as_str()));
+ }
+ if let Some(listing_addr) = &view.listing_addr {
+ rows.push(("listing addr", listing_addr.as_str()));
+ }
+ if let Some(account_id) = &view.buyer_account_id {
+ rows.push(("buyer account", account_id.as_str()));
+ }
+ if let Some(buyer_pubkey) = &view.buyer_pubkey {
+ rows.push(("buyer pubkey", buyer_pubkey.as_str()));
+ }
+ if let Some(seller_pubkey) = &view.seller_pubkey {
+ rows.push(("seller pubkey", seller_pubkey.as_str()));
+ }
+ render_pairs(stdout, "draft", rows.as_slice())?;
+ render_order_items(stdout, &view.items)?;
+ render_order_issues(stdout, &view.issues)?;
+ writeln!(stdout, "source: {}", view.source)?;
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
+fn render_order_get(stdout: &mut dyn Write, view: &OrderGetView) -> Result<(), RuntimeError> {
+ let context = match view.state.as_str() {
+ "missing" => format!("order · {} missing", view.lookup),
+ "submitted" => format!("order · {} submitted", view.lookup),
+ "ready" => format!("order · {} ready", view.lookup),
+ "draft" => format!("order · {} draft", view.lookup),
+ "error" => format!("order · {} error", view.lookup),
+ _ => format!("order · {}", view.lookup),
+ };
+ write_context(stdout, context.as_str())?;
+
+ if view.state == "missing" || view.state == "error" {
+ if let Some(reason) = &view.reason {
+ writeln!(stdout, "{reason}")?;
+ writeln!(stdout)?;
+ }
+ if let Some(file) = &view.file {
+ writeln!(stdout, "file: {file}")?;
+ }
+ writeln!(stdout, "source: {}", view.source)?;
+ render_actions(stdout, &view.actions)?;
+ return Ok(());
+ }
+
+ let mut rows = Vec::<(&str, &str)>::new();
+ if let Some(order_id) = &view.order_id {
+ rows.push(("order id", order_id.as_str()));
+ }
+ if let Some(file) = &view.file {
+ rows.push(("file", file.as_str()));
+ }
+ rows.push(("ready for submit", yes_no(view.ready_for_submit)));
+ if let Some(listing_lookup) = &view.listing_lookup {
+ rows.push(("listing", listing_lookup.as_str()));
+ }
+ if let Some(listing_addr) = &view.listing_addr {
+ rows.push(("listing addr", listing_addr.as_str()));
+ }
+ if let Some(account_id) = &view.buyer_account_id {
+ rows.push(("buyer account", account_id.as_str()));
+ }
+ if let Some(buyer_pubkey) = &view.buyer_pubkey {
+ rows.push(("buyer pubkey", buyer_pubkey.as_str()));
+ }
+ if let Some(seller_pubkey) = &view.seller_pubkey {
+ rows.push(("seller pubkey", seller_pubkey.as_str()));
+ }
+ render_pairs(stdout, "order", rows.as_slice())?;
+ if let Some(updated_at_unix) = view.updated_at_unix {
+ writeln!(
+ stdout,
+ "updated: {}",
+ crate::runtime::job::format_timestamp(updated_at_unix)
+ )?;
+ }
+ render_order_items(stdout, &view.items)?;
+ if let Some(job) = &view.job {
+ render_order_job(stdout, job)?;
+ }
+ render_order_issues(stdout, &view.issues)?;
+ if let Some(reason) = &view.reason {
+ writeln!(stdout, "reason: {reason}")?;
+ }
+ writeln!(stdout, "source: {}", view.source)?;
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
+fn render_order_list(stdout: &mut dyn Write, view: &OrderListView) -> Result<(), RuntimeError> {
+ let context = match view.state.as_str() {
+ "empty" => "orders · no local drafts".to_owned(),
+ "degraded" => format!("orders · {} local drafts with issues", view.count),
+ _ => format!(
+ "orders · {} local draft{}",
+ view.count,
+ if view.count == 1 { "" } else { "s" }
+ ),
+ };
+ write_context(stdout, context.as_str())?;
+ if view.orders.is_empty() {
+ writeln!(stdout, "no order drafts found")?;
+ writeln!(stdout)?;
+ } else {
+ let table = Table {
+ headers: &["order", "listing", "state", "ready", "job", "updated"],
+ rows: view
+ .orders
+ .iter()
+ .map(|order| {
+ vec![
+ order.id.clone(),
+ order
+ .listing_lookup
+ .clone()
+ .or_else(|| order.listing_addr.clone())
+ .unwrap_or_default(),
+ order.state.clone(),
+ yes_no(order.ready_for_submit).to_owned(),
+ order
+ .job
+ .as_ref()
+ .map(|job| job.state.clone())
+ .unwrap_or_default(),
+ crate::runtime::job::format_timestamp(order.updated_at_unix),
+ ]
+ })
+ .collect(),
+ };
+ render_table(stdout, &table)?;
+ writeln!(stdout)?;
+ }
+ writeln!(stdout, "source: {}", view.source)?;
+ render_actions(stdout, &view.actions)?;
+ Ok(())
+}
+
+fn render_order_items(
+ stdout: &mut dyn Write,
+ items: &[OrderDraftItemView],
+) -> Result<(), RuntimeError> {
+ if items.is_empty() {
+ writeln!(stdout, "items: no line items yet")?;
+ writeln!(stdout)?;
+ return Ok(());
+ }
+
+ let table = Table {
+ headers: &["bin", "qty"],
+ rows: items
+ .iter()
+ .map(|item| vec![item.bin_id.clone(), item.bin_count.to_string()])
+ .collect(),
+ };
+ render_table(stdout, &table)?;
+ writeln!(stdout)?;
+ Ok(())
+}
+
+fn render_order_job(stdout: &mut dyn Write, job: &OrderJobView) -> Result<(), RuntimeError> {
+ let mut rows = vec![
+ ("job id", job.job_id.as_str()),
+ ("state", job.state.as_str()),
+ ];
+ if let Some(command) = &job.command {
+ rows.push(("command", command.as_str()));
+ }
+ if let Some(event_id) = &job.event_id {
+ rows.push(("event id", event_id.as_str()));
+ }
+ if let Some(event_addr) = &job.event_addr {
+ rows.push(("event addr", event_addr.as_str()));
+ }
+ render_pairs(stdout, "job", rows.as_slice())?;
+ if let Some(reason) = &job.reason {
+ writeln!(stdout, "job reason: {reason}")?;
+ }
+ Ok(())
+}
+
+fn render_order_issues(
+ stdout: &mut dyn Write,
+ issues: &[crate::domain::runtime::OrderIssueView],
+) -> Result<(), RuntimeError> {
+ if issues.is_empty() {
+ return Ok(());
+ }
+
+ writeln!(stdout, "issues")?;
+ for issue in issues {
+ writeln!(stdout, " {} {}", issue.field, issue.message)?;
+ }
+ writeln!(stdout)?;
+ Ok(())
+}
+
fn render_listing_new(stdout: &mut dyn Write, view: &ListingNewView) -> Result<(), RuntimeError> {
write_context(stdout, "listing · draft created")?;
let mut rows = vec![
@@ -1541,6 +1775,9 @@ fn human_command_name(view: &CommandView) -> &'static str {
CommandView::LocalStatus(_) => "local status",
CommandView::MycStatus(_) => "myc status",
CommandView::NetStatus(_) => "net status",
+ CommandView::OrderGet(_) => "order get",
+ CommandView::OrderList(_) => "order ls",
+ CommandView::OrderNew(_) => "order new",
CommandView::RpcSessions(_) => "rpc sessions",
CommandView::RpcStatus(_) => "rpc status",
CommandView::RelayList(_) => "relay ls",
diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs
@@ -8,6 +8,7 @@ pub mod local;
pub mod logging;
pub mod myc;
pub mod network;
+pub mod order;
pub mod signer;
pub mod sync;
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -0,0 +1,763 @@
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use radroots_events::kinds::KIND_LISTING;
+use radroots_events_codec::d_tag::is_d_tag_base64url;
+use radroots_events_codec::trade::RadrootsTradeListingAddress;
+use serde::{Deserialize, Serialize};
+
+use crate::cli::{OrderNewArgs, RecordKeyArgs};
+use crate::domain::runtime::{
+ OrderDraftItemView, OrderGetView, OrderIssueView, OrderJobView, OrderListView, OrderNewView,
+ OrderSummaryView,
+};
+use crate::runtime::RuntimeError;
+use crate::runtime::accounts;
+use crate::runtime::config::RuntimeConfig;
+use crate::runtime::daemon::{self, DaemonRpcError};
+
+const ORDER_DRAFT_KIND: &str = "order_draft_v1";
+const ORDER_SOURCE: &str = "local order drafts · local first";
+const ORDERS_DIR: &str = "orders/drafts";
+
+static ORDER_COUNTER: AtomicU64 = AtomicU64::new(0);
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct OrderDraftDocument {
+ version: u32,
+ kind: String,
+ order: OrderDraft,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ listing_lookup: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ buyer_account_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ submission: Option<OrderDraftSubmission>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct OrderDraft {
+ order_id: String,
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ listing_addr: String,
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ buyer_pubkey: String,
+ #[serde(default, skip_serializing_if = "String::is_empty")]
+ seller_pubkey: String,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ items: Vec<OrderDraftItem>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct OrderDraftItem {
+ bin_id: String,
+ bin_count: u32,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct OrderDraftSubmission {
+ job_id: String,
+}
+
+#[derive(Debug, Clone)]
+struct LoadedOrderDraft {
+ file: PathBuf,
+ updated_at_unix: u64,
+ document: OrderDraftDocument,
+}
+
+pub fn scaffold(config: &RuntimeConfig, args: &OrderNewArgs) -> Result<OrderNewView, RuntimeError> {
+ validate_scaffold_args(args)?;
+
+ let selected_account = accounts::resolve_account(config)?;
+ let buyer_account_id = selected_account
+ .as_ref()
+ .map(|account| account.record.account_id.to_string());
+ let buyer_pubkey = selected_account
+ .as_ref()
+ .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
+ .as_ref()
+ .map(|listing| listing.seller_pubkey.clone())
+ .unwrap_or_default();
+
+ let items = match normalize_optional(args.bin_id.as_deref()) {
+ Some(bin_id) => vec![OrderDraftItem {
+ bin_id,
+ bin_count: args.bin_count.unwrap_or(1),
+ }],
+ None => Vec::new(),
+ };
+
+ let order_id = next_order_id();
+ let drafts_dir = drafts_dir(config);
+ fs::create_dir_all(&drafts_dir)?;
+ let file = drafts_dir.join(format!("{order_id}.toml"));
+
+ let document = OrderDraftDocument {
+ version: 1,
+ kind: ORDER_DRAFT_KIND.to_owned(),
+ order: OrderDraft {
+ order_id: order_id.clone(),
+ listing_addr,
+ buyer_pubkey,
+ seller_pubkey,
+ items,
+ },
+ listing_lookup,
+ buyer_account_id,
+ submission: None,
+ };
+ fs::write(&file, scaffold_contents(&document)?)?;
+
+ let mut view: OrderNewView = view_from_loaded(
+ config,
+ LoadedOrderDraft {
+ file,
+ updated_at_unix: now_unix(),
+ document,
+ },
+ false,
+ )
+ .into();
+ view.actions
+ .insert(0, format!("radroots order get {}", view.order_id));
+
+ Ok(view)
+}
+
+pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<OrderGetView, RuntimeError> {
+ let lookup = args.key.clone();
+ let file = draft_lookup_path(config, lookup.as_str());
+ if !file.exists() {
+ return Ok(OrderGetView {
+ state: "missing".to_owned(),
+ source: ORDER_SOURCE.to_owned(),
+ lookup: lookup.clone(),
+ order_id: None,
+ file: Some(file.display().to_string()),
+ listing_lookup: None,
+ listing_addr: None,
+ buyer_account_id: None,
+ buyer_pubkey: None,
+ seller_pubkey: None,
+ ready_for_submit: false,
+ items: Vec::new(),
+ updated_at_unix: None,
+ job: None,
+ reason: Some(format!("order draft `{lookup}` was not found")),
+ issues: Vec::new(),
+ actions: vec![
+ "radroots order ls".to_owned(),
+ "radroots order new".to_owned(),
+ ],
+ });
+ }
+
+ match load_draft(file.as_path()) {
+ Ok(loaded) => Ok(view_from_loaded(config, loaded, true)),
+ Err(reason) => Ok(OrderGetView {
+ state: "error".to_owned(),
+ source: ORDER_SOURCE.to_owned(),
+ lookup,
+ order_id: None,
+ file: Some(file.display().to_string()),
+ listing_lookup: None,
+ listing_addr: None,
+ buyer_account_id: None,
+ buyer_pubkey: None,
+ seller_pubkey: None,
+ ready_for_submit: false,
+ items: Vec::new(),
+ updated_at_unix: None,
+ job: None,
+ reason: Some(reason),
+ issues: Vec::new(),
+ actions: Vec::new(),
+ }),
+ }
+}
+
+pub fn list(config: &RuntimeConfig) -> Result<OrderListView, RuntimeError> {
+ let dir = drafts_dir(config);
+ if !dir.exists() {
+ return Ok(OrderListView {
+ state: "empty".to_owned(),
+ source: ORDER_SOURCE.to_owned(),
+ count: 0,
+ orders: Vec::new(),
+ actions: vec!["radroots order new".to_owned()],
+ });
+ }
+
+ let mut orders = Vec::new();
+ for entry in fs::read_dir(&dir)? {
+ let entry = entry?;
+ let path = entry.path();
+ if path.extension().and_then(|value| value.to_str()) != Some("toml") {
+ continue;
+ }
+ match load_draft(path.as_path()) {
+ Ok(loaded) => orders.push(summary_from_loaded(config, &loaded)),
+ Err(reason) => orders.push(summary_for_invalid_file(path.as_path(), reason)),
+ }
+ }
+
+ orders.sort_by(|left, right| {
+ right
+ .updated_at_unix
+ .cmp(&left.updated_at_unix)
+ .then_with(|| left.id.cmp(&right.id))
+ });
+
+ let state = if orders.is_empty() {
+ "empty"
+ } else if orders.iter().any(|order| order.state == "error") {
+ "degraded"
+ } else {
+ "ready"
+ };
+ let actions = if orders.is_empty() {
+ vec!["radroots order new".to_owned()]
+ } else {
+ Vec::new()
+ };
+
+ Ok(OrderListView {
+ state: state.to_owned(),
+ source: ORDER_SOURCE.to_owned(),
+ count: orders.len(),
+ orders,
+ actions,
+ })
+}
+
+fn validate_scaffold_args(args: &OrderNewArgs) -> Result<(), RuntimeError> {
+ match (normalize_optional(args.bin_id.as_deref()), args.bin_count) {
+ (None, Some(_)) => Err(RuntimeError::Config(
+ "`--qty` requires `--bin` when creating an order draft".to_owned(),
+ )),
+ (Some(_), Some(0)) => Err(RuntimeError::Config(
+ "`--qty` must be greater than zero".to_owned(),
+ )),
+ (Some(_), None) | (Some(_), Some(_)) | (None, None) => Ok(()),
+ }
+}
+
+fn view_from_loaded(
+ config: &RuntimeConfig,
+ loaded: LoadedOrderDraft,
+ enrich_job: bool,
+) -> OrderGetView {
+ let OrderInspection {
+ state,
+ ready_for_submit,
+ listing_addr,
+ seller_pubkey,
+ issues,
+ job,
+ } = inspect_document(config, &loaded.document, enrich_job);
+
+ let mut actions =
+ actions_for_document(&loaded.document, loaded.file.as_path(), issues.as_slice());
+ if let Some(job) = &job {
+ actions.push(format!("radroots job get {}", job.job_id));
+ }
+
+ OrderGetView {
+ state,
+ source: ORDER_SOURCE.to_owned(),
+ lookup: loaded.document.order.order_id.clone(),
+ order_id: Some(loaded.document.order.order_id.clone()),
+ file: Some(loaded.file.display().to_string()),
+ listing_lookup: loaded.document.listing_lookup.clone(),
+ listing_addr,
+ buyer_account_id: loaded.document.buyer_account_id.clone(),
+ buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()),
+ seller_pubkey,
+ ready_for_submit,
+ items: loaded
+ .document
+ .order
+ .items
+ .iter()
+ .map(|item| OrderDraftItemView {
+ bin_id: item.bin_id.clone(),
+ bin_count: item.bin_count,
+ })
+ .collect(),
+ updated_at_unix: Some(loaded.updated_at_unix),
+ job,
+ reason: None,
+ issues,
+ actions,
+ }
+}
+
+fn summary_from_loaded(config: &RuntimeConfig, loaded: &LoadedOrderDraft) -> OrderSummaryView {
+ let OrderInspection {
+ state,
+ ready_for_submit,
+ listing_addr,
+ seller_pubkey: _,
+ issues,
+ job,
+ } = inspect_document(config, &loaded.document, false);
+
+ OrderSummaryView {
+ id: loaded.document.order.order_id.clone(),
+ state,
+ ready_for_submit,
+ file: loaded.file.display().to_string(),
+ listing_lookup: loaded.document.listing_lookup.clone(),
+ listing_addr,
+ buyer_account_id: loaded.document.buyer_account_id.clone(),
+ item_count: loaded.document.order.items.len(),
+ updated_at_unix: loaded.updated_at_unix,
+ job,
+ issues,
+ }
+}
+
+fn summary_for_invalid_file(path: &Path, reason: String) -> OrderSummaryView {
+ let id = path
+ .file_stem()
+ .and_then(|value| value.to_str())
+ .unwrap_or("unknown")
+ .to_owned();
+ OrderSummaryView {
+ id,
+ state: "error".to_owned(),
+ ready_for_submit: false,
+ file: path.display().to_string(),
+ listing_lookup: None,
+ listing_addr: None,
+ buyer_account_id: None,
+ item_count: 0,
+ updated_at_unix: modified_unix(path).unwrap_or_default(),
+ job: None,
+ issues: vec![OrderIssueView {
+ field: "draft".to_owned(),
+ message: reason,
+ }],
+ }
+}
+
+fn inspect_document(
+ config: &RuntimeConfig,
+ document: &OrderDraftDocument,
+ enrich_job: bool,
+) -> OrderInspection {
+ let listing_addr = non_empty_string(document.order.listing_addr.clone());
+ let parsed_listing_addr = listing_addr
+ .as_deref()
+ .and_then(|value| parse_listing_addr(value).ok());
+ let seller_pubkey = non_empty_string(document.order.seller_pubkey.clone()).or_else(|| {
+ parsed_listing_addr
+ .as_ref()
+ .map(|listing| listing.seller_pubkey.clone())
+ });
+ let issues = collect_issues(document);
+ let job = submission_job_view(config, document, enrich_job);
+ let ready_for_submit = issues.is_empty() && job.is_none();
+ let state = if job.is_some() {
+ "submitted".to_owned()
+ } else if ready_for_submit {
+ "ready".to_owned()
+ } else {
+ "draft".to_owned()
+ };
+
+ OrderInspection {
+ state,
+ ready_for_submit,
+ listing_addr,
+ seller_pubkey,
+ issues,
+ job,
+ }
+}
+
+fn collect_issues(document: &OrderDraftDocument) -> Vec<OrderIssueView> {
+ let mut issues = Vec::new();
+ if document.version != 1 {
+ issues.push(issue("version", "version must be 1"));
+ }
+ if document.kind != ORDER_DRAFT_KIND {
+ issues.push(issue("kind", format!("kind must be `{ORDER_DRAFT_KIND}`")));
+ }
+ if !is_valid_order_id(document.order.order_id.as_str()) {
+ issues.push(issue(
+ "order.order_id",
+ "order_id must look like `ord_<base64url>`",
+ ));
+ }
+
+ match normalize_optional(Some(document.order.listing_addr.as_str())) {
+ Some(listing_addr) => match parse_listing_addr(listing_addr.as_str()) {
+ Ok(parsed) => {
+ if parsed.kind != KIND_LISTING {
+ issues.push(issue(
+ "order.listing_addr",
+ "listing_addr must reference a public NIP-99 listing",
+ ));
+ }
+ if let Some(seller_pubkey) = non_empty_string(document.order.seller_pubkey.clone())
+ {
+ if seller_pubkey != parsed.seller_pubkey {
+ issues.push(issue(
+ "order.seller_pubkey",
+ "seller_pubkey must match listing_addr seller when both are set",
+ ));
+ }
+ }
+ }
+ Err(error) => issues.push(issue(
+ "order.listing_addr",
+ format!("listing_addr is invalid: {error}"),
+ )),
+ },
+ None => issues.push(issue(
+ "order.listing_addr",
+ "listing_addr is required before order submit",
+ )),
+ }
+
+ if document.order.items.is_empty() {
+ issues.push(issue(
+ "order.items",
+ "at least one order item is required before order submit",
+ ));
+ }
+ for (index, item) in document.order.items.iter().enumerate() {
+ if item.bin_id.trim().is_empty() {
+ issues.push(issue(
+ format!("order.items[{index}].bin_id"),
+ "bin_id must not be empty",
+ ));
+ }
+ if item.bin_count == 0 {
+ issues.push(issue(
+ format!("order.items[{index}].bin_count"),
+ "bin_count must be greater than zero",
+ ));
+ }
+ }
+
+ if document
+ .buyer_account_id
+ .as_deref()
+ .is_none_or(|value| value.trim().is_empty())
+ && document.order.buyer_pubkey.trim().is_empty()
+ {
+ issues.push(issue(
+ "buyer_account_id",
+ "buyer account or buyer_pubkey is required before order submit",
+ ));
+ }
+
+ issues
+}
+
+fn actions_for_document(
+ document: &OrderDraftDocument,
+ file: &Path,
+ issues: &[OrderIssueView],
+) -> Vec<String> {
+ let mut actions = Vec::new();
+ actions.push(format!(
+ "edit {} and fill the remaining draft fields",
+ file.display()
+ ));
+ if document.buyer_account_id.is_none() && document.order.buyer_pubkey.trim().is_empty() {
+ actions.push("radroots account new".to_owned());
+ }
+ if document.order.items.is_empty()
+ || issues
+ .iter()
+ .any(|issue| issue.field.starts_with("order.items["))
+ {
+ actions.push(format!("radroots order get {}", document.order.order_id));
+ }
+ actions
+}
+
+fn submission_job_view(
+ config: &RuntimeConfig,
+ document: &OrderDraftDocument,
+ enrich: bool,
+) -> Option<OrderJobView> {
+ let job_id = document
+ .submission
+ .as_ref()
+ .and_then(|submission| normalize_optional(Some(submission.job_id.as_str())))?;
+ if !enrich || config.rpc.bridge_bearer_token.is_none() {
+ return Some(OrderJobView {
+ job_id,
+ state: "recorded".to_owned(),
+ command: None,
+ event_id: None,
+ event_addr: None,
+ reason: None,
+ });
+ }
+
+ match daemon::bridge_job(config, job_id.as_str()) {
+ Ok(Some(job)) => Some(OrderJobView {
+ job_id,
+ state: job.state,
+ command: Some(job.command),
+ event_id: job.event_id,
+ event_addr: job.event_addr,
+ reason: None,
+ }),
+ Ok(None) => Some(OrderJobView {
+ job_id,
+ state: "missing".to_owned(),
+ command: None,
+ event_id: None,
+ event_addr: None,
+ reason: Some("recorded job id was not found in radrootsd".to_owned()),
+ }),
+ Err(error) => Some(job_view_from_error(job_id, error)),
+ }
+}
+
+fn job_view_from_error(job_id: String, error: DaemonRpcError) -> OrderJobView {
+ match error {
+ DaemonRpcError::Unconfigured(reason)
+ | DaemonRpcError::Unauthorized(reason)
+ | DaemonRpcError::MethodUnavailable(reason) => OrderJobView {
+ job_id,
+ state: "unconfigured".to_owned(),
+ command: None,
+ event_id: None,
+ event_addr: None,
+ reason: Some(reason),
+ },
+ DaemonRpcError::External(reason) => OrderJobView {
+ job_id,
+ state: "unavailable".to_owned(),
+ command: None,
+ event_id: None,
+ event_addr: None,
+ reason: Some(reason),
+ },
+ DaemonRpcError::InvalidResponse(reason)
+ | DaemonRpcError::Remote(reason)
+ | DaemonRpcError::UnknownJob(reason) => OrderJobView {
+ job_id,
+ state: "error".to_owned(),
+ command: None,
+ event_id: None,
+ event_addr: None,
+ reason: Some(reason),
+ },
+ }
+}
+
+fn load_draft(path: &Path) -> Result<LoadedOrderDraft, String> {
+ let contents = fs::read_to_string(path)
+ .map_err(|error| format!("read order draft {}: {error}", path.display()))?;
+ let document = toml::from_str::<OrderDraftDocument>(contents.as_str())
+ .map_err(|error| format!("parse order draft {}: {error}", path.display()))?;
+ Ok(LoadedOrderDraft {
+ file: path.to_path_buf(),
+ updated_at_unix: modified_unix(path).unwrap_or_default(),
+ document,
+ })
+}
+
+fn scaffold_contents(draft: &OrderDraftDocument) -> Result<String, RuntimeError> {
+ let toml = toml::to_string_pretty(draft)
+ .map_err(|error| RuntimeError::Config(format!("render order draft: {error}")))?;
+ Ok(format!(
+ "# radroots order draft v1\n# fill listing_addr and any missing order items before submit\n\n{toml}"
+ ))
+}
+
+fn drafts_dir(config: &RuntimeConfig) -> PathBuf {
+ config.paths.user_state_root.join(ORDERS_DIR)
+}
+
+fn draft_lookup_path(config: &RuntimeConfig, lookup: &str) -> PathBuf {
+ let candidate = PathBuf::from(lookup);
+ if candidate.is_absolute() || lookup.contains(std::path::MAIN_SEPARATOR) {
+ return candidate;
+ }
+ let file_name = if lookup.ends_with(".toml") {
+ lookup.to_owned()
+ } else {
+ format!("{lookup}.toml")
+ };
+ drafts_dir(config).join(file_name)
+}
+
+fn parse_listing_addr(raw: &str) -> Result<RadrootsTradeListingAddress, String> {
+ RadrootsTradeListingAddress::parse(raw).map_err(|error| error.to_string())
+}
+
+fn issue(field: impl Into<String>, message: impl Into<String>) -> OrderIssueView {
+ OrderIssueView {
+ field: field.into(),
+ message: message.into(),
+ }
+}
+
+fn normalize_optional(value: Option<&str>) -> Option<String> {
+ let value = value?;
+ let trimmed = value.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_owned())
+ }
+}
+
+fn non_empty_string(value: String) -> Option<String> {
+ if value.trim().is_empty() {
+ None
+ } else {
+ Some(value)
+ }
+}
+
+fn modified_unix(path: &Path) -> Option<u64> {
+ let modified = fs::metadata(path).ok()?.modified().ok()?;
+ modified
+ .duration_since(UNIX_EPOCH)
+ .ok()
+ .map(|value| value.as_secs())
+}
+
+fn now_unix() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|value| value.as_secs())
+ .unwrap_or_default()
+}
+
+fn next_order_id() -> String {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_nanos())
+ .unwrap_or_default();
+ let counter = ORDER_COUNTER.fetch_add(1, Ordering::Relaxed) as u128;
+ format!(
+ "ord_{}",
+ encode_base64url_no_pad((nanos ^ counter).to_be_bytes())
+ )
+}
+
+fn is_valid_order_id(value: &str) -> bool {
+ let Some(encoded) = value.strip_prefix("ord_") else {
+ return false;
+ };
+ encoded.len() == 22 && is_d_tag_base64url(encoded)
+}
+
+fn encode_base64url_no_pad(bytes: [u8; 16]) -> String {
+ const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
+ let mut output = String::with_capacity(22);
+ let mut index = 0usize;
+ while index + 3 <= bytes.len() {
+ let block = ((bytes[index] as u32) << 16)
+ | ((bytes[index + 1] as u32) << 8)
+ | (bytes[index + 2] as u32);
+ output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char);
+ output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char);
+ output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char);
+ output.push(ALPHABET[(block & 0x3f) as usize] as char);
+ index += 3;
+ }
+ let remaining = bytes.len() - index;
+ if remaining == 1 {
+ let block = (bytes[index] as u32) << 16;
+ output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char);
+ output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char);
+ } else if remaining == 2 {
+ let block = ((bytes[index] as u32) << 16) | ((bytes[index + 1] as u32) << 8);
+ output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char);
+ output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char);
+ output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char);
+ }
+ output
+}
+
+#[derive(Debug, Clone)]
+struct OrderInspection {
+ state: String,
+ ready_for_submit: bool,
+ listing_addr: Option<String>,
+ seller_pubkey: Option<String>,
+ issues: Vec<OrderIssueView>,
+ job: Option<OrderJobView>,
+}
+
+impl From<OrderGetView> for OrderNewView {
+ fn from(view: OrderGetView) -> Self {
+ Self {
+ state: "draft_created".to_owned(),
+ source: view.source,
+ order_id: view.order_id.unwrap_or_default(),
+ file: view.file.unwrap_or_default(),
+ listing_lookup: view.listing_lookup,
+ listing_addr: view.listing_addr,
+ buyer_account_id: view.buyer_account_id,
+ buyer_pubkey: view.buyer_pubkey,
+ seller_pubkey: view.seller_pubkey,
+ ready_for_submit: view.ready_for_submit,
+ items: view.items,
+ issues: view.issues,
+ actions: view.actions,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{ORDER_DRAFT_KIND, OrderDraft, OrderDraftDocument, OrderDraftItem, next_order_id};
+
+ #[test]
+ fn generated_order_id_uses_stable_prefix() {
+ let order_id = next_order_id();
+ assert!(order_id.starts_with("ord_"));
+ assert_eq!(order_id.len(), 26);
+ }
+
+ #[test]
+ fn order_draft_kind_constant_is_stable() {
+ let document = OrderDraftDocument {
+ version: 1,
+ kind: ORDER_DRAFT_KIND.to_owned(),
+ order: OrderDraft {
+ order_id: "ord_AAAAAAAAAAAAAAAAAAAAAg".to_owned(),
+ listing_addr: "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg".to_owned(),
+ buyer_pubkey: "a".repeat(64),
+ seller_pubkey: "b".repeat(64),
+ items: vec![OrderDraftItem {
+ bin_id: "bin-1".to_owned(),
+ bin_count: 2,
+ }],
+ },
+ listing_lookup: Some("fresh-eggs".to_owned()),
+ buyer_account_id: Some("acct_demo".to_owned()),
+ submission: None,
+ };
+
+ let rendered = toml::to_string_pretty(&document).expect("render draft");
+ assert!(rendered.contains("kind = \"order_draft_v1\""));
+ assert!(rendered.contains("order_id = \"ord_AAAAAAAAAAAAAAAAAAAAAg\""));
+ }
+}
diff --git a/tests/order.rs b/tests/order.rs
@@ -0,0 +1,214 @@
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+use std::sync::{Mutex, MutexGuard, OnceLock};
+
+use assert_cmd::prelude::*;
+use serde_json::Value;
+use tempfile::tempdir;
+
+fn order_command_in(workdir: &Path) -> Command {
+ let mut command = Command::cargo_bin("radroots").expect("binary");
+ command.current_dir(workdir);
+ command.env("HOME", workdir.join("home"));
+ for key in [
+ "RADROOTS_ENV_FILE",
+ "RADROOTS_OUTPUT",
+ "RADROOTS_CLI_LOGGING_FILTER",
+ "RADROOTS_CLI_LOGGING_OUTPUT_DIR",
+ "RADROOTS_CLI_LOGGING_STDOUT",
+ "RADROOTS_LOG_FILTER",
+ "RADROOTS_LOG_DIR",
+ "RADROOTS_LOG_STDOUT",
+ "RADROOTS_ACCOUNT",
+ "RADROOTS_IDENTITY_PATH",
+ "RADROOTS_SIGNER",
+ "RADROOTS_RELAYS",
+ "RADROOTS_MYC_EXECUTABLE",
+ "RADROOTS_RPC_URL",
+ "RADROOTS_RPC_BEARER_TOKEN",
+ ] {
+ command.env_remove(key);
+ }
+ command
+}
+
+fn order_test_guard() -> MutexGuard<'static, ()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ .lock()
+ .expect("order test lock")
+}
+
+#[test]
+fn order_new_creates_a_local_draft_with_selected_account_defaults() {
+ let _guard = order_test_guard();
+ let dir = tempdir().expect("tempdir");
+
+ let account_output = order_command_in(dir.path())
+ .args(["--json", "account", "new"])
+ .output()
+ .expect("run account new");
+ assert!(account_output.status.success());
+ let account_json: Value =
+ serde_json::from_slice(account_output.stdout.as_slice()).expect("account json");
+ let account_id = account_json["account"]["id"].as_str().expect("account id");
+ let buyer_pubkey = account_json["public_identity"]["public_key_hex"]
+ .as_str()
+ .expect("buyer pubkey");
+
+ let output = order_command_in(dir.path())
+ .args([
+ "--json",
+ "order",
+ "new",
+ "--listing",
+ "pasture-eggs",
+ "--listing-addr",
+ "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg",
+ "--bin",
+ "bin-1",
+ "--qty",
+ "2",
+ ])
+ .output()
+ .expect("run order new");
+ assert!(output.status.success());
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("order json");
+ 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["ready_for_submit"], true);
+ assert_eq!(json["items"][0]["bin_id"], "bin-1");
+ assert_eq!(json["items"][0]["bin_count"], 2);
+
+ let file = json["file"].as_str().expect("draft file");
+ assert!(file.contains(".local/share/radroots/orders/drafts/ord_"));
+ 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!("buyer_account_id = \"{account_id}\"")));
+}
+
+#[test]
+fn order_get_and_ls_read_local_drafts_and_report_missing() {
+ let _guard = order_test_guard();
+ let dir = tempdir().expect("tempdir");
+ let account_output = order_command_in(dir.path())
+ .args(["--json", "account", "new"])
+ .output()
+ .expect("run account new");
+ assert!(account_output.status.success());
+
+ let first = order_command_in(dir.path())
+ .args([
+ "--json",
+ "order",
+ "new",
+ "--listing",
+ "pasture-eggs",
+ "--listing-addr",
+ "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg",
+ "--bin",
+ "bin-1",
+ ])
+ .output()
+ .expect("run first order new");
+ assert!(first.status.success());
+ let first_json: Value = serde_json::from_slice(first.stdout.as_slice()).expect("first json");
+ 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"])
+ .output()
+ .expect("run second order new");
+ assert!(second.status.success());
+ let second_json: Value = serde_json::from_slice(second.stdout.as_slice()).expect("second json");
+ let second_order_id = second_json["order_id"].as_str().expect("second order id");
+
+ let get_output = order_command_in(dir.path())
+ .args(["--json", "order", "get", first_order_id])
+ .output()
+ .expect("run order get");
+ assert!(get_output.status.success());
+ let get_json: Value = serde_json::from_slice(get_output.stdout.as_slice()).expect("get json");
+ assert_eq!(get_json["state"], "ready");
+ assert_eq!(get_json["order_id"], first_order_id);
+ assert_eq!(get_json["listing_lookup"], "pasture-eggs");
+
+ let missing_output = order_command_in(dir.path())
+ .args(["--json", "order", "get", "ord_missing"])
+ .output()
+ .expect("run missing order get");
+ assert!(missing_output.status.success());
+ let missing_json: Value =
+ serde_json::from_slice(missing_output.stdout.as_slice()).expect("missing json");
+ assert_eq!(missing_json["state"], "missing");
+
+ let human_list = order_command_in(dir.path())
+ .args(["order", "ls"])
+ .output()
+ .expect("run human order ls");
+ assert!(human_list.status.success());
+ let human_text = String::from_utf8(human_list.stdout).expect("human text");
+ assert!(human_text.contains("orders · 2 local drafts"));
+ assert!(human_text.contains(first_order_id));
+ assert!(human_text.contains(second_order_id));
+
+ let ndjson_output = order_command_in(dir.path())
+ .args(["--ndjson", "order", "ls"])
+ .output()
+ .expect("run ndjson order ls");
+ assert!(ndjson_output.status.success());
+ let ndjson = String::from_utf8(ndjson_output.stdout).expect("ndjson text");
+ let lines = ndjson.lines().collect::<Vec<_>>();
+ assert_eq!(lines.len(), 2);
+ assert!(lines.iter().any(|line| line.contains(first_order_id)));
+ assert!(lines.iter().any(|line| line.contains(second_order_id)));
+}
+
+#[test]
+fn order_get_surfaces_recorded_job_metadata_from_the_local_draft_store() {
+ let _guard = order_test_guard();
+ let dir = tempdir().expect("tempdir");
+ let drafts_dir = dir.path().join("home/.local/share/radroots/orders/drafts");
+ fs::create_dir_all(&drafts_dir).expect("create drafts dir");
+ let draft_path = drafts_dir.join("ord_AAAAAAAAAAAAAAAAAAAAAg.toml");
+ fs::write(
+ &draft_path,
+ r#"version = 1
+kind = "order_draft_v1"
+listing_lookup = "fresh-eggs"
+buyer_account_id = "acct_demo"
+
+[order]
+order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg"
+listing_addr = "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg"
+buyer_pubkey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+seller_pubkey = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
+
+[[order.items]]
+bin_id = "bin-1"
+bin_count = 2
+
+[submission]
+job_id = "job_order_01"
+"#,
+ )
+ .expect("write order draft");
+
+ let output = order_command_in(dir.path())
+ .args(["--json", "order", "get", "ord_AAAAAAAAAAAAAAAAAAAAAg"])
+ .output()
+ .expect("run order get");
+ assert!(output.status.success());
+ let json: Value = serde_json::from_slice(output.stdout.as_slice()).expect("json");
+ assert_eq!(json["state"], "submitted");
+ assert_eq!(json["job"]["job_id"], "job_order_01");
+ assert_eq!(json["job"]["state"], "recorded");
+ assert_eq!(json["ready_for_submit"], false);
+}