cli

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

commit 39d3bdc35a8c7a0b750c50fe517a1d12a8919f04
parent 2b5e5f404e01bfacd0341b7020813d68271235aa
Author: triesap <tyson@radroots.org>
Date:   Sun, 10 May 2026 02:56:29 +0000

order: report bound buyer readiness

- validate order draft buyer accounts in local reads
- expose buyer custody and write capability
- surface import attach-secret and rebind repair actions
- cover missing watch-only and mismatched buyer reads

Diffstat:
Msrc/domain/runtime.rs | 16++++++++++++++++
Msrc/runtime/order.rs | 227+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mtests/target_cli.rs | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 415 insertions(+), 29 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1166,6 +1166,10 @@ pub struct OrderNewView { #[serde(skip_serializing_if = "Option::is_none")] pub buyer_actor_source: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_custody: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_write_capable: Option<bool>, + #[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")] @@ -1209,6 +1213,10 @@ pub struct OrderGetView { #[serde(skip_serializing_if = "Option::is_none")] pub buyer_actor_source: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_custody: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_write_capable: Option<bool>, + #[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")] @@ -1277,6 +1285,10 @@ pub struct OrderSubmitView { #[serde(skip_serializing_if = "Option::is_none")] pub buyer_actor_source: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_custody: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_write_capable: Option<bool>, + #[serde(skip_serializing_if = "Option::is_none")] pub seller_pubkey: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub event_id: Option<String>, @@ -2235,6 +2247,10 @@ pub struct OrderSummaryView { pub buyer_pubkey: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub buyer_actor_source: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_custody: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub buyer_write_capable: Option<bool>, pub item_count: usize, #[serde(skip_serializing_if = "Option::is_none")] pub economics: Option<RadrootsTradeOrderEconomics>, diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -351,11 +351,14 @@ pub fn scaffold( }; save_draft(file.as_path(), &document)?; - let mut view: OrderNewView = view_from_loaded(LoadedOrderDraft { - file, - updated_at_unix: now_unix(), - document, - }) + let mut view: OrderNewView = view_from_loaded( + config, + LoadedOrderDraft { + file, + updated_at_unix: now_unix(), + document, + }, + )? .into(); view.actions .insert(0, format!("radroots order get {}", view.order_id)); @@ -425,11 +428,14 @@ pub fn scaffold_preflight( listing_lookup, }; - let mut view: OrderNewView = view_from_loaded(LoadedOrderDraft { - file, - updated_at_unix: now_unix(), - document, - }) + let mut view: OrderNewView = view_from_loaded( + config, + LoadedOrderDraft { + file, + updated_at_unix: now_unix(), + document, + }, + )? .into(); view.state = "dry_run".to_owned(); view.actions @@ -454,6 +460,8 @@ pub fn get(config: &RuntimeConfig, args: &RecordLookupArgs) -> Result<OrderGetVi buyer_account_id: None, buyer_pubkey: None, buyer_actor_source: None, + buyer_custody: None, + buyer_write_capable: None, seller_pubkey: None, ready_for_submit: false, items: Vec::new(), @@ -471,7 +479,7 @@ pub fn get(config: &RuntimeConfig, args: &RecordLookupArgs) -> Result<OrderGetVi } match load_draft(file.as_path()) { - Ok(loaded) => Ok(view_from_loaded(loaded)), + Ok(loaded) => view_from_loaded(config, loaded), Err(reason) => Ok(OrderGetView { state: "error".to_owned(), source: ORDER_SOURCE.to_owned(), @@ -484,6 +492,8 @@ pub fn get(config: &RuntimeConfig, args: &RecordLookupArgs) -> Result<OrderGetVi buyer_account_id: None, buyer_pubkey: None, buyer_actor_source: None, + buyer_custody: None, + buyer_write_capable: None, seller_pubkey: None, ready_for_submit: false, items: Vec::new(), @@ -518,7 +528,7 @@ pub fn list(config: &RuntimeConfig) -> Result<OrderListView, RuntimeError> { continue; } match load_draft(path.as_path()) { - Ok(loaded) => orders.push(summary_from_loaded(&loaded)), + Ok(loaded) => orders.push(summary_from_loaded(config, &loaded)?), Err(reason) => orders.push(summary_for_invalid_file(path.as_path(), reason)), } } @@ -532,7 +542,10 @@ pub fn list(config: &RuntimeConfig) -> Result<OrderListView, RuntimeError> { let state = if orders.is_empty() { "empty" - } else if orders.iter().any(|order| order.state == "error") { + } else if orders + .iter() + .any(|order| order.state == "error" || !order.ready_for_submit) + { "degraded" } else { "ready" @@ -569,6 +582,8 @@ pub fn submit( buyer_account_id: None, buyer_pubkey: None, buyer_actor_source: None, + buyer_custody: None, + buyer_write_capable: None, seller_pubkey: None, event_id: None, event_kind: None, @@ -606,6 +621,8 @@ pub fn submit( buyer_account_id: None, buyer_pubkey: None, buyer_actor_source: None, + buyer_custody: None, + buyer_write_capable: None, seller_pubkey: None, event_id: None, event_kind: None, @@ -645,6 +662,8 @@ pub fn submit( buyer_account_id: buyer_account_id(&loaded.document), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), buyer_actor_source: buyer_actor_source(&loaded.document), + buyer_custody: None, + buyer_write_capable: None, seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), event_id: None, event_kind: None, @@ -8723,19 +8742,24 @@ fn decimal_from_adjustment(value: &str, field: &str) -> Result<RadrootsCoreDecim Ok(parsed) } -fn view_from_loaded(loaded: LoadedOrderDraft) -> OrderGetView { +fn view_from_loaded( + config: &RuntimeConfig, + loaded: LoadedOrderDraft, +) -> Result<OrderGetView, RuntimeError> { let OrderInspection { state, ready_for_submit, listing_addr, listing_event_id, seller_pubkey, + buyer_custody, + buyer_write_capable, issues, - } = inspect_document(&loaded.document); + } = inspect_document(config, &loaded.document)?; let actions = actions_for_document(&loaded.document, loaded.file.as_path(), issues.as_slice()); - OrderGetView { + Ok(OrderGetView { state, source: ORDER_SOURCE.to_owned(), lookup: loaded.document.order.order_id.clone(), @@ -8747,6 +8771,8 @@ fn view_from_loaded(loaded: LoadedOrderDraft) -> OrderGetView { buyer_account_id: buyer_account_id(&loaded.document), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), buyer_actor_source: buyer_actor_source(&loaded.document), + buyer_custody, + buyer_write_capable, seller_pubkey, ready_for_submit, items: loaded @@ -8766,20 +8792,25 @@ fn view_from_loaded(loaded: LoadedOrderDraft) -> OrderGetView { reason: None, issues, actions, - } + }) } -fn summary_from_loaded(loaded: &LoadedOrderDraft) -> OrderSummaryView { +fn summary_from_loaded( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, +) -> Result<OrderSummaryView, RuntimeError> { let OrderInspection { state, ready_for_submit, listing_addr, listing_event_id, seller_pubkey: _, + buyer_custody, + buyer_write_capable, issues, - } = inspect_document(&loaded.document); + } = inspect_document(config, &loaded.document)?; - OrderSummaryView { + Ok(OrderSummaryView { id: loaded.document.order.order_id.clone(), state, ready_for_submit, @@ -8790,12 +8821,14 @@ fn summary_from_loaded(loaded: &LoadedOrderDraft) -> OrderSummaryView { buyer_account_id: buyer_account_id(&loaded.document), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), buyer_actor_source: buyer_actor_source(&loaded.document), + buyer_custody, + buyer_write_capable, item_count: loaded.document.order.items.len(), economics: loaded.document.order.economics.clone(), updated_at_unix: loaded.updated_at_unix, job: None, issues, - } + }) } fn summary_for_invalid_file(path: &Path, reason: String) -> OrderSummaryView { @@ -8815,6 +8848,8 @@ fn summary_for_invalid_file(path: &Path, reason: String) -> OrderSummaryView { buyer_account_id: None, buyer_pubkey: None, buyer_actor_source: None, + buyer_custody: None, + buyer_write_capable: None, item_count: 0, economics: None, updated_at_unix: modified_unix(path).unwrap_or_default(), @@ -8823,7 +8858,10 @@ fn summary_for_invalid_file(path: &Path, reason: String) -> OrderSummaryView { } } -fn inspect_document(document: &OrderDraftDocument) -> OrderInspection { +fn inspect_document( + config: &RuntimeConfig, + document: &OrderDraftDocument, +) -> Result<OrderInspection, RuntimeError> { let listing_addr = non_empty_string(document.order.listing_addr.clone()); let listing_event_id = non_empty_string(document.order.listing_event_id.clone()); let parsed_listing_addr = listing_addr @@ -8834,7 +8872,9 @@ fn inspect_document(document: &OrderDraftDocument) -> OrderInspection { .as_ref() .map(|listing| listing.seller_pubkey.clone()) }); - let issues = collect_issues(document); + let mut issues = collect_issues(document); + let buyer_readiness = inspect_buyer_actor_readiness(config, document)?; + issues.extend(buyer_readiness.issues); let ready_for_submit = issues.is_empty(); let state = if ready_for_submit { "ready".to_owned() @@ -8842,14 +8882,86 @@ fn inspect_document(document: &OrderDraftDocument) -> OrderInspection { "draft".to_owned() }; - OrderInspection { + Ok(OrderInspection { state, ready_for_submit, listing_addr, listing_event_id, seller_pubkey, + buyer_custody: buyer_readiness + .account + .as_ref() + .map(|account| account.custody.as_str().to_owned()), + buyer_write_capable: buyer_readiness + .account + .as_ref() + .map(|account| account.write_capable), issues, + }) +} + +#[derive(Debug, Clone)] +struct OrderBuyerActorReadiness { + account: Option<accounts::AccountRecordView>, + issues: Vec<OrderIssueView>, +} + +fn inspect_buyer_actor_readiness( + config: &RuntimeConfig, + document: &OrderDraftDocument, +) -> Result<OrderBuyerActorReadiness, RuntimeError> { + let account_id = document.buyer_actor.account_id.trim(); + let buyer_pubkey = document.buyer_actor.pubkey.trim(); + if account_id.is_empty() || buyer_pubkey.is_empty() { + return Ok(OrderBuyerActorReadiness { + account: None, + issues: Vec::new(), + }); } + + let snapshot = accounts::snapshot(config)?; + let Some(account) = snapshot + .accounts + .into_iter() + .find(|account| account.record.account_id.as_str() == account_id) + else { + return Ok(OrderBuyerActorReadiness { + account: None, + issues: vec![issue_with_code( + "account_unresolved", + "buyer_actor.account_id", + format!( + "order buyer_actor account_id `{account_id}` is not present in the local account store" + ), + )], + }); + }; + + let account_pubkey = account.record.public_identity.public_key_hex.as_str(); + let mut issues = Vec::new(); + if !account_pubkey.eq_ignore_ascii_case(buyer_pubkey) { + issues.push(issue_with_code( + "account_mismatch", + "buyer_actor.pubkey", + format!( + "order buyer_actor pubkey `{buyer_pubkey}` does not match local account `{account_id}` pubkey `{account_pubkey}`" + ), + )); + } + if !account.write_capable { + issues.push(issue_with_code( + "account_watch_only", + "buyer_actor.account_id", + format!( + "order buyer_actor account `{account_id}` is watch_only and cannot sign until a matching secret is attached" + ), + )); + } + + Ok(OrderBuyerActorReadiness { + account: Some(account), + issues, + }) } fn collect_issues(document: &OrderDraftDocument) -> Vec<OrderIssueView> { @@ -9043,6 +9155,32 @@ fn actions_for_document( document.order.order_id )); } + if issues + .iter() + .any(|issue| issue.code == "account_unresolved") + { + actions.push("radroots account import <path>".to_owned()); + actions.push(format!( + "radroots order rebind {} <selector>", + document.order.order_id + )); + } + if issues + .iter() + .any(|issue| issue.code == "account_watch_only") + { + actions.push(format!( + "radroots account attach-secret {} <path>", + document.buyer_actor.account_id + )); + actions.push(format!("radroots order get {}", document.order.order_id)); + } + if issues.iter().any(|issue| issue.code == "account_mismatch") { + actions.push(format!( + "radroots order rebind {} <selector>", + document.order.order_id + )); + } if document.order.items.is_empty() || issues .iter() @@ -9050,7 +9188,13 @@ fn actions_for_document( { actions.push(format!("radroots order get {}", document.order.order_id)); } - actions + let mut deduped = Vec::new(); + for action in actions { + if !deduped.contains(&action) { + deduped.push(action); + } + } + deduped } fn resolve_initial_buyer_actor( @@ -9332,6 +9476,8 @@ fn order_submit_unconfigured_view( buyer_account_id: buyer_account_id(&loaded.document), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), buyer_actor_source: buyer_actor_source(&loaded.document), + buyer_custody: None, + buyer_write_capable: None, seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), event_id: None, event_kind: None, @@ -9370,6 +9516,8 @@ fn order_submit_invalid_quantity_view( buyer_account_id: buyer_account_id(&loaded.document), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), buyer_actor_source: buyer_actor_source(&loaded.document), + buyer_custody: None, + buyer_write_capable: None, seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), event_id: None, event_kind: None, @@ -9641,6 +9789,8 @@ fn order_submit_deduplicated_view( buyer_account_id: buyer_account_id(&loaded.document), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), buyer_actor_source: buyer_actor_source(&loaded.document), + buyer_custody: None, + buyer_write_capable: None, seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), event_id: Some(request.request_event_id.clone()), event_kind: Some(KIND_TRADE_ORDER_REQUEST), @@ -9680,6 +9830,8 @@ fn order_submit_dry_run_view( buyer_account_id: buyer_account_id(&loaded.document), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), buyer_actor_source: buyer_actor_source(&loaded.document), + buyer_custody: None, + buyer_write_capable: None, seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), event_id: None, event_kind: None, @@ -9725,6 +9877,8 @@ fn order_submit_invalid_existing_request_view( buyer_account_id: buyer_account_id(&loaded.document), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), buyer_actor_source: buyer_actor_source(&loaded.document), + buyer_custody: None, + buyer_write_capable: None, seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), event_id: None, event_kind: Some(KIND_TRADE_ORDER_REQUEST), @@ -9858,6 +10012,8 @@ fn published_order_submit_view( buyer_account_id: buyer_account_id(&loaded.document), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), buyer_actor_source: buyer_actor_source(&loaded.document), + buyer_custody: None, + buyer_write_capable: None, seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), event_id: event_id.or(Some(relay.event_id)), event_kind: event_kind.or(Some(relay.event_kind)), @@ -9903,6 +10059,8 @@ fn order_binding_error_view( buyer_account_id: buyer_account_id(&loaded.document), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), buyer_actor_source: buyer_actor_source(&loaded.document), + buyer_custody: None, + buyer_write_capable: None, seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), event_id: None, event_kind: None, @@ -10402,6 +10560,8 @@ struct OrderInspection { listing_addr: Option<String>, listing_event_id: Option<String>, seller_pubkey: Option<String>, + buyer_custody: Option<String>, + buyer_write_capable: Option<bool>, issues: Vec<OrderIssueView>, } @@ -10418,6 +10578,8 @@ impl From<OrderGetView> for OrderNewView { buyer_account_id: view.buyer_account_id, buyer_pubkey: view.buyer_pubkey, buyer_actor_source: view.buyer_actor_source, + buyer_custody: view.buyer_custody, + buyer_write_capable: view.buyer_write_capable, seller_pubkey: view.seller_pubkey, ready_for_submit: view.ready_for_submit, items: view.items, @@ -10738,6 +10900,13 @@ mod tests { #[test] fn order_draft_requires_listing_event_id_for_submit_readiness() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let buyer = accounts::create_or_migrate_default_account(&config) + .expect("buyer account") + .account; + let buyer_account_id = buyer.record.account_id.to_string(); + let buyer_pubkey = buyer.record.public_identity.public_key_hex; let document = OrderDraftDocument { version: 1, kind: ORDER_DRAFT_KIND.to_owned(), @@ -10745,7 +10914,7 @@ mod tests { order_id: "ord_AAAAAAAAAAAAAAAAAAAAAg".to_owned(), listing_addr: "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg".to_owned(), listing_event_id: String::new(), - buyer_pubkey: "a".repeat(64), + buyer_pubkey: buyer_pubkey.clone(), seller_pubkey: "deadbeef".to_owned(), items: vec![OrderDraftItem { bin_id: "bin-1".to_owned(), @@ -10758,14 +10927,14 @@ mod tests { )), }, buyer_actor: OrderDraftBuyerActor { - account_id: "acct_demo".to_owned(), - pubkey: "a".repeat(64), + account_id: buyer_account_id, + pubkey: buyer_pubkey, source: ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(), }, listing_lookup: Some("fresh-eggs".to_owned()), }; - let inspection = inspect_document(&document); + let inspection = inspect_document(&config, &document).expect("inspect order draft"); assert_eq!(inspection.state, "draft"); assert!(!inspection.ready_for_submit); assert!( diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -3819,6 +3819,39 @@ fn rewrite_order_bin(sandbox: &RadrootsCliSandbox, order_id: &str, bin_id: &str) fs::write(path, updated).expect("rewrite order draft bin"); } +fn rewrite_order_buyer_actor_pubkey(sandbox: &RadrootsCliSandbox, order_id: &str, pubkey: &str) { + let path = sandbox + .root() + .join("data/apps/cli/orders/drafts") + .join(format!("{order_id}.toml")); + let contents = fs::read_to_string(&path).expect("read order draft"); + let mut in_buyer_actor = false; + let mut replaced = false; + let updated = contents + .lines() + .map(|line| { + let trimmed = line.trim_start(); + if trimmed.starts_with('[') { + in_buyer_actor = trimmed == "[buyer_actor]"; + } + if in_buyer_actor && trimmed.starts_with("pubkey =") { + replaced = true; + format!("{}pubkey = \"{}\"", line_indent(line), pubkey) + } else { + line.to_owned() + } + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(replaced, "buyer_actor pubkey field"); + fs::write(path, format!("{updated}\n")).expect("rewrite order draft buyer actor"); +} + +fn line_indent(line: &str) -> &str { + let trimmed = line.trim_start(); + &line[..line.len() - trimmed.len()] +} + #[test] fn buyer_target_flow_acceptance_uses_target_operations() { let sandbox = RadrootsCliSandbox::new(); @@ -4031,6 +4064,174 @@ fn buyer_target_flow_acceptance_uses_target_operations() { } #[test] +fn order_get_and_list_report_missing_bound_buyer_account() { + let sandbox = RadrootsCliSandbox::new(); + let order_id = create_ready_order(&sandbox, "missing_buyer_account"); + + let ready = sandbox.json_success(&["--format", "json", "order", "get", order_id.as_str()]); + let account_id = ready["result"]["buyer_account_id"] + .as_str() + .expect("buyer account id"); + assert_eq!(ready["result"]["state"], "ready"); + assert_eq!(ready["result"]["buyer_account_id"], account_id); + assert_eq!(ready["result"]["buyer_custody"], "secret_backed"); + assert_eq!(ready["result"]["buyer_write_capable"], true); + + sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "remove", + account_id, + ]); + + let missing = sandbox.json_success(&["--format", "json", "order", "get", order_id.as_str()]); + assert_eq!(missing["operation_id"], "order.get"); + assert_eq!(missing["result"]["state"], "draft"); + assert_eq!(missing["result"]["ready_for_submit"], false); + assert_eq!(missing["result"]["buyer_account_id"], account_id); + assert_eq!(missing["result"]["buyer_custody"], Value::Null); + assert!( + missing["result"]["issues"] + .as_array() + .expect("issues") + .iter() + .any(|issue| issue["code"] == "account_unresolved") + ); + assert!( + missing["result"]["actions"] + .as_array() + .expect("actions") + .iter() + .any(|action| action == "radroots account import <path>") + ); + assert!( + missing["result"]["actions"] + .as_array() + .expect("actions") + .iter() + .any(|action| action + == &Value::String(format!("radroots order rebind {order_id} <selector>"))) + ); + + let list = sandbox.json_success(&["--format", "json", "order", "list"]); + assert_eq!(list["result"]["state"], "degraded"); + assert_eq!(list["result"]["orders"][0]["ready_for_submit"], false); + assert!( + list["result"]["orders"][0]["issues"] + .as_array() + .expect("issues") + .iter() + .any(|issue| issue["code"] == "account_unresolved") + ); +} + +#[test] +fn order_get_marks_watch_only_bound_buyer_unready() { + let sandbox = RadrootsCliSandbox::new(); + let public_identity = identity_public(92); + let public_identity_file = + write_public_identity_profile(&sandbox, "order-watch-only-buyer", &public_identity); + let imported = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "import", + "--default", + public_identity_file.to_string_lossy().as_ref(), + ]); + let account_id = imported["result"]["account"]["id"] + .as_str() + .expect("watch account id"); + assert_eq!(imported["result"]["account"]["custody"], "watch_only"); + + seed_orderable_listing(&sandbox, LISTING_ADDR); + sandbox.json_success(&["--format", "json", "basket", "create", "watch_buyer"]); + sandbox.json_success(&[ + "--format", + "json", + "basket", + "item", + "add", + "watch_buyer", + "--listing-addr", + LISTING_ADDR, + "--bin-id", + "bin-1", + "--quantity", + "2", + ]); + let quote = sandbox.json_success(&[ + "--format", + "json", + "basket", + "quote", + "create", + "watch_buyer", + ]); + let order_id = quote["result"]["quote"]["order_id"] + .as_str() + .expect("order id"); + + let get = sandbox.json_success(&["--format", "json", "order", "get", order_id]); + assert_eq!(get["result"]["state"], "draft"); + assert_eq!(get["result"]["ready_for_submit"], false); + assert_eq!(get["result"]["buyer_account_id"], account_id); + assert_eq!(get["result"]["buyer_custody"], "watch_only"); + assert_eq!(get["result"]["buyer_write_capable"], false); + assert!( + get["result"]["issues"] + .as_array() + .expect("issues") + .iter() + .any(|issue| issue["code"] == "account_watch_only") + ); + assert!( + get["result"]["actions"] + .as_array() + .expect("actions") + .iter() + .any(|action| action + == &Value::String(format!( + "radroots account attach-secret {account_id} <path>" + ))) + ); +} + +#[test] +fn order_get_marks_bound_buyer_pubkey_mismatch_unready() { + let sandbox = RadrootsCliSandbox::new(); + let order_id = create_ready_order(&sandbox, "mismatched_buyer_actor"); + let other_pubkey = identity_public(93).public_key_hex; + rewrite_order_buyer_actor_pubkey(&sandbox, order_id.as_str(), other_pubkey.as_str()); + + let get = sandbox.json_success(&["--format", "json", "order", "get", order_id.as_str()]); + assert_eq!(get["result"]["state"], "draft"); + assert_eq!(get["result"]["ready_for_submit"], false); + assert_eq!(get["result"]["buyer_custody"], "secret_backed"); + assert_eq!(get["result"]["buyer_write_capable"], true); + assert!( + get["result"]["issues"] + .as_array() + .expect("issues") + .iter() + .any(|issue| issue["code"] == "account_mismatch") + ); + assert!( + get["result"]["actions"] + .as_array() + .expect("actions") + .iter() + .any(|action| action + == &Value::String(format!("radroots order rebind {order_id} <selector>"))) + ); +} + +#[test] fn order_submit_requires_local_replica_freshness_before_signing() { let sandbox = RadrootsCliSandbox::new(); let order_id = create_ready_order(&sandbox, "freshness_missing_db");