cli

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

commit 06cabe28e7f045d360c01ce3cb07371452182498
parent 39bb2c54aa7bd0dda369702cdbca5007b17eca0e
Author: triesap <tyson@radroots.org>
Date:   Tue, 28 Apr 2026 00:45:43 +0000

order: publish requests through relays

Diffstat:
Msrc/domain/runtime.rs | 10++++++++++
Msrc/operation_order.rs | 4+++-
Msrc/runtime/order.rs | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mtests/signer_runtime_modes.rs | 47++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/target_cli.rs | 7++++---
5 files changed, 197 insertions(+), 24 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1159,10 +1159,20 @@ pub struct OrderSubmitView { pub buyer_pubkey: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub seller_pubkey: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_kind: Option<u32>, #[serde(default)] pub dry_run: bool, #[serde(default)] pub deduplicated: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub target_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub acknowledged_relays: Vec<String>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub failed_relays: Vec<RelayFailureView>, #[serde(skip_serializing_if = "Option::is_none")] pub idempotency_key: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -48,7 +48,9 @@ impl OperationService<OrderSubmitRequest> for OrderOperationService<'_> { if request.context.dry_run { config.output.dry_run = true; } - let view = map_runtime(crate::runtime::order::submit(&config, &args))?; + let view = crate::runtime::order::submit(&config, &args).map_err(|error| { + OperationAdapterError::runtime_failure(request.operation_id(), error) + })?; if request.context.dry_run && view.state == "unconfigured" && !view.issues.is_empty() { serialized_target_result::<OrderSubmitResult, _>(&view) } else { diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -3,24 +3,33 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use radroots_events::RadrootsNostrEventPtr; use radroots_events::kinds::KIND_LISTING; +use radroots_events::trade::{RadrootsTradeOrderItem, RadrootsTradeOrderRequested}; use radroots_events_codec::d_tag::is_d_tag_base64url; -use radroots_events_codec::trade::RadrootsTradeListingAddress; +use radroots_events_codec::trade::{ + RadrootsTradeListingAddress, active_trade_order_request_event_build, +}; use radroots_replica_db::{ReplicaSql, nostr_event_state, trade_product}; use radroots_replica_db_schema::nostr_event_state::{ INostrEventStateFindOne, INostrEventStateFindOneArgs, NostrEventStateQueryBindValues, }; use radroots_replica_db_schema::trade_product::{ITradeProductFieldsFilter, ITradeProductFindMany}; use radroots_sql_core::SqliteExecutor; +use radroots_trade::order::canonicalize_active_order_request_for_signer; use serde::{Deserialize, Serialize}; use crate::domain::runtime::{ OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryView, OrderIssueView, OrderListView, OrderNewView, OrderSubmitView, OrderSummaryView, OrderWatchView, + RelayFailureView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; use crate::runtime::config::{RuntimeConfig, SignerBackend}; +use crate::runtime::direct_relay::{ + DirectRelayFailure, DirectRelayPublishReceipt, publish_parts_with_identity, +}; use crate::runtime::signer::ActorWriteBindingError; use crate::runtime_args::{ OrderDraftCreateArgs, OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs, @@ -28,8 +37,7 @@ use crate::runtime_args::{ const ORDER_DRAFT_KIND: &str = "order_draft_v1"; const ORDER_SOURCE: &str = "local order drafts · local first"; -const ORDER_SUBMIT_UNAVAILABLE_REASON: &str = - "direct Nostr relay order publication is not implemented"; +const ORDER_SUBMIT_SOURCE: &str = "direct Nostr relay publish · local key"; const ORDER_EVENT_STATE_UNAVAILABLE_REASON: &str = "relay-backed order event state is not implemented"; const ORDERS_DIR: &str = "orders/drafts"; @@ -363,8 +371,13 @@ pub fn submit( buyer_account_id: None, buyer_pubkey: None, seller_pubkey: None, + event_id: None, + event_kind: None, dry_run: config.output.dry_run, deduplicated: false, + target_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), signer_mode: None, signer_session_id: None, @@ -393,8 +406,13 @@ pub fn submit( buyer_account_id: None, buyer_pubkey: None, seller_pubkey: None, + event_id: None, + event_kind: None, dry_run: config.output.dry_run, deduplicated: false, + target_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), signer_mode: None, signer_session_id: None, @@ -425,8 +443,13 @@ pub fn submit( buyer_account_id: loaded.document.buyer_account_id.clone(), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), + event_id: None, + event_kind: None, dry_run: config.output.dry_run, deduplicated: false, + target_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), signer_mode: None, signer_session_id: None, @@ -439,7 +462,7 @@ pub fn submit( } if config.output.dry_run { - if let Err(error) = validate_local_order_write_authority( + if let Err(error) = resolve_local_order_signing_identity( config, loaded.document.order.buyer_pubkey.as_str(), ) { @@ -456,8 +479,13 @@ pub fn submit( buyer_account_id: loaded.document.buyer_account_id.clone(), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), + event_id: None, + event_kind: None, dry_run: true, deduplicated: false, + target_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), signer_mode: None, signer_session_id: None, @@ -472,15 +500,24 @@ pub fn submit( }); } - if let Err(error) = - validate_local_order_write_authority(config, loaded.document.order.buyer_pubkey.as_str()) - { - return Ok(order_binding_error_view(config, &loaded, args, error)); + if config.relay.urls.is_empty() { + return Err(RuntimeError::Network( + "order submit requires at least one configured relay before signing".to_owned(), + )); } - Ok(direct_relay_unavailable_order_submit_view( - config, &loaded, args, - )) + let signing = match resolve_local_order_signing_identity( + config, + loaded.document.order.buyer_pubkey.as_str(), + ) { + Ok(signing) => signing, + Err(error) => return Ok(order_binding_error_view(config, &loaded, args, error)), + }; + + match publish_order_request(config, &loaded, args, signing) { + Ok(view) => Ok(view), + Err(error) => Err(error), + } } pub fn watch( @@ -1052,14 +1089,70 @@ fn actions_for_document( actions } -fn direct_relay_unavailable_order_submit_view( +fn publish_order_request( config: &RuntimeConfig, loaded: &LoadedOrderDraft, args: &OrderSubmitArgs, + signing: accounts::AccountSigningIdentity, +) -> Result<OrderSubmitView, RuntimeError> { + let signer_pubkey = signing + .account + .record + .public_identity + .public_key_hex + .as_str(); + let payload = RadrootsTradeOrderRequested { + order_id: loaded.document.order.order_id.clone(), + listing_addr: loaded.document.order.listing_addr.clone(), + buyer_pubkey: loaded.document.order.buyer_pubkey.clone(), + seller_pubkey: loaded.document.order.seller_pubkey.clone(), + items: loaded + .document + .order + .items + .iter() + .map(|item| RadrootsTradeOrderItem { + bin_id: item.bin_id.clone(), + bin_count: item.bin_count, + }) + .collect(), + }; + let payload = canonicalize_active_order_request_for_signer(payload, signer_pubkey) + .map_err(|error| RuntimeError::Config(format!("canonicalize order request: {error}")))?; + let listing_event = RadrootsNostrEventPtr { + id: loaded.document.order.listing_event_id.clone(), + relays: None, + }; + let parts = active_trade_order_request_event_build(&listing_event, &payload) + .map_err(|error| RuntimeError::Config(format!("encode order request event: {error}")))?; + let event_kind = parts.kind; + let receipt = publish_parts_with_identity(&signing.identity, &config.relay.urls, parts) + .map_err(|error| RuntimeError::Network(error.to_string()))?; + + Ok(published_order_submit_view( + config, loaded, args, event_kind, receipt, + )) +} + +fn published_order_submit_view( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, + event_kind: u32, + receipt: DirectRelayPublishReceipt, ) -> OrderSubmitView { + let DirectRelayPublishReceipt { + event_id, + created_at: _, + signature: _, + target_relays, + acknowledged_relays, + failed_relays, + } = receipt; + OrderSubmitView { - state: "unavailable".to_owned(), - source: ORDER_SOURCE.to_owned(), + state: "submitted".to_owned(), + source: ORDER_SUBMIT_SOURCE.to_owned(), order_id: loaded.document.order.order_id.clone(), file: loaded.file.display().to_string(), listing_lookup: loaded.document.listing_lookup.clone(), @@ -1068,13 +1161,18 @@ fn direct_relay_unavailable_order_submit_view( buyer_account_id: loaded.document.buyer_account_id.clone(), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), + event_id: Some(event_id), + event_kind: Some(event_kind), dry_run: false, deduplicated: false, + target_relays, + acknowledged_relays, + failed_relays: relay_failures(failed_relays), idempotency_key: args.idempotency_key.clone(), signer_mode: Some(config.signer.backend.as_str().to_owned()), signer_session_id: None, requested_signer_session_id: None, - reason: Some(ORDER_SUBMIT_UNAVAILABLE_REASON.to_owned()), + reason: None, job: None, issues: Vec::new(), actions: Vec::new(), @@ -1112,8 +1210,13 @@ fn order_binding_error_view( buyer_account_id: loaded.document.buyer_account_id.clone(), buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()), seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), + event_id: None, + event_kind: None, dry_run: config.output.dry_run, deduplicated: false, + target_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), idempotency_key: args.idempotency_key.clone(), signer_mode: Some(config.signer.backend.as_str().to_owned()), signer_session_id: None, @@ -1125,12 +1228,14 @@ fn order_binding_error_view( } } -fn validate_local_order_write_authority( +fn resolve_local_order_signing_identity( config: &RuntimeConfig, buyer_pubkey: &str, -) -> Result<(), ActorWriteBindingError> { +) -> Result<accounts::AccountSigningIdentity, ActorWriteBindingError> { if !matches!(config.signer.backend, SignerBackend::Local) { - return Ok(()); + return Err(ActorWriteBindingError::Unconfigured( + "order submit requires signer mode `local`".to_owned(), + )); } let signing = accounts::resolve_local_signing_identity(config) .map_err(|error| ActorWriteBindingError::Unconfigured(error.to_string()))?; @@ -1145,7 +1250,17 @@ fn validate_local_order_write_authority( "selected local account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" ))); } - Ok(()) + Ok(signing) +} + +fn relay_failures(failures: Vec<DirectRelayFailure>) -> Vec<RelayFailureView> { + failures + .into_iter() + .map(|failure| RelayFailureView { + relay: failure.relay, + reason: failure.reason, + }) + .collect() } fn load_draft(path: &Path) -> Result<LoadedOrderDraft, String> { diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -5,9 +5,13 @@ use std::path::Path; use support::{ RadrootsCliSandbox, assert_contains, assert_no_daemon_runtime_reference, assert_no_removed_command_reference, create_listing_draft, identity_public, - make_listing_publishable, shell_single_quoted, toml_string, write_public_identity_profile, + make_listing_publishable, seed_orderable_listing, shell_single_quoted, toml_string, + write_public_identity_profile, }; +const LISTING_ADDR: &str = + "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; + #[test] fn local_signer_status_reports_unconfigured_without_account() { let sandbox = RadrootsCliSandbox::new(); @@ -715,6 +719,47 @@ fn local_seller_publish_commands_attempt_configured_direct_relay() { "listing.archive", &["listing", "archive"], ); + + seed_orderable_listing(&sandbox, LISTING_ADDR); + sandbox.json_success(&["--format", "json", "basket", "create", "direct_order"]); + sandbox.json_success(&[ + "--format", + "json", + "basket", + "item", + "add", + "direct_order", + "--listing-addr", + LISTING_ADDR, + "--bin-id", + "bin-1", + "--quantity", + "1", + ]); + let quote = sandbox.json_success(&[ + "--format", + "json", + "basket", + "quote", + "create", + "direct_order", + ]); + let order_id = quote["result"]["quote"]["order_id"] + .as_str() + .expect("order id"); + let (order_output, order_value) = sandbox.json_output(&[ + "--format", + "json", + "--relay", + relay, + "--approval-token", + "approve", + "order", + "submit", + order_id, + ]); + assert!(!order_output.status.success()); + assert_direct_relay_connection_failure(&order_value, "order.submit", &["order", "submit"]); } #[test] diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1060,21 +1060,22 @@ fn buyer_target_flow_acceptance_uses_target_operations() { order_id, ]); assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(8)); assert_eq!(unavailable_submit["operation_id"], "order.submit"); assert_eq!(unavailable_submit["result"], Value::Null); assert_eq!( unavailable_submit["errors"][0]["code"], - "operation_unavailable" + "network_unavailable" ); assert_eq!( unavailable_submit["errors"][0]["detail"]["class"], - "operation" + "network" ); assert!( unavailable_submit["errors"][0]["message"] .as_str() .expect("message") - .contains("direct Nostr relay order publication is not implemented") + .contains("order submit requires at least one configured relay before signing") ); assert_no_removed_command_reference(&unavailable_submit, &["order", "submit"]); assert_no_daemon_runtime_reference(&unavailable_submit, &["order", "submit"]);