cli

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

commit ccd8d8d54a95bd57a77c95775b08d0c6e693e4b4
parent 62d17daf5d03f46fc6ff8f05d2923ed131cc8607
Author: triesap <tyson@radroots.org>
Date:   Thu, 18 Jun 2026 21:25:31 -0700

cli: migrate order submit to sdk

Diffstat:
Msrc/ops/exec/order.rs | 2+-
Msrc/runtime/order.rs | 562++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/runtime/sdk.rs | 16++++++++++++++--
Mtests/target_cli.rs | 101+++++++++++++++++++++++++++++++++++++------------------------------------------
4 files changed, 430 insertions(+), 251 deletions(-)

diff --git a/src/ops/exec/order.rs b/src/ops/exec/order.rs @@ -72,7 +72,7 @@ impl OperationService<OrderSubmitRequest> for OrderOperationService<'_> { config.output.dry_run = true; } let view = crate::runtime::order::submit(&config, &args).map_err(|error| { - OperationAdapterError::runtime_failure(request.operation_id(), error) + OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) })?; submit_result::<OrderSubmitResult>(request.operation_id(), &view) } diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -6,12 +6,14 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use radroots_authority::{RadrootsActorContext, RadrootsLocalEventSigner}; use radroots_core::{ RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreDiscount, RadrootsCoreDiscountScope, RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney, RadrootsCoreUnit, convert_unit_decimal, }; use radroots_events::RadrootsNostrEventPtr; +use radroots_events::contract::RadrootsActorRole; use radroots_events::ids::{ RadrootsEconomicsDigest, RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, RadrootsOrderQuoteId, RadrootsOrderRevisionId, RadrootsPublicKey, @@ -53,8 +55,8 @@ use radroots_local_events::{ SourceRuntime, normalize_relay_urls, validate_supported_buyer_order_request_local_work_payload, }; use radroots_nostr::prelude::{ - RadrootsNostrEvent, RadrootsNostrFilter, radroots_event_from_nostr, radroots_nostr_filter_tag, - radroots_nostr_kind, + RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrKeys, radroots_event_from_nostr, + radroots_nostr_filter_tag, radroots_nostr_kind, }; use radroots_replica_db::{ ReplicaSql, ReplicaTradeProductSummaryRow, nostr_event_head, trade_product, @@ -65,15 +67,12 @@ use radroots_replica_db_schema::nostr_event_head::{ use radroots_replica_db_schema::trade_product::{ ITradeProductFieldsFilter, ITradeProductFindMany, TradeProduct, }; -use radroots_sdk::client::{ - RadrootsSdkClient, SdkPublishError, SdkPublishReceipt, SdkRelayFailure, SdkTransportReceipt, -}; -use radroots_sdk::config::{ - RadrootsSdkConfig, SdkEnvironment, SdkTransportMode, SignerConfig as SdkSignerConfig, -}; use radroots_sdk::{ OrderFulfillmentStatusKind, OrderPaymentStateKind, OrderSettlementStateKind, OrderStatusKind, - OrderStatusReceipt, OrderStatusRequest, SdkOrderStatusIssue, + OrderStatusReceipt, OrderStatusRequest, OrderSubmitEnqueueRequest, OrderSubmitPlan, + OrderSubmitPrepareRequest, OrderSubmitReceipt, PushOutboxEventReceipt, PushOutboxEventState, + PushOutboxReceipt, PushOutboxRelayOutcomeKind, PushOutboxRequest, SdkMutationState, + SdkOrderStatusIssue, SdkRelayTargetPolicy, SdkRelayUrlPolicy, }; use radroots_sql_core::SqliteExecutor; use radroots_trade::order::{ @@ -129,7 +128,7 @@ use crate::view::runtime::{ const ORDER_DRAFT_KIND: &str = "order_draft_v1"; const ORDER_SOURCE: &str = "local order drafts · local first"; const ORDER_APP_RECORD_SOURCE: &str = "app-authored shared local order records"; -const ORDER_SUBMIT_SOURCE: &str = "direct Nostr relay publish · local key"; +const ORDER_SUBMIT_SOURCE: &str = "SDK order submit · local key"; const ORDER_DECISION_SOURCE: &str = "direct Nostr relay decision publish · local key"; const ORDER_REVISION_PROPOSAL_SOURCE: &str = "direct Nostr relay revision proposal publish · local key"; @@ -901,7 +900,7 @@ pub fn app_record_export( pub fn submit( config: &RuntimeConfig, args: &OrderSubmitArgs, -) -> Result<OrderSubmitView, RuntimeError> { +) -> Result<OrderSubmitView, CliSdkAdapterError> { let file = draft_lookup_path(config, args.key.as_str()); let (loaded, source_issues) = if file.exists() { match load_draft(file.as_path()) { @@ -1035,20 +1034,15 @@ pub fn submit( return Ok(view); } - if config.relay.urls.is_empty() { - return Err(RuntimeError::Network( - "order submit requires at least one configured relay before publish preflight" - .to_owned(), - )); - } - if let Some(view) = order_submit_listing_provenance_preflight_view(config, &loaded, args)? { return Ok(view); } let signing = match resolve_local_order_signing_identity(config, &loaded) { Ok(signing) => signing, - Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), + Err(ActorWriteBindingError::Account(failure)) => { + return Err(RuntimeError::from(failure).into()); + } Err(error) => return Ok(order_binding_error_view(config, &loaded, args, error)), }; let payload = canonical_order_request_payload_from_loaded( @@ -1060,25 +1054,17 @@ pub fn submit( .public_key_hex .as_str(), )?; + let input = sdk_order_submit_input(config, &loaded, &signing, payload)?; - if let Some(view) = order_submit_market_freshness_view(config, &loaded, args)? { - return Ok(view); + if config.output.dry_run { + return prepare_order_submit_via_sdk(config, &loaded, args, input); } - if let Some(view) = - order_submit_existing_request_preflight_view(config, &loaded, args, &payload)? - { + if let Some(view) = order_submit_market_freshness_view(config, &loaded, args)? { return Ok(view); } - if config.output.dry_run { - return Ok(order_submit_dry_run_view(config, &loaded, args)); - } - - match publish_order_request(config, &loaded, args, signing, payload) { - Ok(view) => Ok(view), - Err(error) => Err(error), - } + submit_via_sdk(config, &loaded, args, signing, input) } pub fn rebind( @@ -11629,6 +11615,9 @@ fn order_submit_listing_provenance_preflight_view( .map_err(|error| RuntimeError::Config(format!("listing provenance relays: {error}")))?; let target_relays = normalize_listing_relay_set(config.relay.urls.iter()) .map_err(|error| RuntimeError::Config(format!("configured relay target: {error}")))?; + if target_relays.is_empty() { + return Ok(None); + } let reachable_relays = listing_relays .iter() .filter(|relay| target_relays.contains(relay)) @@ -11700,7 +11689,7 @@ fn order_submit_market_freshness_view( loaded: &LoadedOrderDraft, args: &OrderSubmitArgs, ) -> Result<Option<OrderSubmitView>, RuntimeError> { - if config.output.dry_run { + if config.output.dry_run || config.relay.urls.is_empty() { return Ok(None); } @@ -11729,33 +11718,6 @@ fn order_submit_market_freshness_view( ))) } -fn order_submit_existing_request_preflight_view( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &OrderSubmitArgs, - payload: &RadrootsOrderRequest, -) -> Result<Option<OrderSubmitView>, RuntimeError> { - let filter = order_request_filter( - loaded.document.order.seller_pubkey.as_str(), - Some(loaded.document.order.order_id.as_str()), - )?; - let receipt = match fetch_events_from_relays(&config.relay.urls, filter) { - Ok(receipt) => receipt, - Err(DirectRelayFetchError::Connect { - reason, - target_relays: _, - failed_relays: _, - }) => { - return Err(RuntimeError::Network(format!( - "direct relay connection failed during submit preflight: {reason}" - ))); - } - Err(error) => return Err(RuntimeError::Network(error.to_string())), - }; - - order_submit_existing_request_view_from_receipt(config, loaded, args, payload, receipt) -} - fn order_submit_existing_request_view_from_receipt( config: &RuntimeConfig, loaded: &LoadedOrderDraft, @@ -11968,6 +11930,8 @@ fn order_submit_dry_run_view( config: &RuntimeConfig, loaded: &LoadedOrderDraft, args: &OrderSubmitArgs, + plan: OrderSubmitPlan, + target_relays: Vec<String>, ) -> OrderSubmitView { OrderSubmitView { state: "dry_run".to_owned(), @@ -11984,11 +11948,11 @@ fn order_submit_dry_run_view( buyer_custody: None, buyer_write_capable: None, seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()), - event_id: None, - event_kind: None, + event_id: Some(plan.expected_event_id.as_str().to_owned()), + event_kind: Some(KIND_ORDER_REQUEST), dry_run: true, deduplicated: false, - target_relays: config.relay.urls.clone(), + target_relays, connected_relays: Vec::new(), acknowledged_relays: Vec::new(), failed_relays: Vec::new(), @@ -11996,9 +11960,7 @@ fn order_submit_dry_run_view( signer_mode: Some(config.signer.backend.as_str().to_owned()), signer_session_id: None, requested_signer_session_id: None, - reason: Some( - "dry run requested; relay order publication skipped after submit preflight".to_owned(), - ), + reason: Some("dry run requested; SDK enqueue and relay push skipped".to_owned()), job: None, issues: Vec::new(), actions: vec![format!( @@ -12092,95 +12054,168 @@ fn canonical_order_request_payload_from_loaded( .map_err(|error| RuntimeError::Config(format!("canonicalize order request: {error}"))) } -fn publish_order_request( +fn sdk_order_submit_input( config: &RuntimeConfig, loaded: &LoadedOrderDraft, - args: &OrderSubmitArgs, - signing: account::AccountSigningIdentity, + signing: &account::AccountSigningIdentity, payload: RadrootsOrderRequest, -) -> Result<OrderSubmitView, RuntimeError> { - let listing_event = order_listing_event_ptr(config, loaded)?; - let client = order_relay_publish_client(config)?; - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|error| { - RuntimeError::Network(format!("build relay order submit runtime: {error}")) - })?; - let receipt = runtime - .block_on(client.order().publish_order_request_with_identity( - &signing.identity, - &listing_event, - &payload, - )) - .map_err(map_sdk_order_submit_error)?; +) -> Result<SdkOrderSubmitInput, CliSdkAdapterError> { + let actor = RadrootsActorContext::local_account( + signing + .account + .record + .public_identity + .public_key_hex + .as_str(), + signing.account.record.account_id.to_string(), + [RadrootsActorRole::Buyer], + ) + .map_err(|error| RuntimeError::Config(format!("invalid order SDK actor: {error}")))?; + let listing_event = order_submit_listing_event_ptr(loaded)?; + let target_relays = order_submit_target_relays(config, loaded)?; + + Ok(SdkOrderSubmitInput { + actor, + listing_event, + order: payload, + target_relays, + }) +} - published_order_submit_view(config, loaded, args, receipt) +#[derive(Debug, Clone)] +struct SdkOrderSubmitInput { + actor: RadrootsActorContext, + listing_event: RadrootsNostrEventPtr, + order: RadrootsOrderRequest, + target_relays: Vec<String>, } -fn order_listing_event_ptr( - config: &RuntimeConfig, +fn order_submit_listing_event_ptr( loaded: &LoadedOrderDraft, ) -> Result<RadrootsNostrEventPtr, RuntimeError> { let listing_relays = normalize_listing_relay_set(loaded.document.order.listing_relays.iter()) .map_err(|error| RuntimeError::Config(format!("listing provenance relays: {error}")))?; - let target_relays = normalize_listing_relay_set(config.relay.urls.iter()) - .map_err(|error| RuntimeError::Config(format!("configured relay target: {error}")))?; - let selected_relay = listing_relays - .iter() - .find(|relay| target_relays.contains(relay)) - .or_else(|| listing_relays.first()) - .cloned(); Ok(RadrootsNostrEventPtr { id: loaded.document.order.listing_event_id.clone(), - relays: selected_relay, + relays: listing_relays.first().cloned(), }) } -fn order_relay_publish_client(config: &RuntimeConfig) -> Result<RadrootsSdkClient, RuntimeError> { - let mut sdk_config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); - sdk_config.transport = SdkTransportMode::RelayDirect; - sdk_config.signer = SdkSignerConfig::LocalIdentity; - sdk_config.relay.urls = config.relay.urls.clone(); - RadrootsSdkClient::from_config(sdk_config) - .map_err(|error| RuntimeError::Config(format!("configure relay order submit: {error}"))) +fn order_submit_target_relays( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, +) -> Result<Vec<String>, RuntimeError> { + let listing_relays = + normalize_listing_relay_set(loaded.document.order.listing_relays.iter()) + .map_err(|error| RuntimeError::Config(format!("listing provenance relays: {error}")))?; + let configured_relays = normalize_listing_relay_set(config.relay.urls.iter()) + .map_err(|error| RuntimeError::Config(format!("configured relay target: {error}")))?; + if configured_relays.is_empty() { + return Ok(listing_relays); + } + Ok(configured_relays + .into_iter() + .filter(|relay| listing_relays.contains(relay)) + .collect()) } -fn map_sdk_order_submit_error(error: SdkPublishError) -> RuntimeError { - let message = format!("relay order submit failed: {error}"); - match error { - SdkPublishError::Config(_) - | SdkPublishError::Encode(_) - | SdkPublishError::UnsupportedTransport { .. } - | SdkPublishError::UnsupportedSignerMode { .. } => RuntimeError::Config(message), - SdkPublishError::Relay(_) - | SdkPublishError::RelaySetup { .. } - | SdkPublishError::RelayNotAcknowledged { .. } - | SdkPublishError::Radrootsd(_) => RuntimeError::Network(message), +fn order_submit_relay_url_policy(target_relays: &[String]) -> SdkRelayUrlPolicy { + if target_relays + .iter() + .any(|relay_url| relay_url.starts_with("ws://")) + { + SdkRelayUrlPolicy::Localhost + } else { + SdkRelayUrlPolicy::Public } } -fn published_order_submit_view( +fn prepare_order_submit_via_sdk( config: &RuntimeConfig, loaded: &LoadedOrderDraft, args: &OrderSubmitArgs, - receipt: SdkPublishReceipt, -) -> Result<OrderSubmitView, RuntimeError> { - let SdkPublishReceipt { - event_id, - event_kind, - transport_receipt, - .. - } = receipt; - let SdkTransportReceipt::RelayDirect(relay) = transport_receipt else { - return Err(RuntimeError::Config( - "relay order submit returned a non-relay transport receipt".to_owned(), - )); - }; + input: SdkOrderSubmitInput, +) -> Result<OrderSubmitView, CliSdkAdapterError> { + let target_relays = input.target_relays.clone(); + let session = CliSdkSession::connect_memory(config)?; + let plan = session + .sdk() + .orders() + .prepare_submit(OrderSubmitPrepareRequest::new( + input.actor, + input.listing_event, + input.order, + ))?; + Ok(order_submit_dry_run_view( + config, + loaded, + args, + plan, + target_relays, + )) +} - Ok(OrderSubmitView { - state: "submitted".to_owned(), +fn submit_via_sdk( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, + signing: account::AccountSigningIdentity, + input: SdkOrderSubmitInput, +) -> Result<OrderSubmitView, CliSdkAdapterError> { + let target_relays = input.target_relays.clone(); + let policy = order_submit_relay_url_policy(target_relays.as_slice()); + let target_policy = SdkRelayTargetPolicy::try_explicit(target_relays, policy)?; + let mut request = OrderSubmitEnqueueRequest::new( + input.actor, + input.listing_event, + input.order, + target_policy, + ); + if let Some(idempotency_key) = args.idempotency_key.as_deref() { + request = request.try_with_idempotency_key(idempotency_key)?; + } + + let session = CliSdkSession::connect(config)?; + let keys: RadrootsNostrKeys = signing.identity.into_keys(); + let signer = RadrootsLocalEventSigner::new(keys) + .map_err(|error| RuntimeError::Config(error.to_string()))?; + let enqueue = session.block_on(session.sdk().orders().enqueue_submit(request, &signer))?; + let push = session.block_on( + session.sdk().sync().push_outbox( + PushOutboxRequest::new() + .with_limit(1) + .with_relay_url_policy(order_submit_relay_url_policy(&enqueue_target_relays( + config, loaded, + )?)), + ), + )?; + Ok(sdk_enqueued_order_submit_view( + config, loaded, args, enqueue, push, + )) +} + +fn enqueue_target_relays( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, +) -> Result<Vec<String>, RuntimeError> { + let target_relays = order_submit_target_relays(config, loaded)?; + if target_relays.is_empty() { + return Ok(config.relay.urls.clone()); + } + Ok(target_relays) +} + +fn sdk_enqueued_order_submit_view( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, + enqueue: OrderSubmitReceipt, + push: PushOutboxReceipt, +) -> OrderSubmitView { + let push_event = sdk_push_event_for_order_submit(&enqueue, &push); + OrderSubmitView { + state: sdk_order_submit_state(push_event), source: ORDER_SUBMIT_SOURCE.to_owned(), order_id: loaded.document.order.order_id.clone(), file: loaded.file.display().to_string(), @@ -12194,23 +12229,147 @@ fn published_order_submit_view( 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)), + event_id: Some(enqueue.signed_event_id.as_str().to_owned()), + event_kind: Some(KIND_ORDER_REQUEST), dry_run: false, - deduplicated: false, - target_relays: relay.target_relays, - connected_relays: relay.connected_relays, - acknowledged_relays: relay.acknowledged_relays, - failed_relays: sdk_relay_failures(relay.failed_relays), + deduplicated: matches!(enqueue.state, SdkMutationState::AlreadyQueued), + target_relays: push_event + .map(sdk_push_target_relays) + .unwrap_or_else(|| enqueue_target_relays(config, loaded).unwrap_or_default()), + connected_relays: push_event + .map(sdk_push_connected_relays) + .unwrap_or_default(), + acknowledged_relays: push_event + .map(sdk_push_acknowledged_relays) + .unwrap_or_default(), + failed_relays: push_event.map(sdk_push_failed_relays).unwrap_or_default(), 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: None, + reason: sdk_order_submit_reason(push_event), job: None, issues: Vec::new(), - actions: Vec::new(), - }) + actions: sdk_order_submit_actions(push_event), + } +} + +fn sdk_push_event_for_order_submit<'a>( + enqueue: &OrderSubmitReceipt, + push: &'a PushOutboxReceipt, +) -> Option<&'a PushOutboxEventReceipt> { + push.events + .iter() + .find(|event| event.event_id == enqueue.signed_event_id) +} + +fn sdk_order_submit_state(push_event: Option<&PushOutboxEventReceipt>) -> String { + match push_event.map(|event| event.final_state) { + Some(PushOutboxEventState::Published) => "submitted", + Some(PushOutboxEventState::PublishRetryable | PushOutboxEventState::FailedTerminal) => { + "unavailable" + } + Some(_) | None => "queued", + } + .to_owned() +} + +fn sdk_order_submit_reason(push_event: Option<&PushOutboxEventReceipt>) -> Option<String> { + match push_event.map(|event| event.final_state) { + Some(PushOutboxEventState::Published) => None, + Some(PushOutboxEventState::PublishRetryable) => Some( + "SDK relay publish did not reach accepted quorum; outbox event remains retryable" + .to_owned(), + ), + Some(PushOutboxEventState::FailedTerminal) => { + Some("SDK relay publish failed terminally".to_owned()) + } + Some(state) => Some(format!("SDK relay push left event in state `{state:?}`")), + None => Some( + "order submit queued in SDK outbox; no ready SDK outbox event was pushed".to_owned(), + ), + } +} + +fn sdk_order_submit_actions(push_event: Option<&PushOutboxEventReceipt>) -> Vec<String> { + if !matches!( + push_event.map(|event| event.final_state), + Some(PushOutboxEventState::Published) + ) { + return vec!["radroots sync push".to_owned()]; + } + Vec::new() +} + +fn sdk_push_target_relays(event: &PushOutboxEventReceipt) -> Vec<String> { + event + .relays + .iter() + .map(|relay| relay.relay_url.clone()) + .collect() +} + +fn sdk_push_connected_relays(event: &PushOutboxEventReceipt) -> Vec<String> { + event + .relays + .iter() + .filter(|relay| relay.attempted) + .map(|relay| relay.relay_url.clone()) + .collect() +} + +fn sdk_push_acknowledged_relays(event: &PushOutboxEventReceipt) -> Vec<String> { + event + .relays + .iter() + .filter(|relay| { + matches!( + relay.outcome_kind, + PushOutboxRelayOutcomeKind::Accepted + | PushOutboxRelayOutcomeKind::DuplicateAccepted + ) + }) + .map(|relay| relay.relay_url.clone()) + .collect() +} + +fn sdk_push_failed_relays(event: &PushOutboxEventReceipt) -> Vec<RelayFailureView> { + event + .relays + .iter() + .filter(|relay| { + !matches!( + relay.outcome_kind, + PushOutboxRelayOutcomeKind::Accepted + | PushOutboxRelayOutcomeKind::DuplicateAccepted + ) + }) + .map(|relay| RelayFailureView { + relay: relay.relay_url.clone(), + reason: relay + .message + .clone() + .unwrap_or_else(|| sdk_relay_outcome_kind(relay.outcome_kind).to_owned()), + }) + .collect() +} + +fn sdk_relay_outcome_kind(kind: PushOutboxRelayOutcomeKind) -> &'static str { + match kind { + PushOutboxRelayOutcomeKind::Accepted => "accepted", + PushOutboxRelayOutcomeKind::DuplicateAccepted => "duplicate_accepted", + PushOutboxRelayOutcomeKind::Blocked => "blocked", + PushOutboxRelayOutcomeKind::RateLimited => "rate_limited", + PushOutboxRelayOutcomeKind::Invalid => "invalid", + PushOutboxRelayOutcomeKind::PowRequired => "pow_required", + PushOutboxRelayOutcomeKind::Restricted => "restricted", + PushOutboxRelayOutcomeKind::AuthRequired => "auth_required", + PushOutboxRelayOutcomeKind::Error => "error", + PushOutboxRelayOutcomeKind::Timeout => "timeout", + PushOutboxRelayOutcomeKind::ConnectionFailed => "connection_failed", + PushOutboxRelayOutcomeKind::Unknown => "unknown", + _ => "unknown", + } } fn order_binding_error_view( @@ -12675,16 +12834,6 @@ fn relay_failures(failures: Vec<DirectRelayFailure>) -> Vec<RelayFailureView> { .collect() } -fn sdk_relay_failures(failures: Vec<SdkRelayFailure>) -> Vec<RelayFailureView> { - failures - .into_iter() - .map(|failure| RelayFailureView { - relay: failure.relay_url, - reason: failure.error, - }) - .collect() -} - 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()))?; @@ -12993,14 +13142,15 @@ mod tests { RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, }; use radroots_events::RadrootsNostrEventPtr; + use radroots_events::draft::RadrootsFrozenEventDraft; use radroots_events::ids::{ RadrootsEconomicsDigest, RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, RadrootsOrderQuoteId, RadrootsOrderRevisionId, RadrootsPublicKey, }; use radroots_events::kinds::{ KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_FULFILLMENT_UPDATE, - KIND_ORDER_PAYMENT_RECORD, KIND_ORDER_RECEIPT, KIND_ORDER_REVISION_DECISION, - KIND_ORDER_REVISION_PROPOSAL, KIND_ORDER_SETTLEMENT_DECISION, + KIND_ORDER_PAYMENT_RECORD, KIND_ORDER_RECEIPT, KIND_ORDER_REQUEST, + KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_ORDER_SETTLEMENT_DECISION, }; use radroots_events::order::{ RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, @@ -13024,7 +13174,8 @@ mod tests { use radroots_runtime_paths::RadrootsMigrationReport; use radroots_sdk::{ OrderPaymentStateKind, OrderSettlementStateKind, OrderStatusKind, OrderStatusReceipt, - SdkOrderStatusIssue, SdkOrderStatusIssueKind, SdkOrderStatusSource, + OrderSubmitPlan, RadrootsSdkTimestamp, SdkOrderStatusIssue, SdkOrderStatusIssueKind, + SdkOrderStatusSource, }; use radroots_secret_vault::RadrootsSecretBackend; use radroots_trade::order::{ @@ -13052,11 +13203,11 @@ mod tests { order_decision_preflight_view_from_status, order_decision_view_from_resolution, order_economics_from_resolved_listing, order_event_list_entry_from_event, order_event_list_from_receipt, order_fulfillment_dry_run_view, - order_fulfillment_preflight_view_from_status, order_listing_event_ptr, - order_payment_dry_run_view, order_payment_event_parts, order_payment_payload_from_status, + order_fulfillment_preflight_view_from_status, order_payment_dry_run_view, + order_payment_event_parts, order_payment_payload_from_status, order_payment_preflight_view_from_status, order_receipt_dry_run_view, order_receipt_event_parts, order_receipt_payload_from_status, - order_receipt_preflight_view_from_status, order_relay_publish_client, order_request_filter, + order_receipt_preflight_view_from_status, order_request_filter, order_revision_decision_event_parts, order_revision_decision_payload_from_proposal, order_revision_decision_preflight_view_from_status, order_revision_event_parts, order_revision_inventory_preflight_view, order_revision_payload_from_status, @@ -13066,10 +13217,10 @@ mod tests { order_status_filter, order_status_from_receipt, order_status_from_receipt_with_context, order_status_from_receipt_with_deferred_payment, order_status_reduction_from_receipt_with_context, order_submit_dry_run_view, - order_submit_existing_request_view_from_receipt, - order_submit_listing_provenance_preflight_view, proposed_accept_decision_record, - resolve_local_order_fulfillment_signing_identity, sdk_order_status_view, - seller_order_request_resolution_from_receipt, + order_submit_existing_request_view_from_receipt, order_submit_listing_event_ptr, + order_submit_listing_provenance_preflight_view, order_submit_target_relays, + proposed_accept_decision_record, resolve_local_order_fulfillment_signing_identity, + sdk_order_status_view, seller_order_request_resolution_from_receipt, }; use crate::cli::global::{ OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, OrderDraftAdjustmentArgs, @@ -13118,6 +13269,26 @@ mod tests { test_event_id(value.to_string().repeat(64).as_str()) } + fn sample_order_submit_plan(fixture: &OrderStatusFixture) -> OrderSubmitPlan { + let frozen_draft = RadrootsFrozenEventDraft::new( + "radroots.order.request.v1", + KIND_ORDER_REQUEST, + 1_700_000_000, + Vec::new(), + "", + fixture.buyer_pubkey.as_str(), + ) + .expect("frozen draft"); + OrderSubmitPlan { + order_id: test_order_id(fixture.order_id.as_str()), + listing_addr: test_listing_addr(fixture.listing_addr.as_str()), + listing_event_id: test_event_id(fixture.listing_event_id.as_str()), + expected_event_id: test_event_id_char('3'), + frozen_draft, + created_at: RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), + } + } + fn test_pubkey(value: &str) -> RadrootsPublicKey { value.parse().expect("valid public key") } @@ -13166,32 +13337,6 @@ mod tests { } #[test] - fn order_relay_publish_client_uses_configured_relay_and_local_identity() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://127.0.0.1:9001".to_owned()]; - - let client = order_relay_publish_client(&config).expect("relay order publish client"); - - assert_eq!( - client.transport(), - radroots_sdk::config::SdkTransportMode::RelayDirect - ); - assert_eq!( - client.signer(), - radroots_sdk::config::SignerConfig::LocalIdentity - ); - match client.resolved_transport_target() { - radroots_sdk::client::SdkResolvedTransportTarget::RelayDirect { relay_urls } => { - assert_eq!(relay_urls.as_slice(), ["ws://127.0.0.1:9001"]); - } - radroots_sdk::client::SdkResolvedTransportTarget::Radrootsd { .. } => { - panic!("order submit must use relay direct transport"); - } - } - } - - #[test] fn order_economics_applies_listing_discounts_and_basket_adjustments() { let listing = ResolvedOrderListing { listing_addr: "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".to_owned(), @@ -14235,19 +14380,29 @@ mod tests { config.relay.urls = vec!["ws://relay.test".to_owned()]; let fixture = order_status_fixture(); let loaded = loaded_order_draft_for_fixture(&fixture); + let plan = sample_order_submit_plan(&fixture); let args = OrderSubmitArgs { key: fixture.order_id.clone(), idempotency_key: Some("idem-dry-submit".to_owned()), }; - let view = order_submit_dry_run_view(&config, &loaded, &args); + let view = order_submit_dry_run_view( + &config, + &loaded, + &args, + plan, + vec!["ws://relay.test".to_owned()], + ); assert_eq!(view.state, "dry_run"); assert_eq!(view.source, ORDER_SUBMIT_SOURCE); assert_eq!(view.dry_run, true); assert_eq!(view.deduplicated, false); - assert_eq!(view.event_id, None); - assert_eq!(view.event_kind, None); + assert_eq!( + view.event_id.as_deref(), + Some("3333333333333333333333333333333333333333333333333333333333333333") + ); + assert_eq!(view.event_kind, Some(3422)); assert_eq!(view.target_relays, vec!["ws://relay.test"]); assert!(view.acknowledged_relays.is_empty()); assert!(view.failed_relays.is_empty()); @@ -14307,15 +14462,34 @@ mod tests { } #[test] - fn order_listing_event_ptr_carries_listing_provenance_relays() { + fn order_submit_listing_provenance_preflight_allows_listing_relays_without_configured_targets() + { + let dir = tempdir().expect("tempdir"); + let mut config = sample_config(dir.path()); + config.relay.urls = Vec::new(); let fixture = order_status_fixture(); let mut loaded = loaded_order_draft_for_fixture(&fixture); - loaded.document.order.listing_relays = vec!["ws://relay.test".to_owned()]; + loaded.document.order.listing_relays = vec!["ws://relay-a.test".to_owned()]; + let args = OrderSubmitArgs { + key: fixture.order_id.clone(), + idempotency_key: None, + }; - let mut config = sample_config(tempdir().expect("tempdir").path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; + let view = order_submit_listing_provenance_preflight_view(&config, &loaded, &args) + .expect("provenance preflight"); + let target_relays = order_submit_target_relays(&config, &loaded).expect("target relays"); + + assert!(view.is_none()); + assert_eq!(target_relays, vec!["ws://relay-a.test"]); + } + + #[test] + fn order_submit_listing_event_ptr_carries_listing_provenance_relays() { + let fixture = order_status_fixture(); + let mut loaded = loaded_order_draft_for_fixture(&fixture); + loaded.document.order.listing_relays = vec!["ws://relay.test".to_owned()]; - let ptr = order_listing_event_ptr(&config, &loaded).expect("listing event ptr"); + let ptr = order_submit_listing_event_ptr(&loaded).expect("listing event ptr"); assert_eq!(ptr.id, fixture.listing_event_id); assert_eq!(ptr.relays, Some("ws://relay.test".to_owned())); diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs @@ -381,8 +381,8 @@ mod tests { "fetch_events_from_relays", "publish_parts_with_identity", ], - owner: "order.lifecycle.preflight-and-mutations", - reason: "non-migrated order lifecycle preflight reads and mutation writes", + owner: "order.lifecycle.non-submit-preflight-and-mutations", + reason: "non-migrated order lifecycle preflight reads and mutation writes outside SDK order submit", lifecycle: "retain until full order lifecycle behavior migrates to SDK APIs", }, LegacyDirectRelayConsumer { @@ -446,6 +446,18 @@ mod tests { required_tokens: &["OrderStatusRequest::parse", "session.sdk().orders().status"], }, MigratedCliPathGuard { + label: "order submit", + path: "src/runtime/order.rs", + start: "fn prepare_order_submit_via_sdk(", + end: "fn enqueue_target_relays(", + required_tokens: &[ + "prepare_submit(OrderSubmitPrepareRequest::new", + "OrderSubmitEnqueueRequest::new", + "enqueue_submit(request, &signer)", + "push_outbox(", + ], + }, + MigratedCliPathGuard { label: "store status", path: "src/runtime/store.rs", start: "pub fn status(config: &RuntimeConfig) -> Result<LocalStatusView, CliSdkAdapterError>", diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -4946,7 +4946,7 @@ fn order_app_records_list_export_get_and_submit_supported_app_order() { assert!(exported_contents.contains(format!("order_id = \"{order_id}\"").as_str())); assert!(exported_contents.contains("source = \"resolved_account\"")); - let (dry_output, submit) = sandbox.json_output(&[ + let submit = sandbox.json_success(&[ "--format", "json", "--dry-run", @@ -4954,15 +4954,20 @@ fn order_app_records_list_export_get_and_submit_supported_app_order() { "submit", record_id.as_str(), ]); - assert!(!dry_output.status.success()); - assert_eq!(dry_output.status.code(), Some(8)); assert_eq!(submit["operation_id"], "order.submit"); - assert_eq!(submit["errors"][0]["code"], "network_unavailable"); - assert!( - submit["errors"][0]["message"] + assert_eq!(submit["result"]["state"], "dry_run"); + assert_eq!(submit["result"]["source"], "SDK order submit · local key"); + assert_eq!(submit["result"]["event_kind"], 3422); + assert_eq!( + submit["result"]["target_relays"][0], + ORDERABLE_LISTING_RELAY + ); + assert_eq!( + submit["result"]["event_id"] .as_str() - .expect("submit message") - .contains("order submit requires at least one configured relay") + .expect("event id") + .len(), + 64 ); } @@ -6590,22 +6595,23 @@ fn buyer_target_flow_acceptance_uses_target_operations() { assert_no_removed_command_reference(&orders, &["order", "list"]); assert_no_daemon_runtime_reference(&orders, &["order", "list"]); - let (dry_output, submit) = - sandbox.json_output(&["--format", "json", "--dry-run", "order", "submit", order_id]); - assert!(!dry_output.status.success()); - assert_eq!(dry_output.status.code(), Some(8)); + let submit = + sandbox.json_success(&["--format", "json", "--dry-run", "order", "submit", order_id]); assert_eq!(submit["operation_id"], "order.submit"); assert_eq!(submit["dry_run"], true); - assert_eq!(submit["result"], Value::Null); - assert_eq!(submit["errors"][0]["code"], "network_unavailable"); - assert_eq!(submit["errors"][0]["detail"]["class"], "network"); - assert!( - submit["errors"][0]["message"] + assert_eq!(submit["result"]["state"], "dry_run"); + assert_eq!(submit["result"]["source"], "SDK order submit · local key"); + assert_eq!(submit["result"]["event_kind"], 3422); + assert_eq!( + submit["result"]["target_relays"][0], + ORDERABLE_LISTING_RELAY + ); + assert_eq!( + submit["result"]["event_id"] .as_str() - .expect("message") - .contains( - "order submit requires at least one configured relay before publish preflight" - ) + .expect("event id") + .len(), + 64 ); assert_no_removed_command_reference(&submit, &["order", "submit", "--dry-run"]); assert_no_daemon_runtime_reference(&submit, &["order", "submit", "--dry-run"]); @@ -6620,24 +6626,26 @@ fn buyer_target_flow_acceptance_uses_target_operations() { order_id, ]); assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(8)); + assert_eq!(output.status.code(), Some(3), "{unavailable_submit}"); assert_eq!(unavailable_submit["operation_id"], "order.submit"); assert_eq!(unavailable_submit["result"], Value::Null); assert_eq!( unavailable_submit["errors"][0]["code"], - "network_unavailable" + "operation_unavailable" ); assert_eq!( unavailable_submit["errors"][0]["detail"]["class"], - "network" + "operation" + ); + assert_eq!( + unavailable_submit["errors"][0]["detail"]["state"], + "unavailable" ); assert!( unavailable_submit["errors"][0]["message"] .as_str() .expect("message") - .contains( - "order submit requires at least one configured relay before publish preflight" - ) + .contains("SDK relay publish") ); assert_no_removed_command_reference(&unavailable_submit, &["order", "submit"]); assert_no_daemon_runtime_reference(&unavailable_submit, &["order", "submit"]); @@ -7879,22 +7887,16 @@ fn ready_order_submit_dry_run_validates_local_buyer_authority() { listing_event_id ); - let (dry_output, dry_run) = - sandbox.json_output(&["--format", "json", "--dry-run", "order", "submit", order_id]); - - assert!(!dry_output.status.success()); - assert_eq!(dry_output.status.code(), Some(8)); + let dry_run = + sandbox.json_success(&["--format", "json", "--dry-run", "order", "submit", order_id]); assert_eq!(dry_run["operation_id"], "order.submit"); assert_eq!(dry_run["dry_run"], true); - assert_eq!(dry_run["result"], Value::Null); - assert_eq!(dry_run["errors"][0]["code"], "network_unavailable"); - assert!( - dry_run["errors"][0]["message"] - .as_str() - .expect("message") - .contains( - "order submit requires at least one configured relay before publish preflight" - ) + assert_eq!(dry_run["result"]["state"], "dry_run"); + assert_eq!(dry_run["result"]["source"], "SDK order submit · local key"); + assert_eq!(dry_run["result"]["event_kind"], 3422); + assert_eq!( + dry_run["result"]["target_relays"][0], + ORDERABLE_LISTING_RELAY ); assert_no_daemon_runtime_reference(&dry_run, &["order", "submit", "--dry-run"]); @@ -7910,20 +7912,11 @@ fn ready_order_submit_dry_run_validates_local_buyer_authority() { "update", second_account_id, ]); - let (drift_output, drift) = - sandbox.json_output(&["--format", "json", "--dry-run", "order", "submit", order_id]); - assert!(!drift_output.status.success()); - assert_eq!(drift_output.status.code(), Some(8)); + let drift = + sandbox.json_success(&["--format", "json", "--dry-run", "order", "submit", order_id]); assert_eq!(drift["operation_id"], "order.submit"); - assert_eq!(drift["errors"][0]["code"], "network_unavailable"); - assert!( - drift["errors"][0]["message"] - .as_str() - .expect("message") - .contains( - "order submit requires at least one configured relay before publish preflight" - ) - ); + assert_eq!(drift["result"]["state"], "dry_run"); + assert_eq!(drift["result"]["buyer_account_id"], first_account_id); let (output, mismatch) = sandbox.json_output(&[ "--format",