cli

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

commit a8b7622b840d8be34f2a4873ff3ef0bfeefe05e0
parent c4b37da68337ec0701b605d5e6c979bdd8659b1c
Author: triesap <tyson@radroots.org>
Date:   Sun, 10 May 2026 03:13:53 +0000

order: add explicit draft rebind

- add order rebind parser and operation contract
- preview and write bound buyer actor changes
- mint order ids when buyer pubkeys change
- refuse relay-visible published order requests

Diffstat:
Msrc/domain/runtime.rs | 40++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 3+++
Msrc/operation_adapter.rs | 36++++++++++++++++++++++++++++++++++++
Msrc/operation_order.rs | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/operation_registry.rs | 18++++++++++++++++++
Msrc/runtime/order.rs | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/runtime_args.rs | 6++++++
Msrc/target_cli.rs | 25+++++++++++++++++++++++++
Mtests/target_cli.rs | 291++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
9 files changed, 751 insertions(+), 16 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1338,6 +1338,46 @@ impl OrderSubmitView { } #[derive(Debug, Clone, Serialize)] +pub struct OrderRebindView { + pub state: String, + pub source: String, + pub lookup: String, + pub file: String, + pub dry_run: bool, + pub from_order_id: String, + pub to_order_id: String, + pub order_id_changed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub from_buyer_account_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub from_buyer_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub from_buyer_actor_source: Option<String>, + pub to_buyer_account_id: String, + pub to_buyer_pubkey: String, + pub to_buyer_actor_source: String, + pub buyer_pubkey_changed: bool, + pub existing_request_check: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub existing_request_event_ids: Vec<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub actions: Vec<String>, +} + +impl OrderRebindView { + pub fn disposition(&self) -> CommandDisposition { + match self.state.as_str() { + "missing" => CommandDisposition::NotFound, + "invalid" => CommandDisposition::ValidationFailed, + "error" => CommandDisposition::InternalError, + _ => CommandDisposition::Success, + } + } +} + +#[derive(Debug, Clone, Serialize)] pub struct OrderDecisionView { pub state: String, pub source: String, diff --git a/src/main.rs b/src/main.rs @@ -288,6 +288,9 @@ fn execute_request( TargetOperationRequest::OrderList(request) => { execute_with(OrderOperationService::new(config), request) } + TargetOperationRequest::OrderRebind(request) => { + execute_with(OrderOperationService::new(config), request) + } TargetOperationRequest::OrderAccept(request) => { execute_with(OrderOperationService::new(config), request) } diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -1455,6 +1455,10 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati insert_string(&mut input, "order_id", &args.order_id); } OrderCommand::Get(args) => insert_string(&mut input, "order_id", &args.order_id), + OrderCommand::Rebind(args) => { + insert_string(&mut input, "order_id", &args.order_id); + insert_string(&mut input, "selector", &args.selector); + } OrderCommand::Accept(args) => insert_string(&mut input, "order_id", &args.order_id), OrderCommand::Decline(args) => { insert_string(&mut input, "order_id", &args.order_id); @@ -1643,6 +1647,7 @@ target_operation_contracts! { OrderSubmit => (OrderSubmitRequest, OrderSubmitResult, "order.submit"), OrderGet => (OrderGetRequest, OrderGetResult, "order.get"), OrderList => (OrderListRequest, OrderListResult, "order.list"), + OrderRebind => (OrderRebindRequest, OrderRebindResult, "order.rebind"), OrderAccept => (OrderAcceptRequest, OrderAcceptResult, "order.accept"), OrderDecline => (OrderDeclineRequest, OrderDeclineResult, "order.decline"), OrderCancel => (OrderCancelRequest, OrderCancelResult, "order.cancel"), @@ -1866,6 +1871,37 @@ mod tests { } #[test] + fn adapter_maps_order_rebind_inputs() { + let parsed = + TargetCliArgs::try_parse_from(["radroots", "order", "rebind", "ord_test", "acct_test"]) + .expect("target args parse"); + + let request = TargetOperationRequest::from_target_args(&parsed) + .expect("operation request from target args"); + let TargetOperationRequest::OrderRebind(request) = request else { + panic!("expected order rebind request") + }; + + assert_eq!(request.operation_id(), "order.rebind"); + assert_eq!( + request + .payload + .input + .get("order_id") + .and_then(Value::as_str), + Some("ord_test") + ); + assert_eq!( + request + .payload + .input + .get("selector") + .and_then(Value::as_str), + Some("acct_test") + ); + } + + #[test] fn adapter_maps_order_fulfillment_update_input() { let parsed = TargetCliArgs::try_parse_from([ "radroots", diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -4,8 +4,8 @@ use serde_json::{Value, json}; use crate::deferred_payment::deferred_payment_message; use crate::domain::runtime::{ CommandDisposition, OrderCancellationView, OrderDecisionView, OrderFulfillmentView, - OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView, OrderStatusView, - OrderSubmitView, + OrderRebindView, OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView, + OrderStatusView, OrderSubmitView, }; use crate::operation_adapter::{ OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, @@ -14,18 +14,19 @@ use crate::operation_adapter::{ OrderEventListRequest, OrderEventListResult, OrderEventWatchRequest, OrderEventWatchResult, OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, OrderGetRequest, OrderGetResult, OrderListRequest, OrderListResult, OrderPaymentRecordRequest, OrderPaymentRecordResult, - OrderReceiptRecordRequest, OrderReceiptRecordResult, OrderRevisionAcceptRequest, - OrderRevisionAcceptResult, OrderRevisionDeclineRequest, OrderRevisionDeclineResult, - OrderRevisionProposeRequest, OrderRevisionProposeResult, OrderSettlementAcceptRequest, - OrderSettlementAcceptResult, OrderSettlementRejectRequest, OrderSettlementRejectResult, - OrderStatusGetRequest, OrderStatusGetResult, OrderSubmitRequest, OrderSubmitResult, + OrderRebindRequest, OrderRebindResult, OrderReceiptRecordRequest, OrderReceiptRecordResult, + OrderRevisionAcceptRequest, OrderRevisionAcceptResult, OrderRevisionDeclineRequest, + OrderRevisionDeclineResult, OrderRevisionProposeRequest, OrderRevisionProposeResult, + OrderSettlementAcceptRequest, OrderSettlementAcceptResult, OrderSettlementRejectRequest, + OrderSettlementRejectResult, OrderStatusGetRequest, OrderStatusGetResult, OrderSubmitRequest, + OrderSubmitResult, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; use crate::runtime_args::{ - OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderReceiptArgs, - OrderRevisionDecisionArg, OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderStatusArgs, - OrderSubmitArgs, RecordLookupArgs, + OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderFulfillmentArgs, OrderRebindArgs, + OrderReceiptArgs, OrderRevisionDecisionArg, OrderRevisionDecisionArgs, + OrderRevisionProposeArgs, OrderStatusArgs, OrderSubmitArgs, RecordLookupArgs, }; const ORDER_EVENT_WATCH_DEFERRED_REASON: &str = "relay-backed order event watch is not implemented"; @@ -100,6 +101,37 @@ impl OperationService<OrderListRequest> for OrderOperationService<'_> { } } +impl OperationService<OrderRebindRequest> for OrderOperationService<'_> { + type Result = OrderRebindResult; + + fn execute( + &self, + request: OperationRequest<OrderRebindRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let args = OrderRebindArgs { + key: required_order_key(&request)?, + selector: required_string_input(&request, "selector")?, + }; + if request.context.dry_run { + let view = + crate::runtime::order::rebind_preflight(self.config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; + return order_rebind_result::<OrderRebindResult>(request.operation_id(), &view); + } + if request.context.requires_approval_token() { + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); + } + + let view = crate::runtime::order::rebind(self.config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; + order_rebind_result::<OrderRebindResult>(request.operation_id(), &view) + } +} + impl OperationService<OrderAcceptRequest> for OrderOperationService<'_> { type Result = OrderAcceptResult; @@ -1218,6 +1250,64 @@ where } } +fn order_rebind_result<R>( + operation_id: &str, + view: &OrderRebindView, +) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, +{ + match view.disposition() { + CommandDisposition::Success => serialized_target_result::<R, _>(view), + CommandDisposition::NotFound => Err(OperationAdapterError::not_found_with_detail( + operation_id, + view.reason + .clone() + .unwrap_or_else(|| format!("order draft `{}` was not found", view.lookup)), + order_rebind_error_detail(view), + )), + CommandDisposition::ValidationFailed => { + Err(OperationAdapterError::validation_failed_with_detail( + operation_id, + view.reason.clone().unwrap_or_else(|| { + format!("order rebind finished with state `{}`", view.state) + }), + order_rebind_error_detail(view), + )) + } + disposition => Err(OperationAdapterError::from_command_disposition( + operation_id, + disposition, + view.reason + .clone() + .unwrap_or_else(|| format!("order rebind finished with state `{}`", view.state)), + )), + } +} + +fn order_rebind_error_detail(view: &OrderRebindView) -> Value { + json!({ + "state": &view.state, + "source": &view.source, + "lookup": &view.lookup, + "file": &view.file, + "dry_run": view.dry_run, + "from_order_id": &view.from_order_id, + "to_order_id": &view.to_order_id, + "order_id_changed": view.order_id_changed, + "from_buyer_account_id": &view.from_buyer_account_id, + "from_buyer_pubkey": &view.from_buyer_pubkey, + "from_buyer_actor_source": &view.from_buyer_actor_source, + "to_buyer_account_id": &view.to_buyer_account_id, + "to_buyer_pubkey": &view.to_buyer_pubkey, + "to_buyer_actor_source": &view.to_buyer_actor_source, + "buyer_pubkey_changed": view.buyer_pubkey_changed, + "existing_request_check": &view.existing_request_check, + "existing_request_event_ids": &view.existing_request_event_ids, + "actions": &view.actions, + }) +} + fn order_submit_error_detail(view: &OrderSubmitView) -> Value { json!({ "state": &view.state, diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -923,6 +923,21 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ false ), operation!( + "order.rebind", + "radroots order rebind", + "order", + "order_rebind", + "OrderRebindRequest", + "OrderRebindResult", + "Rebind a local order draft to an explicit buyer actor.", + Buyer, + true, + Required, + High, + false, + true + ), + operation!( "order.accept", "radroots order accept", "order", @@ -1273,6 +1288,7 @@ mod tests { "order.submit", "order.get", "order.list", + "order.rebind", "order.accept", "order.decline", "order.cancel", @@ -1321,6 +1337,7 @@ mod tests { "basket.adjustment.remove", "basket.quote.create", "order.submit", + "order.rebind", "order.accept", "order.decline", "order.cancel", @@ -1392,6 +1409,7 @@ mod tests { "listing.publish", "listing.archive", "order.submit", + "order.rebind", "order.accept", "order.decline", "order.cancel", diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -84,8 +84,8 @@ use crate::domain::runtime::{ OrderCancellationView, OrderDecisionView, OrderDraftItemView, OrderEventListEntryView, OrderEventListView, OrderFulfillmentView, OrderGetView, OrderInventoryBinView, OrderInventoryView, OrderIssueView, OrderListView, OrderNewView, OrderPaymentView, - OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView, OrderSettlementView, - OrderStatusFulfillmentView, OrderStatusLifecycleCancellationView, + OrderRebindView, OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView, + OrderSettlementView, OrderStatusFulfillmentView, OrderStatusLifecycleCancellationView, OrderStatusLifecycleReceiptView, OrderStatusLifecycleView, OrderStatusPaymentView, OrderStatusRevisionView, OrderStatusView, OrderSubmitView, OrderSummaryView, RelayFailureView, }; @@ -102,9 +102,10 @@ use crate::runtime::sync::{ }; use crate::runtime_args::{ OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderDraftCreateArgs, - OrderFulfillmentArgs, OrderPaymentArgs, OrderReceiptArgs, OrderRevisionDecisionArg, - OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderSettlementArgs, - OrderSettlementDecisionArg, OrderStatusArgs, OrderSubmitArgs, RecordLookupArgs, + OrderFulfillmentArgs, OrderPaymentArgs, OrderRebindArgs, OrderReceiptArgs, + OrderRevisionDecisionArg, OrderRevisionDecisionArgs, OrderRevisionProposeArgs, + OrderSettlementArgs, OrderSettlementDecisionArg, OrderStatusArgs, OrderSubmitArgs, + RecordLookupArgs, }; const ORDER_DRAFT_KIND: &str = "order_draft_v1"; @@ -274,6 +275,12 @@ struct OrderDecisionInventoryPreflight { } #[derive(Debug, Clone)] +struct OrderRebindExistingRequestCheck { + state: String, + event_ids: Vec<String>, +} + +#[derive(Debug, Clone)] struct SellerOrderRequestResolution { target_relays: Vec<String>, connected_relays: Vec<String>, @@ -735,6 +742,168 @@ pub fn submit( } } +pub fn rebind( + config: &RuntimeConfig, + args: &OrderRebindArgs, +) -> Result<OrderRebindView, RuntimeError> { + rebind_inner(config, args, false) +} + +pub fn rebind_preflight( + config: &RuntimeConfig, + args: &OrderRebindArgs, +) -> Result<OrderRebindView, RuntimeError> { + rebind_inner(config, args, true) +} + +fn rebind_inner( + config: &RuntimeConfig, + args: &OrderRebindArgs, + dry_run: bool, +) -> Result<OrderRebindView, RuntimeError> { + let file = draft_lookup_path(config, args.key.as_str()); + if !file.exists() { + return Ok(OrderRebindView { + state: "missing".to_owned(), + source: ORDER_SOURCE.to_owned(), + lookup: args.key.clone(), + file: file.display().to_string(), + dry_run, + from_order_id: args.key.clone(), + to_order_id: args.key.clone(), + order_id_changed: false, + from_buyer_account_id: None, + from_buyer_pubkey: None, + from_buyer_actor_source: None, + to_buyer_account_id: args.selector.clone(), + to_buyer_pubkey: String::new(), + to_buyer_actor_source: ORDER_BUYER_ACTOR_SOURCE_REBIND.to_owned(), + buyer_pubkey_changed: false, + existing_request_check: "not_checked".to_owned(), + existing_request_event_ids: Vec::new(), + reason: Some(format!("order draft `{}` was not found", args.key)), + actions: vec![ + "radroots order list".to_owned(), + "radroots basket create".to_owned(), + ], + }); + } + + let loaded = load_draft(file.as_path()).map_err(RuntimeError::Config)?; + let target_account = accounts::resolve_account_selector(config, args.selector.as_str()) + .map_err(|error| order_rebind_selector_error(args.selector.as_str(), error))?; + let existing_request = order_rebind_existing_request_check(config, &loaded)?; + let from_order_id = loaded.document.order.order_id.clone(); + let from_buyer_account_id = buyer_account_id(&loaded.document); + let from_buyer_pubkey = non_empty_string(loaded.document.buyer_actor.pubkey.clone()); + let from_buyer_actor_source = buyer_actor_source(&loaded.document); + let target_account_id = target_account.record.account_id.to_string(); + let target_pubkey = target_account.record.public_identity.public_key_hex.clone(); + let current_buyer_pubkey = from_buyer_pubkey + .clone() + .or_else(|| non_empty_string(loaded.document.order.buyer_pubkey.clone())); + let buyer_pubkey_changed = current_buyer_pubkey + .as_deref() + .is_none_or(|pubkey| !pubkey.eq_ignore_ascii_case(target_pubkey.as_str())); + + if !existing_request.event_ids.is_empty() { + return Ok(OrderRebindView { + state: "invalid".to_owned(), + source: ORDER_SOURCE.to_owned(), + lookup: args.key.clone(), + file: loaded.file.display().to_string(), + dry_run, + from_order_id: from_order_id.clone(), + to_order_id: from_order_id.clone(), + order_id_changed: false, + from_buyer_account_id, + from_buyer_pubkey, + from_buyer_actor_source, + to_buyer_account_id: target_account_id, + to_buyer_pubkey: target_pubkey, + to_buyer_actor_source: ORDER_BUYER_ACTOR_SOURCE_REBIND.to_owned(), + buyer_pubkey_changed, + existing_request_check: existing_request.state, + existing_request_event_ids: existing_request.event_ids, + reason: Some( + "order rebind refused because a valid order request is already visible for this order id" + .to_owned(), + ), + actions: vec![ + format!("radroots order status get {from_order_id}"), + "radroots basket quote create <basket-id>".to_owned(), + ], + }); + } + + let mut document = loaded.document.clone(); + let to_order_id = if buyer_pubkey_changed { + next_order_id() + } else { + from_order_id.clone() + }; + let order_id_changed = to_order_id != from_order_id; + document.order.order_id = to_order_id.clone(); + document.order.buyer_pubkey = target_pubkey.clone(); + document.buyer_actor.account_id = target_account_id.clone(); + document.buyer_actor.pubkey = target_pubkey.clone(); + document.buyer_actor.source = ORDER_BUYER_ACTOR_SOURCE_REBIND.to_owned(); + if order_id_changed && let Some(economics) = document.order.economics.as_mut() { + economics.quote_id = format!("quote_{to_order_id}"); + } + + let output_file = if order_id_changed { + drafts_dir(config).join(format!("{to_order_id}.toml")) + } else { + loaded.file.clone() + }; + if !dry_run { + if order_id_changed && output_file.exists() { + return Err(RuntimeError::Config(format!( + "order rebind target file {} already exists", + output_file.display() + ))); + } + save_draft(output_file.as_path(), &document)?; + if order_id_changed && output_file != loaded.file { + fs::remove_file(loaded.file.as_path())?; + } + } + + Ok(OrderRebindView { + state: if dry_run { "dry_run" } else { "rebound" }.to_owned(), + source: ORDER_SOURCE.to_owned(), + lookup: args.key.clone(), + file: output_file.display().to_string(), + dry_run, + from_order_id: from_order_id.clone(), + to_order_id: to_order_id.clone(), + order_id_changed, + from_buyer_account_id, + from_buyer_pubkey, + from_buyer_actor_source, + to_buyer_account_id: target_account_id, + to_buyer_pubkey: target_pubkey, + to_buyer_actor_source: ORDER_BUYER_ACTOR_SOURCE_REBIND.to_owned(), + buyer_pubkey_changed, + existing_request_check: existing_request.state, + existing_request_event_ids: Vec::new(), + reason: Some(if dry_run { + "dry run requested; order buyer actor binding was not written".to_owned() + } else { + "order buyer actor binding updated".to_owned() + }), + actions: if dry_run { + vec![format!( + "radroots --approval-token approve order rebind {} {}", + args.key, args.selector + )] + } else { + vec![format!("radroots order get {to_order_id}")] + }, + }) +} + pub fn event_list( config: &RuntimeConfig, order_id: Option<&str>, @@ -9196,6 +9365,65 @@ fn actions_for_document( deduped } +fn order_rebind_selector_error(selector: &str, error: RuntimeError) -> RuntimeError { + match error { + RuntimeError::Accounts(_) | RuntimeError::Account(_) => { + accounts::AccountRuntimeFailure::unresolved_with_detail( + format!("order rebind target selector `{selector}` did not resolve"), + json!({ + "selector": selector, + "actions": [ + "radroots account list", + "radroots account import <path>", + "radroots account create", + ], + }), + ) + .into() + } + other => other, + } +} + +fn order_rebind_existing_request_check( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, +) -> Result<OrderRebindExistingRequestCheck, RuntimeError> { + if config.relay.urls.is_empty() { + return Ok(OrderRebindExistingRequestCheck { + state: "skipped_no_relays".to_owned(), + event_ids: Vec::new(), + }); + } + + let filter = order_request_filter( + loaded.document.order.seller_pubkey.as_str(), + Some(loaded.document.order.order_id.as_str()), + )?; + let receipt = fetch_events_from_relays(&config.relay.urls, filter) + .map_err(|error| RuntimeError::Network(error.to_string()))?; + let mut event_ids = receipt + .events + .iter() + .filter_map(|event| { + order_submit_request_from_event(event, loaded) + .ok() + .map(|request| request.request_event_id) + }) + .collect::<Vec<_>>(); + event_ids.sort(); + event_ids.dedup(); + + Ok(OrderRebindExistingRequestCheck { + state: if event_ids.is_empty() { + "clear".to_owned() + } else { + "blocked_existing_request".to_owned() + }, + event_ids, + }) +} + fn resolve_initial_buyer_actor( config: &RuntimeConfig, ) -> Result<OrderDraftBuyerActor, RuntimeError> { diff --git a/src/runtime_args.rs b/src/runtime_args.rs @@ -206,6 +206,12 @@ pub struct OrderSubmitArgs { pub idempotency_key: Option<String>, } +#[derive(Debug, Clone)] +pub struct OrderRebindArgs { + pub key: String, + pub selector: String, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OrderDecisionArg { Accept, diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -220,6 +220,7 @@ impl TargetCommand { OrderCommand::Submit(_) => "order.submit", OrderCommand::Get(_) => "order.get", OrderCommand::List => "order.list", + OrderCommand::Rebind(_) => "order.rebind", OrderCommand::Accept(_) => "order.accept", OrderCommand::Decline(_) => "order.decline", OrderCommand::Cancel(_) => "order.cancel", @@ -821,6 +822,7 @@ pub enum OrderCommand { Submit(OrderSubmitArgs), Get(OrderKeyArgs), List, + Rebind(OrderRebindArgs), Accept(OrderKeyArgs), Decline(OrderDeclineArgs), Cancel(OrderCancelArgs), @@ -844,6 +846,12 @@ pub struct OrderKeyArgs { } #[derive(Debug, Clone, Args)] +pub struct OrderRebindArgs { + pub order_id: Option<String>, + pub selector: Option<String>, +} + +#[derive(Debug, Clone, Args)] pub struct OrderDeclineArgs { pub order_id: Option<String>, #[arg(long)] @@ -1218,6 +1226,23 @@ mod tests { } #[test] + fn target_parser_accepts_order_rebind_inputs() { + let parsed = + TargetCliArgs::try_parse_from(["radroots", "order", "rebind", "ord_test", "acct_test"]) + .expect("target args parse"); + + assert_eq!(parsed.command.operation_id(), "order.rebind"); + let crate::target_cli::TargetCommand::Order(order) = parsed.command else { + panic!("expected order command") + }; + let OrderCommand::Rebind(args) = order.command else { + panic!("expected order rebind command") + }; + assert_eq!(args.order_id.as_deref(), Some("ord_test")); + assert_eq!(args.selector.as_deref(), Some("acct_test")); + } + + #[test] fn target_parser_accepts_order_fulfillment_update_state() { let parsed = TargetCliArgs::try_parse_from([ "radroots", diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -8,7 +8,13 @@ use std::sync::mpsc::{self, Receiver}; use std::thread::{self, JoinHandle}; use std::time::Duration; +use radroots_events::RadrootsNostrEventPtr; use radroots_events::kinds::{KIND_FARM, KIND_PROFILE}; +use radroots_events::trade::{ + RadrootsTradeOrderEconomics, RadrootsTradeOrderItem, RadrootsTradeOrderRequested, +}; +use radroots_events_codec::trade::active_trade_order_request_event_build; +use radroots_nostr::prelude::{RadrootsNostrEvent, radroots_nostr_build_event}; use radroots_replica_db::{farm, farm_member_claim, migrations}; use radroots_replica_db_schema::farm::IFarmFields; use radroots_replica_db_schema::farm_member_claim::IFarmMemberClaimFields; @@ -21,7 +27,7 @@ use serde_json::json; use support::{ RadrootsCliSandbox, assert_contains, assert_no_daemon_runtime_reference, - assert_no_removed_command_reference, create_listing_draft, identity_public, + assert_no_removed_command_reference, create_listing_draft, identity_public, identity_secret, make_listing_publishable, make_listing_publishable_with_seller, ndjson_from_stdout, radroots, remove_orderable_listing, replace_latest_listing_event_id, seed_orderable_listing, toml_string, write_public_identity_profile, @@ -258,6 +264,66 @@ impl RelayPublishServer { } } +struct RelayFetchServer { + endpoint: String, + handle: JoinHandle<()>, +} + +impl RelayFetchServer { + fn with_events(events: Vec<RadrootsNostrEvent>) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind relay fetch"); + let endpoint = format!("ws://{}", listener.local_addr().expect("relay fetch addr")); + let handle = thread::spawn(move || { + let (stream, _) = listener.accept().expect("accept relay fetch connection"); + handle_relay_fetch_connection(stream, events); + }); + Self { endpoint, handle } + } + + fn endpoint(&self) -> &str { + self.endpoint.as_str() + } + + fn join(self) { + self.handle.join().expect("relay fetch server join"); + } +} + +fn handle_relay_fetch_connection(stream: TcpStream, events: Vec<RadrootsNostrEvent>) { + let mut websocket = tungstenite::accept(stream).expect("accept fetch websocket"); + let subscription_id = read_relay_req_subscription_id(&mut websocket); + for event in events { + websocket + .send(tungstenite::Message::Text( + json!(["EVENT", subscription_id, event]).to_string().into(), + )) + .expect("relay event send"); + } + websocket + .send(tungstenite::Message::Text( + json!(["EOSE", subscription_id]).to_string().into(), + )) + .expect("relay eose send"); +} + +fn read_relay_req_subscription_id(websocket: &mut tungstenite::WebSocket<TcpStream>) -> String { + loop { + let message = websocket.read().expect("relay req message"); + if !message.is_text() { + continue; + } + let value: Value = + serde_json::from_str(message.to_text().expect("relay req text")).expect("relay json"); + if value.get(0).and_then(Value::as_str) == Some("REQ") { + return value + .get(1) + .and_then(Value::as_str) + .expect("subscription id") + .to_owned(); + } + } +} + fn handle_relay_publish_connection( stream: TcpStream, accepted: bool, @@ -3639,6 +3705,11 @@ fn required_approval_token_rejects_absent_empty_and_whitespace_values() { &["--relay", "ws://127.0.0.1:9", "sync", "push"], ); assert_required_approval_token_rejected(&sandbox, "order.submit", &["order", "submit"]); + assert_required_approval_token_rejected( + &sandbox, + "order.rebind", + &["order", "rebind", "ord_missing", "acct_missing"], + ); assert_required_approval_token_rejected(&sandbox, "order.accept", &["order", "accept"]); assert_required_approval_token_rejected( &sandbox, @@ -3852,6 +3923,39 @@ fn line_indent(line: &str) -> &str { &line[..line.len() - trimmed.len()] } +fn signed_order_request_event_for_quote( + buyer: &radroots_identity::RadrootsIdentity, + order_id: &str, + listing_event_id: &str, + economics: RadrootsTradeOrderEconomics, +) -> RadrootsNostrEvent { + let buyer_pubkey = buyer.public_key_hex(); + let seller_pubkey = "1".repeat(64); + let payload = RadrootsTradeOrderRequested { + order_id: order_id.to_owned(), + listing_addr: LISTING_ADDR.to_owned(), + buyer_pubkey, + seller_pubkey, + items: vec![RadrootsTradeOrderItem { + bin_id: "bin-1".to_owned(), + bin_count: 2, + }], + economics, + }; + let parts = active_trade_order_request_event_build( + &RadrootsNostrEventPtr { + id: listing_event_id.to_owned(), + relays: None, + }, + &payload, + ) + .expect("order request parts"); + radroots_nostr_build_event(parts.kind, parts.content, parts.tags) + .expect("nostr event builder") + .sign_with_keys(buyer.keys()) + .expect("signed order request") +} + #[test] fn buyer_target_flow_acceptance_uses_target_operations() { let sandbox = RadrootsCliSandbox::new(); @@ -4257,6 +4361,191 @@ fn order_get_marks_bound_buyer_pubkey_mismatch_unready() { } #[test] +fn order_rebind_previews_and_writes_bound_buyer_actor_updates() { + let sandbox = RadrootsCliSandbox::new(); + let order_id = create_ready_order(&sandbox, "order_rebind"); + let order_file = sandbox + .root() + .join("data/apps/cli/orders/drafts") + .join(format!("{order_id}.toml")); + let before = fs::read_to_string(&order_file).expect("order before rebind"); + let second = sandbox.json_success(&["--format", "json", "account", "create"]); + let second_account_id = second["result"]["account"]["id"] + .as_str() + .expect("second account id"); + + let dry_run = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "order", + "rebind", + order_id.as_str(), + second_account_id, + ]); + assert_eq!(dry_run["operation_id"], "order.rebind"); + assert_eq!(dry_run["result"]["state"], "dry_run"); + assert_eq!(dry_run["result"]["from_order_id"], order_id); + assert_eq!(dry_run["result"]["order_id_changed"], true); + assert_eq!(dry_run["result"]["buyer_pubkey_changed"], true); + assert_eq!(dry_run["result"]["to_buyer_account_id"], second_account_id); + assert_eq!( + dry_run["result"]["existing_request_check"], + "skipped_no_relays" + ); + assert_eq!( + fs::read_to_string(&order_file).expect("order after dry-run rebind"), + before + ); + + let (unapproved_output, unapproved) = sandbox.json_output(&[ + "--format", + "json", + "order", + "rebind", + order_id.as_str(), + second_account_id, + ]); + assert!(!unapproved_output.status.success()); + assert_eq!(unapproved["operation_id"], "order.rebind"); + assert_eq!(unapproved["errors"][0]["code"], "approval_required"); + + let rebound = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "order", + "rebind", + order_id.as_str(), + second_account_id, + ]); + assert_eq!(rebound["operation_id"], "order.rebind"); + assert_eq!(rebound["result"]["state"], "rebound"); + assert_eq!(rebound["result"]["from_order_id"], order_id); + assert_eq!(rebound["result"]["order_id_changed"], true); + let rebound_order_id = rebound["result"]["to_order_id"] + .as_str() + .expect("rebound order id"); + assert_ne!(rebound_order_id, order_id); + let rebound_file = rebound["result"]["file"].as_str().expect("rebound file"); + assert!(!order_file.exists()); + let after = fs::read_to_string(rebound_file).expect("order after rebind"); + assert!(after.contains("[buyer_actor]")); + assert!(after.contains("source = \"order_rebind\"")); + assert!(after.contains(format!("order_id = \"{rebound_order_id}\"").as_str())); + assert!(after.contains(format!("quote_id = \"quote_{rebound_order_id}\"").as_str())); + + let get = sandbox.json_success(&["--format", "json", "order", "get", rebound_order_id]); + assert_eq!(get["result"]["state"], "ready"); + assert_eq!(get["result"]["buyer_account_id"], second_account_id); + assert_eq!(get["result"]["buyer_actor_source"], "order_rebind"); + + let same = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "order", + "rebind", + rebound_order_id, + second_account_id, + ]); + assert_eq!(same["result"]["state"], "rebound"); + assert_eq!(same["result"]["order_id_changed"], false); + assert_eq!(same["result"]["to_order_id"], rebound_order_id); +} + +#[test] +fn order_rebind_refuses_visible_published_request() { + let sandbox = RadrootsCliSandbox::new(); + let buyer = identity_secret(94); + let buyer_public = buyer.to_public(); + let buyer_public_file = + write_public_identity_profile(&sandbox, "rebind-visible-buyer", &buyer_public); + sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "import", + "--default", + buyer_public_file.to_string_lossy().as_ref(), + ]); + let listing_event_id = seed_orderable_listing(&sandbox, LISTING_ADDR); + sandbox.json_success(&["--format", "json", "basket", "create", "visible_rebind"]); + sandbox.json_success(&[ + "--format", + "json", + "basket", + "item", + "add", + "visible_rebind", + "--listing-addr", + LISTING_ADDR, + "--bin-id", + "bin-1", + "--quantity", + "2", + ]); + let quote = sandbox.json_success(&[ + "--format", + "json", + "basket", + "quote", + "create", + "visible_rebind", + ]); + let order_id = quote["result"]["quote"]["order_id"] + .as_str() + .expect("order id"); + let economics: RadrootsTradeOrderEconomics = + serde_json::from_value(quote["result"]["quote"]["economics"].clone()) + .expect("quote economics"); + let event = signed_order_request_event_for_quote( + &buyer, + order_id, + listing_event_id.as_str(), + economics, + ); + let target = sandbox.json_success(&["--format", "json", "account", "create"]); + let target_account_id = target["result"]["account"]["id"] + .as_str() + .expect("target account id"); + let relay = RelayFetchServer::with_events(vec![event]); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "--relay", + relay.endpoint(), + "order", + "rebind", + order_id, + target_account_id, + ]); + relay.join(); + + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(10)); + assert_eq!(value["operation_id"], "order.rebind"); + assert_eq!(value["errors"][0]["code"], "validation_failed"); + assert_eq!( + value["errors"][0]["detail"]["existing_request_check"], + "blocked_existing_request" + ); + assert_eq!( + value["errors"][0]["detail"]["existing_request_event_ids"] + .as_array() + .expect("existing request ids") + .len(), + 1 + ); +} + +#[test] fn order_submit_requires_local_replica_freshness_before_signing() { let sandbox = RadrootsCliSandbox::new(); let order_id = create_ready_order(&sandbox, "freshness_missing_db");