cli

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

commit be9f3a03e553d82c05e2941958b9b4201d0841b1
parent c3c01fcaf71db470c54f8b5b1cf732b166259576
Author: triesap <tyson@radroots.org>
Date:   Fri, 19 Jun 2026 19:11:12 -0700

cli: prune post-agreement order commands

- remove order payment, settlement, receipt, and fulfillment command surfaces
- align operation registry, parser tests, and public CLI rejection coverage
- slim order status views and SDK migration guards to agreement-only workflow state
- update CLI docs and validation copy around active order agreement scope

Diffstat:
Msrc/cli/global.rs | 44--------------------------------------------
Msrc/cli/input.rs | 49+------------------------------------------------
Msrc/cli/mod.rs | 169+------------------------------------------------------------------------------
Msrc/cli/order.rs | 120+------------------------------------------------------------------------------
Dsrc/deferred_payment.rs | 12------------
Msrc/main.rs | 86-------------------------------------------------------------------------------
Msrc/ops/exec/order.rs | 413++-----------------------------------------------------------------------------
Msrc/ops/mod.rs | 171++-----------------------------------------------------------------------------
Msrc/ops/target.rs | 5-----
Msrc/out/envelope.rs | 6+++---
Msrc/registry/mod.rs | 30++----------------------------
Msrc/registry/order.rs | 80-------------------------------------------------------------------------------
Msrc/runtime/direct_relay.rs | 35++---------------------------------
Msrc/runtime/order.rs | 22244+++++++++++++++++++++----------------------------------------------------------
Msrc/runtime/order/sdk_status.rs | 191++++---------------------------------------------------------------------------
Msrc/runtime/sdk.rs | 18++++++------------
Msrc/runtime/signer.rs | 36+++++-------------------------------
Msrc/view/runtime.rs | 397+------------------------------------------------------------------------------
Mtests/signer_runtime_modes.rs | 96-------------------------------------------------------------------------------
Mtests/target_cli.rs | 465++++---------------------------------------------------------------------------
20 files changed, 6030 insertions(+), 18637 deletions(-)

diff --git a/src/cli/global.rs b/src/cli/global.rs @@ -263,33 +263,6 @@ pub struct OrderCancelArgs { } #[derive(Debug, Clone)] -pub struct OrderFulfillmentArgs { - pub key: String, - pub state: String, - pub idempotency_key: Option<String>, -} - -#[derive(Debug, Clone)] -pub struct OrderReceiptArgs { - pub key: String, - pub received: bool, - pub issue: Option<String>, - pub idempotency_key: Option<String>, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct OrderPaymentArgs { - pub key: String, - pub amount: String, - pub currency: String, - pub method: String, - pub reference: Option<String>, - pub paid_at: Option<u64>, - pub idempotency_key: Option<String>, -} - -#[derive(Debug, Clone)] pub struct OrderRevisionProposeArgs { pub key: String, pub reason: String, @@ -334,23 +307,6 @@ pub struct OrderRevisionDecisionArgs { pub idempotency_key: Option<String>, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] -pub enum OrderSettlementDecisionArg { - Accept, - Reject, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct OrderSettlementArgs { - pub key: String, - pub payment_event_id: String, - pub decision: OrderSettlementDecisionArg, - pub reason: Option<String>, - pub idempotency_key: Option<String>, -} - #[derive(Debug, Clone)] pub struct OrderStatusArgs { pub key: String, diff --git a/src/cli/input.rs b/src/cli/input.rs @@ -47,8 +47,7 @@ pub fn target_operation_input(command: &TargetCommand) -> OperationData { BasketItemCommand, BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand, FarmLocationCommand, FarmProfileCommand, ListingAppCommand, ListingCommand, MarketCommand, MarketListingCommand, MarketProductCommand, OrderAppCommand, OrderCommand, - OrderEventCommand, OrderFulfillmentCommand, OrderPaymentCommand, OrderReceiptCommand, - OrderRevisionCommand, OrderSettlementCommand, OrderStatusCommand, StoreBackupCommand, + OrderEventCommand, OrderRevisionCommand, OrderStatusCommand, StoreBackupCommand, StoreCommand, ValidationCommand, ValidationReceiptCommand, }; @@ -279,52 +278,6 @@ pub fn target_operation_input(command: &TargetCommand) -> OperationData { insert_string(&mut input, "reason", &args.reason); } }, - OrderCommand::Fulfillment(fulfillment) => match &fulfillment.command { - OrderFulfillmentCommand::Update(args) => { - insert_string(&mut input, "order_id", &args.order_id); - if let Some(state) = args.state { - input.insert( - "state".to_owned(), - Value::String(state.as_protocol_state().to_owned()), - ); - } - } - }, - OrderCommand::Receipt(receipt) => match &receipt.command { - OrderReceiptCommand::Record(args) => { - insert_string(&mut input, "order_id", &args.order_id); - if args.received { - input.insert("received".to_owned(), Value::Bool(true)); - } - insert_string(&mut input, "issue", &args.issue); - } - }, - OrderCommand::Payment(payment) => match &payment.command { - OrderPaymentCommand::Record(args) => { - insert_string(&mut input, "order_id", &args.order_id); - insert_string(&mut input, "amount", &args.amount); - insert_string(&mut input, "currency", &args.currency); - insert_string(&mut input, "method", &args.method); - insert_string(&mut input, "reference", &args.reference); - if let Some(paid_at) = args.paid_at { - input.insert( - "paid_at".to_owned(), - Value::Number(serde_json::Number::from(paid_at)), - ); - } - } - }, - OrderCommand::Settlement(settlement) => match &settlement.command { - OrderSettlementCommand::Accept(args) => { - insert_string(&mut input, "order_id", &args.order_id); - insert_string(&mut input, "payment_event_id", &args.payment_event_id); - } - OrderSettlementCommand::Reject(args) => { - insert_string(&mut input, "order_id", &args.order_id); - insert_string(&mut input, "payment_event_id", &args.payment_event_id); - insert_string(&mut input, "reason", &args.reason); - } - }, OrderCommand::Status(status) => match &status.command { OrderStatusCommand::Get(args) => { insert_string(&mut input, "order_id", &args.order_id) diff --git a/src/cli/mod.rs b/src/cli/mod.rs @@ -271,19 +271,6 @@ impl TargetCommand { OrderRevisionCommand::Accept(_) => "order.revision.accept", OrderRevisionCommand::Decline(_) => "order.revision.decline", }, - OrderCommand::Fulfillment(fulfillment) => match &fulfillment.command { - OrderFulfillmentCommand::Update(_) => "order.fulfillment.update", - }, - OrderCommand::Receipt(receipt) => match &receipt.command { - OrderReceiptCommand::Record(_) => "order.receipt.record", - }, - OrderCommand::Payment(payment) => match &payment.command { - OrderPaymentCommand::Record(_) => "order.payment.record", - }, - OrderCommand::Settlement(settlement) => match &settlement.command { - OrderSettlementCommand::Accept(_) => "order.settlement.accept", - OrderSettlementCommand::Reject(_) => "order.settlement.reject", - }, OrderCommand::Status(status) => match &status.command { OrderStatusCommand::Get(_) => "order.status.get", }, @@ -309,10 +296,8 @@ mod tests { use clap::{CommandFactory, Parser}; use super::{ - AccountCommand, FarmCommand, ListingCommand, OrderCommand, OrderFulfillmentCommand, - OrderFulfillmentStateArg, OrderPaymentCommand, OrderReceiptCommand, OrderRevisionCommand, - OrderSettlementCommand, TargetCliArgs, TargetOutputFormat, ValidationCommand, - ValidationReceiptCommand, + AccountCommand, FarmCommand, ListingCommand, OrderCommand, OrderRevisionCommand, + TargetCliArgs, TargetOutputFormat, ValidationCommand, ValidationReceiptCommand, }; use crate::registry::OPERATION_REGISTRY; @@ -489,31 +474,6 @@ mod tests { } #[test] - fn target_parser_accepts_order_fulfillment_update_state() { - let parsed = TargetCliArgs::try_parse_from([ - "radroots", - "order", - "fulfillment", - "update", - "ord_test", - "--state", - "ready_for_pickup", - ]) - .expect("target args parse"); - - assert_eq!(parsed.command.operation_id(), "order.fulfillment.update"); - let crate::cli::TargetCommand::Order(order) = parsed.command else { - panic!("expected order command") - }; - let OrderCommand::Fulfillment(fulfillment) = order.command else { - panic!("expected order fulfillment command") - }; - let OrderFulfillmentCommand::Update(args) = fulfillment.command; - assert_eq!(args.order_id.as_deref(), Some("ord_test")); - assert_eq!(args.state, Some(OrderFulfillmentStateArg::ReadyForPickup)); - } - - #[test] fn target_parser_accepts_order_cancel_reason() { let parsed = TargetCliArgs::try_parse_from([ "radroots", @@ -636,42 +596,6 @@ mod tests { } #[test] - fn target_parser_accepts_order_receipt_record_outcomes() { - let received = TargetCliArgs::try_parse_from([ - "radroots", - "order", - "receipt", - "record", - "ord_test", - "--received", - ]) - .expect("target args parse"); - assert_eq!(received.command.operation_id(), "order.receipt.record"); - let crate::cli::TargetCommand::Order(order) = received.command else { - panic!("expected order command") - }; - let OrderCommand::Receipt(receipt) = order.command else { - panic!("expected order receipt command") - }; - let OrderReceiptCommand::Record(args) = receipt.command; - assert_eq!(args.order_id.as_deref(), Some("ord_test")); - assert!(args.received); - assert_eq!(args.issue, None); - - let issue = TargetCliArgs::try_parse_from([ - "radroots", - "order", - "receipt", - "record", - "ord_test", - "--issue", - "damaged items", - ]) - .expect("target args parse"); - assert_eq!(issue.command.operation_id(), "order.receipt.record"); - } - - #[test] fn target_parser_accepts_validation_receipt_commands() { let get = TargetCliArgs::try_parse_from([ "radroots", @@ -725,95 +649,6 @@ mod tests { } #[test] - fn target_parser_accepts_order_payment_record_methods() { - let parsed = TargetCliArgs::try_parse_from([ - "radroots", - "order", - "payment", - "record", - "ord_test", - "--amount", - "12", - "--currency", - "USD", - "--method", - "manual_transfer", - "--reference", - "memo-1", - "--paid-at", - "1777666000", - ]) - .expect("target args parse"); - assert_eq!(parsed.command.operation_id(), "order.payment.record"); - let crate::cli::TargetCommand::Order(order) = parsed.command else { - panic!("expected order command") - }; - let OrderCommand::Payment(payment) = order.command else { - panic!("expected order payment command") - }; - let OrderPaymentCommand::Record(args) = payment.command; - assert_eq!(args.order_id.as_deref(), Some("ord_test")); - assert_eq!(args.amount.as_deref(), Some("12")); - assert_eq!(args.currency.as_deref(), Some("USD")); - assert_eq!(args.method.as_deref(), Some("manual_transfer")); - assert_eq!(args.reference.as_deref(), Some("memo-1")); - assert_eq!(args.paid_at, Some(1_777_666_000)); - - let future_method = TargetCliArgs::try_parse_from([ - "radroots", "order", "payment", "record", "ord_test", "--method", "card", - ]) - .expect("target args parse"); - let crate::cli::TargetCommand::Order(order) = future_method.command else { - panic!("expected order command") - }; - let OrderCommand::Payment(payment) = order.command else { - panic!("expected order payment command") - }; - let OrderPaymentCommand::Record(args) = payment.command; - assert_eq!(args.method.as_deref(), Some("card")); - } - - #[test] - fn target_parser_accepts_order_settlement_decisions() { - let accept = TargetCliArgs::try_parse_from([ - "radroots", - "order", - "settlement", - "accept", - "ord_test", - "--payment-event-id", - "pay_event", - ]) - .expect("target args parse"); - assert_eq!(accept.command.operation_id(), "order.settlement.accept"); - let crate::cli::TargetCommand::Order(order) = accept.command else { - panic!("expected order command") - }; - let OrderCommand::Settlement(settlement) = order.command else { - panic!("expected order settlement command") - }; - let OrderSettlementCommand::Accept(args) = settlement.command else { - panic!("expected settlement accept command") - }; - assert_eq!(args.order_id.as_deref(), Some("ord_test")); - assert_eq!(args.payment_event_id.as_deref(), Some("pay_event")); - - let reject = TargetCliArgs::try_parse_from([ - "radroots", - "order", - "settlement", - "reject", - "ord_test", - "--payment-event-id", - "pay_event", - "--reason", - "reference mismatch", - ]) - .expect("target args parse"); - assert_eq!(reject.command.operation_id(), "order.settlement.reject"); - } - - #[test] fn target_parser_rejects_removed_global_flags() { let rejected = [ vec!["radroots", "--output", "json", "config", "get"], diff --git a/src/cli/order.rs b/src/cli/order.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use clap::{ArgAction, Args, Subcommand, ValueEnum}; +use clap::{Args, Subcommand}; #[derive(Debug, Clone, Args)] pub struct OrderArgs { @@ -19,10 +19,6 @@ pub enum OrderCommand { Decline(OrderDeclineArgs), Cancel(OrderCancelArgs), Revision(OrderRevisionArgs), - Fulfillment(OrderFulfillmentArgs), - Receipt(OrderReceiptArgs), - Payment(OrderPaymentArgs), - Settlement(OrderSettlementArgs), Status(OrderStatusArgs), Event(OrderEventArgs), } @@ -127,120 +123,6 @@ pub struct OrderRevisionDeclineArgs { } #[derive(Debug, Clone, Args)] -pub struct OrderFulfillmentArgs { - #[command(subcommand)] - pub command: OrderFulfillmentCommand, -} - -#[derive(Debug, Clone, Subcommand)] -pub enum OrderFulfillmentCommand { - Update(OrderFulfillmentUpdateArgs), -} - -#[derive(Debug, Clone, Args)] -pub struct OrderFulfillmentUpdateArgs { - pub order_id: Option<String>, - #[arg(long, value_enum)] - pub state: Option<OrderFulfillmentStateArg>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -#[value(rename_all = "snake_case")] -pub enum OrderFulfillmentStateArg { - Preparing, - ReadyForPickup, - OutForDelivery, - Delivered, - SellerCancelled, -} - -impl OrderFulfillmentStateArg { - pub const fn as_protocol_state(self) -> &'static str { - match self { - Self::Preparing => "preparing", - Self::ReadyForPickup => "ready_for_pickup", - Self::OutForDelivery => "out_for_delivery", - Self::Delivered => "delivered", - Self::SellerCancelled => "seller_cancelled", - } - } -} - -#[derive(Debug, Clone, Args)] -pub struct OrderReceiptArgs { - #[command(subcommand)] - pub command: OrderReceiptCommand, -} - -#[derive(Debug, Clone, Subcommand)] -pub enum OrderReceiptCommand { - Record(OrderReceiptRecordArgs), -} - -#[derive(Debug, Clone, Args)] -pub struct OrderReceiptRecordArgs { - pub order_id: Option<String>, - #[arg(long, action = ArgAction::SetTrue, conflicts_with = "issue")] - pub received: bool, - #[arg(long)] - pub issue: Option<String>, -} - -#[derive(Debug, Clone, Args)] -pub struct OrderPaymentArgs { - #[command(subcommand)] - pub command: OrderPaymentCommand, -} - -#[derive(Debug, Clone, Subcommand)] -pub enum OrderPaymentCommand { - Record(OrderPaymentRecordArgs), -} - -#[derive(Debug, Clone, Args)] -pub struct OrderPaymentRecordArgs { - pub order_id: Option<String>, - #[arg(long)] - pub amount: Option<String>, - #[arg(long)] - pub currency: Option<String>, - #[arg(long)] - pub method: Option<String>, - #[arg(long)] - pub reference: Option<String>, - #[arg(long)] - pub paid_at: Option<u64>, -} - -#[derive(Debug, Clone, Args)] -pub struct OrderSettlementArgs { - #[command(subcommand)] - pub command: OrderSettlementCommand, -} - -#[derive(Debug, Clone, Subcommand)] -pub enum OrderSettlementCommand { - Accept(OrderSettlementAcceptArgs), - Reject(OrderSettlementRejectArgs), -} - -#[derive(Debug, Clone, Args)] -pub struct OrderSettlementAcceptArgs { - pub order_id: Option<String>, - #[arg(long)] - pub payment_event_id: Option<String>, -} - -#[derive(Debug, Clone, Args)] -pub struct OrderSettlementRejectArgs { - pub order_id: Option<String>, - #[arg(long)] - pub payment_event_id: Option<String>, - #[arg(long)] - pub reason: Option<String>, -} - -#[derive(Debug, Clone, Args)] pub struct OrderStatusArgs { #[command(subcommand)] pub command: OrderStatusCommand, diff --git a/src/deferred_payment.rs b/src/deferred_payment.rs @@ -1,12 +0,0 @@ -pub const DEFERRED_PAYMENT_MESSAGE: &str = "payments and settlement are not implemented in this Radroots release; order coordination is available now, and payment support is planned for a future phase"; - -pub fn is_deferred_payment_operation(operation_id: &str) -> bool { - matches!( - operation_id, - "order.payment.record" | "order.settlement.accept" | "order.settlement.reject" - ) -} - -pub fn deferred_payment_message() -> String { - DEFERRED_PAYMENT_MESSAGE.to_owned() -} diff --git a/src/main.rs b/src/main.rs @@ -1,7 +1,6 @@ #![forbid(unsafe_code)] mod cli; -mod deferred_payment; mod ops; mod out; mod registry; @@ -18,7 +17,6 @@ use serde_json::{Value, json}; use crate::cli::input::runtime_invocation_args_from_target; use crate::cli::{TargetCliArgs, TargetOutputFormat}; -use crate::deferred_payment::{deferred_payment_message, is_deferred_payment_operation}; use crate::ops::exec::{ BasketOperationService, CoreOperationService, FarmOperationService, ListingOperationService, MarketOperationService, OrderOperationService, RuntimeOperationService, @@ -282,21 +280,6 @@ fn execute_request( TargetOperationRequest::OrderRevisionDecline(request) => { execute_with(OrderOperationService::new(config), request) } - TargetOperationRequest::OrderFulfillmentUpdate(request) => { - execute_with(OrderOperationService::new(config), request) - } - TargetOperationRequest::OrderReceiptRecord(request) => { - execute_with(OrderOperationService::new(config), request) - } - TargetOperationRequest::OrderPaymentRecord(request) => { - execute_with(OrderOperationService::new(config), request) - } - TargetOperationRequest::OrderSettlementAccept(request) => { - execute_with(OrderOperationService::new(config), request) - } - TargetOperationRequest::OrderSettlementReject(request) => { - execute_with(OrderOperationService::new(config), request) - } TargetOperationRequest::OrderStatusGet(request) => { execute_with(OrderOperationService::new(config), request) } @@ -354,12 +337,6 @@ fn validate_pre_runtime_request_contract( request: &TargetOperationRequest, ) -> Result<(), OperationAdapterError> { let spec = request.spec(); - if is_deferred_payment_operation(spec.operation_id) { - return Err(OperationAdapterError::not_implemented( - spec.operation_id, - deferred_payment_message(), - )); - } if matches!( request.context().output_format, OperationOutputFormat::Ndjson @@ -760,66 +737,3 @@ fn envelope_exit_code(envelope: &OutputEnvelope) -> ExitCode { fn operation_config_error(error: OperationAdapterError) -> runtime::RuntimeError { runtime::RuntimeError::Config(error.to_string()) } - -#[cfg(test)] -mod tests { - use std::collections::BTreeSet; - - use super::*; - use crate::registry::{ApprovalPolicy, NetworkRequirement, OPERATION_REGISTRY, RiskLevel}; - - const DEFERRED_PAYMENT_OPERATION_IDS: &[&str] = &[ - "order.payment.record", - "order.settlement.accept", - "order.settlement.reject", - ]; - - #[test] - fn payment_and_settlement_operations_are_pre_runtime_deferred_only() { - let actual = OPERATION_REGISTRY - .iter() - .filter(|operation| { - operation.operation_id.starts_with("order.payment.") - || operation.operation_id.starts_with("order.settlement.") - }) - .map(|operation| operation.operation_id) - .collect::<BTreeSet<_>>(); - let expected = DEFERRED_PAYMENT_OPERATION_IDS - .iter() - .copied() - .collect::<BTreeSet<_>>(); - - assert_eq!(actual, expected); - - for operation_id in DEFERRED_PAYMENT_OPERATION_IDS { - let operation = OPERATION_REGISTRY - .iter() - .find(|operation| operation.operation_id == *operation_id) - .expect("deferred payment operation should be registered"); - - assert!(is_deferred_payment_operation(operation.operation_id)); - assert!(!operation.mutates); - assert_eq!(operation.approval_policy, ApprovalPolicy::None); - assert_eq!(operation.risk_level, RiskLevel::Low); - assert_eq!( - network_requirement(operation.operation_id), - NetworkRequirement::Local - ); - assert!(!requires_local_signer_mode(operation.operation_id)); - assert!(!requires_nostr_relay_publish_mode(operation.operation_id)); - } - } - - #[test] - fn deferred_payment_guard_runs_before_runtime_config_loading() { - let source = include_str!("main.rs"); - let deferred_guard = source - .find("if let Err(error) = validate_pre_runtime_request_contract(&request)") - .expect("run should validate pre-runtime request contract"); - let runtime_config = source - .find("let config = RuntimeConfig::from_system") - .expect("run should load runtime config after pre-runtime guard"); - - assert!(deferred_guard < runtime_config); - } -} diff --git a/src/ops/exec/order.rs b/src/ops/exec/order.rs @@ -5,32 +5,27 @@ use serde_json::{Value, json}; use crate::cli::global::{ OrderAppRecordExportArgs, OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, - OrderFulfillmentArgs, OrderRebindArgs, OrderReceiptArgs, OrderRevisionDecisionArg, - OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderStatusArgs, OrderSubmitArgs, - RecordLookupArgs, + OrderRebindArgs, OrderRevisionDecisionArg, OrderRevisionDecisionArgs, OrderRevisionProposeArgs, + OrderStatusArgs, OrderSubmitArgs, RecordLookupArgs, }; -use crate::deferred_payment::deferred_payment_message; use crate::ops::{ OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, OperationResult, OperationResultData, OperationService, OrderAcceptRequest, OrderAcceptResult, OrderAppExportRequest, OrderAppExportResult, OrderAppListRequest, OrderAppListResult, OrderCancelRequest, OrderCancelResult, OrderDeclineRequest, OrderDeclineResult, OrderEventListRequest, OrderEventListResult, OrderEventWatchRequest, OrderEventWatchResult, - OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, OrderGetRequest, OrderGetResult, - OrderListRequest, OrderListResult, OrderPaymentRecordRequest, OrderPaymentRecordResult, - OrderRebindRequest, OrderRebindResult, OrderReceiptRecordRequest, OrderReceiptRecordResult, - OrderRevisionAcceptRequest, OrderRevisionAcceptResult, OrderRevisionDeclineRequest, - OrderRevisionDeclineResult, OrderRevisionProposeRequest, OrderRevisionProposeResult, - OrderSettlementAcceptRequest, OrderSettlementAcceptResult, OrderSettlementRejectRequest, - OrderSettlementRejectResult, OrderStatusGetRequest, OrderStatusGetResult, OrderSubmitRequest, + OrderGetRequest, OrderGetResult, OrderListRequest, OrderListResult, OrderRebindRequest, + OrderRebindResult, OrderRevisionAcceptRequest, OrderRevisionAcceptResult, + OrderRevisionDeclineRequest, OrderRevisionDeclineResult, OrderRevisionProposeRequest, + OrderRevisionProposeResult, OrderStatusGetRequest, OrderStatusGetResult, OrderSubmitRequest, OrderSubmitResult, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; use crate::view::runtime::{ CommandDisposition, OrderAppRecordExportView, OrderCancellationView, OrderDecisionView, - OrderFulfillmentView, OrderRebindView, OrderReceiptView, OrderRevisionDecisionView, - OrderRevisionProposalView, OrderStatusView, OrderSubmitView, + OrderRebindView, OrderRevisionDecisionView, OrderRevisionProposalView, OrderStatusView, + OrderSubmitView, }; const ORDER_EVENT_WATCH_DEFERRED_REASON: &str = "relay-backed order event watch is not implemented"; @@ -417,132 +412,6 @@ impl OperationService<OrderRevisionDeclineRequest> for OrderOperationService<'_> } } -impl OperationService<OrderFulfillmentUpdateRequest> for OrderOperationService<'_> { - type Result = OrderFulfillmentUpdateResult; - - fn execute( - &self, - request: OperationRequest<OrderFulfillmentUpdateRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let key = required_order_key(&request)?; - let state = string_input(&request, "state") - .map(|state| state.trim().to_owned()) - .filter(|state| !state.is_empty()) - .ok_or_else(|| { - invalid_input( - request.operation_id(), - "missing required `state` input".to_owned(), - ) - })?; - if request.context.requires_approval_token() { - return Err(OperationAdapterError::approval_required( - request.operation_id(), - )); - } - - let args = OrderFulfillmentArgs { - key, - state, - idempotency_key: request - .context - .idempotency_key - .clone() - .or_else(|| string_input(&request, "idempotency_key")), - }; - let mut config = self.config.clone(); - if request.context.dry_run { - config.output.dry_run = true; - } - let view = crate::runtime::order::fulfillment_update(&config, &args).map_err(|error| { - OperationAdapterError::runtime_failure(request.operation_id(), error) - })?; - fulfillment_result::<OrderFulfillmentUpdateResult>(request.operation_id(), &view) - } -} - -impl OperationService<OrderReceiptRecordRequest> for OrderOperationService<'_> { - type Result = OrderReceiptRecordResult; - - fn execute( - &self, - request: OperationRequest<OrderReceiptRecordRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let received = bool_input(&request, "received").unwrap_or(false); - let issue = string_input(&request, "issue") - .map(|issue| issue.trim().to_owned()) - .filter(|issue| !issue.is_empty()); - if received && issue.is_some() { - return Err(invalid_input( - request.operation_id(), - "`received` and `issue` cannot both be set".to_owned(), - )); - } - if !received && issue.is_none() { - return Err(invalid_input( - request.operation_id(), - "missing required receipt outcome input".to_owned(), - )); - } - if request.context.requires_approval_token() { - return Err(OperationAdapterError::approval_required( - request.operation_id(), - )); - } - - let args = OrderReceiptArgs { - key: required_order_key(&request)?, - received, - issue, - idempotency_key: request - .context - .idempotency_key - .clone() - .or_else(|| string_input(&request, "idempotency_key")), - }; - let mut config = self.config.clone(); - if request.context.dry_run { - config.output.dry_run = true; - } - let view = crate::runtime::order::receipt_record(&config, &args).map_err(|error| { - OperationAdapterError::runtime_failure(request.operation_id(), error) - })?; - receipt_result::<OrderReceiptRecordResult>(request.operation_id(), &view) - } -} - -impl OperationService<OrderPaymentRecordRequest> for OrderOperationService<'_> { - type Result = OrderPaymentRecordResult; - - fn execute( - &self, - request: OperationRequest<OrderPaymentRecordRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - Err(deferred_payment_error(request.operation_id())) - } -} - -impl OperationService<OrderSettlementAcceptRequest> for OrderOperationService<'_> { - type Result = OrderSettlementAcceptResult; - - fn execute( - &self, - request: OperationRequest<OrderSettlementAcceptRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - Err(deferred_payment_error(request.operation_id())) - } -} - -impl OperationService<OrderSettlementRejectRequest> for OrderOperationService<'_> { - type Result = OrderSettlementRejectResult; - - fn execute( - &self, - request: OperationRequest<OrderSettlementRejectRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - Err(deferred_payment_error(request.operation_id())) - } -} - impl OperationService<OrderStatusGetRequest> for OrderOperationService<'_> { type Result = OrderStatusGetResult; @@ -692,96 +561,6 @@ fn order_decision_error_detail(view: &OrderDecisionView) -> Value { }) } -fn fulfillment_result<R>( - operation_id: &str, - view: &OrderFulfillmentView, -) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, -{ - match view.disposition() { - CommandDisposition::Success => serialized_target_result::<R, _>(view), - CommandDisposition::ValidationFailed => { - let message = view.reason.clone().unwrap_or_else(|| { - format!( - "order fulfillment update failed validation with state `{}`", - view.state - ) - }); - Err(OperationAdapterError::validation_failed_with_detail( - operation_id, - message, - order_fulfillment_error_detail(view), - )) - } - disposition => { - let message = view.reason.clone().unwrap_or_else(|| { - format!( - "order fulfillment update finished with state `{}`", - view.state - ) - }); - if disposition == CommandDisposition::ExternalUnavailable { - let detail = order_fulfillment_error_detail(view); - if !view.failed_relays.is_empty() && view.connected_relays.is_empty() { - Err(OperationAdapterError::network_unavailable_with_detail( - operation_id, - message, - detail, - )) - } else { - Err(OperationAdapterError::operation_unavailable_with_detail( - operation_id, - message, - detail, - )) - } - } else if disposition == CommandDisposition::Unconfigured { - Err(OperationAdapterError::operation_unavailable_with_detail( - operation_id, - message, - order_fulfillment_error_detail(view), - )) - } else { - Err(OperationAdapterError::from_command_disposition( - operation_id, - disposition, - message, - )) - } - } - } -} - -fn order_fulfillment_error_detail(view: &OrderFulfillmentView) -> Value { - json!({ - "state": &view.state, - "order_id": &view.order_id, - "fulfillment_state": &view.fulfillment_state, - "listing_addr": &view.listing_addr, - "request_event_id": &view.request_event_id, - "decision_event_id": &view.decision_event_id, - "root_event_id": &view.root_event_id, - "prev_event_id": &view.prev_event_id, - "event_id": &view.event_id, - "event_kind": view.event_kind, - "buyer_pubkey": &view.buyer_pubkey, - "seller_pubkey": &view.seller_pubkey, - "dry_run": view.dry_run, - "target_relays": &view.target_relays, - "connected_relays": &view.connected_relays, - "acknowledged_relays": &view.acknowledged_relays, - "failed_relays": &view.failed_relays, - "fetched_count": view.fetched_count, - "decoded_count": view.decoded_count, - "skipped_count": view.skipped_count, - "idempotency_key": &view.idempotency_key, - "signer_mode": &view.signer_mode, - "issues": &view.issues, - "actions": &view.actions, - }) -} - fn cancellation_result<R>( operation_id: &str, view: &OrderCancellationView, @@ -1056,96 +835,6 @@ fn order_revision_decision_error_detail(view: &OrderRevisionDecisionView) -> Val }) } -fn receipt_result<R>( - operation_id: &str, - view: &OrderReceiptView, -) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, -{ - match view.disposition() { - CommandDisposition::Success => serialized_target_result::<R, _>(view), - CommandDisposition::ValidationFailed => { - let message = view.reason.clone().unwrap_or_else(|| { - format!( - "order receipt record failed validation with state `{}`", - view.state - ) - }); - Err(OperationAdapterError::validation_failed_with_detail( - operation_id, - message, - order_receipt_error_detail(view), - )) - } - disposition => { - let message = view.reason.clone().unwrap_or_else(|| { - format!("order receipt record finished with state `{}`", view.state) - }); - if disposition == CommandDisposition::ExternalUnavailable { - let detail = order_receipt_error_detail(view); - if !view.failed_relays.is_empty() && view.connected_relays.is_empty() { - Err(OperationAdapterError::network_unavailable_with_detail( - operation_id, - message, - detail, - )) - } else { - Err(OperationAdapterError::operation_unavailable_with_detail( - operation_id, - message, - detail, - )) - } - } else if disposition == CommandDisposition::Unconfigured { - Err(OperationAdapterError::operation_unavailable_with_detail( - operation_id, - message, - order_receipt_error_detail(view), - )) - } else { - Err(OperationAdapterError::from_command_disposition( - operation_id, - disposition, - message, - )) - } - } - } -} - -fn order_receipt_error_detail(view: &OrderReceiptView) -> Value { - json!({ - "state": &view.state, - "order_id": &view.order_id, - "listing_addr": &view.listing_addr, - "request_event_id": &view.request_event_id, - "decision_event_id": &view.decision_event_id, - "fulfillment_event_id": &view.fulfillment_event_id, - "root_event_id": &view.root_event_id, - "prev_event_id": &view.prev_event_id, - "event_id": &view.event_id, - "event_kind": view.event_kind, - "buyer_pubkey": &view.buyer_pubkey, - "seller_pubkey": &view.seller_pubkey, - "received": view.received, - "issue": &view.issue, - "received_at": &view.received_at, - "dry_run": view.dry_run, - "target_relays": &view.target_relays, - "connected_relays": &view.connected_relays, - "acknowledged_relays": &view.acknowledged_relays, - "failed_relays": &view.failed_relays, - "fetched_count": view.fetched_count, - "decoded_count": view.decoded_count, - "skipped_count": view.skipped_count, - "idempotency_key": &view.idempotency_key, - "signer_mode": &view.signer_mode, - "issues": &view.issues, - "actions": &view.actions, - }) -} - fn status_result<R>( operation_id: &str, view: &OrderStatusView, @@ -1206,9 +895,7 @@ fn order_status_error_detail(view: &OrderStatusView) -> Value { "last_event_id": &view.last_event_id, "revision": &view.revision, "inventory": &view.inventory, - "fulfillment": &view.fulfillment, "lifecycle": &view.lifecycle, - "payment": &view.payment, "reducer_issues": &view.reducer_issues, "target_relays": &view.target_relays, "connected_relays": &view.connected_relays, @@ -1544,10 +1231,6 @@ fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapter result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) } -fn deferred_payment_error(operation_id: &str) -> OperationAdapterError { - OperationAdapterError::not_implemented(operation_id, deferred_payment_message()) -} - fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError { OperationAdapterError::InvalidInput { operation_id: operation_id.to_owned(), @@ -1569,9 +1252,8 @@ mod tests { OperationAdapter, OperationContext, OperationData, OperationRequest, OrderAcceptRequest, OrderAcceptResult, OrderCancelRequest, OrderDeclineRequest, OrderDeclineResult, OrderEventListRequest, OrderEventWatchRequest, OrderGetRequest, OrderListRequest, - OrderPaymentRecordRequest, OrderReceiptRecordRequest, OrderRevisionAcceptRequest, - OrderRevisionDeclineRequest, OrderRevisionProposeRequest, OrderSettlementAcceptRequest, - OrderSettlementRejectRequest, OrderStatusGetRequest, OrderSubmitRequest, + OrderRevisionAcceptRequest, OrderRevisionDeclineRequest, OrderRevisionProposeRequest, + OrderStatusGetRequest, OrderSubmitRequest, }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, @@ -1921,81 +1603,6 @@ mod tests { } #[test] - fn order_receipt_record_requires_outcome_before_approval() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let service = OperationAdapter::new(OrderOperationService::new(&config)); - let receipt = OperationRequest::new( - OperationContext::default(), - OrderReceiptRecordRequest::from_data(data(&[("order_id", "ord_pending")])), - ) - .expect("order receipt request"); - let error = service.execute(receipt).expect_err("outcome required"); - let output_error = error.to_output_error(); - - assert_eq!(output_error.code, "invalid_input"); - assert!(output_error.message.contains("outcome")); - } - - #[test] - fn order_receipt_record_requires_approval_token() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let service = OperationAdapter::new(OrderOperationService::new(&config)); - let mut input = data(&[("order_id", "ord_pending")]); - input.insert("received".to_owned(), Value::Bool(true)); - let receipt = OperationRequest::new( - OperationContext::default(), - OrderReceiptRecordRequest::from_data(input), - ) - .expect("order receipt request"); - let error = service.execute(receipt).expect_err("approval required"); - - assert_eq!(error.to_output_error().code, "approval_required"); - } - - #[test] - fn deferred_payment_commands_return_not_implemented_before_input_or_approval() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let service = OperationAdapter::new(OrderOperationService::new(&config)); - - let payment = OperationRequest::new( - OperationContext::default(), - OrderPaymentRecordRequest::from_data(data(&[("order_id", "ord_pending")])), - ) - .expect("order payment request"); - let payment_error = service.execute(payment).expect_err("payment deferred"); - assert_eq!(payment_error.to_output_error().code, "not_implemented"); - - let settlement_accept = OperationRequest::new( - OperationContext::default(), - OrderSettlementAcceptRequest::from_data(data(&[("order_id", "ord_pending")])), - ) - .expect("order settlement accept request"); - let settlement_accept_error = service - .execute(settlement_accept) - .expect_err("settlement accept deferred"); - assert_eq!( - settlement_accept_error.to_output_error().code, - "not_implemented" - ); - - let settlement_reject = OperationRequest::new( - OperationContext::default(), - OrderSettlementRejectRequest::from_data(data(&[("order_id", "ord_pending")])), - ) - .expect("order settlement reject request"); - let settlement_reject_error = service - .execute(settlement_reject) - .expect_err("settlement reject deferred"); - assert_eq!( - settlement_reject_error.to_output_error().code, - "not_implemented" - ); - } - - #[test] fn order_status_get_uses_local_sdk_projection_without_relay() { let dir = tempdir().expect("tempdir"); let config = sample_config(dir.path()); diff --git a/src/ops/mod.rs b/src/ops/mod.rs @@ -244,40 +244,6 @@ mod tests { } #[test] - fn adapter_maps_order_fulfillment_update_input() { - let parsed = TargetCliArgs::try_parse_from([ - "radroots", - "order", - "fulfillment", - "update", - "ord_test", - "--state", - "seller_cancelled", - ]) - .expect("target args parse"); - - let request = TargetOperationRequest::from_target_args(&parsed) - .expect("operation request from target args"); - let TargetOperationRequest::OrderFulfillmentUpdate(request) = request else { - panic!("expected order fulfillment update request") - }; - - assert_eq!(request.operation_id(), "order.fulfillment.update"); - assert_eq!( - request - .payload - .input - .get("order_id") - .and_then(Value::as_str), - Some("ord_test") - ); - assert_eq!( - request.payload.input.get("state").and_then(Value::as_str), - Some("seller_cancelled") - ); - } - - #[test] fn adapter_maps_order_lifecycle_inputs() { let revision = TargetCliArgs::try_parse_from([ "radroots", @@ -472,135 +438,6 @@ mod tests { request.payload.input.get("reason").and_then(Value::as_str), Some("changed plans") ); - - let receipt = TargetCliArgs::try_parse_from([ - "radroots", - "order", - "receipt", - "record", - "ord_test", - "--issue", - "damaged items", - ]) - .expect("target args parse"); - let request = - TargetOperationRequest::from_target_args(&receipt).expect("operation request"); - let TargetOperationRequest::OrderReceiptRecord(request) = request else { - panic!("expected order receipt record request") - }; - assert_eq!(request.operation_id(), "order.receipt.record"); - assert_eq!( - request - .payload - .input - .get("order_id") - .and_then(Value::as_str), - Some("ord_test") - ); - assert_eq!( - request.payload.input.get("issue").and_then(Value::as_str), - Some("damaged items") - ); - - let payment = TargetCliArgs::try_parse_from([ - "radroots", - "order", - "payment", - "record", - "ord_test", - "--amount", - "12", - "--currency", - "USD", - "--method", - "manual_transfer", - "--reference", - "memo-1", - "--paid-at", - "1777666000", - ]) - .expect("target args parse"); - let request = - TargetOperationRequest::from_target_args(&payment).expect("operation request"); - let TargetOperationRequest::OrderPaymentRecord(request) = request else { - panic!("expected order payment record request") - }; - assert_eq!(request.operation_id(), "order.payment.record"); - assert_eq!( - request - .payload - .input - .get("order_id") - .and_then(Value::as_str), - Some("ord_test") - ); - assert_eq!( - request.payload.input.get("amount").and_then(Value::as_str), - Some("12") - ); - assert_eq!( - request - .payload - .input - .get("currency") - .and_then(Value::as_str), - Some("USD") - ); - assert_eq!( - request.payload.input.get("method").and_then(Value::as_str), - Some("manual_transfer") - ); - assert_eq!( - request - .payload - .input - .get("reference") - .and_then(Value::as_str), - Some("memo-1") - ); - assert_eq!( - request.payload.input.get("paid_at").and_then(Value::as_u64), - Some(1_777_666_000) - ); - - let settlement = TargetCliArgs::try_parse_from([ - "radroots", - "order", - "settlement", - "reject", - "ord_test", - "--payment-event-id", - "pay_event", - "--reason", - "reference mismatch", - ]) - .expect("target args parse"); - let request = - TargetOperationRequest::from_target_args(&settlement).expect("operation request"); - let TargetOperationRequest::OrderSettlementReject(request) = request else { - panic!("expected order settlement reject request") - }; - assert_eq!(request.operation_id(), "order.settlement.reject"); - assert_eq!( - request - .payload - .input - .get("order_id") - .and_then(Value::as_str), - Some("ord_test") - ); - assert_eq!( - request - .payload - .input - .get("payment_event_id") - .and_then(Value::as_str), - Some("pay_event") - ); - assert_eq!( - request.payload.input.get("reason").and_then(Value::as_str), - Some("reference mismatch") - ); } #[test] @@ -646,17 +483,15 @@ mod tests { #[test] fn not_implemented_errors_map_to_structured_exit_code() { - let error = OperationAdapterError::not_implemented( - "order.payment.record", - "coming soon".to_owned(), - ); + let error = + OperationAdapterError::not_implemented("test.operation", "coming soon".to_owned()); let output_error = error.to_output_error(); assert_eq!(output_error.code, "not_implemented"); assert_eq!(output_error.exit_code, 3); assert_eq!( output_error.detail.expect("detail")["operation_id"], - "order.payment.record" + "test.operation" ); } diff --git a/src/ops/target.rs b/src/ops/target.rs @@ -236,11 +236,6 @@ target_operation_contracts! { OrderRevisionPropose => (OrderRevisionProposeRequest, OrderRevisionProposeResult, "order.revision.propose"), OrderRevisionAccept => (OrderRevisionAcceptRequest, OrderRevisionAcceptResult, "order.revision.accept"), OrderRevisionDecline => (OrderRevisionDeclineRequest, OrderRevisionDeclineResult, "order.revision.decline"), - OrderFulfillmentUpdate => (OrderFulfillmentUpdateRequest, OrderFulfillmentUpdateResult, "order.fulfillment.update"), - OrderReceiptRecord => (OrderReceiptRecordRequest, OrderReceiptRecordResult, "order.receipt.record"), - OrderPaymentRecord => (OrderPaymentRecordRequest, OrderPaymentRecordResult, "order.payment.record"), - OrderSettlementAccept => (OrderSettlementAcceptRequest, OrderSettlementAcceptResult, "order.settlement.accept"), - OrderSettlementReject => (OrderSettlementRejectRequest, OrderSettlementRejectResult, "order.settlement.reject"), OrderStatusGet => (OrderStatusGetRequest, OrderStatusGetResult, "order.status.get"), OrderEventList => (OrderEventListRequest, OrderEventListResult, "order.event.list"), OrderEventWatch => (OrderEventWatchRequest, OrderEventWatchResult, "order.event.watch"), diff --git a/src/out/envelope.rs b/src/out/envelope.rs @@ -696,16 +696,16 @@ mod tests { fn ndjson_terminal_frame_carries_status_reason_and_resource() { let mut error = OutputError::new( "not_implemented", - "payments are deferred", + "operation is not implemented", CliExitCode::RuntimeUnavailable, ); error.detail = Some(json!({ "order_id": "ord_test", })); let envelope = OutputEnvelope::failure( - "order.payment.record", + "test.operation", error, - EnvelopeContext::new("req_payment", false), + EnvelopeContext::new("req_test", false), ); let frames = envelope.to_ndjson_frames(); diff --git a/src/registry/mod.rs b/src/registry/mod.rs @@ -165,11 +165,6 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ order::ORDER_REVISION_PROPOSE, order::ORDER_REVISION_ACCEPT, order::ORDER_REVISION_DECLINE, - order::ORDER_FULFILLMENT_UPDATE, - order::ORDER_RECEIPT_RECORD, - order::ORDER_PAYMENT_RECORD, - order::ORDER_SETTLEMENT_ACCEPT, - order::ORDER_SETTLEMENT_REJECT, order::ORDER_STATUS_GET, order::ORDER_EVENT_LIST, order::ORDER_EVENT_WATCH, @@ -206,9 +201,7 @@ pub fn network_requirement(operation_id: &str) -> NetworkRequirement { | "order.cancel" | "order.revision.propose" | "order.revision.accept" - | "order.revision.decline" - | "order.fulfillment.update" - | "order.receipt.record" => NetworkRequirement::External { + | "order.revision.decline" => NetworkRequirement::External { dry_run_requires_network: true, }, _ => NetworkRequirement::Local, @@ -231,8 +224,6 @@ pub fn requires_local_signer_mode(operation_id: &str) -> bool { | "order.revision.propose" | "order.revision.accept" | "order.revision.decline" - | "order.fulfillment.update" - | "order.receipt.record" ) } @@ -251,8 +242,6 @@ pub fn requires_nostr_relay_publish_mode(operation_id: &str) -> bool { | "order.revision.propose" | "order.revision.accept" | "order.revision.decline" - | "order.fulfillment.update" - | "order.receipt.record" ) } @@ -344,11 +333,6 @@ mod tests { "order.revision.propose", "order.revision.accept", "order.revision.decline", - "order.fulfillment.update", - "order.receipt.record", - "order.payment.record", - "order.settlement.accept", - "order.settlement.reject", "order.status.get", "order.event.list", "order.event.watch", @@ -399,8 +383,6 @@ mod tests { "order.revision.propose", "order.revision.accept", "order.revision.decline", - "order.fulfillment.update", - "order.receipt.record", ]; const INTENTIONALLY_UNSUPPORTED_MUTATING_DRY_RUN_OPERATION_IDS: &[&str] = &[]; @@ -413,7 +395,7 @@ mod tests { .copied() .collect::<BTreeSet<_>>(); assert_eq!(actual, expected); - assert_eq!(OPERATION_REGISTRY.len(), 79); + assert_eq!(OPERATION_REGISTRY.len(), 74); } #[test] @@ -472,8 +454,6 @@ mod tests { "order.revision.propose", "order.revision.accept", "order.revision.decline", - "order.fulfillment.update", - "order.receipt.record", ] .into_iter() .collect::<BTreeSet<_>>(); @@ -577,8 +557,6 @@ mod tests { "order.revision.propose", "order.revision.accept", "order.revision.decline", - "order.fulfillment.update", - "order.receipt.record", "order.event.list", "validation.receipt.get", "validation.receipt.list", @@ -611,8 +589,6 @@ mod tests { "order.revision.propose", "order.revision.accept", "order.revision.decline", - "order.fulfillment.update", - "order.receipt.record", ] .into_iter() .collect::<BTreeSet<_>>(); @@ -640,8 +616,6 @@ mod tests { "order.revision.propose", "order.revision.accept", "order.revision.decline", - "order.fulfillment.update", - "order.receipt.record", ] .into_iter() .collect::<BTreeSet<_>>(); diff --git a/src/registry/order.rs b/src/registry/order.rs @@ -192,86 +192,6 @@ pub const ORDER_REVISION_DECLINE: OperationSpec = operation!( true ); -pub const ORDER_FULFILLMENT_UPDATE: OperationSpec = operation!( - "order.fulfillment.update", - "radroots order fulfillment update", - "order", - "order_fulfillment_update", - "OrderFulfillmentUpdateRequest", - "OrderFulfillmentUpdateResult", - "Update seller-authored order fulfillment state.", - Seller, - true, - Required, - High, - false, - true -); - -pub const ORDER_RECEIPT_RECORD: OperationSpec = operation!( - "order.receipt.record", - "radroots order receipt record", - "order", - "order_receipt_record", - "OrderReceiptRecordRequest", - "OrderReceiptRecordResult", - "Record buyer receipt outcome.", - Buyer, - true, - Required, - High, - false, - true -); - -pub const ORDER_PAYMENT_RECORD: OperationSpec = operation!( - "order.payment.record", - "radroots order payment record", - "order", - "order_payment_record", - "OrderPaymentRecordRequest", - "OrderPaymentRecordResult", - "Reserved future buyer manual payment command.", - Buyer, - false, - None, - Low, - false, - true -); - -pub const ORDER_SETTLEMENT_ACCEPT: OperationSpec = operation!( - "order.settlement.accept", - "radroots order settlement accept", - "order", - "order_settlement_accept", - "OrderSettlementAcceptRequest", - "OrderSettlementAcceptResult", - "Reserved future seller settlement acceptance command.", - Seller, - false, - None, - Low, - false, - true -); - -pub const ORDER_SETTLEMENT_REJECT: OperationSpec = operation!( - "order.settlement.reject", - "radroots order settlement reject", - "order", - "order_settlement_reject", - "OrderSettlementRejectRequest", - "OrderSettlementRejectResult", - "Reserved future seller settlement rejection command.", - Seller, - false, - None, - Low, - false, - true -); - pub const ORDER_STATUS_GET: OperationSpec = operation!( "order.status.get", "radroots order status get", diff --git a/src/runtime/direct_relay.rs b/src/runtime/direct_relay.rs @@ -91,19 +91,6 @@ pub enum DirectRelayFetchError { Fetch(#[source] RadrootsNostrError), } -pub fn publish_parts_with_identity( - identity: &RadrootsIdentity, - relay_urls: &[String], - parts: WireEventParts, -) -> Result<DirectRelayPublishReceipt, DirectRelayPublishError> { - if relay_urls.is_empty() { - return Err(DirectRelayPublishError::MissingRelays); - } - - let event = sign_parts_with_identity(identity, parts)?; - publish_signed_event_with_identity(identity, relay_urls, event) -} - pub fn publish_signed_event_with_identity( identity: &RadrootsIdentity, relay_urls: &[String], @@ -318,29 +305,11 @@ mod tests { use radroots_nostr::prelude::RadrootsNostrFilter; use super::{ - DirectRelayFetchError, DirectRelayPublishError, event_created_at_u32, - fetch_events_from_relays_async, fetch_events_from_relays_with_timeout, - publish_parts_with_identity, sign_parts_with_identity, + DirectRelayFetchError, event_created_at_u32, fetch_events_from_relays_async, + fetch_events_from_relays_with_timeout, sign_parts_with_identity, }; #[test] - fn publish_parts_requires_relays_before_runtime_work() { - let identity = RadrootsIdentity::generate(); - let err = publish_parts_with_identity( - &identity, - &[], - WireEventParts { - kind: 30402, - content: "listing".to_owned(), - tags: Vec::new(), - }, - ) - .expect_err("missing relay error"); - - assert!(matches!(err, DirectRelayPublishError::MissingRelays)); - } - - #[test] fn direct_relay_signed_event_preserves_publish_receipt_parity() { let identity = RadrootsIdentity::generate(); let parts = WireEventParts { diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -20,9 +20,8 @@ use radroots_events::ids::{ RadrootsOrderId, RadrootsOrderQuoteId, RadrootsOrderRevisionId, RadrootsPublicKey, }; use radroots_events::kinds::{ - KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_FULFILLMENT_UPDATE, - KIND_ORDER_PAYMENT_RECORD, KIND_ORDER_RECEIPT, KIND_ORDER_REQUEST, - KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_ORDER_SETTLEMENT_DECISION, + KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, + KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, }; use radroots_events::listing::{ RadrootsListing, RadrootsListingAvailability, RadrootsListingStatus, @@ -31,24 +30,18 @@ use radroots_events::order::{ RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, RadrootsOrderEconomicActor, RadrootsOrderEconomicEffect, RadrootsOrderEconomicItem, RadrootsOrderEconomicLine, RadrootsOrderEconomicLineKind, RadrootsOrderEconomics, - RadrootsOrderEventType, RadrootsOrderFulfillmentState, RadrootsOrderFulfillmentUpdate, - RadrootsOrderInventoryCommitment, RadrootsOrderItem, RadrootsOrderPaymentMethod, - RadrootsOrderPaymentRecord, RadrootsOrderPricingBasis, RadrootsOrderReceipt, - RadrootsOrderRequest, RadrootsOrderRevisionDecision, RadrootsOrderRevisionOutcome, - RadrootsOrderRevisionProposal, RadrootsOrderSettlementDecision, RadrootsOrderSettlementOutcome, + RadrootsOrderEventType, RadrootsOrderInventoryCommitment, RadrootsOrderItem, + RadrootsOrderPricingBasis, RadrootsOrderRequest, RadrootsOrderRevisionDecision, + RadrootsOrderRevisionOutcome, RadrootsOrderRevisionProposal, }; use radroots_events::{RadrootsNostrEvent as SdkRadrootsNostrEvent, RadrootsNostrEventPtr}; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::decode::listing_from_event; use radroots_events_codec::order::{ - order_cancellation_event_build, order_cancellation_from_event, order_envelope_from_event, - order_event_context_from_tags, order_fulfillment_update_event_build, - order_fulfillment_update_from_event, order_payment_record_event_build, - order_payment_record_from_event, order_receipt_event_build, order_receipt_from_event, + order_cancellation_from_event, order_envelope_from_event, order_event_context_from_tags, order_request_from_event, order_revision_decision_event_build, order_revision_decision_from_event, order_revision_proposal_event_build, - order_revision_proposal_from_event, order_settlement_decision_event_build, - order_settlement_decision_from_event, + order_revision_proposal_from_event, }; use radroots_events_codec::wire::WireEventParts; use radroots_local_events::{ @@ -72,28 +65,22 @@ use radroots_replica_db_schema::trade_product::{ use radroots_sdk::{ OrderCancellationEnqueueRequest, OrderCancellationPrepareRequest, OrderCancellationReceipt, OrderDecisionEnqueueRequest, OrderDecisionReceipt, OrderEvidenceIngestRequest, - OrderFulfillmentUpdateEnqueueRequest, OrderFulfillmentUpdatePrepareRequest, - OrderFulfillmentUpdateReceipt, OrderReceiptRecordEnqueueRequest, - OrderReceiptRecordPrepareRequest, OrderReceiptRecordReceipt, OrderRequestEvidenceIngestRequest, - OrderRevisionDecisionEnqueueRequest, OrderRevisionDecisionPrepareRequest, - OrderRevisionDecisionReceipt, OrderRevisionProposalEnqueueRequest, - OrderRevisionProposalPrepareRequest, OrderRevisionProposalReceipt, OrderStatusRequest, - OrderSubmitEnqueueRequest, OrderSubmitPlan, OrderSubmitPrepareRequest, OrderSubmitReceipt, - OrderWorkflowEnqueueReceipt, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, - PushOutboxRelayOutcomeKind, PushOutboxRequest, SdkMutationState, SdkRelayTargetPolicy, - SdkRelayUrlPolicy, + OrderRequestEvidenceIngestRequest, OrderRevisionDecisionEnqueueRequest, + OrderRevisionDecisionPrepareRequest, OrderRevisionDecisionReceipt, + OrderRevisionProposalEnqueueRequest, OrderRevisionProposalPrepareRequest, + OrderRevisionProposalReceipt, OrderStatusRequest, OrderSubmitEnqueueRequest, OrderSubmitPlan, + OrderSubmitPrepareRequest, OrderSubmitReceipt, OrderWorkflowEnqueueReceipt, + PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxRelayOutcomeKind, + PushOutboxRequest, SdkMutationState, SdkRelayTargetPolicy, SdkRelayUrlPolicy, }; use radroots_sql_core::SqliteExecutor; use radroots_trade::order::{ RadrootsListingInventoryAccountingInputs, RadrootsListingInventoryAccountingIssue, RadrootsListingInventoryAccountingProjection, RadrootsListingInventoryBinAvailability, - RadrootsOrderCancellationRecord, RadrootsOrderDecisionRecord, RadrootsOrderFulfillmentRecord, - RadrootsOrderIssue, RadrootsOrderPaymentEventRecord, RadrootsOrderPaymentProjection, - RadrootsOrderPaymentState, RadrootsOrderReceiptRecord, RadrootsOrderReductionInputs, - RadrootsOrderRequestRecord, RadrootsOrderRevisionDecisionRecord, - RadrootsOrderRevisionProposalRecord, RadrootsOrderSettlementRecord, - RadrootsOrderSettlementState, RadrootsOrderStatus, canonicalize_order_decision_for_signer, - canonicalize_order_request_for_signer, radroots_order_economics_digest, + RadrootsOrderCancellationRecord, RadrootsOrderDecisionRecord, RadrootsOrderIssue, + RadrootsOrderReductionInputs, RadrootsOrderRequestRecord, RadrootsOrderRevisionDecisionRecord, + RadrootsOrderRevisionProposalRecord, RadrootsOrderStatus, + canonicalize_order_decision_for_signer, canonicalize_order_request_for_signer, reduce_listing_inventory_accounting, reduce_order_events, }; use serde::{Deserialize, Serialize}; @@ -101,17 +88,14 @@ use serde_json::{Value, json}; use crate::cli::global::{ OrderAppRecordExportArgs, OrderCancelArgs, OrderDecisionArg, OrderDecisionArgs, - OrderDraftCreateArgs, OrderFulfillmentArgs, OrderPaymentArgs, OrderRebindArgs, - OrderReceiptArgs, OrderRevisionDecisionArg, OrderRevisionDecisionArgs, - OrderRevisionProposeArgs, OrderSettlementArgs, OrderSettlementDecisionArg, OrderStatusArgs, - OrderSubmitArgs, RecordLookupArgs, + OrderDraftCreateArgs, OrderRebindArgs, OrderRevisionDecisionArg, OrderRevisionDecisionArgs, + OrderRevisionProposeArgs, OrderStatusArgs, OrderSubmitArgs, RecordLookupArgs, }; use crate::runtime::RuntimeError; use crate::runtime::account; use crate::runtime::config::{RuntimeConfig, SignerBackend}; use crate::runtime::direct_relay::{ - DirectRelayFailure, DirectRelayFetchError, DirectRelayFetchReceipt, DirectRelayPublishReceipt, - fetch_events_from_relays, publish_parts_with_identity, + DirectRelayFailure, DirectRelayFetchError, DirectRelayFetchReceipt, fetch_events_from_relays, }; use crate::runtime::local_events::{ get_shared_record, list_shared_records_before, list_shared_records_latest, @@ -126,11 +110,9 @@ use crate::runtime::sync::{ use crate::view::runtime::{ OrderAppRecordExportView, OrderAppRecordListView, OrderAppRecordSummaryView, OrderCancellationView, OrderDecisionView, OrderDraftItemView, OrderEventListEntryView, - OrderEventListView, OrderFulfillmentView, OrderGetView, OrderInventoryBinView, - OrderInventoryView, OrderIssueView, OrderListView, OrderNewView, OrderPaymentView, - OrderRebindView, OrderReceiptView, OrderRevisionDecisionView, OrderRevisionProposalView, - OrderSettlementView, OrderStatusFulfillmentView, OrderStatusLifecycleCancellationView, - OrderStatusLifecycleReceiptView, OrderStatusLifecycleView, OrderStatusPaymentView, + OrderEventListView, OrderGetView, OrderInventoryBinView, OrderInventoryView, OrderIssueView, + OrderListView, OrderNewView, OrderRebindView, OrderRevisionDecisionView, + OrderRevisionProposalView, OrderStatusLifecycleCancellationView, OrderStatusLifecycleView, OrderStatusRevisionView, OrderStatusView, OrderSubmitView, OrderSummaryView, RelayFailureView, }; @@ -143,11 +125,7 @@ const ORDER_SUBMIT_SOURCE: &str = "SDK order submit · local key"; const ORDER_DECISION_SOURCE: &str = "SDK order decision · local key"; const ORDER_REVISION_PROPOSAL_SOURCE: &str = "SDK order revision proposal · local key"; const ORDER_REVISION_DECISION_SOURCE: &str = "SDK order revision decision · local key"; -const ORDER_FULFILLMENT_SOURCE: &str = "SDK order fulfillment update · local key"; const ORDER_CANCELLATION_SOURCE: &str = "SDK order cancellation · local key"; -const ORDER_RECEIPT_SOURCE: &str = "SDK order receipt record · local key"; -const ORDER_PAYMENT_SOURCE: &str = "direct Nostr relay payment publish · local key"; -const ORDER_SETTLEMENT_SOURCE: &str = "direct Nostr relay settlement publish · local key"; const ORDER_EVENT_LIST_SOURCE: &str = "direct Nostr relay fetch · selected seller identity"; const LEGACY_ORDER_PREFLIGHT_STATUS_SOURCE: &str = "legacy direct Nostr relay preflight status · active order reducer"; @@ -1504,7 +1482,7 @@ pub fn revision_propose( let seller_pubkey = status_view.seller_pubkey.as_deref().ok_or_else(|| { RuntimeError::Config("accepted order is missing seller_pubkey".to_owned()) })?; - let signing = match resolve_local_order_fulfillment_signing_identity(config, seller_pubkey) { + let signing = match resolve_local_order_revision_signing_identity(config, seller_pubkey) { Ok(signing) => signing, Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), Err(error) => { @@ -1722,131 +1700,6 @@ pub fn revision_decide( ) } -pub fn fulfillment_update( - config: &RuntimeConfig, - args: &OrderFulfillmentArgs, -) -> Result<OrderFulfillmentView, RuntimeError> { - if config.relay.urls.is_empty() { - let mut view = - order_fulfillment_base_view(config, args, "unconfigured", config.output.dry_run); - view.reason = - Some("order fulfillment update requires at least one configured relay".to_owned()); - return Ok(view); - } - - let fulfillment_state = match parse_fulfillment_state(args.state.as_str()) { - Ok(state) if state.is_publishable_update() => state, - Ok(_) => { - let mut view = - order_fulfillment_base_view(config, args, "invalid", config.output.dry_run); - view.fulfillment_state = - fulfillment_state_name(RadrootsOrderFulfillmentState::AcceptedNotFulfilled) - .to_owned(); - view.reason = Some( - "`accepted_not_fulfilled` is derived from an accepted order and cannot be published" - .to_owned(), - ); - view.issues = vec![issue_with_code( - "fulfillment_state_not_publishable", - "fulfillment_state", - "accepted_not_fulfilled cannot be published as a fulfillment update", - )]; - return Ok(view); - } - Err(reason) => { - let mut view = - order_fulfillment_base_view(config, args, "invalid", config.output.dry_run); - view.reason = Some(reason); - view.issues = vec![issue_with_code( - "unsupported_fulfillment_state", - "fulfillment_state", - "fulfillment state is not part of the active protocol set", - )]; - return Ok(view); - } - }; - - let selected_account = match account::resolve_account(config)? { - Some(account) => account, - None => { - let mut view = - order_fulfillment_base_view(config, args, "unconfigured", config.output.dry_run); - view.reason = - Some("order fulfillment update requires a selected seller account".to_owned()); - view.actions = vec!["radroots account create".to_owned()]; - return Ok(view); - } - }; - let selected_pubkey = selected_account.record.public_identity.public_key_hex; - let filter = order_status_filter(args.key.as_str())?; - let receipt = match fetch_events_from_relays(&config.relay.urls, filter) { - Ok(receipt) => receipt, - Err(DirectRelayFetchError::Connect { - reason, - target_relays, - failed_relays, - }) => { - let mut view = - order_fulfillment_base_view(config, args, "unavailable", config.output.dry_run); - view.seller_pubkey = Some(selected_pubkey); - view.target_relays = target_relays; - view.failed_relays = relay_failures(failed_relays); - view.reason = Some(format!("direct relay connection failed: {reason}")); - return Ok(view); - } - Err(error) => return Err(RuntimeError::Network(error.to_string())), - }; - - let evidence_events = order_evidence_from_relay_events(receipt.events.as_slice()); - let reduction = order_status_reduction_from_receipt_with_context( - OrderStatusContext { - order_id: args.key.as_str(), - buyer_pubkey: None, - seller_pubkey: None, - selected_account_pubkey: Some(selected_pubkey.as_str()), - actor_context_source: ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT, - }, - receipt, - ); - let status_view = reduction.view; - if let Some(view) = order_fulfillment_preflight_view_from_status( - config, - args, - &status_view, - reduction.fulfillment_status, - reduction.fulfillment_event_id.as_deref(), - ) { - return Ok(view); - } - - let seller_pubkey = status_view.seller_pubkey.as_deref().ok_or_else(|| { - RuntimeError::Config("accepted order is missing seller_pubkey".to_owned()) - })?; - let signing = match resolve_local_order_fulfillment_signing_identity(config, seller_pubkey) { - Ok(signing) => signing, - Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), - Err(error) => { - return Ok(order_fulfillment_binding_error_view( - config, - args, - &status_view, - error, - )); - } - }; - let payload = order_fulfillment_payload_from_status(&status_view, fulfillment_state)?; - prepare_order_fulfillment_dry_run_via_sdk(config, &signing, &status_view, &payload)?; - if config.output.dry_run { - return Ok(order_fulfillment_dry_run_view( - config, - args, - &status_view, - fulfillment_state, - )); - } - publish_order_fulfillment(config, args, status_view, signing, payload, evidence_events) -} - pub fn cancel( config: &RuntimeConfig, args: &OrderCancelArgs, @@ -1942,31 +1795,54 @@ pub fn cancel( publish_order_cancellation(config, args, status_view, signing, payload, evidence_events) } -pub fn receipt_record( +pub fn status( config: &RuntimeConfig, - args: &OrderReceiptArgs, -) -> Result<OrderReceiptView, RuntimeError> { - if let Some(view) = order_receipt_args_preflight_view(config, args) { - return Ok(view); - } + args: &OrderStatusArgs, +) -> Result<OrderStatusView, CliSdkAdapterError> { + let request = OrderStatusRequest::parse(args.key.as_str())?; + let session = CliSdkSession::connect(config)?; + let receipt = session.block_on(session.sdk().orders().status(request))?; + Ok(sdk_order_status_view(receipt)) +} + +fn legacy_order_preflight_relay_status( + config: &RuntimeConfig, + args: &OrderStatusArgs, +) -> Result<OrderStatusView, RuntimeError> { if config.relay.urls.is_empty() { - let mut view = order_receipt_base_view(config, args, "unconfigured", config.output.dry_run); - view.reason = - Some("order receipt record requires at least one configured relay".to_owned()); - return Ok(view); + return Ok(OrderStatusView { + state: "unconfigured".to_owned(), + source: LEGACY_ORDER_PREFLIGHT_STATUS_SOURCE.to_owned(), + order_id: args.key.clone(), + actor_context_source: ORDER_ACTOR_CONTEXT_NETWORK_ONLY.to_owned(), + request_event_id: None, + decision_event_id: None, + agreement_event_id: None, + listing_event_id: None, + listing_addr: None, + buyer_pubkey: None, + seller_pubkey: None, + economics: None, + last_event_id: None, + revision: None, + inventory: None, + lifecycle: None, + sdk_receipt: None, + reducer_issues: Vec::new(), + target_relays: Vec::new(), + connected_relays: Vec::new(), + failed_relays: Vec::new(), + fetched_count: 0, + decoded_count: 0, + skipped_count: 0, + reason: Some("order status get requires at least one configured relay".to_owned()), + actions: vec![format!( + "radroots --relay wss://relay.example.com order status get {}", + args.key + )], + }); } - let actor_context = match order_buyer_write_actor_context(config, args.key.as_str())? { - Some(context) => context, - None => { - let mut view = - order_receipt_base_view(config, args, "unconfigured", config.output.dry_run); - view.reason = Some("order receipt record requires a selected buyer account".to_owned()); - view.actions = vec!["radroots account create".to_owned()]; - return Ok(view); - } - }; - let selected_pubkey = actor_context.selected_pubkey.clone(); let filter = order_status_filter(args.key.as_str())?; let receipt = match fetch_events_from_relays(&config.relay.urls, filter) { Ok(receipt) => receipt, @@ -1975,388 +1851,66 @@ pub fn receipt_record( target_relays, failed_relays, }) => { - let mut view = - order_receipt_base_view(config, args, "unavailable", config.output.dry_run); - view.buyer_pubkey = Some(selected_pubkey); - view.target_relays = target_relays; - view.failed_relays = relay_failures(failed_relays); - view.reason = Some(format!("direct relay connection failed: {reason}")); - return Ok(view); + return Ok(OrderStatusView { + state: "unavailable".to_owned(), + source: LEGACY_ORDER_PREFLIGHT_STATUS_SOURCE.to_owned(), + order_id: args.key.clone(), + actor_context_source: ORDER_ACTOR_CONTEXT_NETWORK_ONLY.to_owned(), + request_event_id: None, + decision_event_id: None, + agreement_event_id: None, + listing_event_id: None, + listing_addr: None, + buyer_pubkey: None, + seller_pubkey: None, + economics: None, + last_event_id: None, + revision: None, + inventory: None, + lifecycle: None, + sdk_receipt: None, + reducer_issues: Vec::new(), + target_relays, + connected_relays: Vec::new(), + failed_relays: relay_failures(failed_relays), + fetched_count: 0, + decoded_count: 0, + skipped_count: 0, + reason: Some(format!("direct relay connection failed: {reason}")), + actions: Vec::new(), + }); } Err(error) => return Err(RuntimeError::Network(error.to_string())), }; - let evidence_events = order_evidence_from_relay_events(receipt.events.as_slice()); - let reduction = order_status_reduction_from_receipt_with_context( + let actor_context = order_status_actor_context(config, args.key.as_str())?; + let mut view = order_status_from_receipt_with_context( OrderStatusContext { order_id: args.key.as_str(), - buyer_pubkey: actor_context.status_buyer_pubkey.as_deref(), - seller_pubkey: actor_context.status_seller_pubkey.as_deref(), - selected_account_pubkey: actor_context - .bound - .is_none() - .then_some(selected_pubkey.as_str()), - actor_context_source: actor_context.status_context_source, + buyer_pubkey: actor_context.buyer_pubkey.as_deref(), + seller_pubkey: actor_context.seller_pubkey.as_deref(), + selected_account_pubkey: actor_context.selected_account_pubkey.as_deref(), + actor_context_source: actor_context.source, }, receipt, ); - let status_view = reduction.view; - if let Some(view) = order_receipt_preflight_view_from_status( - config, - args, - &status_view, - selected_pubkey.as_str(), - ) { - return Ok(view); - } + enrich_order_status_inventory(config, &mut view)?; + Ok(view) +} - let buyer_pubkey = status_view.buyer_pubkey.as_deref().ok_or_else(|| { - RuntimeError::Config("receiptable order is missing buyer_pubkey".to_owned()) - })?; - let signing = match actor_context.bound.as_ref() { - Some(bound) => resolve_local_order_bound_buyer_signing_identity( - config, - &bound.loaded, - "order receipt record", - ), - None => resolve_local_order_receipt_signing_identity(config, buyer_pubkey), - }; - let signing = match signing { - Ok(signing) => signing, - Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), - Err(error) => { - return Ok(order_receipt_binding_error_view( - config, - args, - &status_view, - error, - )); - } - }; - let payload = order_receipt_payload_from_status(args, &status_view)?; - prepare_order_receipt_dry_run_via_sdk(config, &signing, &status_view, &payload)?; - if config.output.dry_run { - return Ok(order_receipt_dry_run_view( - config, - args, - &status_view, - &payload, - )); - } - publish_order_receipt(config, args, status_view, signing, payload, evidence_events) +enum OrderStatusRecord { + Request { + listing_event_id: Option<String>, + record: RadrootsOrderRequestRecord, + }, + Decision(RadrootsOrderDecisionRecord), + RevisionProposal(OrderRevisionProposalRecord), + RevisionDecision(OrderRevisionDecisionRecord), + Cancellation(RadrootsOrderCancellationRecord), } -pub fn payment_record( - config: &RuntimeConfig, - args: &OrderPaymentArgs, -) -> Result<OrderPaymentView, RuntimeError> { - if let Some(view) = order_payment_args_preflight_view(config, args) { - return Ok(view); - } - if config.relay.urls.is_empty() { - let mut view = order_payment_base_view(config, args, "unconfigured", config.output.dry_run); - view.reason = - Some("order payment record requires at least one configured relay".to_owned()); - return Ok(view); - } - - let selected_account = match account::resolve_account(config)? { - Some(account) => account, - None => { - let mut view = - order_payment_base_view(config, args, "unconfigured", config.output.dry_run); - view.reason = Some("order payment record requires a selected buyer account".to_owned()); - view.actions = vec!["radroots account create".to_owned()]; - return Ok(view); - } - }; - let selected_pubkey = selected_account.record.public_identity.public_key_hex; - let filter = order_status_filter(args.key.as_str())?; - let receipt = match fetch_events_from_relays(&config.relay.urls, filter) { - Ok(receipt) => receipt, - Err(DirectRelayFetchError::Connect { - reason, - target_relays, - failed_relays, - }) => { - let mut view = - order_payment_base_view(config, args, "unavailable", config.output.dry_run); - view.buyer_pubkey = Some(selected_pubkey); - view.target_relays = target_relays; - view.failed_relays = relay_failures(failed_relays); - view.reason = Some(format!("direct relay connection failed: {reason}")); - return Ok(view); - } - Err(error) => return Err(RuntimeError::Network(error.to_string())), - }; - - let reduction = order_status_reduction_from_receipt_with_context( - OrderStatusContext { - order_id: args.key.as_str(), - buyer_pubkey: None, - seller_pubkey: None, - selected_account_pubkey: Some(selected_pubkey.as_str()), - actor_context_source: ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT, - }, - receipt, - ); - let status_view = reduction.view; - if let Some(view) = order_payment_preflight_view_from_status( - config, - args, - &status_view, - selected_pubkey.as_str(), - ) { - return Ok(view); - } - - let buyer_pubkey = status_view - .buyer_pubkey - .as_deref() - .ok_or_else(|| RuntimeError::Config("payable order is missing buyer_pubkey".to_owned()))?; - let signing = match resolve_local_order_payment_signing_identity(config, buyer_pubkey) { - Ok(signing) => signing, - Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), - Err(error) => { - return Ok(order_payment_binding_error_view( - config, - args, - &status_view, - error, - )); - } - }; - let payload = order_payment_payload_from_status(args, &status_view)?; - let _ = order_payment_event_parts(&status_view, &payload)?; - if config.output.dry_run { - return Ok(order_payment_dry_run_view( - config, - args, - &status_view, - &payload, - )); - } - publish_order_payment(config, args, status_view, signing, payload) -} - -pub fn settlement_decision( - config: &RuntimeConfig, - args: &OrderSettlementArgs, -) -> Result<OrderSettlementView, RuntimeError> { - if let Some(view) = order_settlement_args_preflight_view(config, args) { - return Ok(view); - } - if config.relay.urls.is_empty() { - let mut view = - order_settlement_base_view(config, args, "unconfigured", config.output.dry_run); - view.reason = - Some("order settlement decision requires at least one configured relay".to_owned()); - return Ok(view); - } - - let selected_account = match account::resolve_account(config)? { - Some(account) => account, - None => { - let mut view = - order_settlement_base_view(config, args, "unconfigured", config.output.dry_run); - view.reason = - Some("order settlement decision requires a selected seller account".to_owned()); - view.actions = vec!["radroots account create".to_owned()]; - return Ok(view); - } - }; - let selected_pubkey = selected_account.record.public_identity.public_key_hex; - let filter = order_status_filter(args.key.as_str())?; - let receipt = match fetch_events_from_relays(&config.relay.urls, filter) { - Ok(receipt) => receipt, - Err(DirectRelayFetchError::Connect { - reason, - target_relays, - failed_relays, - }) => { - let mut view = - order_settlement_base_view(config, args, "unavailable", config.output.dry_run); - view.seller_pubkey = Some(selected_pubkey); - view.target_relays = target_relays; - view.failed_relays = relay_failures(failed_relays); - view.reason = Some(format!("direct relay connection failed: {reason}")); - return Ok(view); - } - Err(error) => return Err(RuntimeError::Network(error.to_string())), - }; - - let reduction = order_status_reduction_from_receipt_with_context( - OrderStatusContext { - order_id: args.key.as_str(), - buyer_pubkey: None, - seller_pubkey: None, - selected_account_pubkey: Some(selected_pubkey.as_str()), - actor_context_source: ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT, - }, - receipt, - ); - let status_view = reduction.view; - if let Some(view) = order_settlement_preflight_view_from_status( - config, - args, - &status_view, - selected_pubkey.as_str(), - ) { - return Ok(view); - } - - let seller_pubkey = status_view.seller_pubkey.as_deref().ok_or_else(|| { - RuntimeError::Config("settleable order is missing seller_pubkey".to_owned()) - })?; - let signing = match resolve_local_order_settlement_signing_identity(config, seller_pubkey) { - Ok(signing) => signing, - Err(ActorWriteBindingError::Account(failure)) => return Err(failure.into()), - Err(error) => { - return Ok(order_settlement_binding_error_view( - config, - args, - &status_view, - error, - )); - } - }; - let payload = order_settlement_payload_from_status(args, &status_view)?; - let _ = order_settlement_event_parts(&status_view, &payload)?; - if config.output.dry_run { - return Ok(order_settlement_dry_run_view( - config, - args, - &status_view, - &payload, - )); - } - publish_order_settlement(config, args, status_view, signing, payload) -} - -pub fn status( - config: &RuntimeConfig, - args: &OrderStatusArgs, -) -> Result<OrderStatusView, CliSdkAdapterError> { - let request = OrderStatusRequest::parse(args.key.as_str())?; - let session = CliSdkSession::connect(config)?; - let receipt = session.block_on(session.sdk().orders().status(request))?; - Ok(sdk_order_status_view(receipt)) -} - -fn legacy_order_preflight_relay_status( - config: &RuntimeConfig, - args: &OrderStatusArgs, -) -> Result<OrderStatusView, RuntimeError> { - if config.relay.urls.is_empty() { - return Ok(OrderStatusView { - state: "unconfigured".to_owned(), - source: LEGACY_ORDER_PREFLIGHT_STATUS_SOURCE.to_owned(), - order_id: args.key.clone(), - actor_context_source: ORDER_ACTOR_CONTEXT_NETWORK_ONLY.to_owned(), - request_event_id: None, - decision_event_id: None, - agreement_event_id: None, - listing_event_id: None, - listing_addr: None, - buyer_pubkey: None, - seller_pubkey: None, - economics: None, - last_event_id: None, - revision: None, - inventory: None, - fulfillment: None, - lifecycle: None, - payment: None, - sdk_receipt: None, - reducer_issues: Vec::new(), - target_relays: Vec::new(), - connected_relays: Vec::new(), - failed_relays: Vec::new(), - fetched_count: 0, - decoded_count: 0, - skipped_count: 0, - reason: Some("order status get requires at least one configured relay".to_owned()), - actions: vec![format!( - "radroots --relay wss://relay.example.com order status get {}", - args.key - )], - }); - } - - let filter = order_status_filter(args.key.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 Ok(OrderStatusView { - state: "unavailable".to_owned(), - source: LEGACY_ORDER_PREFLIGHT_STATUS_SOURCE.to_owned(), - order_id: args.key.clone(), - actor_context_source: ORDER_ACTOR_CONTEXT_NETWORK_ONLY.to_owned(), - request_event_id: None, - decision_event_id: None, - agreement_event_id: None, - listing_event_id: None, - listing_addr: None, - buyer_pubkey: None, - seller_pubkey: None, - economics: None, - last_event_id: None, - revision: None, - inventory: None, - fulfillment: None, - lifecycle: None, - payment: None, - sdk_receipt: None, - reducer_issues: Vec::new(), - target_relays, - connected_relays: Vec::new(), - failed_relays: relay_failures(failed_relays), - fetched_count: 0, - decoded_count: 0, - skipped_count: 0, - reason: Some(format!("direct relay connection failed: {reason}")), - actions: Vec::new(), - }); - } - Err(error) => return Err(RuntimeError::Network(error.to_string())), - }; - - let actor_context = order_status_actor_context(config, args.key.as_str())?; - let mut view = order_status_from_receipt_with_context( - OrderStatusContext { - order_id: args.key.as_str(), - buyer_pubkey: actor_context.buyer_pubkey.as_deref(), - seller_pubkey: actor_context.seller_pubkey.as_deref(), - selected_account_pubkey: actor_context.selected_account_pubkey.as_deref(), - actor_context_source: actor_context.source, - }, - receipt, - ); - enrich_order_status_inventory(config, &mut view)?; - Ok(view) -} - -enum OrderStatusRecord { - Request { - listing_event_id: Option<String>, - record: RadrootsOrderRequestRecord, - }, - Decision(RadrootsOrderDecisionRecord), - RevisionProposal(OrderRevisionProposalRecord), - RevisionDecision(OrderRevisionDecisionRecord), - Fulfillment(RadrootsOrderFulfillmentRecord), - Cancellation(RadrootsOrderCancellationRecord), - Receipt(RadrootsOrderReceiptRecord), - Payment(RadrootsOrderPaymentEventRecord), - Settlement(RadrootsOrderSettlementRecord), -} - -type OrderRevisionProposalRecord = RadrootsOrderRevisionProposalRecord; -type OrderRevisionDecisionRecord = RadrootsOrderRevisionDecisionRecord; +type OrderRevisionProposalRecord = RadrootsOrderRevisionProposalRecord; +type OrderRevisionDecisionRecord = RadrootsOrderRevisionDecisionRecord; #[derive(Debug, Clone)] struct OrderRevisionProposalCandidates { @@ -2367,8 +1921,6 @@ struct OrderRevisionProposalCandidates { #[derive(Debug, Clone)] struct OrderStatusReduction { view: OrderStatusView, - fulfillment_event_id: Option<String>, - fulfillment_status: Option<RadrootsOrderFulfillmentState>, } #[derive(Debug, Clone, Copy)] @@ -2400,27 +1952,8 @@ fn order_status_from_receipt(order_id: &str, receipt: DirectRelayFetchReceipt) - ) } -#[cfg(test)] -fn order_status_from_receipt_with_deferred_payment( - order_id: &str, - receipt: DirectRelayFetchReceipt, -) -> OrderStatusView { - order_status_reduction_from_receipt_inner( - OrderStatusContext { - order_id, - buyer_pubkey: None, - seller_pubkey: None, - selected_account_pubkey: None, - actor_context_source: ORDER_ACTOR_CONTEXT_NETWORK_ONLY, - }, - receipt, - true, - ) - .view -} - -fn order_status_from_receipt_with_context( - context: OrderStatusContext<'_>, +fn order_status_from_receipt_with_context( + context: OrderStatusContext<'_>, receipt: DirectRelayFetchReceipt, ) -> OrderStatusView { order_status_reduction_from_receipt_with_context(context, receipt).view @@ -2430,13 +1963,12 @@ fn order_status_reduction_from_receipt_with_context( context: OrderStatusContext<'_>, receipt: DirectRelayFetchReceipt, ) -> OrderStatusReduction { - order_status_reduction_from_receipt_inner(context, receipt, false) + order_status_reduction_from_receipt_inner(context, receipt) } fn order_status_reduction_from_receipt_inner( context: OrderStatusContext<'_>, receipt: DirectRelayFetchReceipt, - include_deferred_payment: bool, ) -> OrderStatusReduction { let DirectRelayFetchReceipt { target_relays, @@ -2451,19 +1983,11 @@ fn order_status_reduction_from_receipt_inner( let mut decisions = Vec::new(); let mut revision_proposals = Vec::new(); let mut revision_decisions = Vec::new(); - let mut fulfillments = Vec::new(); let mut cancellations = Vec::new(); - let mut receipts = Vec::new(); - let mut payments = Vec::new(); - let mut settlements = Vec::new(); let mut request_listing_events = Vec::new(); let mut candidate_issues = Vec::new(); for event in events { - if !include_deferred_payment && deferred_payment_status_event(&event) { - skipped_count += 1; - continue; - } match order_status_record_from_event(&event) { Ok(OrderStatusRecord::Request { listing_event_id, @@ -2489,26 +2013,10 @@ fn order_status_reduction_from_receipt_inner( decoded_count += 1; revision_decisions.push(record); } - Ok(OrderStatusRecord::Fulfillment(record)) => { - decoded_count += 1; - fulfillments.push(record); - } Ok(OrderStatusRecord::Cancellation(record)) => { decoded_count += 1; cancellations.push(record); } - Ok(OrderStatusRecord::Receipt(record)) => { - decoded_count += 1; - receipts.push(record); - } - Ok(OrderStatusRecord::Payment(record)) => { - decoded_count += 1; - payments.push(record); - } - Ok(OrderStatusRecord::Settlement(record)) => { - decoded_count += 1; - settlements.push(record); - } Err(error) => { skipped_count += 1; if order_status_request_candidate(&event, context) { @@ -2551,9 +2059,7 @@ fn order_status_reduction_from_receipt_inner( last_event_id: None, revision: None, inventory: None, - fulfillment: None, lifecycle: None, - payment: None, sdk_receipt: None, reducer_issues: vec![issue("order_id", message.clone())], target_relays, @@ -2565,19 +2071,13 @@ fn order_status_reduction_from_receipt_inner( reason: Some(message), actions: Vec::new(), }; - return OrderStatusReduction { - view, - fulfillment_event_id: None, - fulfillment_status: None, - }; + return OrderStatusReduction { view }; } }; let order_id = context.order_id; let revision_proposal_records = revision_proposals.clone(); let revision_decision_records = revision_decisions.clone(); - let fulfillment_records = fulfillments.clone(); let cancellation_records = cancellations.clone(); - let receipt_records = receipts.clone(); let projection = reduce_order_events( &reducer_order_id, RadrootsOrderReductionInputs { @@ -2585,27 +2085,9 @@ fn order_status_reduction_from_receipt_inner( decisions: decisions.clone(), revision_proposals, revision_decisions, - fulfillments, cancellations, - receipts, - payments, - settlements, }, ); - let fulfillment_event_id = projection.fulfillment_event_id.clone(); - let fulfillment_status = projection.fulfillment_status; - let fulfillment_root_event_id = fulfillment_event_id.as_ref().and_then(|event_id| { - fulfillment_records - .iter() - .find(|record| &record.event_id == event_id) - .map(|record| record.root_event_id.clone()) - }); - let fulfillment_prev_event_id = fulfillment_event_id.as_ref().and_then(|event_id| { - fulfillment_records - .iter() - .find(|record| &record.event_id == event_id) - .map(|record| record.prev_event_id.clone()) - }); let cancellation_root_event_id = projection .cancellation_event_id @@ -2635,18 +2117,6 @@ fn order_status_reduction_from_receipt_inner( .find(|record| &record.event_id == event_id) .map(|record| record.payload.reason.clone()) }); - let receipt_root_event_id = projection.receipt_event_id.as_ref().and_then(|event_id| { - receipt_records - .iter() - .find(|record| &record.event_id == event_id) - .map(|record| record.root_event_id.clone()) - }); - let receipt_prev_event_id = projection.receipt_event_id.as_ref().and_then(|event_id| { - receipt_records - .iter() - .find(|record| &record.event_id == event_id) - .map(|record| record.prev_event_id.clone()) - }); let listing_event_id = projection .request_event_id .as_ref() @@ -2677,37 +2147,14 @@ fn order_status_reduction_from_receipt_inner( &decisions, reducer_issues.as_slice(), ); - let fulfillment = order_status_fulfillment_view( - &projection.status, - optional_string(projection.request_event_id.clone()), - optional_string(projection.decision_event_id.clone()), - optional_string(fulfillment_event_id.clone()), - optional_string(fulfillment_root_event_id.clone()), - optional_string(fulfillment_prev_event_id.clone()), - fulfillment_status, - reducer_issues.as_slice(), - ); let lifecycle = order_status_lifecycle_view( &projection.status, optional_string(projection.request_event_id.clone()), optional_string(projection.last_event_id.clone()), - projection.fulfillment_status, optional_string(projection.cancellation_event_id.clone()), optional_string(cancellation_root_event_id), optional_string(cancellation_prev_event_id), cancellation_reason, - false, - None, - optional_string(projection.receipt_event_id.clone()), - optional_string(receipt_root_event_id), - optional_string(receipt_prev_event_id), - projection.receipt_received.map(|received| { - ( - received, - projection.receipt_issue.clone(), - projection.receipt_received_at, - ) - }), reducer_issues.as_slice(), ); let revision = order_status_revision_view( @@ -2716,9 +2163,6 @@ fn order_status_reduction_from_receipt_inner( &revision_proposal_records, &revision_decision_records, ); - let payment = include_deferred_payment - .then(|| order_status_payment_view(projection.payment, reducer_issues.as_slice())); - let view = OrderStatusView { state, source: LEGACY_ORDER_PREFLIGHT_STATUS_SOURCE.to_owned(), @@ -2735,9 +2179,7 @@ fn order_status_reduction_from_receipt_inner( last_event_id: optional_string(projection.last_event_id), revision, inventory, - fulfillment, lifecycle: Some(lifecycle), - payment, sdk_receipt: None, reducer_issues, target_relays, @@ -2749,11 +2191,7 @@ fn order_status_reduction_from_receipt_inner( reason, actions: Vec::new(), }; - OrderStatusReduction { - view, - fulfillment_event_id: optional_string(fulfillment_event_id), - fulfillment_status, - } + OrderStatusReduction { view } } fn order_status_request_matches_context( @@ -2845,11 +2283,6 @@ fn enrich_order_status_inventory( .into_iter() .filter(|record| request_order_ids.contains(&record.payload.order_id)) .collect::<Vec<_>>(); - let fulfillments = - fetch_listing_accounting_fulfillments_for_status(config, listing_addr.as_str())? - .into_iter() - .filter(|record| request_order_ids.contains(&record.payload.order_id)) - .collect::<Vec<_>>(); let cancellations = fetch_listing_accounting_cancellations_for_status(config, listing_addr.as_str())? .into_iter() @@ -2864,9 +2297,7 @@ fn enrich_order_status_inventory( decisions, revision_proposals, revision_decisions, - fulfillments, cancellations, - receipts: Vec::<RadrootsOrderReceiptRecord>::new(), }, ); let mut relevant_event_ids = Vec::new(); @@ -2888,16 +2319,8 @@ fn enrich_order_status_inventory( .cloned() .collect::<Vec<_>>(); if relevant_issues.is_empty() { - if matches!( - view.state.as_str(), - "accepted" | "cancelled" | "completed" | "disputed" - ) { - let inventory_state = if view - .fulfillment - .as_ref() - .is_some_and(|fulfillment| fulfillment.inventory_released) - || view.state == "cancelled" - { + if matches!(view.state.as_str(), "accepted" | "cancelled") { + let inventory_state = if view.state == "cancelled" { "released" } else { "reserved" @@ -3032,27 +2455,6 @@ fn fetch_listing_accounting_revision_decisions_for_status( Ok(records) } -fn fetch_listing_accounting_fulfillments_for_status( - config: &RuntimeConfig, - listing_addr: &str, -) -> Result<Vec<RadrootsOrderFulfillmentRecord>, RuntimeError> { - let filter = order_listing_fulfillment_filter(listing_addr)?; - let receipt = fetch_events_from_relays(&config.relay.urls, filter) - .map_err(|error| RuntimeError::Network(error.to_string()))?; - let mut records = Vec::new(); - for event in receipt.events { - if event_kind_u32(&event) != KIND_ORDER_FULFILLMENT_UPDATE - || !event_matches_tag_value(&event, "a", listing_addr) - { - continue; - } - if let Ok(OrderStatusRecord::Fulfillment(record)) = order_status_record_from_event(&event) { - records.push(record); - } - } - Ok(records) -} - fn fetch_listing_accounting_cancellations_for_status( config: &RuntimeConfig, listing_addr: &str, @@ -3306,40 +2708,6 @@ fn order_status_record_from_event( }, )) } - KIND_ORDER_FULFILLMENT_UPDATE => { - let event = radroots_event_from_nostr(event); - let event_id = protocol_event_id(event.id.as_str(), "fulfillment_event_id")?; - let author_pubkey = - protocol_pubkey(event.author.as_str(), "fulfillment_author_pubkey")?; - let envelope = order_fulfillment_update_from_event(&event).map_err(|error| { - RuntimeError::Config(format!("decode active fulfillment update event: {error}")) - })?; - let context = order_event_context_from_tags( - RadrootsOrderEventType::FulfillmentUpdated, - &event.tags, - ) - .map_err(|error| { - RuntimeError::Config(format!("decode active fulfillment update tags: {error}")) - })?; - Ok(OrderStatusRecord::Fulfillment( - RadrootsOrderFulfillmentRecord { - event_id, - author_pubkey, - counterparty_pubkey: context.counterparty_pubkey, - root_event_id: required_order_context_event_id( - context.root_event_id, - "e_root", - "active fulfillment update", - )?, - prev_event_id: required_order_context_event_id( - context.prev_event_id, - "e_prev", - "active fulfillment update", - )?, - payload: envelope.payload, - }, - )) - } KIND_ORDER_CANCELLATION => { let event = radroots_event_from_nostr(event); let event_id = protocol_event_id(event.id.as_str(), "cancellation_event_id")?; @@ -3374,101 +2742,6 @@ fn order_status_record_from_event( }, )) } - KIND_ORDER_RECEIPT => { - let event = radroots_event_from_nostr(event); - let event_id = protocol_event_id(event.id.as_str(), "receipt_event_id")?; - let author_pubkey = protocol_pubkey(event.author.as_str(), "receipt_author_pubkey")?; - let envelope = order_receipt_from_event(&event).map_err(|error| { - RuntimeError::Config(format!("decode active buyer receipt event: {error}")) - })?; - let context = - order_event_context_from_tags(RadrootsOrderEventType::BuyerReceipt, &event.tags) - .map_err(|error| { - RuntimeError::Config(format!("decode active buyer receipt tags: {error}")) - })?; - Ok(OrderStatusRecord::Receipt(RadrootsOrderReceiptRecord { - event_id, - author_pubkey, - counterparty_pubkey: context.counterparty_pubkey, - root_event_id: required_order_context_event_id( - context.root_event_id, - "e_root", - "active buyer receipt", - )?, - prev_event_id: required_order_context_event_id( - context.prev_event_id, - "e_prev", - "active buyer receipt", - )?, - payload: envelope.payload, - })) - } - KIND_ORDER_PAYMENT_RECORD => { - let event = radroots_event_from_nostr(event); - let event_id = protocol_event_id(event.id.as_str(), "payment_event_id")?; - let author_pubkey = protocol_pubkey(event.author.as_str(), "payment_author_pubkey")?; - let envelope = order_payment_record_from_event(&event).map_err(|error| { - RuntimeError::Config(format!("decode active payment recorded event: {error}")) - })?; - let context = - order_event_context_from_tags(RadrootsOrderEventType::PaymentRecorded, &event.tags) - .map_err(|error| { - RuntimeError::Config(format!( - "decode active payment recorded tags: {error}" - )) - })?; - Ok(OrderStatusRecord::Payment( - RadrootsOrderPaymentEventRecord { - event_id, - author_pubkey, - counterparty_pubkey: context.counterparty_pubkey, - root_event_id: required_order_context_event_id( - context.root_event_id, - "e_root", - "active payment recorded", - )?, - prev_event_id: required_order_context_event_id( - context.prev_event_id, - "e_prev", - "active payment recorded", - )?, - payload: envelope.payload, - }, - )) - } - KIND_ORDER_SETTLEMENT_DECISION => { - let event = radroots_event_from_nostr(event); - let event_id = protocol_event_id(event.id.as_str(), "settlement_event_id")?; - let author_pubkey = protocol_pubkey(event.author.as_str(), "settlement_author_pubkey")?; - let envelope = order_settlement_decision_from_event(&event).map_err(|error| { - RuntimeError::Config(format!("decode active settlement decision event: {error}")) - })?; - let context = order_event_context_from_tags( - RadrootsOrderEventType::SettlementDecision, - &event.tags, - ) - .map_err(|error| { - RuntimeError::Config(format!("decode active settlement decision tags: {error}")) - })?; - Ok(OrderStatusRecord::Settlement( - RadrootsOrderSettlementRecord { - event_id, - author_pubkey, - counterparty_pubkey: context.counterparty_pubkey, - root_event_id: required_order_context_event_id( - context.root_event_id, - "e_root", - "active settlement decision", - )?, - prev_event_id: required_order_context_event_id( - context.prev_event_id, - "e_prev", - "active settlement decision", - )?, - payload: envelope.payload, - }, - )) - } event_kind => Err(RuntimeError::Config(format!( "order status received unexpected kind `{event_kind}`" ))), @@ -3516,62 +2789,10 @@ fn active_order_status_state(status: &RadrootsOrderStatus) -> &'static str { RadrootsOrderStatus::Accepted => "accepted", RadrootsOrderStatus::Declined => "declined", RadrootsOrderStatus::Cancelled => "cancelled", - RadrootsOrderStatus::Completed => "completed", - RadrootsOrderStatus::Disputed => "disputed", RadrootsOrderStatus::Invalid => "invalid", } } -fn active_order_payment_state(status: &RadrootsOrderPaymentState) -> &'static str { - match status { - RadrootsOrderPaymentState::NotRecorded => "not_recorded", - RadrootsOrderPaymentState::Recorded => "recorded", - RadrootsOrderPaymentState::Settled => "settled", - RadrootsOrderPaymentState::Rejected => "rejected", - RadrootsOrderPaymentState::Invalid => "invalid", - } -} - -fn active_order_settlement_state(status: &RadrootsOrderSettlementState) -> &'static str { - match status { - RadrootsOrderSettlementState::NotRequired => "not_required", - RadrootsOrderSettlementState::Pending => "pending", - RadrootsOrderSettlementState::Accepted => "accepted", - RadrootsOrderSettlementState::Rejected => "rejected", - RadrootsOrderSettlementState::Invalid => "invalid", - } -} - -fn parse_payment_method(value: &str) -> Result<RadrootsOrderPaymentMethod, RuntimeError> { - match value.trim() { - "cash" => Ok(RadrootsOrderPaymentMethod::Cash), - "manual_transfer" => Ok(RadrootsOrderPaymentMethod::ManualTransfer), - "other" => Ok(RadrootsOrderPaymentMethod::Other), - other => Err(RuntimeError::Config(format!( - "unsupported payment method `{other}`" - ))), - } -} - -fn parse_payment_amount(value: &str) -> Result<RadrootsCoreDecimal, RuntimeError> { - let parsed = value - .trim() - .parse::<RadrootsCoreDecimal>() - .map_err(|error| RuntimeError::Config(format!("payment amount is invalid: {error}")))?; - if parsed.is_zero() || parsed.is_sign_negative() { - return Err(RuntimeError::Config( - "payment amount must be greater than zero".to_owned(), - )); - } - Ok(parsed) -} - -fn parse_payment_currency(value: &str) -> Result<RadrootsCoreCurrency, RuntimeError> { - value - .parse::<RadrootsCoreCurrency>() - .map_err(|error| RuntimeError::Config(format!("payment currency is invalid: {error}"))) -} - fn active_order_status_reason(status: &RadrootsOrderStatus, order_id: &str) -> Option<String> { match status { RadrootsOrderStatus::Missing => { @@ -3609,9 +2830,7 @@ fn order_status_inventory_view( .collect::<Vec<_>>(); match status { - RadrootsOrderStatus::Accepted - | RadrootsOrderStatus::Completed - | RadrootsOrderStatus::Disputed => { + RadrootsOrderStatus::Accepted => { let bins = decision_event_id .and_then(|event_id| { decisions @@ -3661,113 +2880,21 @@ fn order_status_inventory_view( } } -fn order_status_fulfillment_view( - status: &RadrootsOrderStatus, - request_event_id: Option<String>, - decision_event_id: Option<String>, - fulfillment_event_id: Option<String>, - fulfillment_root_event_id: Option<String>, - fulfillment_prev_event_id: Option<String>, - fulfillment_status: Option<RadrootsOrderFulfillmentState>, - reducer_issues: &[OrderIssueView], -) -> Option<OrderStatusFulfillmentView> { - let issues = reducer_issues - .iter() - .filter(|issue| fulfillment_issue_code(issue.code.as_str())) - .cloned() - .collect::<Vec<_>>(); - if !issues.is_empty() { - return Some(OrderStatusFulfillmentView { - state: "invalid".to_owned(), - event_id: fulfillment_event_id, - root_event_id: fulfillment_root_event_id.or(request_event_id), - prev_event_id: fulfillment_prev_event_id, - terminal: false, - inventory_released: false, - issues, - }); - } - if !matches!( - status, - RadrootsOrderStatus::Accepted - | RadrootsOrderStatus::Completed - | RadrootsOrderStatus::Disputed - ) { - return None; - } - let fulfillment_status = fulfillment_status?; - let terminal = matches!( - fulfillment_status, - RadrootsOrderFulfillmentState::Delivered | RadrootsOrderFulfillmentState::SellerCancelled - ); - let inventory_released = matches!( - fulfillment_status, - RadrootsOrderFulfillmentState::SellerCancelled - ); - let prev_event_id = fulfillment_prev_event_id.or_else(|| { - if fulfillment_event_id.is_none() { - decision_event_id - } else { - None - } - }); - Some(OrderStatusFulfillmentView { - state: fulfillment_state_name(fulfillment_status).to_owned(), - event_id: fulfillment_event_id, - root_event_id: fulfillment_root_event_id.or(request_event_id), - prev_event_id, - terminal, - inventory_released, - issues, - }) -} - -fn order_status_payment_view( - projection: RadrootsOrderPaymentProjection, - reducer_issues: &[OrderIssueView], -) -> OrderStatusPaymentView { - OrderStatusPaymentView { - state: active_order_payment_state(&projection.state).to_owned(), - settlement_state: active_order_settlement_state(&projection.settlement_state).to_owned(), - payment_event_id: optional_string(projection.payment_event_id), - settlement_event_id: optional_string(projection.settlement_event_id), - agreement_event_id: optional_string(projection.agreement_event_id), - quote_id: optional_string(projection.quote_id), - quote_version: projection.quote_version, - economics_digest: optional_string(projection.economics_digest), - amount: projection.amount, - currency: projection.currency, - method: projection.method, - reference: projection.reference, - paid_at: projection.paid_at, - reason: projection.reason, - issues: reducer_issues.to_vec(), - } -} - fn order_status_lifecycle_view( status: &RadrootsOrderStatus, request_event_id: Option<String>, last_event_id: Option<String>, - fulfillment_status: Option<RadrootsOrderFulfillmentState>, cancellation_event_id: Option<String>, cancellation_root_event_id: Option<String>, cancellation_prev_event_id: Option<String>, cancellation_reason: Option<String>, - settlement_required: bool, - settlement_reason: Option<String>, - receipt_event_id: Option<String>, - receipt_root_event_id: Option<String>, - receipt_prev_event_id: Option<String>, - receipt: Option<(bool, Option<String>, Option<u64>)>, reducer_issues: &[OrderIssueView], ) -> OrderStatusLifecycleView { - let phase = order_status_lifecycle_phase(status, fulfillment_status).to_owned(); + let phase = order_status_lifecycle_phase(status).to_owned(); let terminal = matches!( status, - RadrootsOrderStatus::Cancelled - | RadrootsOrderStatus::Completed - | RadrootsOrderStatus::Disputed + RadrootsOrderStatus::Accepted + | RadrootsOrderStatus::Cancelled | RadrootsOrderStatus::Invalid ); let cancellation = @@ -3781,21 +2908,8 @@ fn order_status_lifecycle_view( prev_event_id: cancellation_prev_event_id.clone(), reason: cancellation_reason.clone(), }); - let receipt_view = receipt_event_id.as_ref().map(|event_id| { - let (received, issue, received_at) = receipt.clone().unwrap_or((false, None, None)); - OrderStatusLifecycleReceiptView { - event_id: event_id.clone(), - root_event_id: receipt_root_event_id.clone().or(request_event_id.clone()), - prev_event_id: receipt_prev_event_id.clone(), - received, - issue, - received_at, - } - }); - let event_id = receipt_event_id.or(cancellation_event_id); - let prev_event_id = receipt_prev_event_id - .or(cancellation_prev_event_id) - .or(last_event_id); + let event_id = cancellation_event_id; + let prev_event_id = cancellation_prev_event_id.or(last_event_id); OrderStatusLifecycleView { phase, terminal, @@ -3803,9 +2917,6 @@ fn order_status_lifecycle_view( root_event_id: request_event_id, prev_event_id, cancellation, - receipt: receipt_view, - settlement_required, - settlement_reason, issues: reducer_issues.to_vec(), } } @@ -3865,51 +2976,17 @@ fn order_status_revision_view_from_decision( } } -fn order_status_lifecycle_phase( - status: &RadrootsOrderStatus, - fulfillment_status: Option<RadrootsOrderFulfillmentState>, -) -> &'static str { +fn order_status_lifecycle_phase(status: &RadrootsOrderStatus) -> &'static str { match status { RadrootsOrderStatus::Missing => "missing", RadrootsOrderStatus::Requested => "requested", - RadrootsOrderStatus::Accepted => match fulfillment_status { - Some(RadrootsOrderFulfillmentState::Preparing) - | Some(RadrootsOrderFulfillmentState::OutForDelivery) => "fulfillment_in_progress", - Some( - RadrootsOrderFulfillmentState::ReadyForPickup - | RadrootsOrderFulfillmentState::Delivered - | RadrootsOrderFulfillmentState::SellerCancelled, - ) => "fulfilled", - Some(RadrootsOrderFulfillmentState::AcceptedNotFulfilled) | None => "accepted", - }, + RadrootsOrderStatus::Accepted => "accepted", RadrootsOrderStatus::Declined => "declined", RadrootsOrderStatus::Cancelled => "cancelled", - RadrootsOrderStatus::Completed => "completed", - RadrootsOrderStatus::Disputed => "disputed", RadrootsOrderStatus::Invalid => "invalid", } } -fn fulfillment_issue_code(code: &str) -> bool { - matches!( - code, - "fulfillment_without_accepted_decision" - | "invalid_fulfillment_payload" - | "fulfillment_order_id_mismatch" - | "fulfillment_author_mismatch" - | "fulfillment_counterparty_mismatch" - | "fulfillment_buyer_mismatch" - | "fulfillment_seller_mismatch" - | "invalid_fulfillment_listing_address" - | "fulfillment_listing_mismatch" - | "fulfillment_root_mismatch" - | "fulfillment_previous_mismatch" - | "fulfillment_status_not_publishable" - | "fulfillment_unsupported_transition" - | "forked_fulfillments" - ) -} - fn inventory_bins_from_decision( decision: &RadrootsOrderDecisionOutcome, ) -> Vec<OrderInventoryBinView> { @@ -4061,14 +3138,6 @@ fn active_order_reducer_issue_view(issue_value: RadrootsOrderIssue) -> OrderIssu "active order reducer reported conflicting decisions", event_ids, ), - RadrootsOrderIssue::RevisionProposalWithoutAcceptedDecision { event_id } => { - issue_with_events( - "revision_proposal_without_accepted_decision", - "revision_event_id", - "active order reducer reported revision proposal without accepted decision", - vec![event_id], - ) - } RadrootsOrderIssue::RevisionProposalPayloadInvalid { event_id } => issue_with_events( "invalid_revision_proposal_payload", "revision_payload", @@ -4205,142 +3274,58 @@ fn active_order_reducer_issue_view(issue_value: RadrootsOrderIssue) -> OrderIssu "active order reducer reported revision decision revision id mismatch", vec![event_id], ), - RadrootsOrderIssue::FulfillmentWithoutAcceptedDecision { event_id } => issue_with_events( - "fulfillment_without_accepted_decision", - "fulfillment_event_id", - "active order reducer reported fulfillment without accepted decision", + RadrootsOrderIssue::CancellationWithoutCancellableOrder { event_id } => issue_with_events( + "cancellation_without_cancellable_order", + "cancellation_event_id", + "active order reducer reported cancellation without cancellable order", vec![event_id], ), - RadrootsOrderIssue::FulfillmentPayloadInvalid { event_id } => issue_with_events( - "invalid_fulfillment_payload", - "fulfillment_payload", - "active order reducer reported invalid fulfillment payload", + RadrootsOrderIssue::CancellationPayloadInvalid { event_id } => issue_with_events( + "invalid_cancellation_payload", + "cancellation_payload", + "active order reducer reported invalid cancellation payload", vec![event_id], ), - RadrootsOrderIssue::FulfillmentOrderIdMismatch { event_id } => issue_with_events( - "fulfillment_order_id_mismatch", + RadrootsOrderIssue::CancellationOrderIdMismatch { event_id } => issue_with_events( + "cancellation_order_id_mismatch", "order_id", - "active order reducer reported fulfillment order id mismatch", + "active order reducer reported cancellation order id mismatch", vec![event_id], ), - RadrootsOrderIssue::FulfillmentAuthorMismatch { event_id } => issue_with_events( - "fulfillment_author_mismatch", - "seller_pubkey", - "active order reducer reported fulfillment author mismatch", + RadrootsOrderIssue::CancellationAuthorMismatch { event_id } => issue_with_events( + "cancellation_author_mismatch", + "buyer_pubkey", + "active order reducer reported cancellation author mismatch", vec![event_id], ), - RadrootsOrderIssue::FulfillmentCounterpartyMismatch { event_id } => issue_with_events( - "fulfillment_counterparty_mismatch", - "buyer_pubkey", - "active order reducer reported fulfillment counterparty mismatch", + RadrootsOrderIssue::CancellationCounterpartyMismatch { event_id } => issue_with_events( + "cancellation_counterparty_mismatch", + "seller_pubkey", + "active order reducer reported cancellation counterparty mismatch", vec![event_id], ), - RadrootsOrderIssue::FulfillmentBuyerMismatch { event_id } => issue_with_events( - "fulfillment_buyer_mismatch", + RadrootsOrderIssue::CancellationBuyerMismatch { event_id } => issue_with_events( + "cancellation_buyer_mismatch", "buyer_pubkey", - "active order reducer reported fulfillment buyer mismatch", + "active order reducer reported cancellation buyer mismatch", vec![event_id], ), - RadrootsOrderIssue::FulfillmentSellerMismatch { event_id } => issue_with_events( - "fulfillment_seller_mismatch", + RadrootsOrderIssue::CancellationSellerMismatch { event_id } => issue_with_events( + "cancellation_seller_mismatch", "seller_pubkey", - "active order reducer reported fulfillment seller mismatch", + "active order reducer reported cancellation seller mismatch", vec![event_id], ), - RadrootsOrderIssue::FulfillmentListingAddressInvalid { event_id } => issue_with_events( - "invalid_fulfillment_listing_address", + RadrootsOrderIssue::CancellationListingAddressInvalid { event_id } => issue_with_events( + "invalid_cancellation_listing_address", "listing_addr", - "active order reducer reported invalid fulfillment listing address", + "active order reducer reported invalid cancellation listing address", vec![event_id], ), - RadrootsOrderIssue::FulfillmentListingMismatch { event_id } => issue_with_events( - "fulfillment_listing_mismatch", + RadrootsOrderIssue::CancellationListingMismatch { event_id } => issue_with_events( + "cancellation_listing_mismatch", "listing_addr", - "active order reducer reported fulfillment listing mismatch", - vec![event_id], - ), - RadrootsOrderIssue::FulfillmentRootMismatch { event_id } => issue_with_events( - "fulfillment_root_mismatch", - "root_event_id", - "active order reducer reported fulfillment root mismatch", - vec![event_id], - ), - RadrootsOrderIssue::FulfillmentPreviousMismatch { event_id } => issue_with_events( - "fulfillment_previous_mismatch", - "prev_event_id", - "active order reducer reported fulfillment previous mismatch", - vec![event_id], - ), - RadrootsOrderIssue::FulfillmentStatusNotPublishable { event_id } => issue_with_events( - "fulfillment_status_not_publishable", - "fulfillment_state", - "active order reducer reported non-publishable fulfillment status", - vec![event_id], - ), - RadrootsOrderIssue::FulfillmentUnsupportedTransition { event_id } => issue_with_events( - "fulfillment_unsupported_transition", - "fulfillment_state", - "active order reducer reported unsupported fulfillment transition", - vec![event_id], - ), - RadrootsOrderIssue::ForkedFulfillments { event_ids } => issue_with_events( - "forked_fulfillments", - "fulfillment_event_id", - "active order reducer reported forked fulfillment updates", - event_ids, - ), - RadrootsOrderIssue::CancellationWithoutCancellableOrder { event_id } => issue_with_events( - "cancellation_without_cancellable_order", - "cancellation_event_id", - "active order reducer reported cancellation without cancellable order", - vec![event_id], - ), - RadrootsOrderIssue::CancellationPayloadInvalid { event_id } => issue_with_events( - "invalid_cancellation_payload", - "cancellation_payload", - "active order reducer reported invalid cancellation payload", - vec![event_id], - ), - RadrootsOrderIssue::CancellationOrderIdMismatch { event_id } => issue_with_events( - "cancellation_order_id_mismatch", - "order_id", - "active order reducer reported cancellation order id mismatch", - vec![event_id], - ), - RadrootsOrderIssue::CancellationAuthorMismatch { event_id } => issue_with_events( - "cancellation_author_mismatch", - "buyer_pubkey", - "active order reducer reported cancellation author mismatch", - vec![event_id], - ), - RadrootsOrderIssue::CancellationCounterpartyMismatch { event_id } => issue_with_events( - "cancellation_counterparty_mismatch", - "seller_pubkey", - "active order reducer reported cancellation counterparty mismatch", - vec![event_id], - ), - RadrootsOrderIssue::CancellationBuyerMismatch { event_id } => issue_with_events( - "cancellation_buyer_mismatch", - "buyer_pubkey", - "active order reducer reported cancellation buyer mismatch", - vec![event_id], - ), - RadrootsOrderIssue::CancellationSellerMismatch { event_id } => issue_with_events( - "cancellation_seller_mismatch", - "seller_pubkey", - "active order reducer reported cancellation seller mismatch", - vec![event_id], - ), - RadrootsOrderIssue::CancellationListingAddressInvalid { event_id } => issue_with_events( - "invalid_cancellation_listing_address", - "listing_addr", - "active order reducer reported invalid cancellation listing address", - vec![event_id], - ), - RadrootsOrderIssue::CancellationListingMismatch { event_id } => issue_with_events( - "cancellation_listing_mismatch", - "listing_addr", - "active order reducer reported cancellation listing mismatch", + "active order reducer reported cancellation listing mismatch", vec![event_id], ), RadrootsOrderIssue::CancellationRootMismatch { event_id } => issue_with_events( @@ -4355,312 +3340,6 @@ fn active_order_reducer_issue_view(issue_value: RadrootsOrderIssue) -> OrderIssu "active order reducer reported cancellation previous mismatch", vec![event_id], ), - RadrootsOrderIssue::CancellationAfterFulfillment { event_id } => issue_with_events( - "cancellation_after_fulfillment", - "fulfillment_event_id", - "active order reducer reported cancellation after fulfillment", - vec![event_id], - ), - RadrootsOrderIssue::ReceiptWithoutEligibleFulfillment { event_id } => issue_with_events( - "receipt_without_eligible_fulfillment", - "receipt_event_id", - "active order reducer reported receipt without eligible fulfillment", - vec![event_id], - ), - RadrootsOrderIssue::ReceiptPayloadInvalid { event_id } => issue_with_events( - "invalid_receipt_payload", - "receipt_payload", - "active order reducer reported invalid receipt payload", - vec![event_id], - ), - RadrootsOrderIssue::ReceiptOrderIdMismatch { event_id } => issue_with_events( - "receipt_order_id_mismatch", - "order_id", - "active order reducer reported receipt order id mismatch", - vec![event_id], - ), - RadrootsOrderIssue::ReceiptAuthorMismatch { event_id } => issue_with_events( - "receipt_author_mismatch", - "buyer_pubkey", - "active order reducer reported receipt author mismatch", - vec![event_id], - ), - RadrootsOrderIssue::ReceiptCounterpartyMismatch { event_id } => issue_with_events( - "receipt_counterparty_mismatch", - "seller_pubkey", - "active order reducer reported receipt counterparty mismatch", - vec![event_id], - ), - RadrootsOrderIssue::ReceiptBuyerMismatch { event_id } => issue_with_events( - "receipt_buyer_mismatch", - "buyer_pubkey", - "active order reducer reported receipt buyer mismatch", - vec![event_id], - ), - RadrootsOrderIssue::ReceiptSellerMismatch { event_id } => issue_with_events( - "receipt_seller_mismatch", - "seller_pubkey", - "active order reducer reported receipt seller mismatch", - vec![event_id], - ), - RadrootsOrderIssue::ReceiptListingAddressInvalid { event_id } => issue_with_events( - "invalid_receipt_listing_address", - "listing_addr", - "active order reducer reported invalid receipt listing address", - vec![event_id], - ), - RadrootsOrderIssue::ReceiptListingMismatch { event_id } => issue_with_events( - "receipt_listing_mismatch", - "listing_addr", - "active order reducer reported receipt listing mismatch", - vec![event_id], - ), - RadrootsOrderIssue::ReceiptRootMismatch { event_id } => issue_with_events( - "receipt_root_mismatch", - "root_event_id", - "active order reducer reported receipt root mismatch", - vec![event_id], - ), - RadrootsOrderIssue::ReceiptPreviousMismatch { event_id } => issue_with_events( - "receipt_previous_mismatch", - "prev_event_id", - "active order reducer reported receipt previous mismatch", - vec![event_id], - ), - RadrootsOrderIssue::PaymentWithoutAcceptedAgreement { event_id } => issue_with_events( - "payment_without_accepted_agreement", - "payment_event_id", - "active order reducer reported payment without accepted agreement", - vec![event_id], - ), - RadrootsOrderIssue::PaymentPayloadInvalid { event_id } => issue_with_events( - "invalid_payment_payload", - "payment_payload", - "active order reducer reported invalid payment payload", - vec![event_id], - ), - RadrootsOrderIssue::PaymentOrderIdMismatch { event_id } => issue_with_events( - "payment_order_id_mismatch", - "order_id", - "active order reducer reported payment order id mismatch", - vec![event_id], - ), - RadrootsOrderIssue::PaymentAuthorMismatch { event_id } => issue_with_events( - "payment_author_mismatch", - "buyer_pubkey", - "active order reducer reported payment author mismatch", - vec![event_id], - ), - RadrootsOrderIssue::PaymentCounterpartyMismatch { event_id } => issue_with_events( - "payment_counterparty_mismatch", - "seller_pubkey", - "active order reducer reported payment counterparty mismatch", - vec![event_id], - ), - RadrootsOrderIssue::PaymentBuyerMismatch { event_id } => issue_with_events( - "payment_buyer_mismatch", - "buyer_pubkey", - "active order reducer reported payment buyer mismatch", - vec![event_id], - ), - RadrootsOrderIssue::PaymentSellerMismatch { event_id } => issue_with_events( - "payment_seller_mismatch", - "seller_pubkey", - "active order reducer reported payment seller mismatch", - vec![event_id], - ), - RadrootsOrderIssue::PaymentListingAddressInvalid { event_id } => issue_with_events( - "invalid_payment_listing_address", - "listing_addr", - "active order reducer reported invalid payment listing address", - vec![event_id], - ), - RadrootsOrderIssue::PaymentListingMismatch { event_id } => issue_with_events( - "payment_listing_mismatch", - "listing_addr", - "active order reducer reported payment listing mismatch", - vec![event_id], - ), - RadrootsOrderIssue::PaymentRootMismatch { event_id } => issue_with_events( - "payment_root_mismatch", - "root_event_id", - "active order reducer reported payment root mismatch", - vec![event_id], - ), - RadrootsOrderIssue::PaymentPreviousMismatch { event_id } => issue_with_events( - "payment_previous_mismatch", - "prev_event_id", - "active order reducer reported payment previous mismatch", - vec![event_id], - ), - RadrootsOrderIssue::PaymentAgreementMismatch { event_id } => issue_with_events( - "payment_agreement_mismatch", - "agreement_event_id", - "active order reducer reported payment agreement mismatch", - vec![event_id], - ), - RadrootsOrderIssue::PaymentQuoteMismatch { event_id } => issue_with_events( - "payment_quote_mismatch", - "quote_id", - "active order reducer reported payment quote mismatch", - vec![event_id], - ), - RadrootsOrderIssue::PaymentQuoteVersionMismatch { event_id } => issue_with_events( - "payment_quote_version_mismatch", - "quote_version", - "active order reducer reported payment quote version mismatch", - vec![event_id], - ), - RadrootsOrderIssue::PaymentEconomicsDigestMismatch { event_id } => issue_with_events( - "payment_economics_digest_mismatch", - "economics_digest", - "active order reducer reported payment economics digest mismatch", - vec![event_id], - ), - RadrootsOrderIssue::PaymentAmountMismatch { event_id } => issue_with_events( - "payment_amount_mismatch", - "amount", - "active order reducer reported payment amount mismatch", - vec![event_id], - ), - RadrootsOrderIssue::PaymentCurrencyMismatch { event_id } => issue_with_events( - "payment_currency_mismatch", - "currency", - "active order reducer reported payment currency mismatch", - vec![event_id], - ), - RadrootsOrderIssue::PaymentAfterCancellation { event_id } => issue_with_events( - "payment_after_cancellation", - "payment_event_id", - "active order reducer reported payment after cancellation", - vec![event_id], - ), - RadrootsOrderIssue::RevisionAfterPayment { event_id } => issue_with_events( - "revision_after_payment", - "revision_event_id", - "active order reducer reported revision after payment", - vec![event_id], - ), - RadrootsOrderIssue::DuplicatePayments { event_ids } => issue_with_events( - "duplicate_payments", - "payment_event_id", - "active order reducer reported duplicate payment events", - event_ids, - ), - RadrootsOrderIssue::SettlementWithoutValidPayment { event_id } => issue_with_events( - "settlement_without_valid_payment", - "settlement_event_id", - "active order reducer reported settlement without valid payment", - vec![event_id], - ), - RadrootsOrderIssue::SettlementPayloadInvalid { event_id } => issue_with_events( - "invalid_settlement_payload", - "settlement_payload", - "active order reducer reported invalid settlement payload", - vec![event_id], - ), - RadrootsOrderIssue::SettlementOrderIdMismatch { event_id } => issue_with_events( - "settlement_order_id_mismatch", - "order_id", - "active order reducer reported settlement order id mismatch", - vec![event_id], - ), - RadrootsOrderIssue::SettlementAuthorMismatch { event_id } => issue_with_events( - "settlement_author_mismatch", - "seller_pubkey", - "active order reducer reported settlement author mismatch", - vec![event_id], - ), - RadrootsOrderIssue::SettlementCounterpartyMismatch { event_id } => issue_with_events( - "settlement_counterparty_mismatch", - "buyer_pubkey", - "active order reducer reported settlement counterparty mismatch", - vec![event_id], - ), - RadrootsOrderIssue::SettlementBuyerMismatch { event_id } => issue_with_events( - "settlement_buyer_mismatch", - "buyer_pubkey", - "active order reducer reported settlement buyer mismatch", - vec![event_id], - ), - RadrootsOrderIssue::SettlementSellerMismatch { event_id } => issue_with_events( - "settlement_seller_mismatch", - "seller_pubkey", - "active order reducer reported settlement seller mismatch", - vec![event_id], - ), - RadrootsOrderIssue::SettlementListingAddressInvalid { event_id } => issue_with_events( - "invalid_settlement_listing_address", - "listing_addr", - "active order reducer reported invalid settlement listing address", - vec![event_id], - ), - RadrootsOrderIssue::SettlementListingMismatch { event_id } => issue_with_events( - "settlement_listing_mismatch", - "listing_addr", - "active order reducer reported settlement listing mismatch", - vec![event_id], - ), - RadrootsOrderIssue::SettlementRootMismatch { event_id } => issue_with_events( - "settlement_root_mismatch", - "root_event_id", - "active order reducer reported settlement root mismatch", - vec![event_id], - ), - RadrootsOrderIssue::SettlementPreviousMismatch { event_id } => issue_with_events( - "settlement_previous_mismatch", - "prev_event_id", - "active order reducer reported settlement previous mismatch", - vec![event_id], - ), - RadrootsOrderIssue::SettlementPaymentEventMismatch { event_id } => issue_with_events( - "settlement_payment_event_mismatch", - "payment_event_id", - "active order reducer reported settlement payment event mismatch", - vec![event_id], - ), - RadrootsOrderIssue::SettlementAgreementMismatch { event_id } => issue_with_events( - "settlement_agreement_mismatch", - "agreement_event_id", - "active order reducer reported settlement agreement mismatch", - vec![event_id], - ), - RadrootsOrderIssue::SettlementQuoteMismatch { event_id } => issue_with_events( - "settlement_quote_mismatch", - "quote_id", - "active order reducer reported settlement quote mismatch", - vec![event_id], - ), - RadrootsOrderIssue::SettlementQuoteVersionMismatch { event_id } => issue_with_events( - "settlement_quote_version_mismatch", - "quote_version", - "active order reducer reported settlement quote version mismatch", - vec![event_id], - ), - RadrootsOrderIssue::SettlementEconomicsDigestMismatch { event_id } => issue_with_events( - "settlement_economics_digest_mismatch", - "economics_digest", - "active order reducer reported settlement economics digest mismatch", - vec![event_id], - ), - RadrootsOrderIssue::SettlementAmountMismatch { event_id } => issue_with_events( - "settlement_amount_mismatch", - "amount", - "active order reducer reported settlement amount mismatch", - vec![event_id], - ), - RadrootsOrderIssue::SettlementCurrencyMismatch { event_id } => issue_with_events( - "settlement_currency_mismatch", - "currency", - "active order reducer reported settlement currency mismatch", - vec![event_id], - ), - RadrootsOrderIssue::DuplicateSettlements { event_ids } => issue_with_events( - "duplicate_settlements", - "settlement_event_id", - "active order reducer reported duplicate settlement events", - event_ids, - ), RadrootsOrderIssue::ForkedLifecycle { event_ids } => issue_with_events( "forked_lifecycle", "event_id", @@ -4901,42 +3580,6 @@ fn order_revision_decision_base_view( } } -fn order_fulfillment_base_view( - config: &RuntimeConfig, - args: &OrderFulfillmentArgs, - state: &str, - dry_run: bool, -) -> OrderFulfillmentView { - OrderFulfillmentView { - state: state.to_owned(), - source: ORDER_FULFILLMENT_SOURCE.to_owned(), - order_id: args.key.clone(), - fulfillment_state: args.state.trim().to_owned(), - listing_addr: None, - buyer_pubkey: None, - seller_pubkey: None, - request_event_id: None, - decision_event_id: None, - root_event_id: None, - prev_event_id: None, - event_id: None, - event_kind: None, - dry_run, - target_relays: config.relay.urls.clone(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - fetched_count: 0, - decoded_count: 0, - skipped_count: 0, - idempotency_key: args.idempotency_key.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - reason: None, - issues: Vec::new(), - actions: Vec::new(), - } -} - fn order_cancellation_base_view( config: &RuntimeConfig, args: &OrderCancelArgs, @@ -4973,312 +3616,36 @@ fn order_cancellation_base_view( } } -fn order_receipt_base_view( - config: &RuntimeConfig, - args: &OrderReceiptArgs, - state: &str, - dry_run: bool, -) -> OrderReceiptView { - OrderReceiptView { - state: state.to_owned(), - source: ORDER_RECEIPT_SOURCE.to_owned(), - order_id: args.key.clone(), - listing_addr: None, - buyer_pubkey: None, - seller_pubkey: None, - request_event_id: None, - decision_event_id: None, - fulfillment_event_id: None, - root_event_id: None, - prev_event_id: None, - event_id: None, - event_kind: None, - received: args.received, - issue: args.issue.as_ref().map(|issue| issue.trim().to_owned()), - received_at: None, - dry_run, - target_relays: config.relay.urls.clone(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - fetched_count: 0, - decoded_count: 0, - skipped_count: 0, - idempotency_key: args.idempotency_key.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - reason: None, - issues: Vec::new(), - actions: Vec::new(), - } -} - -fn order_payment_base_view( - config: &RuntimeConfig, - args: &OrderPaymentArgs, - state: &str, - dry_run: bool, -) -> OrderPaymentView { - OrderPaymentView { - state: state.to_owned(), - source: ORDER_PAYMENT_SOURCE.to_owned(), - order_id: args.key.clone(), - listing_addr: None, - buyer_pubkey: None, - seller_pubkey: None, - request_event_id: None, - agreement_event_id: None, - root_event_id: None, - prev_event_id: None, - event_id: None, - event_kind: None, - quote_id: None, - quote_version: None, - economics_digest: None, - amount: parse_payment_amount(args.amount.as_str()).ok(), - currency: parse_payment_currency(args.currency.as_str()).ok(), - method: parse_payment_method(args.method.as_str()).ok(), - reference: args - .reference - .as_ref() - .map(|reference| reference.trim().to_owned()), - paid_at: args.paid_at, - dry_run, - target_relays: config.relay.urls.clone(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - fetched_count: 0, - decoded_count: 0, - skipped_count: 0, - idempotency_key: args.idempotency_key.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - reason: None, - issues: Vec::new(), - actions: Vec::new(), - } -} - -fn order_settlement_base_view( - config: &RuntimeConfig, - args: &OrderSettlementArgs, - state: &str, - dry_run: bool, -) -> OrderSettlementView { - OrderSettlementView { - state: state.to_owned(), - source: ORDER_SETTLEMENT_SOURCE.to_owned(), - order_id: args.key.clone(), - listing_addr: None, - buyer_pubkey: None, - seller_pubkey: None, - request_event_id: None, - agreement_event_id: None, - root_event_id: None, - prev_event_id: None, - payment_event_id: non_empty_ref(args.payment_event_id.as_str()).map(str::to_owned), - event_id: None, - event_kind: None, - quote_id: None, - quote_version: None, - economics_digest: None, - amount: None, - currency: None, - decision: Some(settlement_decision_protocol(args.decision)), - settlement_reason: args.reason.as_ref().map(|reason| reason.trim().to_owned()), - reason: None, - dry_run, - target_relays: config.relay.urls.clone(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - fetched_count: 0, - decoded_count: 0, - skipped_count: 0, - idempotency_key: args.idempotency_key.clone(), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - issues: Vec::new(), - actions: Vec::new(), - } -} - -const fn settlement_decision_protocol( - decision: OrderSettlementDecisionArg, -) -> RadrootsOrderSettlementOutcome { - match decision { - OrderSettlementDecisionArg::Accept => RadrootsOrderSettlementOutcome::Accepted, - OrderSettlementDecisionArg::Reject => RadrootsOrderSettlementOutcome::Rejected, - } -} - -const fn settlement_decision_state(decision: OrderSettlementDecisionArg) -> &'static str { - match decision { - OrderSettlementDecisionArg::Accept => "accepted", - OrderSettlementDecisionArg::Reject => "rejected", - } -} - -fn apply_order_fulfillment_status(view: &mut OrderFulfillmentView, status: &OrderStatusView) { - view.order_id = status.order_id.clone(); - view.listing_addr = status.listing_addr.clone(); - view.buyer_pubkey = status.buyer_pubkey.clone(); - view.seller_pubkey = status.seller_pubkey.clone(); - view.request_event_id = status.request_event_id.clone(); - view.decision_event_id = status.decision_event_id.clone(); - view.root_event_id = status.request_event_id.clone(); - view.prev_event_id = status.last_event_id.clone(); - view.target_relays = status.target_relays.clone(); - view.connected_relays = status.connected_relays.clone(); - view.failed_relays = status.failed_relays.clone(); - view.fetched_count = status.fetched_count; - view.decoded_count = status.decoded_count; - view.skipped_count = status.skipped_count; - view.issues = status.reducer_issues.clone(); -} - -fn apply_order_cancellation_status(view: &mut OrderCancellationView, status: &OrderStatusView) { - view.order_id = status.order_id.clone(); - view.listing_addr = status.listing_addr.clone(); - view.buyer_pubkey = status.buyer_pubkey.clone(); - view.seller_pubkey = status.seller_pubkey.clone(); - view.request_event_id = status.request_event_id.clone(); - view.decision_event_id = status.decision_event_id.clone(); - view.root_event_id = status.request_event_id.clone(); - view.prev_event_id = order_cancellation_prev_event_id(status); - view.target_relays = status.target_relays.clone(); - view.connected_relays = status.connected_relays.clone(); - view.failed_relays = status.failed_relays.clone(); - view.fetched_count = status.fetched_count; - view.decoded_count = status.decoded_count; - view.skipped_count = status.skipped_count; - view.issues = status.reducer_issues.clone(); -} - -fn apply_order_receipt_status(view: &mut OrderReceiptView, status: &OrderStatusView) { - view.order_id = status.order_id.clone(); - view.listing_addr = status.listing_addr.clone(); - view.buyer_pubkey = status.buyer_pubkey.clone(); - view.seller_pubkey = status.seller_pubkey.clone(); - view.request_event_id = status.request_event_id.clone(); - view.decision_event_id = status.decision_event_id.clone(); - view.fulfillment_event_id = status - .fulfillment - .as_ref() - .and_then(|fulfillment| fulfillment.event_id.clone()); - view.root_event_id = status.request_event_id.clone(); - view.prev_event_id = - order_receipt_prev_event_id(status).or_else(|| status.last_event_id.clone()); - view.target_relays = status.target_relays.clone(); - view.connected_relays = status.connected_relays.clone(); - view.failed_relays = status.failed_relays.clone(); - view.fetched_count = status.fetched_count; - view.decoded_count = status.decoded_count; - view.skipped_count = status.skipped_count; - view.issues = status.reducer_issues.clone(); -} - -fn apply_order_payment_status(view: &mut OrderPaymentView, status: &OrderStatusView) { - view.order_id = status.order_id.clone(); - view.listing_addr = status.listing_addr.clone(); - view.buyer_pubkey = status.buyer_pubkey.clone(); - view.seller_pubkey = status.seller_pubkey.clone(); - view.request_event_id = status.request_event_id.clone(); - view.agreement_event_id = status.agreement_event_id.clone(); - view.root_event_id = status.request_event_id.clone(); - view.prev_event_id = order_payment_prev_event_id(status); - if let Some(economics) = status.economics.as_ref() { - view.quote_id = Some(economics.quote_id.to_string()); - view.quote_version = Some(economics.quote_version); - view.economics_digest = radroots_order_economics_digest(economics).ok(); - view.amount = Some(economics.total.amount); - view.currency = Some(economics.total.currency); - } - view.target_relays = status.target_relays.clone(); - view.connected_relays = status.connected_relays.clone(); - view.failed_relays = status.failed_relays.clone(); - view.fetched_count = status.fetched_count; - view.decoded_count = status.decoded_count; - view.skipped_count = status.skipped_count; - view.issues = status.reducer_issues.clone(); -} - -fn apply_order_settlement_status(view: &mut OrderSettlementView, status: &OrderStatusView) { - view.order_id = status.order_id.clone(); - view.listing_addr = status.listing_addr.clone(); - view.buyer_pubkey = status.buyer_pubkey.clone(); - view.seller_pubkey = status.seller_pubkey.clone(); - view.request_event_id = status.request_event_id.clone(); - view.root_event_id = status.request_event_id.clone(); - view.target_relays = status.target_relays.clone(); - view.connected_relays = status.connected_relays.clone(); - view.failed_relays = status.failed_relays.clone(); - view.fetched_count = status.fetched_count; - view.decoded_count = status.decoded_count; - view.skipped_count = status.skipped_count; - view.issues = status.reducer_issues.clone(); - if let Some(payment) = status.payment.as_ref() { - view.payment_event_id = payment - .payment_event_id - .clone() - .or_else(|| view.payment_event_id.clone()); - view.event_id = payment.settlement_event_id.clone(); - view.event_kind = payment - .settlement_event_id - .as_ref() - .map(|_| KIND_ORDER_SETTLEMENT_DECISION); - view.agreement_event_id = payment.agreement_event_id.clone(); - view.prev_event_id = payment.payment_event_id.clone(); - view.quote_id = payment.quote_id.clone(); - view.quote_version = payment.quote_version; - view.economics_digest = payment.economics_digest.clone(); - view.amount = payment.amount; - view.currency = payment.currency; - view.settlement_reason = payment.reason.clone().or(view.settlement_reason.clone()); - } -} - -fn order_receipt_prev_event_id(status: &OrderStatusView) -> Option<String> { - status.fulfillment.as_ref().and_then(|fulfillment| { - if matches!(fulfillment.state.as_str(), "ready_for_pickup" | "delivered") { - fulfillment.event_id.clone() - } else { - None - } - }) -} - -fn order_payment_prev_event_id(status: &OrderStatusView) -> Option<String> { - status.payment.as_ref().and_then(|payment| { - if payment.state == "rejected" { - payment - .settlement_event_id - .clone() - .or_else(|| status.agreement_event_id.clone()) - } else { - status.agreement_event_id.clone() - } - }) -} - -fn unrejected_payment_state(status: &OrderStatusView) -> Option<&str> { - status - .payment - .as_ref() - .map(|payment| payment.state.as_str()) - .filter(|state| matches!(*state, "recorded" | "settled")) -} - -fn order_cancellation_prev_event_id(status: &OrderStatusView) -> Option<String> { - match status.state.as_str() { - "requested" => status.request_event_id.clone(), - "accepted" => status - .last_event_id - .clone() - .or(status.decision_event_id.clone()), - _ => status.last_event_id.clone(), - } -} - -fn order_cancellation_preflight_view_from_status( +fn apply_order_cancellation_status(view: &mut OrderCancellationView, status: &OrderStatusView) { + view.order_id = status.order_id.clone(); + view.listing_addr = status.listing_addr.clone(); + view.buyer_pubkey = status.buyer_pubkey.clone(); + view.seller_pubkey = status.seller_pubkey.clone(); + view.request_event_id = status.request_event_id.clone(); + view.decision_event_id = status.decision_event_id.clone(); + view.root_event_id = status.request_event_id.clone(); + view.prev_event_id = order_cancellation_prev_event_id(status); + view.target_relays = status.target_relays.clone(); + view.connected_relays = status.connected_relays.clone(); + view.failed_relays = status.failed_relays.clone(); + view.fetched_count = status.fetched_count; + view.decoded_count = status.decoded_count; + view.skipped_count = status.skipped_count; + view.issues = status.reducer_issues.clone(); +} + +fn order_cancellation_prev_event_id(status: &OrderStatusView) -> Option<String> { + match status.state.as_str() { + "requested" => status.request_event_id.clone(), + "accepted" => status + .last_event_id + .clone() + .or(status.decision_event_id.clone()), + _ => status.last_event_id.clone(), + } +} + +fn order_cancellation_preflight_view_from_status( config: &RuntimeConfig, args: &OrderCancelArgs, status: &OrderStatusView, @@ -5288,22 +3655,10 @@ fn order_cancellation_preflight_view_from_status( .buyer_pubkey .as_deref() .is_some_and(|buyer| buyer.eq_ignore_ascii_case(selected_pubkey)); - let payment_state = unrejected_payment_state(status); let state = match status.state.as_str() { "requested" if buyer_matches => return None, - "accepted" if buyer_matches && payment_state.is_some() => "invalid", - "accepted" - if buyer_matches - && status - .fulfillment - .as_ref() - .and_then(|fulfillment| fulfillment.event_id.as_ref()) - .is_none() => - { - return None; - } - "accepted" if buyer_matches => "fulfilled", - "cancelled" | "completed" | "disputed" => "terminal", + "accepted" if buyer_matches => "finalized", + "cancelled" => "terminal", "missing" | "declined" | "invalid" | "unavailable" | "unconfigured" => { status.state.as_str() } @@ -5330,25 +3685,10 @@ fn order_cancellation_preflight_view_from_status( args.key ) } - "fulfilled" => format!( - "order cancel refused because order `{}` already has seller fulfillment", + "finalized" => format!( + "order cancel refused because order `{}` already has an accepted agreement", args.key ), - "invalid" if buyer_matches && payment_state.is_some() => { - if let Some(payment_state) = payment_state { - format!( - "order cancel refused because order `{}` already has unrejected payment state `{payment_state}`", - args.key - ) - } else { - status.reason.clone().unwrap_or_else(|| { - format!( - "order cancel refused because active order events for `{}` are invalid", - args.key - ) - }) - } - } "invalid" if !buyer_matches && status.buyer_pubkey.is_some() => format!( "order cancel refused because selected account is not buyer for order `{}`", args.key @@ -5366,2991 +3706,2412 @@ fn order_cancellation_preflight_view_from_status( ) }), }); - if state == "invalid" && buyer_matches && payment_state.is_some() { - view.issues.push(issue_with_code( - "payment_blocks_cancellation", - "payment.state", - "orders with unrejected recorded payment cannot be cancelled", - )); - } view.actions = vec![format!("radroots order status get {}", args.key)]; Some(view) } -fn order_receipt_args_preflight_view( +fn order_decision_view_from_resolution( config: &RuntimeConfig, - args: &OrderReceiptArgs, -) -> Option<OrderReceiptView> { - let issue = args - .issue - .as_deref() - .map(str::trim) - .filter(|issue| !issue.is_empty()); - let reason = if args.received && issue.is_some() { - Some("order receipt record cannot set both received and issue".to_owned()) - } else if !args.received && issue.is_none() { - Some("order receipt record requires --received or a non-empty --issue".to_owned()) - } else { - None - }?; - let mut view = order_receipt_base_view(config, args, "invalid", config.output.dry_run); - view.reason = Some(reason); - view.issues = vec![issue_with_code( - "invalid_receipt_outcome", - "receipt", - "receipt outcome must be either received or issue", - )]; - Some(view) -} + args: &OrderDecisionArgs, + seller_pubkey: String, + resolution: SellerOrderRequestResolution, +) -> OrderDecisionView { + let SellerOrderRequestResolution { + target_relays, + connected_relays, + failed_relays, + fetched_count, + decoded_count, + skipped_count, + requests, + candidate_issues, + } = resolution; + let mut view = order_decision_base_view(config, args, "missing", config.output.dry_run); + view.seller_pubkey = Some(seller_pubkey); + view.target_relays = target_relays; + view.connected_relays = connected_relays; + view.failed_relays = relay_failures(failed_relays); + view.fetched_count = fetched_count; + view.decoded_count = decoded_count; + view.skipped_count = skipped_count; + view.issues = candidate_issues; -fn order_receipt_preflight_view_from_status( - config: &RuntimeConfig, - args: &OrderReceiptArgs, - status: &OrderStatusView, - selected_pubkey: &str, -) -> Option<OrderReceiptView> { - let buyer_matches = status - .buyer_pubkey - .as_deref() - .is_some_and(|buyer| buyer.eq_ignore_ascii_case(selected_pubkey)); - let eligible_fulfillment = order_receipt_prev_event_id(status).is_some(); - let state = match status.state.as_str() { - "accepted" if buyer_matches && eligible_fulfillment => return None, - "accepted" if buyer_matches => "invalid", - "completed" | "disputed" => "terminal", - "missing" | "requested" | "declined" | "cancelled" | "invalid" | "unavailable" - | "unconfigured" => status.state.as_str(), - _ => "invalid", - }; - let mut view = order_receipt_base_view(config, args, state, config.output.dry_run); - apply_order_receipt_status(&mut view, status); - if matches!(status.state.as_str(), "completed" | "disputed") { - view.event_id = status - .lifecycle - .as_ref() - .and_then(|lifecycle| lifecycle.event_id.clone()); - view.event_kind = Some(KIND_ORDER_RECEIPT); - if let Some(receipt) = status - .lifecycle - .as_ref() - .and_then(|lifecycle| lifecycle.receipt.as_ref()) - { - view.received = receipt.received; - view.issue = receipt.issue.clone(); - view.received_at = receipt.received_at; - } - } - view.reason = Some(match state { - "missing" => format!("no active order events matched `{}`", args.key), - "requested" => format!( - "order receipt record refused because order `{}` has no accepted seller decision", - args.key - ), - "declined" => format!( - "order receipt record refused because order `{}` was declined", + if !view.issues.is_empty() { + view.state = "invalid".to_owned(); + view.reason = Some(format!( + "seller order request preflight found invalid request candidates for `{}`", args.key - ), - "cancelled" | "terminal" => { - format!( - "order receipt record refused because order `{}` is already terminal", + )); + view.actions = vec![format!("radroots order status get {}", args.key)]; + return view; + } + match requests.as_slice() { + [] => { + view.reason = Some(format!( + "no seller-targeted order request event matched `{}`", args.key - ) + )); + view } - "invalid" if !buyer_matches && status.buyer_pubkey.is_some() => format!( - "order receipt record refused because selected account is not buyer for order `{}`", - args.key - ), - "invalid" if status.state == "accepted" => format!( - "order receipt record refused because order `{}` has no eligible seller fulfillment", - args.key - ), - "invalid" => status.reason.clone().unwrap_or_else(|| { - format!( - "order receipt record refused because active order events for `{}` are invalid", + _ => { + let event_ids = requests + .iter() + .map(|request| request.request_event_id.to_string()) + .collect::<Vec<_>>(); + view.state = "invalid".to_owned(); + view.reason = Some(format!( + "multiple seller-targeted order request events matched `{}`; refusing to choose an order root", args.key - ) - }), - _ => status.reason.clone().unwrap_or_else(|| { - format!( - "order receipt record status preflight failed with state `{}`", - status.state - ) - }), - }); - view.actions = vec![format!("radroots order status get {}", args.key)]; - Some(view) + )); + view.issues = vec![issue_with_events( + "multiple_request_candidates", + "request_event_id", + format!( + "matched {} request events for the same order id: {}", + requests.len(), + event_ids.join(", ") + ), + event_ids, + )]; + view.actions = vec![format!("radroots order status get {}", args.key)]; + view + } + } } -fn order_payment_args_preflight_view( - config: &RuntimeConfig, - args: &OrderPaymentArgs, -) -> Option<OrderPaymentView> { - let (reason, issue) = if args.amount.trim().is_empty() { - ( - "order payment record requires --amount".to_owned(), - issue_with_code( - "missing_payment_amount", - "amount", - "payment amount is required", - ), - ) - } else if parse_payment_amount(args.amount.as_str()).is_err() { - ( - format!( - "order payment record received invalid amount `{}`", - args.amount - ), - issue_with_code( - "invalid_payment_amount", - "amount", - "payment amount must be greater than zero", - ), - ) - } else if args.currency.trim().is_empty() { - ( - "order payment record requires --currency".to_owned(), - issue_with_code( - "missing_payment_currency", - "currency", - "payment currency is required", - ), - ) - } else if parse_payment_currency(args.currency.as_str()).is_err() { - ( - format!( - "order payment record received invalid currency `{}`", - args.currency - ), - issue_with_code( - "invalid_payment_currency", - "currency", - "payment currency must be a 3-letter code", - ), - ) - } else if args.method.trim().is_empty() { - ( - "order payment record requires --method".to_owned(), - issue_with_code( - "missing_payment_method", - "method", - "payment method is required", - ), - ) - } else if parse_payment_method(args.method.as_str()).is_err() { - ( - format!( - "order payment record received unsupported method `{}`", - args.method - ), - issue_with_code( - "invalid_payment_method", - "method", - "payment method must be cash, manual_transfer, or other", - ), - ) - } else { - return None; - }; - let mut view = order_payment_base_view(config, args, "invalid", config.output.dry_run); - view.reason = Some(reason); - view.issues = vec![issue]; - Some(view) +fn apply_order_decision_resolution( + view: &mut OrderDecisionView, + resolution: &SellerOrderRequestResolution, +) { + view.target_relays = resolution.target_relays.clone(); + view.connected_relays = resolution.connected_relays.clone(); + view.failed_relays = relay_failures(resolution.failed_relays.clone()); + view.fetched_count = resolution.fetched_count; + view.decoded_count = resolution.decoded_count; + view.skipped_count = resolution.skipped_count; +} + +fn apply_order_decision_request( + view: &mut OrderDecisionView, + request: &ResolvedSellerOrderRequest, +) { + view.order_id = request.order_id.to_string(); + view.listing_addr = Some(request.listing_addr.to_string()); + view.buyer_pubkey = Some(request.buyer_pubkey.to_string()); + view.seller_pubkey = Some(request.seller_pubkey.to_string()); + view.request_event_id = Some(request.request_event_id.to_string()); + view.listing_event_id = request.listing_event_id.clone(); + view.root_event_id = Some(request.request_event_id.to_string()); + view.prev_event_id = Some(request.request_event_id.to_string()); +} + +fn apply_order_decision_status(view: &mut OrderDecisionView, status: &OrderStatusView) { + view.target_relays = status.target_relays.clone(); + view.connected_relays = status.connected_relays.clone(); + view.failed_relays = status.failed_relays.clone(); + view.fetched_count = status.fetched_count; + view.decoded_count = status.decoded_count; + view.skipped_count = status.skipped_count; + view.issues = status.reducer_issues.clone(); + view.inventory = status.inventory.clone(); +} + +fn apply_order_revision_status(view: &mut OrderRevisionProposalView, status: &OrderStatusView) { + view.order_id = status.order_id.clone(); + view.listing_addr = status.listing_addr.clone(); + view.buyer_pubkey = status.buyer_pubkey.clone(); + view.seller_pubkey = status.seller_pubkey.clone(); + view.request_event_id = status.request_event_id.clone(); + view.decision_event_id = status.decision_event_id.clone(); + view.root_event_id = status.request_event_id.clone(); + view.prev_event_id = status.last_event_id.clone(); + view.economics = status.economics.clone(); + view.inventory = status.inventory.clone(); + view.target_relays = status.target_relays.clone(); + view.connected_relays = status.connected_relays.clone(); + view.failed_relays = status.failed_relays.clone(); + view.fetched_count = status.fetched_count; + view.decoded_count = status.decoded_count; + view.skipped_count = status.skipped_count; + view.issues = status.reducer_issues.clone(); +} + +fn apply_order_revision_decision_status( + view: &mut OrderRevisionDecisionView, + status: &OrderStatusView, +) { + view.order_id = status.order_id.clone(); + view.listing_addr = status.listing_addr.clone(); + view.buyer_pubkey = status.buyer_pubkey.clone(); + view.seller_pubkey = status.seller_pubkey.clone(); + view.request_event_id = status.request_event_id.clone(); + view.decision_event_id = status.decision_event_id.clone(); + view.agreement_event_id = status.agreement_event_id.clone(); + view.root_event_id = status.request_event_id.clone(); + view.prev_event_id = status.last_event_id.clone(); + view.economics = status.economics.clone(); + view.inventory = status.inventory.clone(); + view.target_relays = status.target_relays.clone(); + view.connected_relays = status.connected_relays.clone(); + view.failed_relays = status.failed_relays.clone(); + view.fetched_count = status.fetched_count; + view.decoded_count = status.decoded_count; + view.skipped_count = status.skipped_count; + view.issues = status.reducer_issues.clone(); } -fn order_payment_preflight_view_from_status( +fn order_decision_preflight_view_from_status( config: &RuntimeConfig, - args: &OrderPaymentArgs, + args: &OrderDecisionArgs, + request: &ResolvedSellerOrderRequest, + resolution: &SellerOrderRequestResolution, status: &OrderStatusView, - selected_pubkey: &str, -) -> Option<OrderPaymentView> { - let buyer_matches = status - .buyer_pubkey - .as_deref() - .is_some_and(|buyer| buyer.eq_ignore_ascii_case(selected_pubkey)); - let payment_state = status - .payment - .as_ref() - .map(|payment| payment.state.as_str()) - .unwrap_or("not_recorded"); - let payment_open = matches!(payment_state, "not_recorded" | "rejected"); - let different_existing_payment = matches!(payment_state, "recorded" | "settled") - && buyer_matches - && !status - .payment - .as_ref() - .is_some_and(|payment| payment_args_match_existing_payment(args, payment)); +) -> Option<OrderDecisionView> { let state = match status.state.as_str() { - "accepted" | "completed" | "disputed" if buyer_matches && payment_open => { - if let Some(view) = order_payment_terms_preflight_view_from_status(config, args, status) - { - return Some(view); - } - return None; - } - "accepted" | "completed" | "disputed" if different_existing_payment => "invalid", - "accepted" | "completed" | "disputed" if buyer_matches => payment_state, - "missing" | "requested" | "declined" | "cancelled" | "invalid" | "unavailable" - | "unconfigured" => status.state.as_str(), - _ => "invalid", + "accepted" | "declined" => "already_decided", + "cancelled" => "terminal", + "invalid" => "invalid", + "unavailable" => "unavailable", + "unconfigured" => "unconfigured", + _ => return None, }; - let mut view = order_payment_base_view(config, args, state, config.output.dry_run); - apply_order_payment_status(&mut view, status); - if let Some(payment) = status.payment.as_ref() { - view.event_id = payment.payment_event_id.clone(); - view.event_kind = payment - .payment_event_id - .as_ref() - .map(|_| KIND_ORDER_PAYMENT_RECORD); - view.quote_id = payment.quote_id.clone().or(view.quote_id); - view.quote_version = payment.quote_version.or(view.quote_version); - view.economics_digest = payment.economics_digest.clone().or(view.economics_digest); - view.amount = payment.amount.or(view.amount); - view.currency = payment.currency.or(view.currency); - view.method = payment.method.or(view.method); - view.reference = payment.reference.clone().or(view.reference); - view.paid_at = payment.paid_at.or(view.paid_at); + let mut view = order_decision_base_view(config, args, state, config.output.dry_run); + apply_order_decision_resolution(&mut view, resolution); + apply_order_decision_request(&mut view, request); + apply_order_decision_status(&mut view, status); + if let Some(decision_event_id) = &status.decision_event_id { + view.event_id = Some(decision_event_id.clone()); + view.event_kind = Some(KIND_ORDER_DECISION); } - view.reason = Some(match state { - "missing" => format!("no active order events matched `{}`", args.key), - "requested" => format!( - "order payment record refused because order `{}` has no accepted seller decision", - args.key - ), - "declined" => format!( - "order payment record refused because order `{}` was declined", - args.key + view.reason = Some(match status.state.as_str() { + "accepted" | "declined" => format!( + "order {} refused because order `{}` already has a visible `{}` seller decision", + args.decision.command(), + request.order_id, + status.state ), "cancelled" => format!( - "order payment record refused because order `{}` was cancelled", - args.key - ), - "recorded" | "settled" => format!( - "order payment record skipped because order `{}` already has payment state `{state}`", - args.key - ), - "invalid" if different_existing_payment => format!( - "order payment record refused because order `{}` already has a different unrejected payment", - args.key - ), - "invalid" if !buyer_matches && status.buyer_pubkey.is_some() => format!( - "order payment record refused because selected account is not buyer for order `{}`", - args.key + "order {} refused because order `{}` is already terminal", + args.decision.command(), + request.order_id ), "invalid" => status.reason.clone().unwrap_or_else(|| { format!( - "order payment record refused because active order events for `{}` are invalid", - args.key + "order {} refused because active order events for `{}` are invalid", + args.decision.command(), + request.order_id ) }), _ => status.reason.clone().unwrap_or_else(|| { format!( - "order payment record status preflight failed with state `{}`", + "order {} status preflight failed with state `{}`", + args.decision.command(), status.state ) }), }); - if different_existing_payment { - view.issues = vec![issue_with_code( - "duplicate_payment_attempt", - "payment", - "a different payment already exists for this unrejected payment state", - )]; - } - view.actions = vec![format!("radroots order status get {}", args.key)]; + view.actions = vec![format!("radroots order status get {}", request.order_id)]; Some(view) } -fn payment_args_match_existing_payment( - args: &OrderPaymentArgs, - payment: &OrderStatusPaymentView, -) -> bool { - let amount_matches = parse_payment_amount(args.amount.as_str()) - .ok() - .is_some_and(|amount| Some(amount) == payment.amount); - let currency_matches = parse_payment_currency(args.currency.as_str()) - .ok() - .is_some_and(|currency| Some(currency) == payment.currency); - let method_matches = parse_payment_method(args.method.as_str()) - .ok() - .is_some_and(|method| Some(method) == payment.method); - let reference = args - .reference - .as_deref() - .map(str::trim) - .filter(|reference| !reference.is_empty()); - amount_matches - && currency_matches - && method_matches - && reference == payment.reference.as_deref() - && args.paid_at == payment.paid_at -} - -fn order_payment_terms_preflight_view_from_status( +fn order_revision_args_preflight_view( config: &RuntimeConfig, - args: &OrderPaymentArgs, - status: &OrderStatusView, -) -> Option<OrderPaymentView> { - let requested_amount = parse_payment_amount(args.amount.as_str()).ok()?; - let requested_currency = parse_payment_currency(args.currency.as_str()).ok()?; - let Some(economics) = status.economics.as_ref() else { - let mut view = order_payment_base_view(config, args, "invalid", config.output.dry_run); - apply_order_payment_status(&mut view, status); - view.reason = Some(format!( - "order payment record refused because order `{}` has no accepted economics", - args.key + args: &OrderRevisionProposeArgs, +) -> Option<OrderRevisionProposalView> { + let mut issues = Vec::new(); + let has_bin_id = args.bin_id.as_deref().and_then(non_empty_ref).is_some(); + let has_bin_count = args.bin_count.is_some(); + if has_bin_id != has_bin_count { + issues.push(issue_with_code( + "revision_item_change_incomplete", + "bin_id", + "`bin_id` and `bin_count` must be supplied together", )); - view.issues = vec![issue_with_code( - "missing_payment_economics", - "amount", - "active order has no accepted economics for payment comparison", - )]; - view.actions = vec![format!("radroots order status get {}", args.key)]; - return Some(view); - }; - if requested_amount != economics.total.amount { - let mut view = order_payment_base_view(config, args, "invalid", config.output.dry_run); - apply_order_payment_status(&mut view, status); - view.amount = Some(requested_amount); - view.currency = Some(requested_currency); - view.reason = Some(format!( - "order payment record refused because amount `{}` does not match current agreement total `{}`", - args.amount, economics.total.amount + } + if args.bin_count == Some(0) { + issues.push(issue_with_code( + "revision_bin_count_invalid", + "bin_count", + "bin_count must be greater than zero", )); - view.issues = vec![issue_with_code( - "payment_amount_mismatch", - "amount", - "payment amount must match the current accepted agreement total", - )]; - view.actions = vec![format!("radroots order status get {}", args.key)]; - return Some(view); } - if requested_currency != economics.total.currency { - let mut view = order_payment_base_view(config, args, "invalid", config.output.dry_run); - apply_order_payment_status(&mut view, status); - view.amount = Some(requested_amount); - view.currency = Some(requested_currency); - view.reason = Some(format!( - "order payment record refused because currency `{}` does not match current agreement currency `{}`", - args.currency, economics.total.currency + + let adjustment_inputs = [ + args.adjustment_id.as_deref(), + args.adjustment_effect.as_deref(), + args.adjustment_amount.as_deref(), + args.adjustment_currency.as_deref(), + args.adjustment_reason.as_deref(), + ]; + let adjustment_supplied = adjustment_inputs + .iter() + .any(|value| value.and_then(non_empty_ref).is_some()); + let adjustment_complete = adjustment_inputs + .iter() + .all(|value| value.and_then(non_empty_ref).is_some()); + if adjustment_supplied && !adjustment_complete { + issues.push(issue_with_code( + "revision_adjustment_incomplete", + "adjustment", + "all revision adjustment fields must be supplied together", )); - view.issues = vec![issue_with_code( - "payment_currency_mismatch", - "currency", - "payment currency must match the current accepted agreement currency", - )]; - view.actions = vec![format!("radroots order status get {}", args.key)]; - return Some(view); } - None + + if !has_bin_id && !adjustment_supplied { + issues.push(issue_with_code( + "revision_no_changes", + "revision", + "order revision propose requires a bin-count change or revision adjustment", + )); + } + + if issues.is_empty() { + return None; + } + let mut view = order_revision_base_view(config, args, "invalid", config.output.dry_run); + view.reason = Some(format!( + "order revision propose inputs for `{}` failed validation", + args.key + )); + view.issues = issues; + Some(view) } -fn order_settlement_args_preflight_view( +fn order_revision_decision_args_preflight_view( config: &RuntimeConfig, - args: &OrderSettlementArgs, -) -> Option<OrderSettlementView> { - let (reason, issue) = if args.payment_event_id.trim().is_empty() { - ( - "order settlement decision requires --payment-event-id".to_owned(), - issue_with_code( - "missing_payment_event_id", - "payment_event_id", - "payment event id is required", - ), - ) - } else if matches!(args.decision, OrderSettlementDecisionArg::Reject) - && args.reason.as_deref().and_then(non_empty_ref).is_none() - { - ( - "order settlement reject requires --reason".to_owned(), - issue_with_code( - "missing_settlement_reason", - "reason", - "settlement rejection reason is required", - ), - ) - } else if matches!(args.decision, OrderSettlementDecisionArg::Accept) - && args.reason.as_deref().and_then(non_empty_ref).is_some() + args: &OrderRevisionDecisionArgs, +) -> Option<OrderRevisionDecisionView> { + let mut issues = Vec::new(); + if args.revision_id.trim().is_empty() { + issues.push(issue_with_code( + "revision_id_required", + "revision_id", + "order revision decision requires --revision-id", + )); + } + if args.decision == OrderRevisionDecisionArg::Decline + && args + .reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()) + .is_none() { - ( - "order settlement accept does not accept --reason".to_owned(), - issue_with_code( - "unexpected_settlement_reason", - "reason", - "settlement acceptance must not carry a reason", - ), - ) - } else { + issues.push(issue_with_code( + "revision_decline_reason_required", + "reason", + "order revision decline requires a non-empty reason", + )); + } + + if issues.is_empty() { return None; - }; - let mut view = order_settlement_base_view(config, args, "invalid", config.output.dry_run); - view.reason = Some(reason); - view.issues = vec![issue]; + } + let mut view = + order_revision_decision_base_view(config, args, "invalid", config.output.dry_run); + view.reason = Some(format!( + "order revision {} inputs for `{}` failed validation", + args.decision.command(), + args.key + )); + view.issues = issues; Some(view) } -fn order_settlement_preflight_view_from_status( +fn order_revision_preflight_view_from_status( config: &RuntimeConfig, - args: &OrderSettlementArgs, + args: &OrderRevisionProposeArgs, status: &OrderStatusView, selected_pubkey: &str, -) -> Option<OrderSettlementView> { + candidates: &OrderRevisionProposalCandidates, +) -> Option<OrderRevisionProposalView> { + let pending_revision = pending_revision_proposal_candidate(status, candidates); let seller_matches = status .seller_pubkey .as_deref() .is_some_and(|seller| seller.eq_ignore_ascii_case(selected_pubkey)); - let payment = status.payment.as_ref(); - let current_payment_id = payment.and_then(|payment| payment.payment_event_id.as_deref()); - if matches!(status.state.as_str(), "accepted" | "completed" | "disputed") - && seller_matches - && payment.is_some_and(|payment| { - payment.state == "recorded" && payment.settlement_state == "pending" - }) - && current_payment_id == Some(args.payment_event_id.as_str()) - { - return None; - } - let state = match status.state.as_str() { - "missing" | "requested" | "declined" | "cancelled" | "invalid" | "unavailable" - | "unconfigured" => status.state.as_str(), - "accepted" | "completed" | "disputed" if !seller_matches => "invalid", - "accepted" | "completed" | "disputed" => match payment { - None => "not_recorded", - Some(payment) => { - if payment.payment_event_id.as_deref() != Some(args.payment_event_id.as_str()) { - "invalid" - } else if matches!(payment.settlement_state.as_str(), "accepted" | "rejected") { - "already_decided" - } else if payment.state != "recorded" { - payment.state.as_str() - } else { - payment.settlement_state.as_str() - } - } - }, + "accepted" + if seller_matches && candidates.issues.is_empty() && pending_revision.is_none() => + { + return None; + } + "accepted" if !seller_matches => "invalid", + "accepted" if !candidates.issues.is_empty() => "invalid", + "accepted" if pending_revision.is_some() => "forked", + "cancelled" => "terminal", + "missing" | "requested" | "declined" | "invalid" | "unavailable" | "unconfigured" => { + status.state.as_str() + } _ => "invalid", }; - let mut view = order_settlement_base_view(config, args, state, config.output.dry_run); - apply_order_settlement_status(&mut view, status); + let mut view = order_revision_base_view(config, args, state, config.output.dry_run); + apply_order_revision_status(&mut view, status); + if let Some(record) = pending_revision { + view.event_id = Some(record.event_id.to_string()); + view.event_kind = Some(KIND_ORDER_REVISION_PROPOSAL); + view.revision_id = Some(record.payload.revision_id.to_string()); + } view.reason = Some(match state { "missing" => format!("no active order events matched `{}`", args.key), "requested" => format!( - "order settlement decision refused because order `{}` has no accepted seller decision", + "order revision propose refused because order `{}` has no accepted seller decision", args.key ), "declined" => format!( - "order settlement decision refused because order `{}` was declined", + "order revision propose refused because order `{}` was declined", args.key ), - "cancelled" => format!( - "order settlement decision refused because order `{}` was cancelled", + "terminal" => format!( + "order revision propose refused because order `{}` is already terminal", args.key ), - "not_recorded" => format!( - "order settlement decision refused because order `{}` has no recorded payment", + "forked" => format!( + "order revision propose refused because order `{}` already has a pending revision proposal", args.key ), - "already_decided" => format!( - "order settlement decision skipped because payment `{}` already has settlement state `{}`", - args.payment_event_id, - payment - .map(|payment| payment.settlement_state.as_str()) - .unwrap_or("unknown") - ), "invalid" if !seller_matches && status.seller_pubkey.is_some() => format!( - "order settlement decision refused because selected account is not seller for order `{}`", + "order revision propose refused because selected account is not seller for order `{}`", args.key ), - "invalid" if current_payment_id.is_some() => format!( - "order settlement decision refused because payment event `{}` is not the current recorded payment", - args.payment_event_id + "invalid" if !candidates.issues.is_empty() => format!( + "order revision propose refused because revision proposal candidates for `{}` are invalid", + args.key ), "invalid" => status.reason.clone().unwrap_or_else(|| { format!( - "order settlement decision refused because active order events for `{}` are invalid", + "order revision propose refused because active order events for `{}` are invalid", args.key ) }), _ => status.reason.clone().unwrap_or_else(|| { format!( - "order settlement decision status preflight failed with state `{}`", + "order revision propose status preflight failed with state `{}`", status.state ) }), }); - if state == "invalid" && current_payment_id.is_some() && seller_matches { - view.issues = vec![issue_with_code( - "stale_payment_event", - "payment_event_id", - "settlement payment event id must match the current recorded payment", - )]; + if state == "forked" { + view.issues.push(issue_with_events( + "pending_revision_exists", + "revision_id", + "a seller revision proposal is already visible for this accepted order", + candidates + .records + .iter() + .filter(|record| Some(record.event_id.as_str()) == status.last_event_id.as_deref()) + .map(|record| record.event_id.clone()) + .collect(), + )); } + view.issues.extend(candidates.issues.clone()); view.actions = vec![format!("radroots order status get {}", args.key)]; Some(view) } -fn order_fulfillment_preflight_view_from_status( +fn order_revision_decision_preflight_view_from_status( config: &RuntimeConfig, - args: &OrderFulfillmentArgs, + args: &OrderRevisionDecisionArgs, status: &OrderStatusView, - current_fulfillment_status: Option<RadrootsOrderFulfillmentState>, - current_fulfillment_event_id: Option<&str>, -) -> Option<OrderFulfillmentView> { + selected_pubkey: &str, + candidates: &OrderRevisionProposalCandidates, +) -> Option<OrderRevisionDecisionView> { + let pending_revision = pending_revision_proposal_candidate(status, candidates); + let buyer_matches = status + .buyer_pubkey + .as_deref() + .is_some_and(|buyer| buyer.eq_ignore_ascii_case(selected_pubkey)); let state = match status.state.as_str() { - "accepted" => { - if matches!( - current_fulfillment_status, - Some( - RadrootsOrderFulfillmentState::Delivered - | RadrootsOrderFulfillmentState::SellerCancelled - ) - ) { - "invalid" - } else { - return None; - } + "accepted" + if buyer_matches && candidates.issues.is_empty() && pending_revision.is_some() => + { + return None; } - "missing" | "requested" | "declined" | "invalid" | "unavailable" | "unconfigured" => { + "accepted" if !buyer_matches => "invalid", + "accepted" if !candidates.issues.is_empty() => "invalid", + "accepted" => "missing", + "cancelled" => "terminal", + "declined" => "order_declined", + "missing" | "requested" | "invalid" | "unavailable" | "unconfigured" => { status.state.as_str() } - "cancelled" | "completed" | "disputed" => "terminal", - _ => return None, + _ => "invalid", }; - let mut view = order_fulfillment_base_view(config, args, state, config.output.dry_run); - apply_order_fulfillment_status(&mut view, status); + let mut view = order_revision_decision_base_view(config, args, state, config.output.dry_run); + apply_order_revision_decision_status(&mut view, status); + if let Some(record) = pending_revision { + apply_order_revision_decision_proposal(&mut view, record); + view.event_id = Some(record.event_id.to_string()); + view.event_kind = Some(KIND_ORDER_REVISION_PROPOSAL); + } view.reason = Some(match state { + "missing" if status.state == "accepted" => format!( + "order revision {} refused because order `{}` has no pending revision proposal", + args.decision.command(), + args.key + ), "missing" => format!("no active order events matched `{}`", args.key), "requested" => format!( - "order fulfillment update refused because order `{}` has no accepted seller decision", + "order revision {} refused because order `{}` has no accepted seller decision", + args.decision.command(), args.key ), - "declined" => format!( - "order fulfillment update refused because order `{}` was declined", + "order_declined" => format!( + "order revision {} refused because order `{}` was declined", + args.decision.command(), + args.key + ), + "terminal" => format!( + "order revision {} refused because order `{}` is already terminal", + args.decision.command(), + args.key + ), + "invalid" if !buyer_matches && status.buyer_pubkey.is_some() => format!( + "order revision {} refused because selected account is not buyer for order `{}`", + args.decision.command(), + args.key + ), + "invalid" if !candidates.issues.is_empty() => format!( + "order revision {} refused because revision proposal candidates for `{}` are invalid", + args.decision.command(), args.key ), - "invalid" - if matches!( - current_fulfillment_status, - Some( - RadrootsOrderFulfillmentState::Delivered - | RadrootsOrderFulfillmentState::SellerCancelled - ) - ) => - { - let current = current_fulfillment_status - .map(fulfillment_state_name) - .unwrap_or("unknown"); - view.issues.push(issue_with_events( - "fulfillment_unsupported_transition", - "fulfillment_state", - format!( - "order `{}` already has terminal fulfillment state `{current}`", - args.key - ), - current_fulfillment_event_id - .map(str::to_owned) - .into_iter() - .collect(), - )); - format!( - "order fulfillment update refused because order `{}` already has terminal fulfillment state `{current}`", - args.key - ) - } "invalid" => status.reason.clone().unwrap_or_else(|| { format!( - "order fulfillment update refused because active order events for `{}` are invalid", + "order revision {} refused because active order events for `{}` are invalid", + args.decision.command(), args.key ) }), - "terminal" => { - format!( - "order fulfillment update refused because order `{}` is already terminal", - args.key - ) - } _ => status.reason.clone().unwrap_or_else(|| { format!( - "order fulfillment update status preflight failed with state `{}`", + "order revision {} status preflight failed with state `{}`", + args.decision.command(), status.state ) }), }); + view.issues.extend(candidates.issues.clone()); view.actions = vec![format!("radroots order status get {}", args.key)]; Some(view) } -fn order_decision_view_from_resolution( - config: &RuntimeConfig, - args: &OrderDecisionArgs, - seller_pubkey: String, - resolution: SellerOrderRequestResolution, -) -> OrderDecisionView { - let SellerOrderRequestResolution { - target_relays, - connected_relays, - failed_relays, - fetched_count, - decoded_count, - skipped_count, - requests, - candidate_issues, - } = resolution; - let mut view = order_decision_base_view(config, args, "missing", config.output.dry_run); - view.seller_pubkey = Some(seller_pubkey); - view.target_relays = target_relays; - view.connected_relays = connected_relays; - view.failed_relays = relay_failures(failed_relays); - view.fetched_count = fetched_count; - view.decoded_count = decoded_count; - view.skipped_count = skipped_count; - view.issues = candidate_issues; - - if !view.issues.is_empty() { - view.state = "invalid".to_owned(); - view.reason = Some(format!( - "seller order request preflight found invalid request candidates for `{}`", - args.key - )); - view.actions = vec![format!("radroots order status get {}", args.key)]; - return view; - } - match requests.as_slice() { - [] => { - view.reason = Some(format!( - "no seller-targeted order request event matched `{}`", - args.key - )); - view - } - _ => { - let event_ids = requests - .iter() - .map(|request| request.request_event_id.to_string()) - .collect::<Vec<_>>(); - view.state = "invalid".to_owned(); - view.reason = Some(format!( - "multiple seller-targeted order request events matched `{}`; refusing to choose an order root", - args.key - )); - view.issues = vec![issue_with_events( - "multiple_request_candidates", - "request_event_id", - format!( - "matched {} request events for the same order id: {}", - requests.len(), - event_ids.join(", ") - ), - event_ids, - )]; - view.actions = vec![format!("radroots order status get {}", args.key)]; - view - } - } -} - -fn apply_order_decision_resolution( - view: &mut OrderDecisionView, - resolution: &SellerOrderRequestResolution, -) { - view.target_relays = resolution.target_relays.clone(); - view.connected_relays = resolution.connected_relays.clone(); - view.failed_relays = relay_failures(resolution.failed_relays.clone()); - view.fetched_count = resolution.fetched_count; - view.decoded_count = resolution.decoded_count; - view.skipped_count = resolution.skipped_count; -} - -fn apply_order_decision_request( - view: &mut OrderDecisionView, - request: &ResolvedSellerOrderRequest, -) { - view.order_id = request.order_id.to_string(); - view.listing_addr = Some(request.listing_addr.to_string()); - view.buyer_pubkey = Some(request.buyer_pubkey.to_string()); - view.seller_pubkey = Some(request.seller_pubkey.to_string()); - view.request_event_id = Some(request.request_event_id.to_string()); - view.listing_event_id = request.listing_event_id.clone(); - view.root_event_id = Some(request.request_event_id.to_string()); - view.prev_event_id = Some(request.request_event_id.to_string()); -} - -fn apply_order_decision_status(view: &mut OrderDecisionView, status: &OrderStatusView) { - view.target_relays = status.target_relays.clone(); - view.connected_relays = status.connected_relays.clone(); - view.failed_relays = status.failed_relays.clone(); - view.fetched_count = status.fetched_count; - view.decoded_count = status.decoded_count; - view.skipped_count = status.skipped_count; - view.issues = status.reducer_issues.clone(); - view.inventory = status.inventory.clone(); -} - -fn apply_order_revision_status(view: &mut OrderRevisionProposalView, status: &OrderStatusView) { - view.order_id = status.order_id.clone(); - view.listing_addr = status.listing_addr.clone(); - view.buyer_pubkey = status.buyer_pubkey.clone(); - view.seller_pubkey = status.seller_pubkey.clone(); - view.request_event_id = status.request_event_id.clone(); - view.decision_event_id = status.decision_event_id.clone(); - view.root_event_id = status.request_event_id.clone(); - view.prev_event_id = status.last_event_id.clone(); - view.economics = status.economics.clone(); - view.inventory = status.inventory.clone(); - view.target_relays = status.target_relays.clone(); - view.connected_relays = status.connected_relays.clone(); - view.failed_relays = status.failed_relays.clone(); - view.fetched_count = status.fetched_count; - view.decoded_count = status.decoded_count; - view.skipped_count = status.skipped_count; - view.issues = status.reducer_issues.clone(); -} - -fn apply_order_revision_decision_status( - view: &mut OrderRevisionDecisionView, +fn pending_revision_proposal_candidate<'a>( status: &OrderStatusView, -) { - view.order_id = status.order_id.clone(); - view.listing_addr = status.listing_addr.clone(); - view.buyer_pubkey = status.buyer_pubkey.clone(); - view.seller_pubkey = status.seller_pubkey.clone(); - view.request_event_id = status.request_event_id.clone(); - view.decision_event_id = status.decision_event_id.clone(); - view.agreement_event_id = status.agreement_event_id.clone(); - view.root_event_id = status.request_event_id.clone(); - view.prev_event_id = status.last_event_id.clone(); - view.economics = status.economics.clone(); - view.inventory = status.inventory.clone(); - view.target_relays = status.target_relays.clone(); - view.connected_relays = status.connected_relays.clone(); - view.failed_relays = status.failed_relays.clone(); - view.fetched_count = status.fetched_count; - view.decoded_count = status.decoded_count; - view.skipped_count = status.skipped_count; - view.issues = status.reducer_issues.clone(); + candidates: &'a OrderRevisionProposalCandidates, +) -> Option<&'a OrderRevisionProposalRecord> { + let last_event_id = status.last_event_id.as_deref()?; + candidates + .records + .iter() + .find(|record| record.event_id == last_event_id) } -fn order_decision_preflight_view_from_status( +fn order_accept_inventory_preflight_view( config: &RuntimeConfig, args: &OrderDecisionArgs, request: &ResolvedSellerOrderRequest, resolution: &SellerOrderRequestResolution, status: &OrderStatusView, -) -> Option<OrderDecisionView> { - let state = match status.state.as_str() { - "accepted" | "declined" => "already_decided", - "cancelled" | "completed" | "disputed" => "terminal", - "invalid" => "invalid", - "unavailable" => "unavailable", - "unconfigured" => "unconfigured", - _ => return None, - }; - let mut view = order_decision_base_view(config, args, state, config.output.dry_run); - apply_order_decision_resolution(&mut view, resolution); - apply_order_decision_request(&mut view, request); - apply_order_decision_status(&mut view, status); - if let Some(decision_event_id) = &status.decision_event_id { - view.event_id = Some(decision_event_id.clone()); - view.event_kind = Some(KIND_ORDER_DECISION); +) -> Result<OrderDecisionInventoryPreflight, RuntimeError> { + if args.decision != OrderDecisionArg::Accept { + return Ok(OrderDecisionInventoryPreflight { + invalid_view: None, + inventory: Some(order_declined_inventory_view(request)), + }); } - view.reason = Some(match status.state.as_str() { - "accepted" | "declined" => format!( - "order {} refused because order `{}` already has a visible `{}` seller decision", - args.decision.command(), - request.order_id, - status.state - ), - "cancelled" | "completed" | "disputed" => format!( - "order {} refused because order `{}` is already terminal", - args.decision.command(), - request.order_id - ), - "invalid" => status.reason.clone().unwrap_or_else(|| { - format!( - "order {} refused because active order events for `{}` are invalid", - args.decision.command(), - request.order_id - ) - }), - _ => status.reason.clone().unwrap_or_else(|| { - format!( - "order {} status preflight failed with state `{}`", - args.decision.command(), - status.state - ) - }), - }); - view.actions = vec![format!("radroots order status get {}", request.order_id)]; - Some(view) -} -fn order_revision_args_preflight_view( - config: &RuntimeConfig, - args: &OrderRevisionProposeArgs, -) -> Option<OrderRevisionProposalView> { - let mut issues = Vec::new(); - let has_bin_id = args.bin_id.as_deref().and_then(non_empty_ref).is_some(); - let has_bin_count = args.bin_count.is_some(); - if has_bin_id != has_bin_count { - issues.push(issue_with_code( - "revision_item_change_incomplete", - "bin_id", - "`bin_id` and `bin_count` must be supplied together", - )); - } - if args.bin_count == Some(0) { - issues.push(issue_with_code( - "revision_bin_count_invalid", - "bin_count", - "bin_count must be greater than zero", - )); + let listing = match fetch_current_inventory_listing(config, args, request, resolution, status)? + { + Ok(listing) => listing, + Err(view) => { + return Ok(OrderDecisionInventoryPreflight { + invalid_view: Some(view), + inventory: None, + }); + } + }; + if Some(listing.event_id.to_string()) != request.listing_event_id { + return Ok(OrderDecisionInventoryPreflight { + invalid_view: Some(order_decision_inventory_invalid_view( + config, + args, + request, + resolution, + status, + "order accept refused because the request listing event is not current", + vec![issue_with_events( + "stale_request_listing_event", + "listing_event_id", + format!( + "request listing_event_id does not match current listing event `{}`", + listing.event_id + ), + request.listing_event_id.clone().into_iter().collect(), + )], + )), + inventory: None, + }); } - - let adjustment_inputs = [ - args.adjustment_id.as_deref(), - args.adjustment_effect.as_deref(), - args.adjustment_amount.as_deref(), - args.adjustment_currency.as_deref(), - args.adjustment_reason.as_deref(), - ]; - let adjustment_supplied = adjustment_inputs - .iter() - .any(|value| value.and_then(non_empty_ref).is_some()); - let adjustment_complete = adjustment_inputs - .iter() - .all(|value| value.and_then(non_empty_ref).is_some()); - if adjustment_supplied && !adjustment_complete { - issues.push(issue_with_code( - "revision_adjustment_incomplete", - "adjustment", - "all revision adjustment fields must be supplied together", - )); + if !listing_is_active(&listing.listing) { + return Ok(OrderDecisionInventoryPreflight { + invalid_view: Some(order_decision_inventory_invalid_view( + config, + args, + request, + resolution, + status, + "order accept refused because the listing is not active", + vec![issue_with_code( + "listing_not_active", + "listing_addr", + "current listing event is not active", + )], + )), + inventory: None, + }); } - if !has_bin_id && !adjustment_supplied { - issues.push(issue_with_code( - "revision_no_changes", - "revision", - "order revision propose requires a bin-count change or revision adjustment", - )); + let accounting_requests = fetch_listing_accounting_requests(config, request, &listing)?; + let mut requests = accounting_requests + .into_iter() + .filter(|record| { + record.listing_event_id.as_deref() == Some(listing.event_id.to_string().as_str()) + }) + .map(|record| record.record) + .collect::<Vec<_>>(); + requests.push(active_request_record_from_resolved(request)); + let mut request_order_ids = requests + .iter() + .map(|record| record.payload.order_id.clone()) + .collect::<Vec<_>>(); + request_order_ids.sort(); + request_order_ids.dedup(); + + let mut decisions = fetch_listing_accounting_decisions(config, request)? + .into_iter() + .filter(|record| request_order_ids.contains(&record.payload.order_id)) + .collect::<Vec<_>>(); + decisions.push(proposed_accept_decision_record(request)?); + let revision_proposals = fetch_listing_accounting_revision_proposals_for_status( + config, + request.listing_addr.as_str(), + )? + .into_iter() + .filter(|record| request_order_ids.contains(&record.payload.order_id)) + .collect::<Vec<_>>(); + let revision_decisions = fetch_listing_accounting_revision_decisions_for_status( + config, + request.listing_addr.as_str(), + )? + .into_iter() + .filter(|record| request_order_ids.contains(&record.payload.order_id)) + .collect::<Vec<_>>(); + let cancellations = fetch_listing_accounting_cancellations(config, request)? + .into_iter() + .filter(|record| request_order_ids.contains(&record.payload.order_id)) + .collect::<Vec<_>>(); + + let projection = reduce_listing_inventory_accounting( + &request.listing_addr, + &listing.event_id, + RadrootsListingInventoryAccountingInputs { + bins: listing.bins, + requests, + decisions, + revision_proposals, + revision_decisions, + cancellations, + }, + ); + Ok(order_accept_inventory_preflight_view_from_projection( + config, args, request, resolution, status, projection, + )) +} + +fn order_accept_inventory_preflight_view_from_projection( + config: &RuntimeConfig, + args: &OrderDecisionArgs, + request: &ResolvedSellerOrderRequest, + resolution: &SellerOrderRequestResolution, + status: &OrderStatusView, + projection: RadrootsListingInventoryAccountingProjection, +) -> OrderDecisionInventoryPreflight { + if projection.issues.is_empty() { + return OrderDecisionInventoryPreflight { + invalid_view: None, + inventory: Some(order_inventory_view_from_listing_projection( + &projection, + "reserved", + true, + )), + }; } - if issues.is_empty() { - return None; + let inventory = order_inventory_view_from_listing_projection(&projection, "invalid", false); + let issues = projection + .issues + .into_iter() + .map(listing_inventory_accounting_issue_view) + .collect::<Vec<_>>(); + let mut view = order_decision_inventory_invalid_view( + config, + args, + request, + resolution, + status, + "order accept refused because visible inventory accounting is invalid", + issues, + ); + view.inventory = Some(inventory); + OrderDecisionInventoryPreflight { + invalid_view: Some(view), + inventory: None, } - let mut view = order_revision_base_view(config, args, "invalid", config.output.dry_run); - view.reason = Some(format!( - "order revision propose inputs for `{}` failed validation", - args.key - )); - view.issues = issues; - Some(view) } -fn order_revision_decision_args_preflight_view( - config: &RuntimeConfig, - args: &OrderRevisionDecisionArgs, -) -> Option<OrderRevisionDecisionView> { - let mut issues = Vec::new(); - if args.revision_id.trim().is_empty() { - issues.push(issue_with_code( - "revision_id_required", - "revision_id", - "order revision decision requires --revision-id", - )); +fn order_inventory_view_from_listing_projection( + projection: &RadrootsListingInventoryAccountingProjection, + state: &str, + commitment_valid: bool, +) -> OrderInventoryView { + OrderInventoryView { + state: state.to_owned(), + listing_event_id: Some(projection.listing_event_id.to_string()), + commitment_valid, + bins: projection + .bins + .iter() + .map(|bin| OrderInventoryBinView { + bin_id: bin.bin_id.to_string(), + committed_count: bin.accepted_reserved_count, + available_count: Some(bin.available_count), + remaining_count: Some(bin.remaining_count), + over_reserved: bin.over_reserved, + }) + .collect(), + issues: projection + .issues + .iter() + .cloned() + .map(listing_inventory_accounting_issue_view) + .collect(), } - if args.decision == OrderRevisionDecisionArg::Decline - && args - .reason - .as_deref() - .map(str::trim) - .filter(|reason| !reason.is_empty()) - .is_none() - { - issues.push(issue_with_code( - "revision_decline_reason_required", - "reason", - "order revision decline requires a non-empty reason", - )); +} + +fn order_declined_inventory_view(request: &ResolvedSellerOrderRequest) -> OrderInventoryView { + OrderInventoryView { + state: "not_reserved".to_owned(), + listing_event_id: request.listing_event_id.clone(), + commitment_valid: true, + bins: Vec::new(), + issues: Vec::new(), } +} - if issues.is_empty() { - return None; +fn order_decision_inventory_for_view( + args: &OrderDecisionArgs, + request: &ResolvedSellerOrderRequest, + inventory: Option<OrderInventoryView>, +) -> Option<OrderInventoryView> { + match args.decision { + OrderDecisionArg::Accept => inventory, + OrderDecisionArg::Decline => Some(order_declined_inventory_view(request)), } - let mut view = - order_revision_decision_base_view(config, args, "invalid", config.output.dry_run); - view.reason = Some(format!( - "order revision {} inputs for `{}` failed validation", - args.decision.command(), - args.key - )); - view.issues = issues; - Some(view) } -fn order_revision_preflight_view_from_status( +fn fetch_current_inventory_listing( config: &RuntimeConfig, - args: &OrderRevisionProposeArgs, + args: &OrderDecisionArgs, + request: &ResolvedSellerOrderRequest, + resolution: &SellerOrderRequestResolution, status: &OrderStatusView, - selected_pubkey: &str, - candidates: &OrderRevisionProposalCandidates, -) -> Option<OrderRevisionProposalView> { - let pending_revision = pending_revision_proposal_candidate(status, candidates); - let seller_matches = status - .seller_pubkey - .as_deref() - .is_some_and(|seller| seller.eq_ignore_ascii_case(selected_pubkey)); - let payment_state = unrejected_payment_state(status); - let state = match status.state.as_str() { - "accepted" - if seller_matches - && payment_state.is_none() - && status - .fulfillment - .as_ref() - .and_then(|fulfillment| fulfillment.event_id.as_ref()) - .is_none() - && candidates.issues.is_empty() - && pending_revision.is_none() => - { - return None; - } - "accepted" if !seller_matches => "invalid", - "accepted" if payment_state.is_some() => "invalid", - "accepted" - if status - .fulfillment - .as_ref() - .and_then(|fulfillment| fulfillment.event_id.as_ref()) - .is_some() => - { - "fulfilled" - } - "accepted" if !candidates.issues.is_empty() => "invalid", - "accepted" if pending_revision.is_some() => "forked", - "cancelled" | "completed" | "disputed" => "terminal", - "missing" | "requested" | "declined" | "invalid" | "unavailable" | "unconfigured" => { - status.state.as_str() - } - _ => "invalid", - }; - let mut view = order_revision_base_view(config, args, state, config.output.dry_run); - apply_order_revision_status(&mut view, status); - if let Some(record) = pending_revision { - view.event_id = Some(record.event_id.to_string()); - view.event_kind = Some(KIND_ORDER_REVISION_PROPOSAL); - view.revision_id = Some(record.payload.revision_id.to_string()); - } - view.reason = Some(match state { - "missing" => format!("no active order events matched `{}`", args.key), - "requested" => format!( - "order revision propose refused because order `{}` has no accepted seller decision", - args.key - ), - "declined" => format!( - "order revision propose refused because order `{}` was declined", - args.key - ), - "terminal" => format!( - "order revision propose refused because order `{}` is already terminal", - args.key - ), - "fulfilled" => format!( - "order revision propose refused because order `{}` already has seller fulfillment", - args.key - ), - "forked" => format!( - "order revision propose refused because order `{}` already has a pending revision proposal", - args.key - ), - "invalid" if seller_matches && payment_state.is_some() => { - if let Some(payment_state) = payment_state { - format!( - "order revision propose refused because order `{}` already has unrejected payment state `{payment_state}`", - args.key - ) - } else { - status.reason.clone().unwrap_or_else(|| { - format!( - "order revision propose refused because active order events for `{}` are invalid", - args.key - ) - }) - } +) -> Result<Result<ResolvedInventoryListing, OrderDecisionView>, RuntimeError> { + let parsed = parse_listing_addr(request.listing_addr.as_str()).map_err(|error| { + RuntimeError::Config(format!("order request listing_addr is invalid: {error}")) + })?; + let filter = listing_event_filter(&parsed)?; + let receipt = match fetch_events_from_relays(&config.relay.urls, filter) { + Ok(receipt) => receipt, + Err(DirectRelayFetchError::Connect { + reason, + target_relays, + failed_relays, + }) => { + let mut view = + order_decision_base_view(config, args, "unavailable", config.output.dry_run); + apply_order_decision_resolution(&mut view, resolution); + apply_order_decision_request(&mut view, request); + apply_order_decision_status(&mut view, status); + view.target_relays = target_relays; + view.failed_relays = relay_failures(failed_relays); + view.reason = Some(format!("direct relay connection failed: {reason}")); + return Ok(Err(view)); } - "invalid" if !seller_matches && status.seller_pubkey.is_some() => format!( - "order revision propose refused because selected account is not seller for order `{}`", - args.key - ), - "invalid" if !candidates.issues.is_empty() => format!( - "order revision propose refused because revision proposal candidates for `{}` are invalid", - args.key - ), - "invalid" => status.reason.clone().unwrap_or_else(|| { - format!( - "order revision propose refused because active order events for `{}` are invalid", - args.key - ) - }), - _ => status.reason.clone().unwrap_or_else(|| { - format!( - "order revision propose status preflight failed with state `{}`", - status.state - ) - }), - }); - if state == "forked" { - view.issues.push(issue_with_events( - "pending_revision_exists", - "revision_id", - "a seller revision proposal is already visible for this accepted order", - candidates - .records - .iter() - .filter(|record| Some(record.event_id.as_str()) == status.last_event_id.as_deref()) - .map(|record| record.event_id.clone()) - .collect(), - )); + Err(error) => return Err(RuntimeError::Network(error.to_string())), + }; + + let listing = current_inventory_listing_from_receipt(request, receipt)?; + Ok(match listing { + Some(listing) => Ok(listing), + None => Err(order_decision_inventory_invalid_view( + config, + args, + request, + resolution, + status, + "order accept refused because the current listing event was not visible", + vec![issue_with_code( + "current_listing_missing", + "listing_event_id", + "current listing event was not visible on the configured relays", + )], + )), + }) +} + +fn current_inventory_listing_from_receipt( + request: &ResolvedSellerOrderRequest, + receipt: DirectRelayFetchReceipt, +) -> Result<Option<ResolvedInventoryListing>, RuntimeError> { + let parsed = parse_listing_addr(request.listing_addr.as_str()).map_err(|error| { + RuntimeError::Config(format!("order request listing_addr is invalid: {error}")) + })?; + current_inventory_listing_from_parts(parsed, receipt) +} + +fn current_inventory_listing_from_parts( + parsed: ParsedListingAddress, + receipt: DirectRelayFetchReceipt, +) -> Result<Option<ResolvedInventoryListing>, RuntimeError> { + let mut candidates = Vec::new(); + for event in receipt.events { + if event_kind_u32(&event) != KIND_LISTING { + continue; + } + let event = radroots_event_from_nostr(&event); + if event.author != parsed.seller_pubkey { + continue; + } + let listing = listing_from_event(event.kind, &event.tags, &event.content) + .map_err(|error| RuntimeError::Config(format!("decode listing event: {error}")))?; + if listing.d_tag != parsed.listing_id { + continue; + } + let bins = listing_inventory_bins(&listing)?; + let event_id = protocol_event_id(event.id.as_str(), "listing_event_id")?; + candidates.push((event.created_at, event_id, listing, bins)); } - if state == "invalid" && seller_matches && payment_state.is_some() { - view.issues.push(issue_with_code( - "payment_blocks_revision", - "payment.state", - "orders with unrejected recorded payment cannot be economically revised", + candidates.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1))); + Ok(candidates + .into_iter() + .next() + .map(|(_, event_id, listing, bins)| ResolvedInventoryListing { + event_id, + listing, + bins, + })) +} + +fn listing_inventory_bins( + listing: &RadrootsListing, +) -> Result<Vec<RadrootsListingInventoryBinAvailability>, RuntimeError> { + if !listing + .bins + .iter() + .any(|bin| bin.bin_id == listing.primary_bin_id) + { + return Err(RuntimeError::Config( + "current listing primary bin is missing from listing bins".to_owned(), )); } - view.issues.extend(candidates.issues.clone()); - view.actions = vec![format!("radroots order status get {}", args.key)]; - Some(view) + let available_count = listing + .inventory_available + .as_ref() + .ok_or_else(|| { + RuntimeError::Config("current listing inventory availability is missing".to_owned()) + })? + .to_u64_exact() + .ok_or_else(|| { + RuntimeError::Config( + "current listing inventory availability must be a whole number".to_owned(), + ) + })?; + Ok(vec![RadrootsListingInventoryBinAvailability { + bin_id: listing.primary_bin_id.clone(), + available_count, + }]) } -fn order_revision_decision_preflight_view_from_status( +fn listing_is_active(listing: &RadrootsListing) -> bool { + match listing.availability.as_ref() { + Some(RadrootsListingAvailability::Status { status }) => { + matches!(status, RadrootsListingStatus::Active) + } + Some(RadrootsListingAvailability::Window { .. }) | None => true, + } +} + +fn fetch_listing_accounting_requests( config: &RuntimeConfig, - args: &OrderRevisionDecisionArgs, - status: &OrderStatusView, - selected_pubkey: &str, - candidates: &OrderRevisionProposalCandidates, -) -> Option<OrderRevisionDecisionView> { - let pending_revision = pending_revision_proposal_candidate(status, candidates); - let buyer_matches = status - .buyer_pubkey - .as_deref() - .is_some_and(|buyer| buyer.eq_ignore_ascii_case(selected_pubkey)); - let state = match status.state.as_str() { - "accepted" - if buyer_matches - && status - .fulfillment - .as_ref() - .and_then(|fulfillment| fulfillment.event_id.as_ref()) - .is_none() - && candidates.issues.is_empty() - && pending_revision.is_some() => + request: &ResolvedSellerOrderRequest, + listing: &ResolvedInventoryListing, +) -> Result<Vec<ResolvedAccountingRequest>, RuntimeError> { + let filter = order_listing_request_filter( + request.seller_pubkey.as_str(), + request.listing_addr.as_str(), + )?; + let receipt = fetch_events_from_relays(&config.relay.urls, filter) + .map_err(|error| RuntimeError::Network(error.to_string()))?; + let mut records = Vec::new(); + for event in receipt.events { + if event_kind_u32(&event) != KIND_ORDER_REQUEST + || !event_matches_tag_value(&event, "a", request.listing_addr.as_str()) { - return None; + continue; } - "accepted" if !buyer_matches => "invalid", - "accepted" - if status - .fulfillment - .as_ref() - .and_then(|fulfillment| fulfillment.event_id.as_ref()) - .is_some() => + if let Ok(record) = listing_accounting_request_from_event(&event) + && record.listing_event_id.as_deref() == Some(listing.event_id.as_str()) { - "fulfilled" + records.push(record); } - "accepted" if !candidates.issues.is_empty() => "invalid", - "accepted" => "missing", - "cancelled" | "completed" | "disputed" => "terminal", - "declined" => "order_declined", - "missing" | "requested" | "invalid" | "unavailable" | "unconfigured" => { - status.state.as_str() + } + Ok(records) +} + +fn fetch_listing_accounting_decisions( + config: &RuntimeConfig, + request: &ResolvedSellerOrderRequest, +) -> Result<Vec<RadrootsOrderDecisionRecord>, RuntimeError> { + let filter = order_listing_decision_filter(request.listing_addr.as_str())?; + let receipt = fetch_events_from_relays(&config.relay.urls, filter) + .map_err(|error| RuntimeError::Network(error.to_string()))?; + let mut records = Vec::new(); + for event in receipt.events { + if event_kind_u32(&event) != KIND_ORDER_DECISION + || !event_matches_tag_value(&event, "a", request.listing_addr.as_str()) + { + continue; + } + if let Ok(OrderStatusRecord::Decision(record)) = order_status_record_from_event(&event) { + records.push(record); } - _ => "invalid", - }; - let mut view = order_revision_decision_base_view(config, args, state, config.output.dry_run); - apply_order_revision_decision_status(&mut view, status); - if let Some(record) = pending_revision { - apply_order_revision_decision_proposal(&mut view, record); - view.event_id = Some(record.event_id.to_string()); - view.event_kind = Some(KIND_ORDER_REVISION_PROPOSAL); } - view.reason = Some(match state { - "missing" if status.state == "accepted" => format!( - "order revision {} refused because order `{}` has no pending revision proposal", - args.decision.command(), - args.key - ), - "missing" => format!("no active order events matched `{}`", args.key), - "requested" => format!( - "order revision {} refused because order `{}` has no accepted seller decision", - args.decision.command(), - args.key - ), - "order_declined" => format!( - "order revision {} refused because order `{}` was declined", - args.decision.command(), - args.key - ), - "terminal" => format!( - "order revision {} refused because order `{}` is already terminal", - args.decision.command(), - args.key - ), - "fulfilled" => format!( - "order revision {} refused because order `{}` already has seller fulfillment", - args.decision.command(), - args.key - ), - "invalid" if !buyer_matches && status.buyer_pubkey.is_some() => format!( - "order revision {} refused because selected account is not buyer for order `{}`", - args.decision.command(), - args.key - ), - "invalid" if !candidates.issues.is_empty() => format!( - "order revision {} refused because revision proposal candidates for `{}` are invalid", - args.decision.command(), - args.key - ), - "invalid" => status.reason.clone().unwrap_or_else(|| { - format!( - "order revision {} refused because active order events for `{}` are invalid", - args.decision.command(), - args.key - ) - }), - _ => status.reason.clone().unwrap_or_else(|| { - format!( - "order revision {} status preflight failed with state `{}`", - args.decision.command(), - status.state - ) - }), - }); - view.issues.extend(candidates.issues.clone()); - view.actions = vec![format!("radroots order status get {}", args.key)]; - Some(view) -} - -fn pending_revision_proposal_candidate<'a>( - status: &OrderStatusView, - candidates: &'a OrderRevisionProposalCandidates, -) -> Option<&'a OrderRevisionProposalRecord> { - let last_event_id = status.last_event_id.as_deref()?; - candidates - .records - .iter() - .find(|record| record.event_id == last_event_id) + Ok(records) } -fn order_accept_inventory_preflight_view( +fn fetch_listing_accounting_cancellations( config: &RuntimeConfig, - args: &OrderDecisionArgs, request: &ResolvedSellerOrderRequest, - resolution: &SellerOrderRequestResolution, - status: &OrderStatusView, -) -> Result<OrderDecisionInventoryPreflight, RuntimeError> { - if args.decision != OrderDecisionArg::Accept { - return Ok(OrderDecisionInventoryPreflight { - invalid_view: None, - inventory: Some(order_declined_inventory_view(request)), - }); - } - - let listing = match fetch_current_inventory_listing(config, args, request, resolution, status)? - { - Ok(listing) => listing, - Err(view) => { - return Ok(OrderDecisionInventoryPreflight { - invalid_view: Some(view), - inventory: None, - }); +) -> Result<Vec<RadrootsOrderCancellationRecord>, RuntimeError> { + let filter = order_listing_cancellation_filter(request.listing_addr.as_str())?; + let receipt = fetch_events_from_relays(&config.relay.urls, filter) + .map_err(|error| RuntimeError::Network(error.to_string()))?; + let mut records = Vec::new(); + for event in receipt.events { + if event_kind_u32(&event) != KIND_ORDER_CANCELLATION + || !event_matches_tag_value(&event, "a", request.listing_addr.as_str()) + { + continue; + } + if let Ok(OrderStatusRecord::Cancellation(record)) = order_status_record_from_event(&event) + { + records.push(record); } - }; - if Some(listing.event_id.to_string()) != request.listing_event_id { - return Ok(OrderDecisionInventoryPreflight { - invalid_view: Some(order_decision_inventory_invalid_view( - config, - args, - request, - resolution, - status, - "order accept refused because the request listing event is not current", - vec![issue_with_events( - "stale_request_listing_event", - "listing_event_id", - format!( - "request listing_event_id does not match current listing event `{}`", - listing.event_id - ), - request.listing_event_id.clone().into_iter().collect(), - )], - )), - inventory: None, - }); - } - if !listing_is_active(&listing.listing) { - return Ok(OrderDecisionInventoryPreflight { - invalid_view: Some(order_decision_inventory_invalid_view( - config, - args, - request, - resolution, - status, - "order accept refused because the listing is not active", - vec![issue_with_code( - "listing_not_active", - "listing_addr", - "current listing event is not active", - )], - )), - inventory: None, - }); } + Ok(records) +} - let accounting_requests = fetch_listing_accounting_requests(config, request, &listing)?; - let mut requests = accounting_requests - .into_iter() - .filter(|record| { - record.listing_event_id.as_deref() == Some(listing.event_id.to_string().as_str()) - }) - .map(|record| record.record) - .collect::<Vec<_>>(); - requests.push(active_request_record_from_resolved(request)); - let mut request_order_ids = requests - .iter() - .map(|record| record.payload.order_id.clone()) - .collect::<Vec<_>>(); - request_order_ids.sort(); - request_order_ids.dedup(); - - let mut decisions = fetch_listing_accounting_decisions(config, request)? - .into_iter() - .filter(|record| request_order_ids.contains(&record.payload.order_id)) - .collect::<Vec<_>>(); - decisions.push(proposed_accept_decision_record(request)?); - let revision_proposals = fetch_listing_accounting_revision_proposals_for_status( - config, - request.listing_addr.as_str(), - )? - .into_iter() - .filter(|record| request_order_ids.contains(&record.payload.order_id)) - .collect::<Vec<_>>(); - let revision_decisions = fetch_listing_accounting_revision_decisions_for_status( - config, - request.listing_addr.as_str(), - )? - .into_iter() - .filter(|record| request_order_ids.contains(&record.payload.order_id)) - .collect::<Vec<_>>(); - let fulfillments = fetch_listing_accounting_fulfillments(config, request)? - .into_iter() - .filter(|record| request_order_ids.contains(&record.payload.order_id)) - .collect::<Vec<_>>(); - let cancellations = fetch_listing_accounting_cancellations(config, request)? - .into_iter() - .filter(|record| request_order_ids.contains(&record.payload.order_id)) - .collect::<Vec<_>>(); +fn listing_accounting_request_from_event( + event: &RadrootsNostrEvent, +) -> Result<ResolvedAccountingRequest, RuntimeError> { + let event = radroots_event_from_nostr(event); + let event_id = protocol_event_id(event.id.as_str(), "event_id")?; + let author_pubkey = protocol_pubkey(event.author.as_str(), "author_pubkey")?; + let envelope = order_request_from_event(&event) + .map_err(|error| RuntimeError::Config(format!("decode order request event: {error}")))?; + let context = + order_event_context_from_tags(RadrootsOrderEventType::OrderRequested, &event.tags) + .map_err(|error| RuntimeError::Config(format!("decode order request tags: {error}")))?; + Ok(ResolvedAccountingRequest { + listing_event_id: context.listing_event.as_ref().map(|event| event.id.clone()), + record: RadrootsOrderRequestRecord { + event_id, + author_pubkey, + payload: envelope.payload, + }, + }) +} - let projection = reduce_listing_inventory_accounting( - &request.listing_addr, - &listing.event_id, - RadrootsListingInventoryAccountingInputs { - bins: listing.bins, - requests, - decisions, - revision_proposals, - revision_decisions, - fulfillments, - cancellations, - receipts: Vec::<RadrootsOrderReceiptRecord>::new(), +fn active_request_record_from_resolved( + request: &ResolvedSellerOrderRequest, +) -> RadrootsOrderRequestRecord { + RadrootsOrderRequestRecord { + event_id: request.request_event_id.clone(), + author_pubkey: request.buyer_pubkey.clone(), + payload: RadrootsOrderRequest { + order_id: request.order_id.clone(), + listing_addr: request.listing_addr.clone(), + buyer_pubkey: request.buyer_pubkey.clone(), + seller_pubkey: request.seller_pubkey.clone(), + items: request.items.clone(), + economics: request.economics.clone(), }, - ); - Ok(order_accept_inventory_preflight_view_from_projection( - config, args, request, resolution, status, projection, - )) + } } -fn order_accept_inventory_preflight_view_from_projection( +fn proposed_accept_decision_record( + request: &ResolvedSellerOrderRequest, +) -> Result<RadrootsOrderDecisionRecord, RuntimeError> { + let payload = accepted_order_decision_payload_from_request(request); + let signer_pubkey = request.seller_pubkey.to_string(); + let payload = canonicalize_order_decision_for_signer(payload, signer_pubkey.as_str()) + .map_err(|error| RuntimeError::Config(format!("canonicalize order decision: {error}")))?; + Ok(RadrootsOrderDecisionRecord { + event_id: request.request_event_id.clone(), + author_pubkey: request.seller_pubkey.clone(), + counterparty_pubkey: request.buyer_pubkey.clone(), + root_event_id: request.request_event_id.clone(), + prev_event_id: request.request_event_id.clone(), + payload, + }) +} + +fn order_decision_inventory_invalid_view( config: &RuntimeConfig, args: &OrderDecisionArgs, request: &ResolvedSellerOrderRequest, resolution: &SellerOrderRequestResolution, status: &OrderStatusView, - projection: RadrootsListingInventoryAccountingProjection, -) -> OrderDecisionInventoryPreflight { - if projection.issues.is_empty() { - return OrderDecisionInventoryPreflight { - invalid_view: None, - inventory: Some(order_inventory_view_from_listing_projection( - &projection, - "reserved", - true, - )), - }; - } - - let inventory = order_inventory_view_from_listing_projection(&projection, "invalid", false); - let issues = projection - .issues - .into_iter() - .map(listing_inventory_accounting_issue_view) - .collect::<Vec<_>>(); - let mut view = order_decision_inventory_invalid_view( - config, - args, - request, - resolution, - status, - "order accept refused because visible inventory accounting is invalid", - issues, - ); - view.inventory = Some(inventory); - OrderDecisionInventoryPreflight { - invalid_view: Some(view), - inventory: None, - } -} - -fn order_inventory_view_from_listing_projection( - projection: &RadrootsListingInventoryAccountingProjection, - state: &str, - commitment_valid: bool, -) -> OrderInventoryView { - OrderInventoryView { - state: state.to_owned(), - listing_event_id: Some(projection.listing_event_id.to_string()), - commitment_valid, - bins: projection - .bins - .iter() - .map(|bin| OrderInventoryBinView { - bin_id: bin.bin_id.to_string(), - committed_count: bin.accepted_reserved_count, - available_count: Some(bin.available_count), - remaining_count: Some(bin.remaining_count), - over_reserved: bin.over_reserved, - }) - .collect(), - issues: projection - .issues - .iter() - .cloned() - .map(listing_inventory_accounting_issue_view) - .collect(), - } + reason: impl Into<String>, + issues: Vec<OrderIssueView>, +) -> OrderDecisionView { + let mut view = order_decision_base_view(config, args, "invalid", config.output.dry_run); + apply_order_decision_resolution(&mut view, resolution); + apply_order_decision_request(&mut view, request); + apply_order_decision_status(&mut view, status); + view.reason = Some(reason.into()); + view.issues.extend(issues); + view.actions = vec![format!("radroots order status get {}", request.order_id)]; + view } -fn order_declined_inventory_view(request: &ResolvedSellerOrderRequest) -> OrderInventoryView { - OrderInventoryView { - state: "not_reserved".to_owned(), - listing_event_id: request.listing_event_id.clone(), - commitment_valid: true, - bins: Vec::new(), - issues: Vec::new(), +fn listing_inventory_accounting_issue_view( + issue_value: RadrootsListingInventoryAccountingIssue, +) -> OrderIssueView { + match issue_value { + RadrootsListingInventoryAccountingIssue::InvalidOrder { + order_id, + event_ids, + } => issue_with_events( + "invalid_inventory_order", + "order_id", + format!("inventory accounting reported invalid active order `{order_id}`"), + event_ids, + ), + RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { bin_id, event_ids } => { + issue_with_events( + "listing_inventory_arithmetic_overflow", + "inventory.count", + format!("inventory accounting overflowed for bin `{bin_id}`"), + event_ids, + ) + } + RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { bin_id, event_ids } => { + issue_with_events( + "unknown_inventory_bin", + "inventory.bin_id", + format!("inventory accounting reported unknown bin `{bin_id}`"), + event_ids, + ) + } + RadrootsListingInventoryAccountingIssue::OverReserved { + bin_id, + available_count, + reserved_count, + event_ids, + } => issue_with_events( + "listing_inventory_over_reserved", + "inventory.available", + format!( + "inventory accounting reported bin `{bin_id}` over-reserved: reserved {reserved_count}, available {available_count}" + ), + event_ids, + ), } } -fn order_decision_inventory_for_view( +fn order_decision_dry_run_view( + config: &RuntimeConfig, args: &OrderDecisionArgs, request: &ResolvedSellerOrderRequest, + status: &OrderStatusView, inventory: Option<OrderInventoryView>, -) -> Option<OrderInventoryView> { - match args.decision { - OrderDecisionArg::Accept => inventory, - OrderDecisionArg::Decline => Some(order_declined_inventory_view(request)), - } +) -> OrderDecisionView { + let decision_reason = args + .reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()); + let mut view = order_decision_base_view(config, args, "dry_run", true); + apply_order_decision_request(&mut view, request); + apply_order_decision_status(&mut view, status); + view.inventory = order_decision_inventory_for_view(args, request, inventory); + view.reason = Some(match decision_reason { + Some(reason) => format!( + "dry run requested; seller order decision publication skipped with reason `{reason}`" + ), + None => "dry run requested; seller order decision publication skipped".to_owned(), + }); + view.actions = vec![format!("radroots order status get {}", request.order_id)]; + view } -fn fetch_current_inventory_listing( +fn order_revision_invalid_view( config: &RuntimeConfig, - args: &OrderDecisionArgs, - request: &ResolvedSellerOrderRequest, - resolution: &SellerOrderRequestResolution, + args: &OrderRevisionProposeArgs, status: &OrderStatusView, -) -> Result<Result<ResolvedInventoryListing, OrderDecisionView>, RuntimeError> { - let parsed = parse_listing_addr(request.listing_addr.as_str()).map_err(|error| { - RuntimeError::Config(format!("order request listing_addr is invalid: {error}")) - })?; - let filter = listing_event_filter(&parsed)?; - let receipt = match fetch_events_from_relays(&config.relay.urls, filter) { - Ok(receipt) => receipt, - Err(DirectRelayFetchError::Connect { - reason, - target_relays, - failed_relays, - }) => { - let mut view = - order_decision_base_view(config, args, "unavailable", config.output.dry_run); - apply_order_decision_resolution(&mut view, resolution); - apply_order_decision_request(&mut view, request); - apply_order_decision_status(&mut view, status); - view.target_relays = target_relays; - view.failed_relays = relay_failures(failed_relays); - view.reason = Some(format!("direct relay connection failed: {reason}")); - return Ok(Err(view)); - } - Err(error) => return Err(RuntimeError::Network(error.to_string())), - }; + reason: impl Into<String>, + issues: Vec<OrderIssueView>, +) -> OrderRevisionProposalView { + let mut view = order_revision_base_view(config, args, "invalid", config.output.dry_run); + apply_order_revision_status(&mut view, status); + view.reason = Some(reason.into()); + view.issues.extend(issues); + view.actions = vec![format!("radroots order status get {}", args.key)]; + view +} - let listing = current_inventory_listing_from_receipt(request, receipt)?; - Ok(match listing { - Some(listing) => Ok(listing), - None => Err(order_decision_inventory_invalid_view( - config, - args, - request, - resolution, - status, - "order accept refused because the current listing event was not visible", - vec![issue_with_code( - "current_listing_missing", - "listing_event_id", - "current listing event was not visible on the configured relays", - )], - )), - }) +fn order_revision_decision_invalid_view( + config: &RuntimeConfig, + args: &OrderRevisionDecisionArgs, + status: &OrderStatusView, + reason: impl Into<String>, + issues: Vec<OrderIssueView>, +) -> OrderRevisionDecisionView { + let mut view = + order_revision_decision_base_view(config, args, "invalid", config.output.dry_run); + apply_order_revision_decision_status(&mut view, status); + view.reason = Some(reason.into()); + view.issues.extend(issues); + view.actions = vec![format!("radroots order status get {}", args.key)]; + view } -fn current_inventory_listing_from_receipt( - request: &ResolvedSellerOrderRequest, - receipt: DirectRelayFetchReceipt, -) -> Result<Option<ResolvedInventoryListing>, RuntimeError> { - let parsed = parse_listing_addr(request.listing_addr.as_str()).map_err(|error| { - RuntimeError::Config(format!("order request listing_addr is invalid: {error}")) - })?; - current_inventory_listing_from_parts(parsed, receipt) +fn order_revision_dry_run_view( + config: &RuntimeConfig, + args: &OrderRevisionProposeArgs, + status: &OrderStatusView, + payload: &RadrootsOrderRevisionProposal, +) -> OrderRevisionProposalView { + let mut view = order_revision_base_view(config, args, "dry_run", true); + apply_order_revision_status(&mut view, status); + apply_order_revision_payload(&mut view, payload); + view.reason = + Some("dry run requested; seller revision proposal publication skipped".to_owned()); + view.actions = vec![format!("radroots order status get {}", status.order_id)]; + view } -fn current_inventory_listing_from_parts( - parsed: ParsedListingAddress, - receipt: DirectRelayFetchReceipt, -) -> Result<Option<ResolvedInventoryListing>, RuntimeError> { - let mut candidates = Vec::new(); - for event in receipt.events { - if event_kind_u32(&event) != KIND_LISTING { - continue; - } - let event = radroots_event_from_nostr(&event); - if event.author != parsed.seller_pubkey { - continue; - } - let listing = listing_from_event(event.kind, &event.tags, &event.content) - .map_err(|error| RuntimeError::Config(format!("decode listing event: {error}")))?; - if listing.d_tag != parsed.listing_id { - continue; - } - let bins = listing_inventory_bins(&listing)?; - let event_id = protocol_event_id(event.id.as_str(), "listing_event_id")?; - candidates.push((event.created_at, event_id, listing, bins)); - } - candidates.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1))); - Ok(candidates - .into_iter() - .next() - .map(|(_, event_id, listing, bins)| ResolvedInventoryListing { - event_id, - listing, - bins, - })) +fn order_revision_decision_dry_run_view( + config: &RuntimeConfig, + args: &OrderRevisionDecisionArgs, + status: &OrderStatusView, + proposal: &OrderRevisionProposalRecord, + payload: &RadrootsOrderRevisionDecision, +) -> OrderRevisionDecisionView { + let mut view = order_revision_decision_base_view(config, args, "dry_run", true); + apply_order_revision_decision_status(&mut view, status); + apply_order_revision_decision_payload(&mut view, proposal, payload); + view.reason = Some(format!( + "dry run requested; buyer revision {} publication skipped", + args.decision.command() + )); + view.actions = vec![format!("radroots order status get {}", status.order_id)]; + view } -fn listing_inventory_bins( - listing: &RadrootsListing, -) -> Result<Vec<RadrootsListingInventoryBinAvailability>, RuntimeError> { - if !listing - .bins - .iter() - .any(|bin| bin.bin_id == listing.primary_bin_id) - { - return Err(RuntimeError::Config( - "current listing primary bin is missing from listing bins".to_owned(), - )); - } - let available_count = listing - .inventory_available - .as_ref() - .ok_or_else(|| { - RuntimeError::Config("current listing inventory availability is missing".to_owned()) - })? - .to_u64_exact() - .ok_or_else(|| { - RuntimeError::Config( - "current listing inventory availability must be a whole number".to_owned(), - ) - })?; - Ok(vec![RadrootsListingInventoryBinAvailability { - bin_id: listing.primary_bin_id.clone(), - available_count, - }]) +fn order_cancellation_dry_run_view( + config: &RuntimeConfig, + args: &OrderCancelArgs, + status: &OrderStatusView, +) -> OrderCancellationView { + let mut view = order_cancellation_base_view(config, args, "dry_run", true); + apply_order_cancellation_status(&mut view, status); + view.reason = + Some("dry run requested; buyer order cancellation publication skipped".to_owned()); + view.actions = vec![format!("radroots order status get {}", status.order_id)]; + view } -fn listing_is_active(listing: &RadrootsListing) -> bool { - match listing.availability.as_ref() { - Some(RadrootsListingAvailability::Status { status }) => { - matches!(status, RadrootsListingStatus::Active) - } - Some(RadrootsListingAvailability::Window { .. }) | None => true, - } +fn order_cancellation_payload_from_status( + args: &OrderCancelArgs, + status: &OrderStatusView, +) -> Result<RadrootsOrderCancellation, RuntimeError> { + Ok(RadrootsOrderCancellation { + order_id: protocol_order_id(status.order_id.as_str(), "order_id")?, + listing_addr: protocol_listing_addr( + status.listing_addr.as_deref().ok_or_else(|| { + RuntimeError::Config("cancellable order is missing listing_addr".to_owned()) + })?, + "listing_addr", + )?, + buyer_pubkey: protocol_pubkey( + status.buyer_pubkey.as_deref().ok_or_else(|| { + RuntimeError::Config("cancellable order is missing buyer_pubkey".to_owned()) + })?, + "buyer_pubkey", + )?, + seller_pubkey: protocol_pubkey( + status.seller_pubkey.as_deref().ok_or_else(|| { + RuntimeError::Config("cancellable order is missing seller_pubkey".to_owned()) + })?, + "seller_pubkey", + )?, + reason: args.reason.trim().to_owned(), + }) } -fn fetch_listing_accounting_requests( - config: &RuntimeConfig, - request: &ResolvedSellerOrderRequest, - listing: &ResolvedInventoryListing, -) -> Result<Vec<ResolvedAccountingRequest>, RuntimeError> { - let filter = order_listing_request_filter( - request.seller_pubkey.as_str(), - request.listing_addr.as_str(), - )?; - let receipt = fetch_events_from_relays(&config.relay.urls, filter) - .map_err(|error| RuntimeError::Network(error.to_string()))?; - let mut records = Vec::new(); - for event in receipt.events { - if event_kind_u32(&event) != KIND_ORDER_REQUEST - || !event_matches_tag_value(&event, "a", request.listing_addr.as_str()) - { - continue; - } - if let Ok(record) = listing_accounting_request_from_event(&event) - && record.listing_event_id.as_deref() == Some(listing.event_id.as_str()) - { - records.push(record); - } - } - Ok(records) +fn order_revision_payload_from_status( + args: &OrderRevisionProposeArgs, + status: &OrderStatusView, +) -> Result<RadrootsOrderRevisionProposal, RuntimeError> { + let revision_id = protocol_revision_id(next_revision_id().as_str(), "revision_id")?; + let economics = status.economics.clone().ok_or_else(|| { + RuntimeError::Config("accepted order is missing current agreement economics".to_owned()) + })?; + let economics = revised_order_economics(args, revision_id.as_str(), &economics)?; + let items = economics + .items + .iter() + .map(|item| RadrootsOrderItem { + bin_id: item.bin_id.clone(), + bin_count: item.bin_count, + }) + .collect::<Vec<_>>(); + Ok(RadrootsOrderRevisionProposal { + revision_id, + order_id: protocol_order_id(status.order_id.as_str(), "order_id")?, + listing_addr: protocol_listing_addr( + status.listing_addr.as_deref().ok_or_else(|| { + RuntimeError::Config("accepted order is missing listing_addr".to_owned()) + })?, + "listing_addr", + )?, + buyer_pubkey: protocol_pubkey( + status.buyer_pubkey.as_deref().ok_or_else(|| { + RuntimeError::Config("accepted order is missing buyer_pubkey".to_owned()) + })?, + "buyer_pubkey", + )?, + seller_pubkey: protocol_pubkey( + status.seller_pubkey.as_deref().ok_or_else(|| { + RuntimeError::Config("accepted order is missing seller_pubkey".to_owned()) + })?, + "seller_pubkey", + )?, + root_event_id: protocol_event_id( + status.request_event_id.as_deref().ok_or_else(|| { + RuntimeError::Config("accepted order is missing request_event_id".to_owned()) + })?, + "request_event_id", + )?, + prev_event_id: protocol_event_id( + status + .last_event_id + .as_deref() + .or(status.decision_event_id.as_deref()) + .ok_or_else(|| { + RuntimeError::Config("accepted order is missing previous event id".to_owned()) + })?, + "prev_event_id", + )?, + items, + economics, + reason: args.reason.trim().to_owned(), + }) } -fn fetch_listing_accounting_decisions( - config: &RuntimeConfig, - request: &ResolvedSellerOrderRequest, -) -> Result<Vec<RadrootsOrderDecisionRecord>, RuntimeError> { - let filter = order_listing_decision_filter(request.listing_addr.as_str())?; - let receipt = fetch_events_from_relays(&config.relay.urls, filter) - .map_err(|error| RuntimeError::Network(error.to_string()))?; - let mut records = Vec::new(); - for event in receipt.events { - if event_kind_u32(&event) != KIND_ORDER_DECISION - || !event_matches_tag_value(&event, "a", request.listing_addr.as_str()) - { - continue; - } - if let Ok(OrderStatusRecord::Decision(record)) = order_status_record_from_event(&event) { - records.push(record); +fn revised_order_economics( + args: &OrderRevisionProposeArgs, + revision_id: &str, + current: &RadrootsOrderEconomics, +) -> Result<RadrootsOrderEconomics, RuntimeError> { + let mut current_canonical = current.clone(); + current_canonical.canonicalize(); + let mut economics = current_canonical.clone(); + let mut changed = false; + economics.quote_id = protocol_quote_id(format!("revision_{revision_id}").as_str(), "quote_id")?; + economics.quote_version = economics + .quote_version + .checked_add(1) + .ok_or_else(|| RuntimeError::Config("revision quote_version overflowed".to_owned()))?; + + if let Some(bin_id) = args.bin_id.as_deref().and_then(non_empty_ref) { + let bin_id = protocol_inventory_bin_id(bin_id, "revision bin_id")?; + let bin_count = args.bin_count.ok_or_else(|| { + RuntimeError::Config("revision bin_count is required with bin_id".to_owned()) + })?; + let Some(item) = economics + .items + .iter_mut() + .find(|item| item.bin_id == bin_id) + else { + return Err(RuntimeError::Config(format!( + "revision bin `{bin_id}` is not part of the current agreement" + ))); + }; + if item.bin_count != bin_count { + changed = true; } + item.bin_count = bin_count; + item.line_subtotal = RadrootsCoreMoney::new( + item.unit_price_amount * item.quantity_amount * RadrootsCoreDecimal::from(bin_count), + item.unit_price_currency, + ); } - Ok(records) -} -fn fetch_listing_accounting_fulfillments( - config: &RuntimeConfig, - request: &ResolvedSellerOrderRequest, -) -> Result<Vec<RadrootsOrderFulfillmentRecord>, RuntimeError> { - let filter = order_listing_fulfillment_filter(request.listing_addr.as_str())?; - let receipt = fetch_events_from_relays(&config.relay.urls, filter) - .map_err(|error| RuntimeError::Network(error.to_string()))?; - let mut records = Vec::new(); - for event in receipt.events { - if event_kind_u32(&event) != KIND_ORDER_FULFILLMENT_UPDATE - || !event_matches_tag_value(&event, "a", request.listing_addr.as_str()) + if let Some(line) = revision_adjustment_line(args, economics.currency)? { + changed = true; + if economics + .adjustments + .iter() + .any(|existing| existing.id == line.id) { - continue; - } - if let Ok(OrderStatusRecord::Fulfillment(record)) = order_status_record_from_event(&event) { - records.push(record); + return Err(RuntimeError::Config(format!( + "revision adjustment id `{}` already exists in current agreement economics", + line.id + ))); } + economics.adjustments.push(line); } - Ok(records) -} -fn fetch_listing_accounting_cancellations( - config: &RuntimeConfig, - request: &ResolvedSellerOrderRequest, -) -> Result<Vec<RadrootsOrderCancellationRecord>, RuntimeError> { - let filter = order_listing_cancellation_filter(request.listing_addr.as_str())?; - let receipt = fetch_events_from_relays(&config.relay.urls, filter) - .map_err(|error| RuntimeError::Network(error.to_string()))?; - let mut records = Vec::new(); - for event in receipt.events { - if event_kind_u32(&event) != KIND_ORDER_CANCELLATION - || !event_matches_tag_value(&event, "a", request.listing_addr.as_str()) - { - continue; - } - if let Ok(OrderStatusRecord::Cancellation(record)) = order_status_record_from_event(&event) - { - records.push(record); - } + economics.canonicalize(); + economics + .validate() + .map_err(|error| RuntimeError::Config(format!("build revision economics: {error}")))?; + if !changed { + return Err(RuntimeError::Config( + "order revision propose requires a changed item count or adjustment".to_owned(), + )); } - Ok(records) -} - -fn listing_accounting_request_from_event( - event: &RadrootsNostrEvent, -) -> Result<ResolvedAccountingRequest, RuntimeError> { - let event = radroots_event_from_nostr(event); - let event_id = protocol_event_id(event.id.as_str(), "event_id")?; - let author_pubkey = protocol_pubkey(event.author.as_str(), "author_pubkey")?; - let envelope = order_request_from_event(&event) - .map_err(|error| RuntimeError::Config(format!("decode order request event: {error}")))?; - let context = - order_event_context_from_tags(RadrootsOrderEventType::OrderRequested, &event.tags) - .map_err(|error| RuntimeError::Config(format!("decode order request tags: {error}")))?; - Ok(ResolvedAccountingRequest { - listing_event_id: context.listing_event.as_ref().map(|event| event.id.clone()), - record: RadrootsOrderRequestRecord { - event_id, - author_pubkey, - payload: envelope.payload, - }, - }) + Ok(economics) } -fn active_request_record_from_resolved( - request: &ResolvedSellerOrderRequest, -) -> RadrootsOrderRequestRecord { - RadrootsOrderRequestRecord { - event_id: request.request_event_id.clone(), - author_pubkey: request.buyer_pubkey.clone(), - payload: RadrootsOrderRequest { - order_id: request.order_id.clone(), - listing_addr: request.listing_addr.clone(), - buyer_pubkey: request.buyer_pubkey.clone(), - seller_pubkey: request.seller_pubkey.clone(), - items: request.items.clone(), - economics: request.economics.clone(), - }, +fn revision_adjustment_line( + args: &OrderRevisionProposeArgs, + expected_currency: RadrootsCoreCurrency, +) -> Result<Option<RadrootsOrderEconomicLine>, RuntimeError> { + let Some(id) = args.adjustment_id.as_deref().and_then(non_empty_ref) else { + return Ok(None); + }; + let effect = match args + .adjustment_effect + .as_deref() + .and_then(non_empty_ref) + .ok_or_else(|| RuntimeError::Config("revision adjustment effect is required".to_owned()))? + { + "increase" => RadrootsOrderEconomicEffect::Increase, + "decrease" => RadrootsOrderEconomicEffect::Decrease, + other => { + return Err(RuntimeError::Config(format!( + "revision adjustment effect `{other}` is invalid" + ))); + } + }; + let currency = parse_economics_currency( + args.adjustment_currency + .as_deref() + .and_then(non_empty_ref) + .ok_or_else(|| { + RuntimeError::Config("revision adjustment currency is required".to_owned()) + })?, + "revision_adjustment_currency", + )?; + if currency != expected_currency { + return Err(RuntimeError::Config( + "revision adjustment currency must match current agreement currency".to_owned(), + )); } + let amount = decimal_from_adjustment( + args.adjustment_amount + .as_deref() + .and_then(non_empty_ref) + .ok_or_else(|| { + RuntimeError::Config("revision adjustment amount is required".to_owned()) + })?, + "revision_adjustment_amount", + )?; + if amount.is_zero() { + return Err(RuntimeError::Config( + "revision adjustment amount must be greater than zero".to_owned(), + )); + } + let reason = args + .adjustment_reason + .as_deref() + .and_then(non_empty_ref) + .ok_or_else(|| RuntimeError::Config("revision adjustment reason is required".to_owned()))?; + Ok(Some(RadrootsOrderEconomicLine { + id: id.to_owned(), + kind: RadrootsOrderEconomicLineKind::RevisionAdjustment, + actor: RadrootsOrderEconomicActor::Seller, + effect, + amount: RadrootsCoreMoney::new(amount, currency), + reason: reason.to_owned(), + })) } -fn proposed_accept_decision_record( - request: &ResolvedSellerOrderRequest, -) -> Result<RadrootsOrderDecisionRecord, RuntimeError> { - let payload = accepted_order_decision_payload_from_request(request); - let signer_pubkey = request.seller_pubkey.to_string(); - let payload = canonicalize_order_decision_for_signer(payload, signer_pubkey.as_str()) - .map_err(|error| RuntimeError::Config(format!("canonicalize order decision: {error}")))?; - Ok(RadrootsOrderDecisionRecord { - event_id: request.request_event_id.clone(), - author_pubkey: request.seller_pubkey.clone(), - counterparty_pubkey: request.buyer_pubkey.clone(), - root_event_id: request.request_event_id.clone(), - prev_event_id: request.request_event_id.clone(), - payload, +fn order_revision_event_parts( + status: &OrderStatusView, + payload: &RadrootsOrderRevisionProposal, +) -> Result<WireEventParts, RuntimeError> { + let root_event_id = status.request_event_id.as_deref().ok_or_else(|| { + RuntimeError::Config("accepted order is missing request_event_id".to_owned()) + })?; + let prev_event_id = status + .last_event_id + .as_deref() + .or(status.decision_event_id.as_deref()) + .ok_or_else(|| { + RuntimeError::Config("accepted order is missing previous event id".to_owned()) + })?; + let root_event_id = protocol_event_id(root_event_id, "request_event_id")?; + let prev_event_id = protocol_event_id(prev_event_id, "prev_event_id")?; + if payload.root_event_id != root_event_id || payload.prev_event_id != prev_event_id { + return Err(RuntimeError::Config( + "order revision proposal payload chain does not match order status".to_owned(), + )); + } + order_revision_proposal_event_build(&root_event_id, &prev_event_id, payload).map_err(|error| { + RuntimeError::Config(format!("encode order revision proposal event: {error}")) }) } -fn order_decision_inventory_invalid_view( +fn order_revision_inventory_preflight_view( config: &RuntimeConfig, - args: &OrderDecisionArgs, - request: &ResolvedSellerOrderRequest, - resolution: &SellerOrderRequestResolution, + args: &OrderRevisionProposeArgs, status: &OrderStatusView, - reason: impl Into<String>, - issues: Vec<OrderIssueView>, -) -> OrderDecisionView { - let mut view = order_decision_base_view(config, args, "invalid", config.output.dry_run); - apply_order_decision_resolution(&mut view, resolution); - apply_order_decision_request(&mut view, request); - apply_order_decision_status(&mut view, status); - view.reason = Some(reason.into()); - view.issues.extend(issues); - view.actions = vec![format!("radroots order status get {}", request.order_id)]; - view + payload: &RadrootsOrderRevisionProposal, +) -> Option<OrderRevisionProposalView> { + let issues = order_revision_inventory_issues(status, payload); + if issues.is_empty() { + return None; + } + let mut view = order_revision_invalid_view( + config, + args, + status, + "order revision propose refused because visible inventory is unavailable for the revised items", + issues, + ); + apply_order_revision_payload(&mut view, payload); + Some(view) } -fn deferred_payment_status_event(event: &RadrootsNostrEvent) -> bool { - matches!( - event_kind_u32(event), - KIND_ORDER_PAYMENT_RECORD | KIND_ORDER_SETTLEMENT_DECISION - ) -} +fn order_revision_inventory_issues( + status: &OrderStatusView, + payload: &RadrootsOrderRevisionProposal, +) -> Vec<OrderIssueView> { + let Some(current) = status.economics.as_ref() else { + return vec![issue_with_code( + "revision_current_economics_missing", + "economics", + "current agreement economics are required before revision proposal", + )]; + }; -fn listing_inventory_accounting_issue_view( - issue_value: RadrootsListingInventoryAccountingIssue, -) -> OrderIssueView { - match issue_value { - RadrootsListingInventoryAccountingIssue::InvalidOrder { - order_id, - event_ids, - } => issue_with_events( - "invalid_inventory_order", - "order_id", - format!("inventory accounting reported invalid active order `{order_id}`"), - event_ids, - ), - RadrootsListingInventoryAccountingIssue::ArithmeticOverflow { bin_id, event_ids } => { - issue_with_events( - "listing_inventory_arithmetic_overflow", - "inventory.count", - format!("inventory accounting overflowed for bin `{bin_id}`"), - event_ids, - ) + let current_counts = current + .items + .iter() + .map(|item| (item.bin_id.as_str(), u64::from(item.bin_count))) + .collect::<Vec<_>>(); + let mut issues = Vec::new(); + for item in &payload.items { + let current_count = current_counts + .iter() + .find(|(bin_id, _)| *bin_id == item.bin_id) + .map(|(_, count)| *count) + .unwrap_or_default(); + let revised_count = u64::from(item.bin_count); + if revised_count <= current_count { + continue; } - RadrootsListingInventoryAccountingIssue::UnknownInventoryBin { bin_id, event_ids } => { - issue_with_events( - "unknown_inventory_bin", + let Some(bin) = status + .inventory + .as_ref() + .and_then(|inventory| inventory.bins.iter().find(|bin| bin.bin_id == item.bin_id)) + else { + issues.push(issue_with_code( + "revision_inventory_unavailable", "inventory.bin_id", - format!("inventory accounting reported unknown bin `{bin_id}`"), - event_ids, - ) - } - RadrootsListingInventoryAccountingIssue::OverReserved { - bin_id, - available_count, - reserved_count, - event_ids, - } => issue_with_events( - "listing_inventory_over_reserved", - "inventory.available", - format!( - "inventory accounting reported bin `{bin_id}` over-reserved: reserved {reserved_count}, available {available_count}" - ), - event_ids, - ), + format!( + "inventory availability for revised bin `{}` is not visible", + item.bin_id + ), + )); + continue; + }; + let Some(remaining_count) = bin.remaining_count else { + issues.push(issue_with_code( + "revision_inventory_unavailable", + "inventory.remaining_count", + format!( + "remaining inventory for revised bin `{}` is not visible", + item.bin_id + ), + )); + continue; + }; + let available_for_revision = remaining_count.saturating_add(current_count); + if revised_count > available_for_revision { + issues.push(issue_with_code( + "revision_inventory_unavailable", + "inventory.remaining_count", + format!( + "revision requests {revised_count} of bin `{}`, but only {available_for_revision} are available after current reservation", + item.bin_id + ), + )); + } } + + issues } -fn order_decision_dry_run_view( - config: &RuntimeConfig, - args: &OrderDecisionArgs, - request: &ResolvedSellerOrderRequest, - status: &OrderStatusView, - inventory: Option<OrderInventoryView>, -) -> OrderDecisionView { - let decision_reason = args - .reason - .as_deref() - .map(str::trim) - .filter(|reason| !reason.is_empty()); - let mut view = order_decision_base_view(config, args, "dry_run", true); - apply_order_decision_request(&mut view, request); - apply_order_decision_status(&mut view, status); - view.inventory = order_decision_inventory_for_view(args, request, inventory); - view.reason = Some(match decision_reason { - Some(reason) => format!( - "dry run requested; seller order decision publication skipped with reason `{reason}`" - ), - None => "dry run requested; seller order decision publication skipped".to_owned(), - }); - view.actions = vec![format!("radroots order status get {}", request.order_id)]; - view +fn apply_order_revision_payload( + view: &mut OrderRevisionProposalView, + payload: &RadrootsOrderRevisionProposal, +) { + view.revision_id = Some(payload.revision_id.to_string()); + view.root_event_id = Some(payload.root_event_id.to_string()); + view.prev_event_id = Some(payload.prev_event_id.to_string()); + view.items = payload + .items + .iter() + .map(|item| OrderDraftItemView { + bin_id: item.bin_id.to_string(), + bin_count: item.bin_count, + }) + .collect(); + view.economics = Some(payload.economics.clone()); } -fn order_revision_invalid_view( - config: &RuntimeConfig, - args: &OrderRevisionProposeArgs, - status: &OrderStatusView, - reason: impl Into<String>, - issues: Vec<OrderIssueView>, -) -> OrderRevisionProposalView { - let mut view = order_revision_base_view(config, args, "invalid", config.output.dry_run); - apply_order_revision_status(&mut view, status); - view.reason = Some(reason.into()); - view.issues.extend(issues); - view.actions = vec![format!("radroots order status get {}", args.key)]; - view +fn apply_order_revision_decision_proposal( + view: &mut OrderRevisionDecisionView, + proposal: &OrderRevisionProposalRecord, +) { + view.revision_id = Some(proposal.payload.revision_id.to_string()); + view.root_event_id = Some(proposal.payload.root_event_id.to_string()); + view.prev_event_id = Some(proposal.event_id.to_string()); + view.event_id = Some(proposal.event_id.to_string()); + view.event_kind = Some(KIND_ORDER_REVISION_PROPOSAL); + if view.decision.as_deref() == Some("accepted") { + view.economics = Some(proposal.payload.economics.clone()); + } } -fn order_revision_decision_invalid_view( - config: &RuntimeConfig, +fn apply_order_revision_decision_payload( + view: &mut OrderRevisionDecisionView, + proposal: &OrderRevisionProposalRecord, + payload: &RadrootsOrderRevisionDecision, +) { + view.revision_id = Some(payload.revision_id.to_string()); + view.root_event_id = Some(payload.root_event_id.to_string()); + view.prev_event_id = Some(payload.prev_event_id.to_string()); + view.decision = Some( + match &payload.decision { + RadrootsOrderRevisionOutcome::Accepted => "accepted", + RadrootsOrderRevisionOutcome::Declined { .. } => "declined", + } + .to_owned(), + ); + if matches!(payload.decision, RadrootsOrderRevisionOutcome::Accepted) { + view.agreement_event_id = view.event_id.clone(); + view.economics = Some(proposal.payload.economics.clone()); + } +} + +fn order_revision_decision_payload_from_proposal( args: &OrderRevisionDecisionArgs, - status: &OrderStatusView, - reason: impl Into<String>, - issues: Vec<OrderIssueView>, -) -> OrderRevisionDecisionView { - let mut view = - order_revision_decision_base_view(config, args, "invalid", config.output.dry_run); - apply_order_revision_decision_status(&mut view, status); - view.reason = Some(reason.into()); - view.issues.extend(issues); - view.actions = vec![format!("radroots order status get {}", args.key)]; - view + proposal: &OrderRevisionProposalRecord, +) -> Result<RadrootsOrderRevisionDecision, RuntimeError> { + let decision = match args.decision { + OrderRevisionDecisionArg::Accept => RadrootsOrderRevisionOutcome::Accepted, + OrderRevisionDecisionArg::Decline => { + let reason = args + .reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()) + .ok_or_else(|| { + RuntimeError::Config( + "order revision decline requires a non-empty reason".to_owned(), + ) + })?; + RadrootsOrderRevisionOutcome::Declined { + reason: reason.to_owned(), + } + } + }; + Ok(RadrootsOrderRevisionDecision { + revision_id: proposal.payload.revision_id.clone(), + order_id: proposal.payload.order_id.clone(), + listing_addr: proposal.payload.listing_addr.clone(), + buyer_pubkey: proposal.payload.buyer_pubkey.clone(), + seller_pubkey: proposal.payload.seller_pubkey.clone(), + root_event_id: proposal.payload.root_event_id.clone(), + prev_event_id: proposal.event_id.clone(), + decision, + }) } -fn order_revision_dry_run_view( +fn order_revision_decision_event_parts( + payload: &RadrootsOrderRevisionDecision, +) -> Result<WireEventParts, RuntimeError> { + order_revision_decision_event_build(&payload.root_event_id, &payload.prev_event_id, payload) + .map_err(|error| { + RuntimeError::Config(format!("encode order revision decision event: {error}")) + }) +} + +fn publish_order_revision( config: &RuntimeConfig, args: &OrderRevisionProposeArgs, - status: &OrderStatusView, - payload: &RadrootsOrderRevisionProposal, -) -> OrderRevisionProposalView { - let mut view = order_revision_base_view(config, args, "dry_run", true); - apply_order_revision_status(&mut view, status); - apply_order_revision_payload(&mut view, payload); - view.reason = - Some("dry run requested; seller revision proposal publication skipped".to_owned()); - view.actions = vec![format!("radroots order status get {}", status.order_id)]; - view + status: OrderStatusView, + signing: account::AccountSigningIdentity, + payload: RadrootsOrderRevisionProposal, + evidence_events: Vec<SdkRadrootsNostrEvent>, +) -> Result<OrderRevisionProposalView, RuntimeError> { + enqueue_order_revision_proposal_via_sdk(config, args, status, signing, payload, evidence_events) + .map_err(cli_sdk_error_to_runtime) } -fn order_revision_decision_dry_run_view( +fn publish_order_revision_decision( config: &RuntimeConfig, args: &OrderRevisionDecisionArgs, - status: &OrderStatusView, + status: OrderStatusView, proposal: &OrderRevisionProposalRecord, - payload: &RadrootsOrderRevisionDecision, -) -> OrderRevisionDecisionView { - let mut view = order_revision_decision_base_view(config, args, "dry_run", true); - apply_order_revision_decision_status(&mut view, status); - apply_order_revision_decision_payload(&mut view, proposal, payload); - view.reason = Some(format!( - "dry run requested; buyer revision {} publication skipped", - args.decision.command() - )); - view.actions = vec![format!("radroots order status get {}", status.order_id)]; - view + signing: account::AccountSigningIdentity, + payload: RadrootsOrderRevisionDecision, + evidence_events: Vec<SdkRadrootsNostrEvent>, +) -> Result<OrderRevisionDecisionView, RuntimeError> { + enqueue_order_revision_decision_via_sdk( + config, + args, + status, + proposal, + signing, + payload, + evidence_events, + ) + .map_err(cli_sdk_error_to_runtime) } -fn order_fulfillment_dry_run_view( +fn publish_order_cancellation( config: &RuntimeConfig, - args: &OrderFulfillmentArgs, - status: &OrderStatusView, - fulfillment_state: RadrootsOrderFulfillmentState, -) -> OrderFulfillmentView { - let mut view = order_fulfillment_base_view(config, args, "dry_run", true); - apply_order_fulfillment_status(&mut view, status); - view.fulfillment_state = fulfillment_state_name(fulfillment_state).to_owned(); - view.reason = - Some("dry run requested; seller fulfillment update publication skipped".to_owned()); - view.actions = vec![format!("radroots order status get {}", status.order_id)]; - view + args: &OrderCancelArgs, + status: OrderStatusView, + signing: account::AccountSigningIdentity, + payload: RadrootsOrderCancellation, + evidence_events: Vec<SdkRadrootsNostrEvent>, +) -> Result<OrderCancellationView, RuntimeError> { + enqueue_order_cancellation_via_sdk(config, args, status, signing, payload, evidence_events) + .map_err(cli_sdk_error_to_runtime) } -fn order_revision_payload_from_status( - args: &OrderRevisionProposeArgs, - status: &OrderStatusView, -) -> Result<RadrootsOrderRevisionProposal, RuntimeError> { - let revision_id = protocol_revision_id(next_revision_id().as_str(), "revision_id")?; - let economics = status.economics.clone().ok_or_else(|| { - RuntimeError::Config("accepted order is missing current agreement economics".to_owned()) - })?; - let economics = revised_order_economics(args, revision_id.as_str(), &economics)?; - let items = economics - .items - .iter() - .map(|item| RadrootsOrderItem { - bin_id: item.bin_id.clone(), - bin_count: item.bin_count, - }) - .collect::<Vec<_>>(); - Ok(RadrootsOrderRevisionProposal { - revision_id, - order_id: protocol_order_id(status.order_id.as_str(), "order_id")?, - listing_addr: protocol_listing_addr( - status.listing_addr.as_deref().ok_or_else(|| { - RuntimeError::Config("accepted order is missing listing_addr".to_owned()) - })?, - "listing_addr", - )?, - buyer_pubkey: protocol_pubkey( - status.buyer_pubkey.as_deref().ok_or_else(|| { - RuntimeError::Config("accepted order is missing buyer_pubkey".to_owned()) - })?, - "buyer_pubkey", - )?, - seller_pubkey: protocol_pubkey( - status.seller_pubkey.as_deref().ok_or_else(|| { - RuntimeError::Config("accepted order is missing seller_pubkey".to_owned()) - })?, - "seller_pubkey", - )?, - root_event_id: protocol_event_id( - status.request_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("accepted order is missing request_event_id".to_owned()) - })?, - "request_event_id", - )?, - prev_event_id: protocol_event_id( - status - .last_event_id - .as_deref() - .or(status.decision_event_id.as_deref()) - .ok_or_else(|| { - RuntimeError::Config("accepted order is missing previous event id".to_owned()) - })?, - "prev_event_id", - )?, - items, - economics, - reason: args.reason.trim().to_owned(), - }) +fn prepare_order_revision_proposal_dry_run_via_sdk( + config: &RuntimeConfig, + signing: &account::AccountSigningIdentity, + payload: &RadrootsOrderRevisionProposal, +) -> Result<(), RuntimeError> { + let actor = sdk_order_lifecycle_actor(signing, RadrootsActorRole::Seller, "revision") + .map_err(cli_sdk_error_to_runtime)?; + let session = CliSdkSession::connect_memory(config).map_err(cli_sdk_error_to_runtime)?; + session + .sdk() + .orders() + .prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new( + actor, + sdk_order_event_ptr(&payload.root_event_id, config.relay.urls.as_slice()), + sdk_order_event_ptr(&payload.prev_event_id, config.relay.urls.as_slice()), + payload.clone(), + )) + .map(|_| ()) + .map_err(|error| RuntimeError::Config(error.to_string())) } -fn revised_order_economics( - args: &OrderRevisionProposeArgs, - revision_id: &str, - current: &RadrootsOrderEconomics, -) -> Result<RadrootsOrderEconomics, RuntimeError> { - let mut current_canonical = current.clone(); - current_canonical.canonicalize(); - let mut economics = current_canonical.clone(); - let mut changed = false; - economics.quote_id = protocol_quote_id(format!("revision_{revision_id}").as_str(), "quote_id")?; - economics.quote_version = economics - .quote_version - .checked_add(1) - .ok_or_else(|| RuntimeError::Config("revision quote_version overflowed".to_owned()))?; - - if let Some(bin_id) = args.bin_id.as_deref().and_then(non_empty_ref) { - let bin_id = protocol_inventory_bin_id(bin_id, "revision bin_id")?; - let bin_count = args.bin_count.ok_or_else(|| { - RuntimeError::Config("revision bin_count is required with bin_id".to_owned()) - })?; - let Some(item) = economics - .items - .iter_mut() - .find(|item| item.bin_id == bin_id) - else { - return Err(RuntimeError::Config(format!( - "revision bin `{bin_id}` is not part of the current agreement" - ))); - }; - if item.bin_count != bin_count { - changed = true; - } - item.bin_count = bin_count; - item.line_subtotal = RadrootsCoreMoney::new( - item.unit_price_amount * item.quantity_amount * RadrootsCoreDecimal::from(bin_count), - item.unit_price_currency, - ); - } - - if let Some(line) = revision_adjustment_line(args, economics.currency)? { - changed = true; - if economics - .adjustments - .iter() - .any(|existing| existing.id == line.id) - { - return Err(RuntimeError::Config(format!( - "revision adjustment id `{}` already exists in current agreement economics", - line.id - ))); - } - economics.adjustments.push(line); - } - - economics.canonicalize(); - economics - .validate() - .map_err(|error| RuntimeError::Config(format!("build revision economics: {error}")))?; - if !changed { - return Err(RuntimeError::Config( - "order revision propose requires a changed item count or adjustment".to_owned(), - )); - } - Ok(economics) +fn prepare_order_revision_decision_dry_run_via_sdk( + config: &RuntimeConfig, + signing: &account::AccountSigningIdentity, + payload: &RadrootsOrderRevisionDecision, +) -> Result<(), RuntimeError> { + let actor = sdk_order_lifecycle_actor(signing, RadrootsActorRole::Buyer, "revision decision") + .map_err(cli_sdk_error_to_runtime)?; + let session = CliSdkSession::connect_memory(config).map_err(cli_sdk_error_to_runtime)?; + session + .sdk() + .orders() + .prepare_revision_decision(OrderRevisionDecisionPrepareRequest::new( + actor, + sdk_order_event_ptr(&payload.root_event_id, config.relay.urls.as_slice()), + sdk_order_event_ptr(&payload.prev_event_id, config.relay.urls.as_slice()), + payload.clone(), + )) + .map(|_| ()) + .map_err(|error| RuntimeError::Config(error.to_string())) } -fn revision_adjustment_line( - args: &OrderRevisionProposeArgs, - expected_currency: RadrootsCoreCurrency, -) -> Result<Option<RadrootsOrderEconomicLine>, RuntimeError> { - let Some(id) = args.adjustment_id.as_deref().and_then(non_empty_ref) else { - return Ok(None); - }; - let effect = match args - .adjustment_effect - .as_deref() - .and_then(non_empty_ref) - .ok_or_else(|| RuntimeError::Config("revision adjustment effect is required".to_owned()))? - { - "increase" => RadrootsOrderEconomicEffect::Increase, - "decrease" => RadrootsOrderEconomicEffect::Decrease, - other => { - return Err(RuntimeError::Config(format!( - "revision adjustment effect `{other}` is invalid" - ))); - } - }; - let currency = parse_economics_currency( - args.adjustment_currency - .as_deref() - .and_then(non_empty_ref) - .ok_or_else(|| { - RuntimeError::Config("revision adjustment currency is required".to_owned()) - })?, - "revision_adjustment_currency", +fn prepare_order_cancellation_dry_run_via_sdk( + config: &RuntimeConfig, + signing: &account::AccountSigningIdentity, + status: &OrderStatusView, + payload: &RadrootsOrderCancellation, +) -> Result<(), RuntimeError> { + let actor = sdk_order_lifecycle_actor(signing, RadrootsActorRole::Buyer, "cancellation") + .map_err(cli_sdk_error_to_runtime)?; + let root_event_id = protocol_event_id( + status.request_event_id.as_deref().ok_or_else(|| { + RuntimeError::Config("cancellable order is missing request_event_id".to_owned()) + })?, + "request_event_id", )?; - if currency != expected_currency { - return Err(RuntimeError::Config( - "revision adjustment currency must match current agreement currency".to_owned(), - )); - } - let amount = decimal_from_adjustment( - args.adjustment_amount - .as_deref() - .and_then(non_empty_ref) + let previous_event_id = protocol_event_id( + order_cancellation_prev_event_id(status) .ok_or_else(|| { - RuntimeError::Config("revision adjustment amount is required".to_owned()) - })?, - "revision_adjustment_amount", + RuntimeError::Config("cancellable order is missing previous event id".to_owned()) + })? + .as_str(), + "prev_event_id", )?; - if amount.is_zero() { - return Err(RuntimeError::Config( - "revision adjustment amount must be greater than zero".to_owned(), - )); - } - let reason = args - .adjustment_reason - .as_deref() - .and_then(non_empty_ref) - .ok_or_else(|| RuntimeError::Config("revision adjustment reason is required".to_owned()))?; - Ok(Some(RadrootsOrderEconomicLine { - id: id.to_owned(), - kind: RadrootsOrderEconomicLineKind::RevisionAdjustment, - actor: RadrootsOrderEconomicActor::Seller, - effect, - amount: RadrootsCoreMoney::new(amount, currency), - reason: reason.to_owned(), - })) + let session = CliSdkSession::connect_memory(config).map_err(cli_sdk_error_to_runtime)?; + session + .sdk() + .orders() + .prepare_cancellation(OrderCancellationPrepareRequest::new( + actor, + sdk_order_event_ptr(&root_event_id, config.relay.urls.as_slice()), + sdk_order_event_ptr(&previous_event_id, config.relay.urls.as_slice()), + payload.clone(), + )) + .map(|_| ()) + .map_err(|error| RuntimeError::Config(error.to_string())) } -fn order_revision_event_parts( - status: &OrderStatusView, - payload: &RadrootsOrderRevisionProposal, -) -> Result<WireEventParts, RuntimeError> { - let root_event_id = status.request_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("accepted order is missing request_event_id".to_owned()) - })?; - let prev_event_id = status - .last_event_id - .as_deref() - .or(status.decision_event_id.as_deref()) - .ok_or_else(|| { - RuntimeError::Config("accepted order is missing previous event id".to_owned()) - })?; - let root_event_id = protocol_event_id(root_event_id, "request_event_id")?; - let prev_event_id = protocol_event_id(prev_event_id, "prev_event_id")?; - if payload.root_event_id != root_event_id || payload.prev_event_id != prev_event_id { - return Err(RuntimeError::Config( - "order revision proposal payload chain does not match order status".to_owned(), - )); +fn enqueue_order_revision_proposal_via_sdk( + config: &RuntimeConfig, + args: &OrderRevisionProposeArgs, + status: OrderStatusView, + signing: account::AccountSigningIdentity, + payload: RadrootsOrderRevisionProposal, + evidence_events: Vec<SdkRadrootsNostrEvent>, +) -> Result<OrderRevisionProposalView, CliSdkAdapterError> { + let target_relays = order_decision_target_relays(config)?; + let policy = order_decision_relay_url_policy(target_relays.as_slice()); + let actor = sdk_order_lifecycle_actor(&signing, RadrootsActorRole::Seller, "revision")?; + let signer = sdk_signer_from_account(signing)?; + let target_policy = SdkRelayTargetPolicy::try_explicit(target_relays.clone(), policy)?; + let mut request = OrderRevisionProposalEnqueueRequest::new( + actor, + sdk_order_event_ptr(&payload.root_event_id, target_relays.as_slice()), + sdk_order_event_ptr(&payload.prev_event_id, target_relays.as_slice()), + payload.clone(), + target_policy, + ); + if let Some(idempotency_key) = args.idempotency_key.as_deref() { + request = request.try_with_idempotency_key(idempotency_key)?; } - order_revision_proposal_event_build(&root_event_id, &prev_event_id, payload).map_err(|error| { - RuntimeError::Config(format!("encode order revision proposal event: {error}")) - }) + + let session = CliSdkSession::connect(config)?; + ingest_order_evidence_events(&session, evidence_events)?; + let enqueue = session.block_on( + session + .sdk() + .orders() + .enqueue_revision_proposal(request, &signer), + )?; + let push = push_one_sdk_outbox_event(&session, policy)?; + Ok(sdk_enqueued_order_revision_view( + config, + args, + &status, + &payload, + enqueue, + push, + target_relays, + )) } -fn order_revision_inventory_preflight_view( +fn enqueue_order_revision_decision_via_sdk( config: &RuntimeConfig, - args: &OrderRevisionProposeArgs, - status: &OrderStatusView, - payload: &RadrootsOrderRevisionProposal, -) -> Option<OrderRevisionProposalView> { - let issues = order_revision_inventory_issues(status, payload); - if issues.is_empty() { - return None; + args: &OrderRevisionDecisionArgs, + status: OrderStatusView, + proposal: &OrderRevisionProposalRecord, + signing: account::AccountSigningIdentity, + payload: RadrootsOrderRevisionDecision, + evidence_events: Vec<SdkRadrootsNostrEvent>, +) -> Result<OrderRevisionDecisionView, CliSdkAdapterError> { + let target_relays = order_decision_target_relays(config)?; + let policy = order_decision_relay_url_policy(target_relays.as_slice()); + let actor = sdk_order_lifecycle_actor(&signing, RadrootsActorRole::Buyer, "revision decision")?; + let signer = sdk_signer_from_account(signing)?; + let target_policy = SdkRelayTargetPolicy::try_explicit(target_relays.clone(), policy)?; + let mut request = OrderRevisionDecisionEnqueueRequest::new( + actor, + sdk_order_event_ptr(&payload.root_event_id, target_relays.as_slice()), + sdk_order_event_ptr(&payload.prev_event_id, target_relays.as_slice()), + payload.clone(), + target_policy, + ); + if let Some(idempotency_key) = args.idempotency_key.as_deref() { + request = request.try_with_idempotency_key(idempotency_key)?; } - let mut view = order_revision_invalid_view( + + let session = CliSdkSession::connect(config)?; + ingest_order_evidence_events(&session, evidence_events)?; + let enqueue = session.block_on( + session + .sdk() + .orders() + .enqueue_revision_decision(request, &signer), + )?; + let push = push_one_sdk_outbox_event(&session, policy)?; + Ok(sdk_enqueued_order_revision_decision_view( config, args, - status, - "order revision propose refused because visible inventory is unavailable for the revised items", - issues, - ); - apply_order_revision_payload(&mut view, payload); - Some(view) + &status, + proposal, + &payload, + enqueue, + push, + target_relays, + )) } -fn order_revision_inventory_issues( - status: &OrderStatusView, - payload: &RadrootsOrderRevisionProposal, -) -> Vec<OrderIssueView> { - let Some(current) = status.economics.as_ref() else { - return vec![issue_with_code( - "revision_current_economics_missing", - "economics", - "current agreement economics are required before revision proposal", - )]; - }; - - let current_counts = current - .items - .iter() - .map(|item| (item.bin_id.as_str(), u64::from(item.bin_count))) - .collect::<Vec<_>>(); - let mut issues = Vec::new(); - for item in &payload.items { - let current_count = current_counts - .iter() - .find(|(bin_id, _)| *bin_id == item.bin_id) - .map(|(_, count)| *count) - .unwrap_or_default(); - let revised_count = u64::from(item.bin_count); - if revised_count <= current_count { - continue; - } - let Some(bin) = status - .inventory - .as_ref() - .and_then(|inventory| inventory.bins.iter().find(|bin| bin.bin_id == item.bin_id)) - else { - issues.push(issue_with_code( - "revision_inventory_unavailable", - "inventory.bin_id", - format!( - "inventory availability for revised bin `{}` is not visible", - item.bin_id - ), - )); - continue; - }; - let Some(remaining_count) = bin.remaining_count else { - issues.push(issue_with_code( - "revision_inventory_unavailable", - "inventory.remaining_count", - format!( - "remaining inventory for revised bin `{}` is not visible", - item.bin_id - ), - )); - continue; - }; - let available_for_revision = remaining_count.saturating_add(current_count); - if revised_count > available_for_revision { - issues.push(issue_with_code( - "revision_inventory_unavailable", - "inventory.remaining_count", - format!( - "revision requests {revised_count} of bin `{}`, but only {available_for_revision} are available after current reservation", - item.bin_id - ), - )); - } +fn enqueue_order_cancellation_via_sdk( + config: &RuntimeConfig, + args: &OrderCancelArgs, + status: OrderStatusView, + signing: account::AccountSigningIdentity, + payload: RadrootsOrderCancellation, + evidence_events: Vec<SdkRadrootsNostrEvent>, +) -> Result<OrderCancellationView, CliSdkAdapterError> { + let root_event_id = protocol_event_id( + status.request_event_id.as_deref().ok_or_else(|| { + RuntimeError::Config("cancellable order is missing request_event_id".to_owned()) + })?, + "request_event_id", + )?; + let previous_event_id = protocol_event_id( + order_cancellation_prev_event_id(&status) + .ok_or_else(|| { + RuntimeError::Config("cancellable order is missing previous event id".to_owned()) + })? + .as_str(), + "prev_event_id", + )?; + let target_relays = order_decision_target_relays(config)?; + let policy = order_decision_relay_url_policy(target_relays.as_slice()); + let actor = sdk_order_lifecycle_actor(&signing, RadrootsActorRole::Buyer, "cancellation")?; + let signer = sdk_signer_from_account(signing)?; + let target_policy = SdkRelayTargetPolicy::try_explicit(target_relays.clone(), policy)?; + let mut request = OrderCancellationEnqueueRequest::new( + actor, + sdk_order_event_ptr(&root_event_id, target_relays.as_slice()), + sdk_order_event_ptr(&previous_event_id, target_relays.as_slice()), + payload, + target_policy, + ); + if let Some(idempotency_key) = args.idempotency_key.as_deref() { + request = request.try_with_idempotency_key(idempotency_key)?; } - issues + let session = CliSdkSession::connect(config)?; + ingest_order_evidence_events(&session, evidence_events)?; + let enqueue = session.block_on( + session + .sdk() + .orders() + .enqueue_cancellation(request, &signer), + )?; + let push = push_one_sdk_outbox_event(&session, policy)?; + Ok(sdk_enqueued_order_cancellation_view( + config, + args, + &status, + enqueue, + push, + target_relays, + )) } -fn apply_order_revision_payload( - view: &mut OrderRevisionProposalView, - payload: &RadrootsOrderRevisionProposal, -) { - view.revision_id = Some(payload.revision_id.to_string()); - view.root_event_id = Some(payload.root_event_id.to_string()); - view.prev_event_id = Some(payload.prev_event_id.to_string()); - view.items = payload - .items - .iter() - .map(|item| OrderDraftItemView { - bin_id: item.bin_id.to_string(), - bin_count: item.bin_count, - }) - .collect(); - view.economics = Some(payload.economics.clone()); +fn sdk_order_lifecycle_actor( + signing: &account::AccountSigningIdentity, + role: RadrootsActorRole, + workflow: &str, +) -> Result<RadrootsActorContext, CliSdkAdapterError> { + RadrootsActorContext::local_account( + signing + .account + .record + .public_identity + .public_key_hex + .as_str(), + signing.account.record.account_id.to_string(), + [role], + ) + .map_err(|error| { + RuntimeError::Config(format!("invalid order {workflow} SDK actor: {error}")).into() + }) } -fn apply_order_revision_decision_proposal( - view: &mut OrderRevisionDecisionView, - proposal: &OrderRevisionProposalRecord, -) { - view.revision_id = Some(proposal.payload.revision_id.to_string()); - view.root_event_id = Some(proposal.payload.root_event_id.to_string()); - view.prev_event_id = Some(proposal.event_id.to_string()); - view.event_id = Some(proposal.event_id.to_string()); - view.event_kind = Some(KIND_ORDER_REVISION_PROPOSAL); - if view.decision.as_deref() == Some("accepted") { - view.economics = Some(proposal.payload.economics.clone()); - } +fn sdk_signer_from_account( + signing: account::AccountSigningIdentity, +) -> Result<RadrootsLocalEventSigner, CliSdkAdapterError> { + let keys: RadrootsNostrKeys = signing.identity.into_keys(); + RadrootsLocalEventSigner::new(keys) + .map_err(|error| RuntimeError::Config(error.to_string()).into()) } -fn apply_order_revision_decision_payload( - view: &mut OrderRevisionDecisionView, - proposal: &OrderRevisionProposalRecord, - payload: &RadrootsOrderRevisionDecision, -) { - view.revision_id = Some(payload.revision_id.to_string()); - view.root_event_id = Some(payload.root_event_id.to_string()); - view.prev_event_id = Some(payload.prev_event_id.to_string()); - view.decision = Some( - match &payload.decision { - RadrootsOrderRevisionOutcome::Accepted => "accepted", - RadrootsOrderRevisionOutcome::Declined { .. } => "declined", - } - .to_owned(), - ); - if matches!(payload.decision, RadrootsOrderRevisionOutcome::Accepted) { - view.agreement_event_id = view.event_id.clone(); - view.economics = Some(proposal.payload.economics.clone()); +fn sdk_order_event_ptr( + event_id: &RadrootsEventId, + target_relays: &[String], +) -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: event_id.as_str().to_owned(), + relays: target_relays.first().cloned(), } } -fn order_revision_decision_payload_from_proposal( - args: &OrderRevisionDecisionArgs, - proposal: &OrderRevisionProposalRecord, -) -> Result<RadrootsOrderRevisionDecision, RuntimeError> { - let decision = match args.decision { - OrderRevisionDecisionArg::Accept => RadrootsOrderRevisionOutcome::Accepted, - OrderRevisionDecisionArg::Decline => { - let reason = args - .reason - .as_deref() - .map(str::trim) - .filter(|reason| !reason.is_empty()) - .ok_or_else(|| { - RuntimeError::Config( - "order revision decline requires a non-empty reason".to_owned(), - ) - })?; - RadrootsOrderRevisionOutcome::Declined { - reason: reason.to_owned(), - } - } - }; - Ok(RadrootsOrderRevisionDecision { - revision_id: proposal.payload.revision_id.clone(), - order_id: proposal.payload.order_id.clone(), - listing_addr: proposal.payload.listing_addr.clone(), - buyer_pubkey: proposal.payload.buyer_pubkey.clone(), - seller_pubkey: proposal.payload.seller_pubkey.clone(), - root_event_id: proposal.payload.root_event_id.clone(), - prev_event_id: proposal.event_id.clone(), - decision, - }) +fn ingest_order_evidence_events( + session: &CliSdkSession, + events: Vec<SdkRadrootsNostrEvent>, +) -> Result<(), CliSdkAdapterError> { + for event in events { + session.block_on( + session + .sdk() + .orders() + .ingest_evidence(OrderEvidenceIngestRequest::new(event)), + )?; + } + Ok(()) } -fn order_revision_decision_event_parts( - payload: &RadrootsOrderRevisionDecision, -) -> Result<WireEventParts, RuntimeError> { - order_revision_decision_event_build(&payload.root_event_id, &payload.prev_event_id, payload) - .map_err(|error| { - RuntimeError::Config(format!("encode order revision decision event: {error}")) - }) +fn push_one_sdk_outbox_event( + session: &CliSdkSession, + policy: SdkRelayUrlPolicy, +) -> Result<PushOutboxReceipt, CliSdkAdapterError> { + Ok(session.block_on( + session.sdk().sync().push_outbox( + PushOutboxRequest::new() + .with_limit(1) + .with_relay_url_policy(policy), + ), + )?) } -fn order_fulfillment_payload_from_status( +fn sdk_enqueued_order_revision_view( + config: &RuntimeConfig, + args: &OrderRevisionProposeArgs, status: &OrderStatusView, - fulfillment_state: RadrootsOrderFulfillmentState, -) -> Result<RadrootsOrderFulfillmentUpdate, RuntimeError> { - Ok(RadrootsOrderFulfillmentUpdate { - order_id: protocol_order_id(status.order_id.as_str(), "order_id")?, - listing_addr: protocol_listing_addr( - status.listing_addr.as_deref().ok_or_else(|| { - RuntimeError::Config("accepted order is missing listing_addr".to_owned()) - })?, - "listing_addr", - )?, - buyer_pubkey: protocol_pubkey( - status.buyer_pubkey.as_deref().ok_or_else(|| { - RuntimeError::Config("accepted order is missing buyer_pubkey".to_owned()) - })?, - "buyer_pubkey", - )?, - seller_pubkey: protocol_pubkey( - status.seller_pubkey.as_deref().ok_or_else(|| { - RuntimeError::Config("accepted order is missing seller_pubkey".to_owned()) - })?, - "seller_pubkey", - )?, - status: fulfillment_state, - }) + payload: &RadrootsOrderRevisionProposal, + enqueue: OrderRevisionProposalReceipt, + push: PushOutboxReceipt, + target_relays: Vec<String>, +) -> OrderRevisionProposalView { + let push_event = sdk_push_event_for_event_id(&enqueue.signed_event_id, &push); + let mut view = order_revision_base_view( + config, + args, + sdk_order_lifecycle_state("proposed", push_event).as_str(), + false, + ); + apply_order_revision_status(&mut view, status); + apply_order_revision_payload(&mut view, payload); + view.event_id = Some(enqueue.signed_event_id.as_str().to_owned()); + view.event_kind = Some(KIND_ORDER_REVISION_PROPOSAL); + view.target_relays = push_event + .map(sdk_push_target_relays) + .unwrap_or(target_relays); + view.connected_relays = push_event + .map(sdk_push_connected_relays) + .unwrap_or_default(); + view.acknowledged_relays = push_event + .map(sdk_push_acknowledged_relays) + .unwrap_or_default(); + view.failed_relays = push_event.map(sdk_push_failed_relays).unwrap_or_default(); + view.reason = + sdk_order_lifecycle_reason("order revision proposal", &enqueue.workflow, push_event); + view.actions = sdk_order_lifecycle_actions(push_event); + view } -fn order_fulfillment_event_parts( +fn sdk_enqueued_order_revision_decision_view( + config: &RuntimeConfig, + args: &OrderRevisionDecisionArgs, status: &OrderStatusView, - payload: &RadrootsOrderFulfillmentUpdate, -) -> Result<WireEventParts, RuntimeError> { - let root_event_id = status.request_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("accepted order is missing request_event_id".to_owned()) - })?; - let prev_event_id = status - .last_event_id - .as_deref() - .or(status.decision_event_id.as_deref()) - .ok_or_else(|| { - RuntimeError::Config("accepted order is missing previous event id".to_owned()) - })?; - let root_event_id = protocol_event_id(root_event_id, "request_event_id")?; - let prev_event_id = protocol_event_id(prev_event_id, "prev_event_id")?; - order_fulfillment_update_event_build(&root_event_id, &prev_event_id, payload) - .map_err(|error| RuntimeError::Config(format!("encode fulfillment update event: {error}"))) + proposal: &OrderRevisionProposalRecord, + payload: &RadrootsOrderRevisionDecision, + enqueue: OrderRevisionDecisionReceipt, + push: PushOutboxReceipt, + target_relays: Vec<String>, +) -> OrderRevisionDecisionView { + let push_event = sdk_push_event_for_event_id(&enqueue.signed_event_id, &push); + let success_state = match payload.decision { + RadrootsOrderRevisionOutcome::Accepted => "accepted", + RadrootsOrderRevisionOutcome::Declined { .. } => "declined", + }; + let mut view = order_revision_decision_base_view( + config, + args, + sdk_order_lifecycle_state(success_state, push_event).as_str(), + false, + ); + apply_order_revision_decision_status(&mut view, status); + apply_order_revision_decision_payload(&mut view, proposal, payload); + view.event_id = Some(enqueue.signed_event_id.as_str().to_owned()); + view.event_kind = Some(KIND_ORDER_REVISION_DECISION); + if matches!(payload.decision, RadrootsOrderRevisionOutcome::Accepted) { + view.agreement_event_id = Some(enqueue.signed_event_id.as_str().to_owned()); + } + view.target_relays = push_event + .map(sdk_push_target_relays) + .unwrap_or(target_relays); + view.connected_relays = push_event + .map(sdk_push_connected_relays) + .unwrap_or_default(); + view.acknowledged_relays = push_event + .map(sdk_push_acknowledged_relays) + .unwrap_or_default(); + view.failed_relays = push_event.map(sdk_push_failed_relays).unwrap_or_default(); + view.reason = + sdk_order_lifecycle_reason("order revision decision", &enqueue.workflow, push_event); + view.actions = sdk_order_lifecycle_actions(push_event); + view } -fn order_cancellation_payload_from_status( +fn sdk_enqueued_order_cancellation_view( + config: &RuntimeConfig, args: &OrderCancelArgs, status: &OrderStatusView, -) -> Result<RadrootsOrderCancellation, RuntimeError> { - Ok(RadrootsOrderCancellation { - order_id: protocol_order_id(status.order_id.as_str(), "order_id")?, - listing_addr: protocol_listing_addr( - status.listing_addr.as_deref().ok_or_else(|| { - RuntimeError::Config("cancellable order is missing listing_addr".to_owned()) - })?, - "listing_addr", - )?, - buyer_pubkey: protocol_pubkey( - status.buyer_pubkey.as_deref().ok_or_else(|| { - RuntimeError::Config("cancellable order is missing buyer_pubkey".to_owned()) - })?, - "buyer_pubkey", - )?, - seller_pubkey: protocol_pubkey( - status.seller_pubkey.as_deref().ok_or_else(|| { - RuntimeError::Config("cancellable order is missing seller_pubkey".to_owned()) - })?, - "seller_pubkey", - )?, - reason: args.reason.trim().to_owned(), - }) -} - -fn order_cancellation_event_parts( - status: &OrderStatusView, - payload: &RadrootsOrderCancellation, -) -> Result<WireEventParts, RuntimeError> { - let root_event_id = status.request_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("cancellable order is missing request_event_id".to_owned()) - })?; - let prev_event_id = order_cancellation_prev_event_id(status).ok_or_else(|| { - RuntimeError::Config("cancellable order is missing previous event id".to_owned()) - })?; - let root_event_id = protocol_event_id(root_event_id, "request_event_id")?; - let prev_event_id = protocol_event_id(prev_event_id.as_str(), "prev_event_id")?; - order_cancellation_event_build(&root_event_id, &prev_event_id, payload) - .map_err(|error| RuntimeError::Config(format!("encode order cancellation event: {error}"))) -} - -fn order_receipt_payload_from_status( - args: &OrderReceiptArgs, - status: &OrderStatusView, -) -> Result<RadrootsOrderReceipt, RuntimeError> { - Ok(RadrootsOrderReceipt { - order_id: protocol_order_id(status.order_id.as_str(), "order_id")?, - listing_addr: protocol_listing_addr( - status.listing_addr.as_deref().ok_or_else(|| { - RuntimeError::Config("receiptable order is missing listing_addr".to_owned()) - })?, - "listing_addr", - )?, - buyer_pubkey: protocol_pubkey( - status.buyer_pubkey.as_deref().ok_or_else(|| { - RuntimeError::Config("receiptable order is missing buyer_pubkey".to_owned()) - })?, - "buyer_pubkey", - )?, - seller_pubkey: protocol_pubkey( - status.seller_pubkey.as_deref().ok_or_else(|| { - RuntimeError::Config("receiptable order is missing seller_pubkey".to_owned()) - })?, - "seller_pubkey", - )?, - received: args.received, - issue: if args.received { - None - } else { - Some( - args.issue - .as_deref() - .map(str::trim) - .filter(|issue| !issue.is_empty()) - .ok_or_else(|| { - RuntimeError::Config( - "receipt issue is required when received is false".to_owned(), - ) - })? - .to_owned(), - ) - }, - received_at: now_unix(), - }) + enqueue: OrderCancellationReceipt, + push: PushOutboxReceipt, + target_relays: Vec<String>, +) -> OrderCancellationView { + let push_event = sdk_push_event_for_event_id(&enqueue.signed_event_id, &push); + let mut view = order_cancellation_base_view( + config, + args, + sdk_order_lifecycle_state("cancelled", push_event).as_str(), + false, + ); + apply_order_cancellation_status(&mut view, status); + view.event_id = Some(enqueue.signed_event_id.as_str().to_owned()); + view.event_kind = Some(KIND_ORDER_CANCELLATION); + view.target_relays = push_event + .map(sdk_push_target_relays) + .unwrap_or(target_relays); + view.connected_relays = push_event + .map(sdk_push_connected_relays) + .unwrap_or_default(); + view.acknowledged_relays = push_event + .map(sdk_push_acknowledged_relays) + .unwrap_or_default(); + view.failed_relays = push_event.map(sdk_push_failed_relays).unwrap_or_default(); + view.reason = sdk_order_lifecycle_reason("order cancellation", &enqueue.workflow, push_event); + view.actions = sdk_order_lifecycle_actions(push_event); + view } -fn order_receipt_event_parts( - status: &OrderStatusView, - payload: &RadrootsOrderReceipt, -) -> Result<WireEventParts, RuntimeError> { - let root_event_id = status.request_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("receiptable order is missing request_event_id".to_owned()) - })?; - let prev_event_id = order_receipt_prev_event_id(status).ok_or_else(|| { - RuntimeError::Config( - "receiptable order is missing eligible fulfillment event id".to_owned(), - ) - })?; - let root_event_id = protocol_event_id(root_event_id, "request_event_id")?; - let prev_event_id = protocol_event_id(prev_event_id.as_str(), "prev_event_id")?; - order_receipt_event_build(&root_event_id, &prev_event_id, payload) - .map_err(|error| RuntimeError::Config(format!("encode buyer receipt event: {error}"))) +fn sdk_push_event_for_event_id<'a>( + event_id: &RadrootsEventId, + push: &'a PushOutboxReceipt, +) -> Option<&'a PushOutboxEventReceipt> { + push.events.iter().find(|event| event.event_id == *event_id) } -fn order_payment_payload_from_status( - args: &OrderPaymentArgs, - status: &OrderStatusView, -) -> Result<RadrootsOrderPaymentRecord, RuntimeError> { - let economics = status - .economics - .as_ref() - .ok_or_else(|| RuntimeError::Config("payable order is missing economics".to_owned()))?; - let agreement_event_id = status.agreement_event_id.clone().ok_or_else(|| { - RuntimeError::Config("payable order is missing agreement_event_id".to_owned()) - })?; - let amount = parse_payment_amount(args.amount.as_str())?; - let currency = parse_payment_currency(args.currency.as_str())?; - if amount != economics.total.amount { - return Err(RuntimeError::Config( - "payment amount must match accepted agreement total".to_owned(), - )); - } - if currency != economics.total.currency { - return Err(RuntimeError::Config( - "payment currency must match accepted agreement currency".to_owned(), - )); +fn sdk_order_lifecycle_state( + published_state: &str, + push_event: Option<&PushOutboxEventReceipt>, +) -> String { + match push_event.map(|event| event.final_state) { + Some(PushOutboxEventState::Published) => published_state, + Some(PushOutboxEventState::PublishRetryable | PushOutboxEventState::FailedTerminal) => { + "unavailable" + } + Some(_) | None => "queued", } - Ok(RadrootsOrderPaymentRecord { - order_id: protocol_order_id(status.order_id.as_str(), "order_id")?, - listing_addr: protocol_listing_addr( - status.listing_addr.as_deref().ok_or_else(|| { - RuntimeError::Config("payable order is missing listing_addr".to_owned()) - })?, - "listing_addr", - )?, - buyer_pubkey: protocol_pubkey( - status.buyer_pubkey.as_deref().ok_or_else(|| { - RuntimeError::Config("payable order is missing buyer_pubkey".to_owned()) - })?, - "buyer_pubkey", - )?, - seller_pubkey: protocol_pubkey( - status.seller_pubkey.as_deref().ok_or_else(|| { - RuntimeError::Config("payable order is missing seller_pubkey".to_owned()) - })?, - "seller_pubkey", - )?, - root_event_id: protocol_event_id( - status.request_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("payable order is missing request_event_id".to_owned()) - })?, - "request_event_id", - )?, - previous_event_id: protocol_event_id( - order_payment_prev_event_id(status) - .ok_or_else(|| { - RuntimeError::Config( - "payable order is missing payment previous event id".to_owned(), - ) - })? - .as_str(), - "prev_event_id", - )?, - agreement_event_id: protocol_event_id(agreement_event_id.as_str(), "agreement_event_id")?, - quote_id: economics.quote_id.clone(), - quote_version: economics.quote_version, - economics_digest: protocol_economics_digest( - radroots_order_economics_digest(economics) - .map_err(|error| RuntimeError::Config(error.to_string()))? - .as_str(), - "economics_digest", - )?, - amount, - currency, - method: parse_payment_method(args.method.as_str())?, - reference: args - .reference - .as_deref() - .map(str::trim) - .filter(|reference| !reference.is_empty()) - .map(str::to_owned), - paid_at: args.paid_at, - }) -} - -fn order_payment_event_parts( - status: &OrderStatusView, - payload: &RadrootsOrderPaymentRecord, -) -> Result<WireEventParts, RuntimeError> { - let root_event_id = status.request_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("payable order is missing request_event_id".to_owned()) - })?; - let prev_event_id = order_payment_prev_event_id(status).ok_or_else(|| { - RuntimeError::Config("payable order is missing payment previous event id".to_owned()) - })?; - let root_event_id = protocol_event_id(root_event_id, "request_event_id")?; - let prev_event_id = protocol_event_id(prev_event_id.as_str(), "prev_event_id")?; - order_payment_record_event_build(&root_event_id, &prev_event_id, payload) - .map_err(|error| RuntimeError::Config(format!("encode payment recorded event: {error}"))) + .to_owned() } -fn order_settlement_payload_from_status( - args: &OrderSettlementArgs, - status: &OrderStatusView, -) -> Result<RadrootsOrderSettlementDecision, RuntimeError> { - let payment = status - .payment - .as_ref() - .ok_or_else(|| RuntimeError::Config("settleable order is missing payment".to_owned()))?; - let payment_event_id = payment.payment_event_id.clone().ok_or_else(|| { - RuntimeError::Config("settleable order is missing payment_event_id".to_owned()) - })?; - if payment_event_id != args.payment_event_id { - return Err(RuntimeError::Config( - "settlement payment event id must match current recorded payment".to_owned(), - )); - } - if payment.state != "recorded" || payment.settlement_state != "pending" { - return Err(RuntimeError::Config( - "settlement requires a recorded payment with pending settlement".to_owned(), - )); +fn sdk_order_lifecycle_reason( + workflow: &str, + enqueue: &OrderWorkflowEnqueueReceipt, + push_event: Option<&PushOutboxEventReceipt>, +) -> Option<String> { + match push_event.map(|event| event.final_state) { + Some(PushOutboxEventState::Published) => None, + Some(PushOutboxEventState::PublishRetryable) => Some(format!( + "{}; SDK relay publish for {workflow} did not reach accepted quorum; outbox event remains retryable; {}", + sdk_order_enqueue_summary(enqueue), + sdk_order_enqueue_retry_summary(enqueue) + )), + Some(PushOutboxEventState::FailedTerminal) => Some(format!( + "{}; SDK relay publish for {workflow} failed terminally; {}", + sdk_order_enqueue_summary(enqueue), + sdk_order_enqueue_retry_summary(enqueue) + )), + Some(state) => Some(format!( + "{}; SDK relay push for {workflow} left event in state `{state:?}`; {}", + sdk_order_enqueue_summary(enqueue), + sdk_order_enqueue_retry_summary(enqueue) + )), + None => Some(format!( + "{}; {workflow} queued in SDK outbox; no ready SDK outbox event was pushed; {}", + sdk_order_enqueue_summary(enqueue), + sdk_order_enqueue_retry_summary(enqueue) + )), } - let decision = settlement_decision_protocol(args.decision); - Ok(RadrootsOrderSettlementDecision { - order_id: protocol_order_id(status.order_id.as_str(), "order_id")?, - listing_addr: protocol_listing_addr( - status.listing_addr.as_deref().ok_or_else(|| { - RuntimeError::Config("settleable order is missing listing_addr".to_owned()) - })?, - "listing_addr", - )?, - seller_pubkey: protocol_pubkey( - status.seller_pubkey.as_deref().ok_or_else(|| { - RuntimeError::Config("settleable order is missing seller_pubkey".to_owned()) - })?, - "seller_pubkey", - )?, - buyer_pubkey: protocol_pubkey( - status.buyer_pubkey.as_deref().ok_or_else(|| { - RuntimeError::Config("settleable order is missing buyer_pubkey".to_owned()) - })?, - "buyer_pubkey", - )?, - root_event_id: protocol_event_id( - status.request_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("settleable order is missing request_event_id".to_owned()) - })?, - "request_event_id", - )?, - previous_event_id: protocol_event_id(payment_event_id.as_str(), "prev_event_id")?, - agreement_event_id: protocol_event_id( - payment.agreement_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("settleable order is missing agreement_event_id".to_owned()) - })?, - "agreement_event_id", - )?, - payment_event_id: protocol_event_id(payment_event_id.as_str(), "payment_event_id")?, - quote_id: protocol_quote_id( - payment.quote_id.as_deref().ok_or_else(|| { - RuntimeError::Config("settleable order is missing quote_id".to_owned()) - })?, - "quote_id", - )?, - quote_version: payment.quote_version.ok_or_else(|| { - RuntimeError::Config("settleable order is missing quote_version".to_owned()) - })?, - economics_digest: protocol_economics_digest( - payment.economics_digest.as_deref().ok_or_else(|| { - RuntimeError::Config("settleable order is missing economics_digest".to_owned()) - })?, - "economics_digest", - )?, - amount: payment - .amount - .ok_or_else(|| RuntimeError::Config("settleable order is missing amount".to_owned()))?, - currency: payment.currency.ok_or_else(|| { - RuntimeError::Config("settleable order is missing currency".to_owned()) - })?, - decision, - reason: if matches!(args.decision, OrderSettlementDecisionArg::Reject) { - Some( - args.reason - .as_deref() - .and_then(non_empty_ref) - .ok_or_else(|| { - RuntimeError::Config("settlement rejection reason is required".to_owned()) - })? - .to_owned(), - ) - } else { - None - }, - }) } -fn order_settlement_event_parts( - status: &OrderStatusView, - payload: &RadrootsOrderSettlementDecision, -) -> Result<WireEventParts, RuntimeError> { - let root_event_id = status.request_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("settleable order is missing request_event_id".to_owned()) - })?; - let root_event_id = protocol_event_id(root_event_id, "request_event_id")?; - order_settlement_decision_event_build(&root_event_id, &payload.payment_event_id, payload) - .map_err(|error| RuntimeError::Config(format!("encode settlement decision event: {error}"))) +fn sdk_order_lifecycle_actions(push_event: Option<&PushOutboxEventReceipt>) -> Vec<String> { + if !matches!( + push_event.map(|event| event.final_state), + Some(PushOutboxEventState::Published) + ) { + return sdk_order_push_recovery_actions(); + } + Vec::new() } -fn apply_order_payment_payload(view: &mut OrderPaymentView, payload: &RadrootsOrderPaymentRecord) { - view.root_event_id = Some(payload.root_event_id.to_string()); - view.prev_event_id = Some(payload.previous_event_id.to_string()); - view.agreement_event_id = Some(payload.agreement_event_id.to_string()); - view.quote_id = Some(payload.quote_id.to_string()); - view.quote_version = Some(payload.quote_version); - view.economics_digest = Some(payload.economics_digest.to_string()); - view.amount = Some(payload.amount); - view.currency = Some(payload.currency); - view.method = Some(payload.method); - view.reference = payload.reference.clone(); - view.paid_at = payload.paid_at; -} - -fn apply_order_settlement_payload( - view: &mut OrderSettlementView, - payload: &RadrootsOrderSettlementDecision, -) { - view.root_event_id = Some(payload.root_event_id.to_string()); - view.prev_event_id = Some(payload.previous_event_id.to_string()); - view.payment_event_id = Some(payload.payment_event_id.to_string()); - view.agreement_event_id = Some(payload.agreement_event_id.to_string()); - view.quote_id = Some(payload.quote_id.to_string()); - view.quote_version = Some(payload.quote_version); - view.economics_digest = Some(payload.economics_digest.to_string()); - view.amount = Some(payload.amount); - view.currency = Some(payload.currency); - view.decision = Some(payload.decision); - view.settlement_reason = payload.reason.clone(); +fn sdk_order_enqueue_summary(enqueue: &OrderWorkflowEnqueueReceipt) -> String { + format!( + "local SDK enqueue completed for `{}` as `{}` with outbox_event_id {}; {}", + enqueue.operation_kind, + sdk_mutation_state_label(&enqueue.state), + enqueue.outbox_event_id, + sdk_order_idempotency_summary(enqueue) + ) } -fn order_cancellation_dry_run_view( - config: &RuntimeConfig, - args: &OrderCancelArgs, - status: &OrderStatusView, -) -> OrderCancellationView { - let mut view = order_cancellation_base_view(config, args, "dry_run", true); - apply_order_cancellation_status(&mut view, status); - view.reason = - Some("dry run requested; buyer order cancellation publication skipped".to_owned()); - view.actions = vec![format!("radroots order status get {}", status.order_id)]; - view +fn sdk_order_idempotency_summary(enqueue: &OrderWorkflowEnqueueReceipt) -> &'static str { + if enqueue.idempotency.replayed_existing_operation { + "idempotency replayed an existing queued operation" + } else if enqueue.idempotency.safe_to_retry_with_same_idempotency_key { + "same idempotency key remains retry-safe" + } else { + "same idempotency key retry safety is unavailable" + } } -fn order_receipt_dry_run_view( - config: &RuntimeConfig, - args: &OrderReceiptArgs, - status: &OrderStatusView, - payload: &RadrootsOrderReceipt, -) -> OrderReceiptView { - let mut view = order_receipt_base_view(config, args, "dry_run", true); - apply_order_receipt_status(&mut view, status); - view.received = payload.received; - view.issue = payload.issue.clone(); - view.received_at = Some(payload.received_at); - view.reason = Some("dry run requested; buyer receipt publication skipped".to_owned()); - view.actions = vec![format!("radroots order status get {}", status.order_id)]; - view +fn sdk_order_enqueue_retry_summary(enqueue: &OrderWorkflowEnqueueReceipt) -> &'static str { + if enqueue + .retry + .safe_to_retry_enqueue_with_same_idempotency_key + { + "enqueue is safe to retry with the same idempotency key" + } else if enqueue.retry.retryable_after_error { + "inspect local SDK state before retrying enqueue" + } else { + "do not retry enqueue before inspecting local SDK state" + } } -fn order_payment_dry_run_view( - config: &RuntimeConfig, - args: &OrderPaymentArgs, - status: &OrderStatusView, - payload: &RadrootsOrderPaymentRecord, -) -> OrderPaymentView { - let mut view = order_payment_base_view(config, args, "dry_run", true); - apply_order_payment_status(&mut view, status); - apply_order_payment_payload(&mut view, payload); - view.reason = Some("dry run requested; buyer payment publication skipped".to_owned()); - view.actions = vec![format!("radroots order status get {}", status.order_id)]; - view +fn sdk_mutation_state_label(state: &SdkMutationState) -> &'static str { + match state { + SdkMutationState::StoredAndQueued => "stored_and_queued", + SdkMutationState::AlreadyQueued => "already_queued", + _ => "unknown", + } } -fn order_settlement_dry_run_view( - config: &RuntimeConfig, - args: &OrderSettlementArgs, - status: &OrderStatusView, - payload: &RadrootsOrderSettlementDecision, -) -> OrderSettlementView { - let mut view = order_settlement_base_view(config, args, "dry_run", true); - apply_order_settlement_status(&mut view, status); - apply_order_settlement_payload(&mut view, payload); - view.reason = Some("dry run requested; seller settlement publication skipped".to_owned()); - view.actions = vec![format!("radroots order status get {}", status.order_id)]; - view +fn sdk_order_push_recovery_actions() -> Vec<String> { + vec![ + "radroots sync push".to_owned(), + "radroots sync status get".to_owned(), + ] } -fn publish_order_revision( +fn order_actor_write_binding_error_parts( + error: ActorWriteBindingError, +) -> (String, String, Vec<String>) { + ( + "unconfigured".to_owned(), + error.reason(), + vec!["run radroots signer status get".to_owned()], + ) +} + +fn order_revision_binding_error_view( config: &RuntimeConfig, args: &OrderRevisionProposeArgs, - status: OrderStatusView, - signing: account::AccountSigningIdentity, - payload: RadrootsOrderRevisionProposal, - evidence_events: Vec<SdkRadrootsNostrEvent>, -) -> Result<OrderRevisionProposalView, RuntimeError> { - enqueue_order_revision_proposal_via_sdk(config, args, status, signing, payload, evidence_events) - .map_err(cli_sdk_error_to_runtime) + status: &OrderStatusView, + error: ActorWriteBindingError, +) -> OrderRevisionProposalView { + let (state, reason, actions) = order_actor_write_binding_error_parts(error); + let mut view = order_revision_base_view(config, args, state.as_str(), config.output.dry_run); + apply_order_revision_status(&mut view, status); + view.reason = Some(reason); + view.actions = actions; + view } -fn publish_order_revision_decision( +fn order_revision_decision_binding_error_view( config: &RuntimeConfig, args: &OrderRevisionDecisionArgs, - status: OrderStatusView, - proposal: &OrderRevisionProposalRecord, - signing: account::AccountSigningIdentity, - payload: RadrootsOrderRevisionDecision, - evidence_events: Vec<SdkRadrootsNostrEvent>, -) -> Result<OrderRevisionDecisionView, RuntimeError> { - enqueue_order_revision_decision_via_sdk( - config, - args, - status, - proposal, - signing, - payload, - evidence_events, - ) - .map_err(cli_sdk_error_to_runtime) + status: &OrderStatusView, + error: ActorWriteBindingError, +) -> OrderRevisionDecisionView { + let (state, reason, actions) = order_actor_write_binding_error_parts(error); + let mut view = + order_revision_decision_base_view(config, args, state.as_str(), config.output.dry_run); + apply_order_revision_decision_status(&mut view, status); + view.reason = Some(reason); + view.actions = actions; + view } -fn publish_order_fulfillment( +fn order_cancellation_binding_error_view( config: &RuntimeConfig, - args: &OrderFulfillmentArgs, - status: OrderStatusView, - signing: account::AccountSigningIdentity, - payload: RadrootsOrderFulfillmentUpdate, - evidence_events: Vec<SdkRadrootsNostrEvent>, -) -> Result<OrderFulfillmentView, RuntimeError> { - enqueue_order_fulfillment_via_sdk(config, args, status, signing, payload, evidence_events) - .map_err(cli_sdk_error_to_runtime) + args: &OrderCancelArgs, + status: &OrderStatusView, + error: ActorWriteBindingError, +) -> OrderCancellationView { + let (state, reason, actions) = order_actor_write_binding_error_parts(error); + let mut view = + order_cancellation_base_view(config, args, state.as_str(), config.output.dry_run); + apply_order_cancellation_status(&mut view, status); + view.reason = Some(reason); + view.actions = actions; + view } -fn publish_order_cancellation( - config: &RuntimeConfig, - args: &OrderCancelArgs, - status: OrderStatusView, - signing: account::AccountSigningIdentity, - payload: RadrootsOrderCancellation, - evidence_events: Vec<SdkRadrootsNostrEvent>, -) -> Result<OrderCancellationView, RuntimeError> { - enqueue_order_cancellation_via_sdk(config, args, status, signing, payload, evidence_events) - .map_err(cli_sdk_error_to_runtime) +fn seller_order_request_resolution_from_receipt( + seller_pubkey: &str, + order_id: &str, + receipt: DirectRelayFetchReceipt, +) -> Result<SellerOrderRequestResolution, RuntimeError> { + let DirectRelayFetchReceipt { + target_relays, + connected_relays, + failed_relays, + events, + } = receipt; + let fetched_count = events.len(); + let mut skipped_count = 0usize; + let mut decoded_count = 0usize; + let mut requests = Vec::new(); + let mut candidate_issues = Vec::new(); + let candidate_context = OrderRequestCandidateContext { + order_id, + seller_pubkey: Some(seller_pubkey), + }; + + for event in events { + if !order_request_candidate_matches(&event, candidate_context) { + skipped_count += 1; + continue; + } + let event_id = event.id.to_string(); + match seller_order_request_from_event(&event, seller_pubkey, order_id) { + Ok(request) => { + decoded_count += 1; + requests.push(request); + } + Err(error) => { + skipped_count += 1; + candidate_issues.push(issue_with_events( + "invalid_request_candidate", + "request_event_id", + format!("request event `{event_id}` failed seller decision preflight: {error}"), + vec![event_id], + )); + } + } + } + + requests.sort_by(|left, right| left.request_event_id.cmp(&right.request_event_id)); + candidate_issues.sort_by(|left, right| left.message.cmp(&right.message)); + + Ok(SellerOrderRequestResolution { + target_relays, + connected_relays, + failed_relays, + fetched_count, + decoded_count, + skipped_count, + requests, + candidate_issues, + }) } -fn publish_order_receipt( - config: &RuntimeConfig, - args: &OrderReceiptArgs, - status: OrderStatusView, - signing: account::AccountSigningIdentity, - payload: RadrootsOrderReceipt, - evidence_events: Vec<SdkRadrootsNostrEvent>, -) -> Result<OrderReceiptView, RuntimeError> { - enqueue_order_receipt_via_sdk(config, args, status, signing, payload, evidence_events) - .map_err(cli_sdk_error_to_runtime) +fn event_matches_tag_value(event: &RadrootsNostrEvent, key: &str, value: &str) -> bool { + event.tags.iter().any(|tag| { + let values = tag.as_slice(); + values.first().map(String::as_str) == Some(key) + && values.get(1).map(String::as_str) == Some(value) + }) } -fn prepare_order_revision_proposal_dry_run_via_sdk( - config: &RuntimeConfig, - signing: &account::AccountSigningIdentity, - payload: &RadrootsOrderRevisionProposal, -) -> Result<(), RuntimeError> { - let actor = sdk_order_lifecycle_actor(signing, RadrootsActorRole::Seller, "revision") - .map_err(cli_sdk_error_to_runtime)?; - let session = CliSdkSession::connect_memory(config).map_err(cli_sdk_error_to_runtime)?; - session - .sdk() - .orders() - .prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new( - actor, - sdk_order_event_ptr(&payload.root_event_id, config.relay.urls.as_slice()), - sdk_order_event_ptr(&payload.prev_event_id, config.relay.urls.as_slice()), - payload.clone(), - )) - .map(|_| ()) - .map_err(|error| RuntimeError::Config(error.to_string())) +fn seller_order_request_from_event( + event: &RadrootsNostrEvent, + seller_pubkey: &str, + order_id: &str, +) -> Result<ResolvedSellerOrderRequest, RuntimeError> { + let event_kind = event_kind_u32(event); + if event_kind != KIND_ORDER_REQUEST { + return Err(RuntimeError::Config(format!( + "order decision received unexpected kind `{event_kind}`" + ))); + } + + let request_event = radroots_event_from_nostr(event); + let event_id = protocol_event_id(request_event.id.as_str(), "request_event_id")?; + let seller_protocol_pubkey = protocol_pubkey(seller_pubkey, "seller_pubkey")?; + let envelope = order_request_from_event(&request_event) + .map_err(|error| RuntimeError::Config(format!("decode order request event: {error}")))?; + let context = + order_event_context_from_tags(RadrootsOrderEventType::OrderRequested, &request_event.tags) + .map_err(|error| RuntimeError::Config(format!("decode order request tags: {error}")))?; + + if envelope.order_id.to_string() != order_id + || envelope.payload.order_id.to_string() != order_id + { + return Err(RuntimeError::Config( + "order request does not match requested order id".to_owned(), + )); + } + if context.counterparty_pubkey != seller_protocol_pubkey + || envelope.payload.seller_pubkey != seller_protocol_pubkey + { + return Err(RuntimeError::Config( + "order request is not targeted at the selected seller".to_owned(), + )); + } + let listing_addr = + parse_listing_addr(envelope.payload.listing_addr.as_str()).map_err(|error| { + RuntimeError::Config(format!("order request listing_addr is invalid: {error}")) + })?; + if listing_addr.seller_pubkey != seller_pubkey { + return Err(RuntimeError::Config( + "order request listing address is outside selected seller authority".to_owned(), + )); + } + let listing_event_id = context.listing_event.as_ref().map(|event| event.id.clone()); + + Ok(ResolvedSellerOrderRequest { + request_event, + request_event_id: event_id, + listing_event_id, + order_id: envelope.payload.order_id, + listing_addr: envelope.payload.listing_addr, + buyer_pubkey: envelope.payload.buyer_pubkey, + seller_pubkey: envelope.payload.seller_pubkey, + items: envelope.payload.items, + economics: envelope.payload.economics, + }) } -fn prepare_order_revision_decision_dry_run_via_sdk( +fn publish_order_decision( config: &RuntimeConfig, - signing: &account::AccountSigningIdentity, - payload: &RadrootsOrderRevisionDecision, -) -> Result<(), RuntimeError> { - let actor = sdk_order_lifecycle_actor(signing, RadrootsActorRole::Buyer, "revision decision") + args: &OrderDecisionArgs, + request: ResolvedSellerOrderRequest, + resolution: SellerOrderRequestResolution, + signing: account::AccountSigningIdentity, + payload: RadrootsOrderDecision, + inventory: Option<OrderInventoryView>, +) -> Result<OrderDecisionView, RuntimeError> { + let input = sdk_order_decision_input(config, &request, &signing, payload) .map_err(cli_sdk_error_to_runtime)?; - let session = CliSdkSession::connect_memory(config).map_err(cli_sdk_error_to_runtime)?; - session - .sdk() - .orders() - .prepare_revision_decision(OrderRevisionDecisionPrepareRequest::new( - actor, - sdk_order_event_ptr(&payload.root_event_id, config.relay.urls.as_slice()), - sdk_order_event_ptr(&payload.prev_event_id, config.relay.urls.as_slice()), - payload.clone(), - )) - .map(|_| ()) - .map_err(|error| RuntimeError::Config(error.to_string())) + enqueue_order_decision_via_sdk(config, args, request, resolution, signing, input, inventory) + .map_err(cli_sdk_error_to_runtime) } -fn prepare_order_fulfillment_dry_run_via_sdk( +fn sdk_order_decision_input( config: &RuntimeConfig, + request: &ResolvedSellerOrderRequest, signing: &account::AccountSigningIdentity, - status: &OrderStatusView, - payload: &RadrootsOrderFulfillmentUpdate, -) -> Result<(), RuntimeError> { - let actor = sdk_order_lifecycle_actor(signing, RadrootsActorRole::Seller, "fulfillment") - .map_err(cli_sdk_error_to_runtime)?; - let root_event_id = protocol_event_id( - status.request_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("accepted order is missing request_event_id".to_owned()) - })?, - "request_event_id", - )?; - let previous_event_id = protocol_event_id( - status - .last_event_id - .as_deref() - .or(status.decision_event_id.as_deref()) - .ok_or_else(|| { - RuntimeError::Config("accepted order is missing previous event id".to_owned()) - })?, - "prev_event_id", - )?; - let session = CliSdkSession::connect_memory(config).map_err(cli_sdk_error_to_runtime)?; - session - .sdk() - .orders() - .prepare_fulfillment_update(OrderFulfillmentUpdatePrepareRequest::new( - actor, - sdk_order_event_ptr(&root_event_id, config.relay.urls.as_slice()), - sdk_order_event_ptr(&previous_event_id, config.relay.urls.as_slice()), - payload.clone(), - )) - .map(|_| ()) - .map_err(|error| RuntimeError::Config(error.to_string())) + payload: RadrootsOrderDecision, +) -> Result<SdkOrderDecisionInput, CliSdkAdapterError> { + let actor = RadrootsActorContext::local_account( + signing + .account + .record + .public_identity + .public_key_hex + .as_str(), + signing.account.record.account_id.to_string(), + [RadrootsActorRole::Seller], + ) + .map_err(|error| RuntimeError::Config(format!("invalid order decision SDK actor: {error}")))?; + let target_relays = order_decision_target_relays(config)?; + Ok(SdkOrderDecisionInput { + actor, + request_event: request.request_event.clone(), + request_event_ptr: order_decision_request_event_ptr(request, target_relays.as_slice()), + decision: payload, + target_relays, + }) } -fn prepare_order_cancellation_dry_run_via_sdk( - config: &RuntimeConfig, - signing: &account::AccountSigningIdentity, - status: &OrderStatusView, - payload: &RadrootsOrderCancellation, -) -> Result<(), RuntimeError> { - let actor = sdk_order_lifecycle_actor(signing, RadrootsActorRole::Buyer, "cancellation") - .map_err(cli_sdk_error_to_runtime)?; - let root_event_id = protocol_event_id( - status.request_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("cancellable order is missing request_event_id".to_owned()) - })?, - "request_event_id", - )?; - let previous_event_id = protocol_event_id( - order_cancellation_prev_event_id(status) - .ok_or_else(|| { - RuntimeError::Config("cancellable order is missing previous event id".to_owned()) - })? - .as_str(), - "prev_event_id", - )?; - let session = CliSdkSession::connect_memory(config).map_err(cli_sdk_error_to_runtime)?; - session - .sdk() - .orders() - .prepare_cancellation(OrderCancellationPrepareRequest::new( - actor, - sdk_order_event_ptr(&root_event_id, config.relay.urls.as_slice()), - sdk_order_event_ptr(&previous_event_id, config.relay.urls.as_slice()), - payload.clone(), - )) - .map(|_| ()) - .map_err(|error| RuntimeError::Config(error.to_string())) +#[derive(Debug, Clone)] +struct SdkOrderDecisionInput { + actor: RadrootsActorContext, + request_event: SdkRadrootsNostrEvent, + request_event_ptr: RadrootsNostrEventPtr, + decision: RadrootsOrderDecision, + target_relays: Vec<String>, } -fn prepare_order_receipt_dry_run_via_sdk( - config: &RuntimeConfig, - signing: &account::AccountSigningIdentity, - status: &OrderStatusView, - payload: &RadrootsOrderReceipt, -) -> Result<(), RuntimeError> { - let actor = sdk_order_lifecycle_actor(signing, RadrootsActorRole::Buyer, "receipt") - .map_err(cli_sdk_error_to_runtime)?; - let root_event_id = protocol_event_id( - status.request_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("receiptable order is missing request_event_id".to_owned()) - })?, - "request_event_id", - )?; - let previous_event_id = protocol_event_id( - order_receipt_prev_event_id(status) - .ok_or_else(|| { - RuntimeError::Config( - "receiptable order is missing eligible fulfillment event id".to_owned(), - ) - })? - .as_str(), - "prev_event_id", - )?; - let session = CliSdkSession::connect_memory(config).map_err(cli_sdk_error_to_runtime)?; - session - .sdk() - .orders() - .prepare_receipt_record(OrderReceiptRecordPrepareRequest::new( - actor, - sdk_order_event_ptr(&root_event_id, config.relay.urls.as_slice()), - sdk_order_event_ptr(&previous_event_id, config.relay.urls.as_slice()), - payload.clone(), - )) - .map(|_| ()) - .map_err(|error| RuntimeError::Config(error.to_string())) +fn order_decision_request_event_ptr( + request: &ResolvedSellerOrderRequest, + target_relays: &[String], +) -> RadrootsNostrEventPtr { + RadrootsNostrEventPtr { + id: request.request_event_id.as_str().to_owned(), + relays: target_relays.first().cloned(), + } } -fn enqueue_order_revision_proposal_via_sdk( - config: &RuntimeConfig, - args: &OrderRevisionProposeArgs, - status: OrderStatusView, - signing: account::AccountSigningIdentity, - payload: RadrootsOrderRevisionProposal, - evidence_events: Vec<SdkRadrootsNostrEvent>, -) -> Result<OrderRevisionProposalView, CliSdkAdapterError> { - let target_relays = order_decision_target_relays(config)?; - let policy = order_decision_relay_url_policy(target_relays.as_slice()); - let actor = sdk_order_lifecycle_actor(&signing, RadrootsActorRole::Seller, "revision")?; - let signer = sdk_signer_from_account(signing)?; - let target_policy = SdkRelayTargetPolicy::try_explicit(target_relays.clone(), policy)?; - let mut request = OrderRevisionProposalEnqueueRequest::new( - actor, - sdk_order_event_ptr(&payload.root_event_id, target_relays.as_slice()), - sdk_order_event_ptr(&payload.prev_event_id, target_relays.as_slice()), - payload.clone(), - target_policy, - ); - if let Some(idempotency_key) = args.idempotency_key.as_deref() { - request = request.try_with_idempotency_key(idempotency_key)?; +fn order_decision_target_relays(config: &RuntimeConfig) -> Result<Vec<String>, RuntimeError> { + 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 Err(RuntimeError::Config( + "order decision requires at least one configured relay".to_owned(), + )); } + Ok(target_relays) +} - let session = CliSdkSession::connect(config)?; - ingest_order_evidence_events(&session, evidence_events)?; - let enqueue = session.block_on( - session - .sdk() - .orders() - .enqueue_revision_proposal(request, &signer), - )?; - let push = push_one_sdk_outbox_event(&session, policy)?; - Ok(sdk_enqueued_order_revision_view( - config, - args, - &status, - &payload, - enqueue, - push, - target_relays, - )) +fn order_decision_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 enqueue_order_revision_decision_via_sdk( +fn enqueue_order_decision_via_sdk( config: &RuntimeConfig, - args: &OrderRevisionDecisionArgs, - status: OrderStatusView, - proposal: &OrderRevisionProposalRecord, + args: &OrderDecisionArgs, + request_context: ResolvedSellerOrderRequest, + resolution: SellerOrderRequestResolution, signing: account::AccountSigningIdentity, - payload: RadrootsOrderRevisionDecision, - evidence_events: Vec<SdkRadrootsNostrEvent>, -) -> Result<OrderRevisionDecisionView, CliSdkAdapterError> { - let target_relays = order_decision_target_relays(config)?; + input: SdkOrderDecisionInput, + inventory: Option<OrderInventoryView>, +) -> Result<OrderDecisionView, CliSdkAdapterError> { + let target_relays = input.target_relays.clone(); let policy = order_decision_relay_url_policy(target_relays.as_slice()); - let actor = sdk_order_lifecycle_actor(&signing, RadrootsActorRole::Buyer, "revision decision")?; - let signer = sdk_signer_from_account(signing)?; let target_policy = SdkRelayTargetPolicy::try_explicit(target_relays.clone(), policy)?; - let mut request = OrderRevisionDecisionEnqueueRequest::new( - actor, - sdk_order_event_ptr(&payload.root_event_id, target_relays.as_slice()), - sdk_order_event_ptr(&payload.prev_event_id, target_relays.as_slice()), - payload.clone(), + let mut request = OrderDecisionEnqueueRequest::new( + input.actor, + input.request_event_ptr, + input.decision, target_policy, ); if let Some(idempotency_key) = args.idempotency_key.as_deref() { @@ -8358,446 +6119,130 @@ fn enqueue_order_revision_decision_via_sdk( } let session = CliSdkSession::connect(config)?; - ingest_order_evidence_events(&session, evidence_events)?; - let enqueue = session.block_on( + session.block_on( session .sdk() .orders() - .enqueue_revision_decision(request, &signer), + .ingest_request_evidence(OrderRequestEvidenceIngestRequest::new(input.request_event)), )?; - let push = push_one_sdk_outbox_event(&session, policy)?; - Ok(sdk_enqueued_order_revision_decision_view( + 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_decision(request, &signer))?; + let push = session.block_on( + session.sdk().sync().push_outbox( + PushOutboxRequest::new() + .with_limit(1) + .with_relay_url_policy(policy), + ), + )?; + Ok(sdk_enqueued_order_decision_view( config, args, - &status, - proposal, - &payload, + request_context, + resolution, enqueue, push, target_relays, + inventory, )) } -fn enqueue_order_fulfillment_via_sdk( - config: &RuntimeConfig, - args: &OrderFulfillmentArgs, - status: OrderStatusView, - signing: account::AccountSigningIdentity, - payload: RadrootsOrderFulfillmentUpdate, - evidence_events: Vec<SdkRadrootsNostrEvent>, -) -> Result<OrderFulfillmentView, CliSdkAdapterError> { - let root_event_id = protocol_event_id( - status.request_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("accepted order is missing request_event_id".to_owned()) - })?, - "request_event_id", - )?; - let previous_event_id = protocol_event_id( - status - .last_event_id - .as_deref() - .or(status.decision_event_id.as_deref()) - .ok_or_else(|| { - RuntimeError::Config("accepted order is missing previous event id".to_owned()) - })?, - "prev_event_id", - )?; - let target_relays = order_decision_target_relays(config)?; - let policy = order_decision_relay_url_policy(target_relays.as_slice()); - let actor = sdk_order_lifecycle_actor(&signing, RadrootsActorRole::Seller, "fulfillment")?; - let signer = sdk_signer_from_account(signing)?; - let target_policy = SdkRelayTargetPolicy::try_explicit(target_relays.clone(), policy)?; - let mut request = OrderFulfillmentUpdateEnqueueRequest::new( - actor, - sdk_order_event_ptr(&root_event_id, target_relays.as_slice()), - sdk_order_event_ptr(&previous_event_id, target_relays.as_slice()), - payload.clone(), - target_policy, - ); - if let Some(idempotency_key) = args.idempotency_key.as_deref() { - request = request.try_with_idempotency_key(idempotency_key)?; +fn cli_sdk_error_to_runtime(error: CliSdkAdapterError) -> RuntimeError { + match error { + CliSdkAdapterError::Runtime(error) => error, + CliSdkAdapterError::Sdk(error) => RuntimeError::Config(error.to_string()), } - - let session = CliSdkSession::connect(config)?; - ingest_order_evidence_events(&session, evidence_events)?; - let enqueue = session.block_on( - session - .sdk() - .orders() - .enqueue_fulfillment_update(request, &signer), - )?; - let push = push_one_sdk_outbox_event(&session, policy)?; - Ok(sdk_enqueued_order_fulfillment_view( - config, - args, - &status, - payload.status, - enqueue, - push, - target_relays, - )) } -fn enqueue_order_cancellation_via_sdk( - config: &RuntimeConfig, - args: &OrderCancelArgs, - status: OrderStatusView, - signing: account::AccountSigningIdentity, - payload: RadrootsOrderCancellation, - evidence_events: Vec<SdkRadrootsNostrEvent>, -) -> Result<OrderCancellationView, CliSdkAdapterError> { - let root_event_id = protocol_event_id( - status.request_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("cancellable order is missing request_event_id".to_owned()) - })?, - "request_event_id", - )?; - let previous_event_id = protocol_event_id( - order_cancellation_prev_event_id(&status) - .ok_or_else(|| { - RuntimeError::Config("cancellable order is missing previous event id".to_owned()) - })? - .as_str(), - "prev_event_id", - )?; - let target_relays = order_decision_target_relays(config)?; - let policy = order_decision_relay_url_policy(target_relays.as_slice()); - let actor = sdk_order_lifecycle_actor(&signing, RadrootsActorRole::Buyer, "cancellation")?; - let signer = sdk_signer_from_account(signing)?; - let target_policy = SdkRelayTargetPolicy::try_explicit(target_relays.clone(), policy)?; - let mut request = OrderCancellationEnqueueRequest::new( - actor, - sdk_order_event_ptr(&root_event_id, target_relays.as_slice()), - sdk_order_event_ptr(&previous_event_id, target_relays.as_slice()), - payload, - 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)?; - ingest_order_evidence_events(&session, evidence_events)?; - let enqueue = session.block_on( - session - .sdk() - .orders() - .enqueue_cancellation(request, &signer), - )?; - let push = push_one_sdk_outbox_event(&session, policy)?; - Ok(sdk_enqueued_order_cancellation_view( - config, - args, - &status, - enqueue, - push, - target_relays, - )) +fn canonical_order_decision_payload( + args: &OrderDecisionArgs, + request: &ResolvedSellerOrderRequest, + signer_pubkey: &str, +) -> Result<RadrootsOrderDecision, RuntimeError> { + let payload = order_decision_payload_from_request(args, request)?; + canonicalize_order_decision_for_signer(payload, signer_pubkey) + .map_err(|error| RuntimeError::Config(format!("canonicalize order decision: {error}"))) } -fn enqueue_order_receipt_via_sdk( - config: &RuntimeConfig, - args: &OrderReceiptArgs, - status: OrderStatusView, - signing: account::AccountSigningIdentity, - payload: RadrootsOrderReceipt, - evidence_events: Vec<SdkRadrootsNostrEvent>, -) -> Result<OrderReceiptView, CliSdkAdapterError> { - let root_event_id = protocol_event_id( - status.request_event_id.as_deref().ok_or_else(|| { - RuntimeError::Config("receiptable order is missing request_event_id".to_owned()) - })?, - "request_event_id", - )?; - let previous_event_id = protocol_event_id( - order_receipt_prev_event_id(&status) - .ok_or_else(|| { - RuntimeError::Config( - "receiptable order is missing eligible fulfillment event id".to_owned(), - ) - })? - .as_str(), - "prev_event_id", - )?; - let target_relays = order_decision_target_relays(config)?; - let policy = order_decision_relay_url_policy(target_relays.as_slice()); - let actor = sdk_order_lifecycle_actor(&signing, RadrootsActorRole::Buyer, "receipt")?; - let signer = sdk_signer_from_account(signing)?; - let target_policy = SdkRelayTargetPolicy::try_explicit(target_relays.clone(), policy)?; - let mut request = OrderReceiptRecordEnqueueRequest::new( - actor, - sdk_order_event_ptr(&root_event_id, target_relays.as_slice()), - sdk_order_event_ptr(&previous_event_id, target_relays.as_slice()), - payload.clone(), - target_policy, - ); - if let Some(idempotency_key) = args.idempotency_key.as_deref() { - request = request.try_with_idempotency_key(idempotency_key)?; +fn order_decision_payload_from_request( + args: &OrderDecisionArgs, + request: &ResolvedSellerOrderRequest, +) -> Result<RadrootsOrderDecision, RuntimeError> { + match args.decision { + OrderDecisionArg::Accept => Ok(accepted_order_decision_payload_from_request(request)), + OrderDecisionArg::Decline => { + let reason = args + .reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()) + .ok_or_else(|| { + RuntimeError::Config("order decline requires a non-empty reason".to_owned()) + })?; + Ok(declined_order_decision_payload_from_request( + request, reason, + )) + } } - - let session = CliSdkSession::connect(config)?; - ingest_order_evidence_events(&session, evidence_events)?; - let enqueue = session.block_on( - session - .sdk() - .orders() - .enqueue_receipt_record(request, &signer), - )?; - let push = push_one_sdk_outbox_event(&session, policy)?; - Ok(sdk_enqueued_order_receipt_view( - config, - args, - &status, - &payload, - enqueue, - push, - target_relays, - )) -} - -fn sdk_order_lifecycle_actor( - signing: &account::AccountSigningIdentity, - role: RadrootsActorRole, - workflow: &str, -) -> Result<RadrootsActorContext, CliSdkAdapterError> { - RadrootsActorContext::local_account( - signing - .account - .record - .public_identity - .public_key_hex - .as_str(), - signing.account.record.account_id.to_string(), - [role], - ) - .map_err(|error| { - RuntimeError::Config(format!("invalid order {workflow} SDK actor: {error}")).into() - }) -} - -fn sdk_signer_from_account( - signing: account::AccountSigningIdentity, -) -> Result<RadrootsLocalEventSigner, CliSdkAdapterError> { - let keys: RadrootsNostrKeys = signing.identity.into_keys(); - RadrootsLocalEventSigner::new(keys) - .map_err(|error| RuntimeError::Config(error.to_string()).into()) } -fn sdk_order_event_ptr( - event_id: &RadrootsEventId, - target_relays: &[String], -) -> RadrootsNostrEventPtr { - RadrootsNostrEventPtr { - id: event_id.as_str().to_owned(), - relays: target_relays.first().cloned(), +fn accepted_order_decision_payload_from_request( + request: &ResolvedSellerOrderRequest, +) -> RadrootsOrderDecision { + RadrootsOrderDecision { + order_id: request.order_id.clone(), + listing_addr: request.listing_addr.clone(), + buyer_pubkey: request.buyer_pubkey.clone(), + seller_pubkey: request.seller_pubkey.clone(), + decision: RadrootsOrderDecisionOutcome::Accepted { + inventory_commitments: request + .items + .iter() + .map(|item| RadrootsOrderInventoryCommitment { + bin_id: item.bin_id.clone(), + bin_count: item.bin_count, + }) + .collect(), + }, } } -fn ingest_order_evidence_events( - session: &CliSdkSession, - events: Vec<SdkRadrootsNostrEvent>, -) -> Result<(), CliSdkAdapterError> { - for event in events { - session.block_on( - session - .sdk() - .orders() - .ingest_evidence(OrderEvidenceIngestRequest::new(event)), - )?; +fn declined_order_decision_payload_from_request( + request: &ResolvedSellerOrderRequest, + reason: &str, +) -> RadrootsOrderDecision { + RadrootsOrderDecision { + order_id: request.order_id.clone(), + listing_addr: request.listing_addr.clone(), + buyer_pubkey: request.buyer_pubkey.clone(), + seller_pubkey: request.seller_pubkey.clone(), + decision: RadrootsOrderDecisionOutcome::Declined { + reason: reason.to_owned(), + }, } - Ok(()) -} - -fn push_one_sdk_outbox_event( - session: &CliSdkSession, - policy: SdkRelayUrlPolicy, -) -> Result<PushOutboxReceipt, CliSdkAdapterError> { - Ok(session.block_on( - session.sdk().sync().push_outbox( - PushOutboxRequest::new() - .with_limit(1) - .with_relay_url_policy(policy), - ), - )?) } -fn sdk_enqueued_order_revision_view( +fn sdk_enqueued_order_decision_view( config: &RuntimeConfig, - args: &OrderRevisionProposeArgs, - status: &OrderStatusView, - payload: &RadrootsOrderRevisionProposal, - enqueue: OrderRevisionProposalReceipt, + args: &OrderDecisionArgs, + request: ResolvedSellerOrderRequest, + resolution: SellerOrderRequestResolution, + enqueue: OrderDecisionReceipt, push: PushOutboxReceipt, target_relays: Vec<String>, -) -> OrderRevisionProposalView { - let push_event = sdk_push_event_for_event_id(&enqueue.signed_event_id, &push); - let mut view = order_revision_base_view( - config, - args, - sdk_order_lifecycle_state("proposed", push_event).as_str(), - false, - ); - apply_order_revision_status(&mut view, status); - apply_order_revision_payload(&mut view, payload); - view.event_id = Some(enqueue.signed_event_id.as_str().to_owned()); - view.event_kind = Some(KIND_ORDER_REVISION_PROPOSAL); - view.target_relays = push_event - .map(sdk_push_target_relays) - .unwrap_or(target_relays); - view.connected_relays = push_event - .map(sdk_push_connected_relays) - .unwrap_or_default(); - view.acknowledged_relays = push_event - .map(sdk_push_acknowledged_relays) - .unwrap_or_default(); - view.failed_relays = push_event.map(sdk_push_failed_relays).unwrap_or_default(); - view.reason = - sdk_order_lifecycle_reason("order revision proposal", &enqueue.workflow, push_event); - view.actions = sdk_order_lifecycle_actions(push_event); - view -} - -fn sdk_enqueued_order_revision_decision_view( - config: &RuntimeConfig, - args: &OrderRevisionDecisionArgs, - status: &OrderStatusView, - proposal: &OrderRevisionProposalRecord, - payload: &RadrootsOrderRevisionDecision, - enqueue: OrderRevisionDecisionReceipt, - push: PushOutboxReceipt, - target_relays: Vec<String>, -) -> OrderRevisionDecisionView { - let push_event = sdk_push_event_for_event_id(&enqueue.signed_event_id, &push); - let success_state = match payload.decision { - RadrootsOrderRevisionOutcome::Accepted => "accepted", - RadrootsOrderRevisionOutcome::Declined { .. } => "declined", - }; - let mut view = order_revision_decision_base_view( - config, - args, - sdk_order_lifecycle_state(success_state, push_event).as_str(), - false, - ); - apply_order_revision_decision_status(&mut view, status); - apply_order_revision_decision_payload(&mut view, proposal, payload); - view.event_id = Some(enqueue.signed_event_id.as_str().to_owned()); - view.event_kind = Some(KIND_ORDER_REVISION_DECISION); - if matches!(payload.decision, RadrootsOrderRevisionOutcome::Accepted) { - view.agreement_event_id = Some(enqueue.signed_event_id.as_str().to_owned()); - } - view.target_relays = push_event - .map(sdk_push_target_relays) - .unwrap_or(target_relays); - view.connected_relays = push_event - .map(sdk_push_connected_relays) - .unwrap_or_default(); - view.acknowledged_relays = push_event - .map(sdk_push_acknowledged_relays) - .unwrap_or_default(); - view.failed_relays = push_event.map(sdk_push_failed_relays).unwrap_or_default(); - view.reason = - sdk_order_lifecycle_reason("order revision decision", &enqueue.workflow, push_event); - view.actions = sdk_order_lifecycle_actions(push_event); - view -} - -fn sdk_enqueued_order_fulfillment_view( - config: &RuntimeConfig, - args: &OrderFulfillmentArgs, - status: &OrderStatusView, - fulfillment_state: RadrootsOrderFulfillmentState, - enqueue: OrderFulfillmentUpdateReceipt, - push: PushOutboxReceipt, - target_relays: Vec<String>, -) -> OrderFulfillmentView { - let push_event = sdk_push_event_for_event_id(&enqueue.signed_event_id, &push); - let state = fulfillment_state_name(fulfillment_state); - let mut view = order_fulfillment_base_view( - config, - args, - sdk_order_lifecycle_state(state, push_event).as_str(), - false, - ); - apply_order_fulfillment_status(&mut view, status); - view.fulfillment_state = state.to_owned(); - view.event_id = Some(enqueue.signed_event_id.as_str().to_owned()); - view.event_kind = Some(KIND_ORDER_FULFILLMENT_UPDATE); - view.target_relays = push_event - .map(sdk_push_target_relays) - .unwrap_or(target_relays); - view.connected_relays = push_event - .map(sdk_push_connected_relays) - .unwrap_or_default(); - view.acknowledged_relays = push_event - .map(sdk_push_acknowledged_relays) - .unwrap_or_default(); - view.failed_relays = push_event.map(sdk_push_failed_relays).unwrap_or_default(); - view.reason = - sdk_order_lifecycle_reason("order fulfillment update", &enqueue.workflow, push_event); - view.actions = sdk_order_lifecycle_actions(push_event); - view -} - -fn sdk_enqueued_order_cancellation_view( - config: &RuntimeConfig, - args: &OrderCancelArgs, - status: &OrderStatusView, - enqueue: OrderCancellationReceipt, - push: PushOutboxReceipt, - target_relays: Vec<String>, -) -> OrderCancellationView { - let push_event = sdk_push_event_for_event_id(&enqueue.signed_event_id, &push); - let mut view = order_cancellation_base_view( - config, - args, - sdk_order_lifecycle_state("cancelled", push_event).as_str(), - false, - ); - apply_order_cancellation_status(&mut view, status); - view.event_id = Some(enqueue.signed_event_id.as_str().to_owned()); - view.event_kind = Some(KIND_ORDER_CANCELLATION); - view.target_relays = push_event - .map(sdk_push_target_relays) - .unwrap_or(target_relays); - view.connected_relays = push_event - .map(sdk_push_connected_relays) - .unwrap_or_default(); - view.acknowledged_relays = push_event - .map(sdk_push_acknowledged_relays) - .unwrap_or_default(); - view.failed_relays = push_event.map(sdk_push_failed_relays).unwrap_or_default(); - view.reason = sdk_order_lifecycle_reason("order cancellation", &enqueue.workflow, push_event); - view.actions = sdk_order_lifecycle_actions(push_event); - view -} - -fn sdk_enqueued_order_receipt_view( - config: &RuntimeConfig, - args: &OrderReceiptArgs, - status: &OrderStatusView, - payload: &RadrootsOrderReceipt, - enqueue: OrderReceiptRecordReceipt, - push: PushOutboxReceipt, - target_relays: Vec<String>, -) -> OrderReceiptView { - let push_event = sdk_push_event_for_event_id(&enqueue.signed_event_id, &push); - let success_state = if payload.received { - "completed" - } else { - "disputed" - }; - let mut view = order_receipt_base_view( + inventory: Option<OrderInventoryView>, +) -> OrderDecisionView { + let push_event = sdk_push_event_for_order_decision(&enqueue, &push); + let mut view = order_decision_base_view( config, args, - sdk_order_lifecycle_state(success_state, push_event).as_str(), + sdk_order_decision_state(args.decision, push_event).as_str(), false, ); - apply_order_receipt_status(&mut view, status); - view.received = payload.received; - view.issue = payload.issue.clone(); - view.received_at = Some(payload.received_at); + apply_order_decision_request(&mut view, &request); view.event_id = Some(enqueue.signed_event_id.as_str().to_owned()); - view.event_kind = Some(KIND_ORDER_RECEIPT); + view.event_kind = Some(KIND_ORDER_DECISION); view.target_relays = push_event .map(sdk_push_target_relays) .unwrap_or(target_relays); @@ -8808,24 +6253,30 @@ fn sdk_enqueued_order_receipt_view( .map(sdk_push_acknowledged_relays) .unwrap_or_default(); view.failed_relays = push_event.map(sdk_push_failed_relays).unwrap_or_default(); - view.reason = sdk_order_lifecycle_reason("order receipt record", &enqueue.workflow, push_event); - view.actions = sdk_order_lifecycle_actions(push_event); + view.fetched_count = resolution.fetched_count; + view.decoded_count = resolution.decoded_count; + view.skipped_count = resolution.skipped_count; + view.inventory = order_decision_inventory_for_view(args, &request, inventory); + view.reason = sdk_order_decision_reason(&enqueue.workflow, push_event); + view.actions = sdk_order_decision_actions(push_event); view } -fn sdk_push_event_for_event_id<'a>( - event_id: &RadrootsEventId, +fn sdk_push_event_for_order_decision<'a>( + enqueue: &OrderDecisionReceipt, push: &'a PushOutboxReceipt, ) -> Option<&'a PushOutboxEventReceipt> { - push.events.iter().find(|event| event.event_id == *event_id) + push.events + .iter() + .find(|event| event.event_id == enqueue.signed_event_id) } -fn sdk_order_lifecycle_state( - published_state: &str, +fn sdk_order_decision_state( + decision: OrderDecisionArg, push_event: Option<&PushOutboxEventReceipt>, ) -> String { match push_event.map(|event| event.final_state) { - Some(PushOutboxEventState::Published) => published_state, + Some(PushOutboxEventState::Published) => decision.as_str(), Some(PushOutboxEventState::PublishRetryable | PushOutboxEventState::FailedTerminal) => { "unavailable" } @@ -8834,37 +6285,36 @@ fn sdk_order_lifecycle_state( .to_owned() } -fn sdk_order_lifecycle_reason( - workflow: &str, +fn sdk_order_decision_reason( enqueue: &OrderWorkflowEnqueueReceipt, push_event: Option<&PushOutboxEventReceipt>, ) -> Option<String> { match push_event.map(|event| event.final_state) { Some(PushOutboxEventState::Published) => None, Some(PushOutboxEventState::PublishRetryable) => Some(format!( - "{}; SDK relay publish for {workflow} did not reach accepted quorum; outbox event remains retryable; {}", + "{}; SDK relay publish did not reach accepted quorum; outbox event remains retryable; {}", sdk_order_enqueue_summary(enqueue), sdk_order_enqueue_retry_summary(enqueue) )), Some(PushOutboxEventState::FailedTerminal) => Some(format!( - "{}; SDK relay publish for {workflow} failed terminally; {}", + "{}; SDK relay publish failed terminally; {}", sdk_order_enqueue_summary(enqueue), sdk_order_enqueue_retry_summary(enqueue) )), Some(state) => Some(format!( - "{}; SDK relay push for {workflow} left event in state `{state:?}`; {}", + "{}; SDK relay push left event in state `{state:?}`; {}", sdk_order_enqueue_summary(enqueue), sdk_order_enqueue_retry_summary(enqueue) )), None => Some(format!( - "{}; {workflow} queued in SDK outbox; no ready SDK outbox event was pushed; {}", + "{}; order decision queued in SDK outbox; no ready SDK outbox event was pushed; {}", sdk_order_enqueue_summary(enqueue), sdk_order_enqueue_retry_summary(enqueue) )), } } -fn sdk_order_lifecycle_actions(push_event: Option<&PushOutboxEventReceipt>) -> Vec<String> { +fn sdk_order_decision_actions(push_event: Option<&PushOutboxEventReceipt>) -> Vec<String> { if !matches!( push_event.map(|event| event.final_state), Some(PushOutboxEventState::Published) @@ -8874,11787 +6324,4025 @@ fn sdk_order_lifecycle_actions(push_event: Option<&PushOutboxEventReceipt>) -> V Vec::new() } -fn sdk_order_enqueue_summary(enqueue: &OrderWorkflowEnqueueReceipt) -> String { - format!( - "local SDK enqueue completed for `{}` as `{}` with outbox_event_id {}; {}", - enqueue.operation_kind, - sdk_mutation_state_label(&enqueue.state), - enqueue.outbox_event_id, - sdk_order_idempotency_summary(enqueue) - ) +fn order_decision_binding_error_view( + config: &RuntimeConfig, + args: &OrderDecisionArgs, + request: ResolvedSellerOrderRequest, + resolution: SellerOrderRequestResolution, + error: ActorWriteBindingError, +) -> OrderDecisionView { + let (state, reason, actions) = order_actor_write_binding_error_parts(error); + let mut view = order_decision_base_view(config, args, state.as_str(), config.output.dry_run); + apply_order_decision_resolution(&mut view, &resolution); + apply_order_decision_request(&mut view, &request); + view.reason = Some(reason); + view.actions = actions; + view } -fn sdk_order_idempotency_summary(enqueue: &OrderWorkflowEnqueueReceipt) -> &'static str { - if enqueue.idempotency.replayed_existing_operation { - "idempotency replayed an existing queued operation" - } else if enqueue.idempotency.safe_to_retry_with_same_idempotency_key { - "same idempotency key remains retry-safe" - } else { - "same idempotency key retry safety is unavailable" +fn order_event_list_entry_from_event( + event: &RadrootsNostrEvent, + seller_pubkey: &str, +) -> Result<OrderEventListEntryView, RuntimeError> { + let event_kind = event_kind_u32(event); + if event_kind != KIND_ORDER_REQUEST { + return Err(RuntimeError::Config(format!( + "order event list received unexpected kind `{event_kind}`" + ))); } -} -fn sdk_order_enqueue_retry_summary(enqueue: &OrderWorkflowEnqueueReceipt) -> &'static str { - if enqueue - .retry - .safe_to_retry_enqueue_with_same_idempotency_key - { - "enqueue is safe to retry with the same idempotency key" - } else if enqueue.retry.retryable_after_error { - "inspect local SDK state before retrying enqueue" - } else { - "do not retry enqueue before inspecting local SDK state" - } -} + let event = radroots_event_from_nostr(event); + let envelope = order_request_from_event(&event) + .map_err(|error| RuntimeError::Config(format!("decode order request event: {error}")))?; + let context = + order_event_context_from_tags(RadrootsOrderEventType::OrderRequested, &event.tags) + .map_err(|error| RuntimeError::Config(format!("decode order request tags: {error}")))?; -fn sdk_mutation_state_label(state: &SdkMutationState) -> &'static str { - match state { - SdkMutationState::StoredAndQueued => "stored_and_queued", - SdkMutationState::AlreadyQueued => "already_queued", - _ => "unknown", + if context.counterparty_pubkey != seller_pubkey + || envelope.payload.seller_pubkey != seller_pubkey + { + return Err(RuntimeError::Config( + "order request is not targeted at the selected seller".to_owned(), + )); } -} -fn sdk_order_push_recovery_actions() -> Vec<String> { - vec![ - "radroots sync push".to_owned(), - "radroots sync status get".to_owned(), - ] -} + let listing_event_id = context.listing_event.as_ref().map(|event| event.id.clone()); + let created_at_unix = u64::from(event.created_at); -fn publish_order_payment( - config: &RuntimeConfig, - args: &OrderPaymentArgs, - status: OrderStatusView, - signing: account::AccountSigningIdentity, - payload: RadrootsOrderPaymentRecord, -) -> Result<OrderPaymentView, RuntimeError> { - let parts = order_payment_event_parts(&status, &payload)?; - 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_payment_view( - config, args, &status, &payload, event_kind, receipt, - )) -} - -fn publish_order_settlement( - config: &RuntimeConfig, - args: &OrderSettlementArgs, - status: OrderStatusView, - signing: account::AccountSigningIdentity, - payload: RadrootsOrderSettlementDecision, -) -> Result<OrderSettlementView, RuntimeError> { - let parts = order_settlement_event_parts(&status, &payload)?; - 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_settlement_view( - config, args, &status, &payload, event_kind, receipt, - )) -} - -fn published_order_fulfillment_view( - config: &RuntimeConfig, - args: &OrderFulfillmentArgs, - status: &OrderStatusView, - fulfillment_state: RadrootsOrderFulfillmentState, - event_kind: u32, - receipt: DirectRelayPublishReceipt, -) -> OrderFulfillmentView { - let DirectRelayPublishReceipt { - event: _, - event_id, - created_at: _, - signature: _, - target_relays, - connected_relays: _, - acknowledged_relays, - failed_relays, - } = receipt; - let state = fulfillment_state_name(fulfillment_state); - let mut view = order_fulfillment_base_view(config, args, state, false); - apply_order_fulfillment_status(&mut view, status); - view.fulfillment_state = state.to_owned(); - view.event_id = Some(event_id); - view.event_kind = Some(event_kind); - view.target_relays = target_relays; - view.acknowledged_relays = acknowledged_relays; - view.failed_relays = relay_failures(failed_relays); - view + Ok(OrderEventListEntryView { + id: envelope.order_id.clone(), + state: "requested".to_owned(), + event_id: Some(event.id), + event_kind: Some(event.kind), + listing_lookup: None, + listing_addr: Some(envelope.listing_addr), + listing_event_id, + buyer_account_id: None, + buyer_pubkey: Some(envelope.payload.buyer_pubkey.to_string()), + seller_pubkey: Some(envelope.payload.seller_pubkey.to_string()), + item_count: Some(envelope.payload.items.len()), + created_at_unix: Some(created_at_unix), + submitted_at_unix: Some(created_at_unix), + updated_at_unix: created_at_unix, + job: None, + workflow: None, + issues: Vec::new(), + }) } -fn published_order_cancellation_view( - config: &RuntimeConfig, - args: &OrderCancelArgs, - status: &OrderStatusView, - event_kind: u32, - receipt: DirectRelayPublishReceipt, -) -> OrderCancellationView { - let DirectRelayPublishReceipt { - event: _, - event_id, - created_at: _, - signature: _, - target_relays, - connected_relays: _, - acknowledged_relays, - failed_relays, - } = receipt; - let mut view = order_cancellation_base_view(config, args, "cancelled", false); - apply_order_cancellation_status(&mut view, status); - view.event_id = Some(event_id); - view.event_kind = Some(event_kind); - view.target_relays = target_relays; - view.acknowledged_relays = acknowledged_relays; - view.failed_relays = relay_failures(failed_relays); - view +fn order_request_filter( + seller_pubkey: &str, + order_id: Option<&str>, +) -> Result<RadrootsNostrFilter, RuntimeError> { + let filter = RadrootsNostrFilter::new() + .kind(radroots_nostr_kind(KIND_ORDER_REQUEST as u16)) + .limit(1_000); + let filter = radroots_nostr_filter_tag(filter, "p", vec![seller_pubkey.to_owned()]) + .map_err(|error| RuntimeError::Config(format!("build order event filter: {error}")))?; + if let Some(order_id) = order_id { + return radroots_nostr_filter_tag(filter, "d", vec![order_id.to_owned()]) + .map_err(|error| RuntimeError::Config(format!("build order event filter: {error}"))); + } + Ok(filter) } -fn published_order_receipt_view( - config: &RuntimeConfig, - args: &OrderReceiptArgs, - status: &OrderStatusView, - payload: &RadrootsOrderReceipt, - event_kind: u32, - receipt: DirectRelayPublishReceipt, -) -> OrderReceiptView { - let DirectRelayPublishReceipt { - event: _, - event_id, - created_at: _, - signature: _, - target_relays, - connected_relays: _, - acknowledged_relays, - failed_relays, - } = receipt; - let state = if payload.received { - "completed" - } else { - "disputed" - }; - let mut view = order_receipt_base_view(config, args, state, false); - apply_order_receipt_status(&mut view, status); - view.received = payload.received; - view.issue = payload.issue.clone(); - view.received_at = Some(payload.received_at); - view.event_id = Some(event_id); - view.event_kind = Some(event_kind); - view.target_relays = target_relays; - view.acknowledged_relays = acknowledged_relays; - view.failed_relays = relay_failures(failed_relays); - view +fn listing_event_filter( + listing_addr: &ParsedListingAddress, +) -> Result<RadrootsNostrFilter, RuntimeError> { + let filter = RadrootsNostrFilter::new() + .kind(radroots_nostr_kind(KIND_LISTING as u16)) + .limit(100); + radroots_nostr_filter_tag(filter, "d", vec![listing_addr.listing_id.clone()]) + .map_err(|error| RuntimeError::Config(format!("build listing event filter: {error}"))) } -fn published_order_payment_view( - config: &RuntimeConfig, - args: &OrderPaymentArgs, - status: &OrderStatusView, - payload: &RadrootsOrderPaymentRecord, - event_kind: u32, - receipt: DirectRelayPublishReceipt, -) -> OrderPaymentView { - let DirectRelayPublishReceipt { - event: _, - event_id, - created_at: _, - signature: _, - target_relays, - connected_relays: _, - acknowledged_relays, - failed_relays, - } = receipt; - let mut view = order_payment_base_view(config, args, "recorded", false); - apply_order_payment_status(&mut view, status); - apply_order_payment_payload(&mut view, payload); - view.event_id = Some(event_id); - view.event_kind = Some(event_kind); - view.target_relays = target_relays; - view.acknowledged_relays = acknowledged_relays; - view.failed_relays = relay_failures(failed_relays); - view +fn order_listing_request_filter( + seller_pubkey: &str, + listing_addr: &str, +) -> Result<RadrootsNostrFilter, RuntimeError> { + let filter = RadrootsNostrFilter::new() + .kind(radroots_nostr_kind(KIND_ORDER_REQUEST as u16)) + .limit(1_000); + let filter = radroots_nostr_filter_tag(filter, "p", vec![seller_pubkey.to_owned()]) + .map_err(|error| RuntimeError::Config(format!("build order request filter: {error}")))?; + radroots_nostr_filter_tag(filter, "a", vec![listing_addr.to_owned()]) + .map_err(|error| RuntimeError::Config(format!("build order request filter: {error}"))) } -fn published_order_settlement_view( - config: &RuntimeConfig, - args: &OrderSettlementArgs, - status: &OrderStatusView, - payload: &RadrootsOrderSettlementDecision, - event_kind: u32, - receipt: DirectRelayPublishReceipt, -) -> OrderSettlementView { - let DirectRelayPublishReceipt { - event: _, - event_id, - created_at: _, - signature: _, - target_relays, - connected_relays: _, - acknowledged_relays, - failed_relays, - } = receipt; - let mut view = order_settlement_base_view( - config, - args, - settlement_decision_state(args.decision), - false, - ); - apply_order_settlement_status(&mut view, status); - apply_order_settlement_payload(&mut view, payload); - view.event_id = Some(event_id); - view.event_kind = Some(event_kind); - view.target_relays = target_relays; - view.acknowledged_relays = acknowledged_relays; - view.failed_relays = relay_failures(failed_relays); - view +fn order_listing_decision_filter(listing_addr: &str) -> Result<RadrootsNostrFilter, RuntimeError> { + let filter = RadrootsNostrFilter::new() + .kind(radroots_nostr_kind(KIND_ORDER_DECISION as u16)) + .limit(1_000); + radroots_nostr_filter_tag(filter, "a", vec![listing_addr.to_owned()]) + .map_err(|error| RuntimeError::Config(format!("build order decision filter: {error}"))) } -fn order_actor_write_binding_error_parts( - error: ActorWriteBindingError, -) -> (String, String, Vec<String>) { - ( - "unconfigured".to_owned(), - error.reason(), - vec!["run radroots signer status get".to_owned()], - ) +fn order_listing_revision_proposal_filter( + listing_addr: &str, +) -> Result<RadrootsNostrFilter, RuntimeError> { + let filter = RadrootsNostrFilter::new() + .kind(radroots_nostr_kind(KIND_ORDER_REVISION_PROPOSAL as u16)) + .limit(1_000); + radroots_nostr_filter_tag(filter, "a", vec![listing_addr.to_owned()]) + .map_err(|error| RuntimeError::Config(format!("build revision proposal filter: {error}"))) } -fn order_fulfillment_binding_error_view( - config: &RuntimeConfig, - args: &OrderFulfillmentArgs, - status: &OrderStatusView, - error: ActorWriteBindingError, -) -> OrderFulfillmentView { - let (state, reason, actions) = order_actor_write_binding_error_parts(error); - let mut view = order_fulfillment_base_view(config, args, state.as_str(), config.output.dry_run); - apply_order_fulfillment_status(&mut view, status); - view.reason = Some(reason); - view.actions = actions; - view +fn order_listing_revision_decision_filter( + listing_addr: &str, +) -> Result<RadrootsNostrFilter, RuntimeError> { + let filter = RadrootsNostrFilter::new() + .kind(radroots_nostr_kind(KIND_ORDER_REVISION_DECISION as u16)) + .limit(1_000); + radroots_nostr_filter_tag(filter, "a", vec![listing_addr.to_owned()]) + .map_err(|error| RuntimeError::Config(format!("build revision decision filter: {error}"))) } -fn order_revision_binding_error_view( - config: &RuntimeConfig, - args: &OrderRevisionProposeArgs, - status: &OrderStatusView, - error: ActorWriteBindingError, -) -> OrderRevisionProposalView { - let (state, reason, actions) = order_actor_write_binding_error_parts(error); - let mut view = order_revision_base_view(config, args, state.as_str(), config.output.dry_run); - apply_order_revision_status(&mut view, status); - view.reason = Some(reason); - view.actions = actions; - view +fn order_listing_cancellation_filter( + listing_addr: &str, +) -> Result<RadrootsNostrFilter, RuntimeError> { + let filter = RadrootsNostrFilter::new() + .kind(radroots_nostr_kind(KIND_ORDER_CANCELLATION as u16)) + .limit(1_000); + radroots_nostr_filter_tag(filter, "a", vec![listing_addr.to_owned()]) + .map_err(|error| RuntimeError::Config(format!("build cancellation filter: {error}"))) } -fn order_revision_decision_binding_error_view( - config: &RuntimeConfig, - args: &OrderRevisionDecisionArgs, - status: &OrderStatusView, - error: ActorWriteBindingError, -) -> OrderRevisionDecisionView { - let (state, reason, actions) = order_actor_write_binding_error_parts(error); - let mut view = - order_revision_decision_base_view(config, args, state.as_str(), config.output.dry_run); - apply_order_revision_decision_status(&mut view, status); - view.reason = Some(reason); - view.actions = actions; - view +fn order_status_filter(order_id: &str) -> Result<RadrootsNostrFilter, RuntimeError> { + let filter = RadrootsNostrFilter::new() + .kinds([ + radroots_nostr_kind(KIND_ORDER_REQUEST as u16), + radroots_nostr_kind(KIND_ORDER_DECISION as u16), + radroots_nostr_kind(KIND_ORDER_REVISION_PROPOSAL as u16), + radroots_nostr_kind(KIND_ORDER_REVISION_DECISION as u16), + radroots_nostr_kind(KIND_ORDER_CANCELLATION as u16), + ]) + .limit(1_000); + radroots_nostr_filter_tag(filter, "d", vec![order_id.to_owned()]) + .map_err(|error| RuntimeError::Config(format!("build order status filter: {error}"))) } -fn order_cancellation_binding_error_view( - config: &RuntimeConfig, - args: &OrderCancelArgs, - status: &OrderStatusView, - error: ActorWriteBindingError, -) -> OrderCancellationView { - let (state, reason, actions) = order_actor_write_binding_error_parts(error); - let mut view = - order_cancellation_base_view(config, args, state.as_str(), config.output.dry_run); - apply_order_cancellation_status(&mut view, status); - view.reason = Some(reason); - view.actions = actions; - view +fn event_kind_u32(event: &RadrootsNostrEvent) -> u32 { + u32::from(event.kind.as_u16()) } -fn order_receipt_binding_error_view( - config: &RuntimeConfig, - args: &OrderReceiptArgs, - status: &OrderStatusView, - error: ActorWriteBindingError, -) -> OrderReceiptView { - let (state, reason, actions) = order_actor_write_binding_error_parts(error); - let mut view = order_receipt_base_view(config, args, state.as_str(), config.output.dry_run); - apply_order_receipt_status(&mut view, status); - view.reason = Some(reason); - view.actions = actions; - view +fn order_evidence_from_relay_events(events: &[RadrootsNostrEvent]) -> Vec<SdkRadrootsNostrEvent> { + events.iter().map(radroots_event_from_nostr).collect() } -fn order_payment_binding_error_view( - config: &RuntimeConfig, - args: &OrderPaymentArgs, - status: &OrderStatusView, - error: ActorWriteBindingError, -) -> OrderPaymentView { - let (state, reason, actions) = order_actor_write_binding_error_parts(error); - let mut view = order_payment_base_view(config, args, state.as_str(), config.output.dry_run); - apply_order_payment_status(&mut view, status); - view.reason = Some(reason); - view.actions = actions; - view +fn validate_scaffold_args(args: &OrderDraftCreateArgs) -> Result<(), RuntimeError> { + match (normalize_optional(args.bin_id.as_deref()), args.bin_count) { + (None, Some(_)) => Err(RuntimeError::Config( + "`--qty` requires `--bin` when creating an order draft".to_owned(), + )), + (Some(_), Some(0)) => Err(RuntimeError::Config( + "`--qty` must be greater than zero".to_owned(), + )), + (Some(_), None) | (Some(_), Some(_)) | (None, None) => Ok(()), + } } -fn order_settlement_binding_error_view( +fn resolve_order_listing( config: &RuntimeConfig, - args: &OrderSettlementArgs, - status: &OrderStatusView, - error: ActorWriteBindingError, -) -> OrderSettlementView { - let (state, reason, actions) = order_actor_write_binding_error_parts(error); - let mut view = order_settlement_base_view(config, args, state.as_str(), config.output.dry_run); - apply_order_settlement_status(&mut view, status); - view.reason = Some(reason); - view.actions = actions; - view -} - -fn seller_order_request_resolution_from_receipt( - seller_pubkey: &str, - order_id: &str, - receipt: DirectRelayFetchReceipt, -) -> Result<SellerOrderRequestResolution, RuntimeError> { - let DirectRelayFetchReceipt { - target_relays, - connected_relays, - failed_relays, - events, - } = receipt; - let fetched_count = events.len(); - let mut skipped_count = 0usize; - let mut decoded_count = 0usize; - let mut requests = Vec::new(); - let mut candidate_issues = Vec::new(); - let candidate_context = OrderRequestCandidateContext { - order_id, - seller_pubkey: Some(seller_pubkey), - }; - - for event in events { - if !order_request_candidate_matches(&event, candidate_context) { - skipped_count += 1; - continue; - } - let event_id = event.id.to_string(); - match seller_order_request_from_event(&event, seller_pubkey, order_id) { - Ok(request) => { - decoded_count += 1; - requests.push(request); - } - Err(error) => { - skipped_count += 1; - candidate_issues.push(issue_with_events( - "invalid_request_candidate", - "request_event_id", - format!("request event `{event_id}` failed seller decision preflight: {error}"), - vec![event_id], - )); - } + listing_lookup: Option<&str>, + explicit_listing_addr: Option<&str>, +) -> Result<Option<ResolvedOrderListing>, RuntimeError> { + if let Some(listing_addr) = explicit_listing_addr { + let parsed = parse_listing_addr(listing_addr).map_err(|error| { + RuntimeError::Config(format!("explicit listing_addr is invalid: {error}")) + })?; + if parsed.kind != KIND_LISTING { + return Err(RuntimeError::Config( + "explicit listing_addr must reference a public NIP-99 listing".to_owned(), + )); } + let replica_listing_event_id = + resolve_active_listing_event_id(config, listing_addr, &parsed)?; + let shared_provenance = resolve_shared_signed_listing_provenance( + config, + listing_addr, + replica_listing_event_id.as_deref(), + )?; + let listing_event_id = replica_listing_event_id + .or_else(|| { + shared_provenance + .as_ref() + .map(|provenance| provenance.event_id.clone()) + }) + .unwrap_or_default(); + let listing_relays = listing_provenance_relays( + config, + listing_event_id.as_str(), + shared_provenance.as_ref(), + )?; + let economics_product = resolve_trade_product_by_listing_addr(config, listing_addr)?; + return Ok(Some(ResolvedOrderListing { + listing_addr: listing_addr.to_owned(), + listing_event_id, + listing_relays, + seller_pubkey: parsed.seller_pubkey, + economics_product, + })); } - requests.sort_by(|left, right| left.request_event_id.cmp(&right.request_event_id)); - candidate_issues.sort_by(|left, right| left.message.cmp(&right.message)); - - Ok(SellerOrderRequestResolution { - target_relays, - connected_relays, - failed_relays, - fetched_count, - decoded_count, - skipped_count, - requests, - candidate_issues, - }) -} - -fn event_matches_tag_value(event: &RadrootsNostrEvent, key: &str, value: &str) -> bool { - event.tags.iter().any(|tag| { - let values = tag.as_slice(); - values.first().map(String::as_str) == Some(key) - && values.get(1).map(String::as_str) == Some(value) - }) -} + let Some(listing_lookup) = listing_lookup else { + return Ok(None); + }; -fn seller_order_request_from_event( - event: &RadrootsNostrEvent, - seller_pubkey: &str, - order_id: &str, -) -> Result<ResolvedSellerOrderRequest, RuntimeError> { - let event_kind = event_kind_u32(event); - if event_kind != KIND_ORDER_REQUEST { + if !config.local.replica_db_path.exists() { return Err(RuntimeError::Config(format!( - "order decision received unexpected kind `{event_kind}`" + "order listing lookup `{listing_lookup}` requires local market data; run `radroots store init` and `radroots market refresh` before creating an order from a listing" ))); } - let request_event = radroots_event_from_nostr(event); - let event_id = protocol_event_id(request_event.id.as_str(), "request_event_id")?; - let seller_protocol_pubkey = protocol_pubkey(seller_pubkey, "seller_pubkey")?; - let envelope = order_request_from_event(&request_event) - .map_err(|error| RuntimeError::Config(format!("decode order request event: {error}")))?; - let context = - order_event_context_from_tags(RadrootsOrderEventType::OrderRequested, &request_event.tags) - .map_err(|error| RuntimeError::Config(format!("decode order request tags: {error}")))?; + let db = ReplicaSql::new(SqliteExecutor::open(&config.local.replica_db_path)?); + let rows = db.trade_product_lookup(listing_lookup)?; + match rows.len() { + 0 => Err(RuntimeError::Config(format!( + "listing `{listing_lookup}` is not available in the local replica; run `radroots market refresh` or pass `--listing-addr`" + ))), + 1 => { + let row = rows.into_iter().next().expect("one row"); + let economics_product = ResolvedOrderEconomicsProduct::from_summary(&row); + let listing_addr = normalize_optional(row.listing_addr.as_deref()).ok_or_else(|| { + RuntimeError::Config(format!( + "listing `{listing_lookup}` is missing a canonical listing address; run `radroots market refresh` or pass `--listing-addr`" + )) + })?; + let parsed = parse_listing_addr(listing_addr.as_str()).map_err(|error| { + RuntimeError::Config(format!( + "listing `{listing_lookup}` has invalid listing_addr: {error}; run `radroots market refresh` or pass `--listing-addr`" + )) + })?; + if parsed.kind != KIND_LISTING { + return Err(RuntimeError::Config(format!( + "listing `{listing_lookup}` listing_addr must reference a public NIP-99 listing; run `radroots market refresh` or pass `--listing-addr`" + ))); + } - if envelope.order_id.to_string() != order_id - || envelope.payload.order_id.to_string() != order_id - { - return Err(RuntimeError::Config( - "order request does not match requested order id".to_owned(), - )); - } - if context.counterparty_pubkey != seller_protocol_pubkey - || envelope.payload.seller_pubkey != seller_protocol_pubkey - { - return Err(RuntimeError::Config( - "order request is not targeted at the selected seller".to_owned(), - )); - } - let listing_addr = - parse_listing_addr(envelope.payload.listing_addr.as_str()).map_err(|error| { - RuntimeError::Config(format!("order request listing_addr is invalid: {error}")) - })?; - if listing_addr.seller_pubkey != seller_pubkey { - return Err(RuntimeError::Config( - "order request listing address is outside selected seller authority".to_owned(), - )); - } - let listing_event_id = context.listing_event.as_ref().map(|event| event.id.clone()); + let listing_event_id = resolve_active_listing_event_id( + config, + listing_addr.as_str(), + &parsed, + )? + .ok_or_else(|| { + RuntimeError::Config(format!( + "listing `{listing_lookup}` is missing the latest listing event pointer; run `radroots market refresh` before creating an order from this listing" + )) + })?; + let shared_provenance = resolve_shared_signed_listing_provenance( + config, + listing_addr.as_str(), + Some(listing_event_id.as_str()), + )?; + let listing_relays = listing_provenance_relays( + config, + listing_event_id.as_str(), + shared_provenance.as_ref(), + )?; - Ok(ResolvedSellerOrderRequest { - request_event, - request_event_id: event_id, - listing_event_id, - order_id: envelope.payload.order_id, - listing_addr: envelope.payload.listing_addr, - buyer_pubkey: envelope.payload.buyer_pubkey, - seller_pubkey: envelope.payload.seller_pubkey, - items: envelope.payload.items, - economics: envelope.payload.economics, - }) + Ok(Some(ResolvedOrderListing { + listing_addr, + listing_event_id, + listing_relays, + seller_pubkey: parsed.seller_pubkey, + economics_product: Some(economics_product), + })) + } + count => Err(RuntimeError::Config(format!( + "listing lookup `{listing_lookup}` matched {count} local listings; use a unique product key or pass `--listing-addr`" + ))), + } } -fn publish_order_decision( +fn resolve_trade_product_by_listing_addr( config: &RuntimeConfig, - args: &OrderDecisionArgs, - request: ResolvedSellerOrderRequest, - resolution: SellerOrderRequestResolution, - signing: account::AccountSigningIdentity, - payload: RadrootsOrderDecision, - inventory: Option<OrderInventoryView>, -) -> Result<OrderDecisionView, RuntimeError> { - let input = sdk_order_decision_input(config, &request, &signing, payload) - .map_err(cli_sdk_error_to_runtime)?; - enqueue_order_decision_via_sdk(config, args, request, resolution, signing, input, inventory) - .map_err(cli_sdk_error_to_runtime) -} + listing_addr: &str, +) -> Result<Option<ResolvedOrderEconomicsProduct>, RuntimeError> { + if !config.local.replica_db_path.exists() { + return Ok(None); + } -fn sdk_order_decision_input( - config: &RuntimeConfig, - request: &ResolvedSellerOrderRequest, - signing: &account::AccountSigningIdentity, - payload: RadrootsOrderDecision, -) -> Result<SdkOrderDecisionInput, CliSdkAdapterError> { - let actor = RadrootsActorContext::local_account( - signing - .account - .record - .public_identity - .public_key_hex - .as_str(), - signing.account.record.account_id.to_string(), - [RadrootsActorRole::Seller], + let executor = SqliteExecutor::open(&config.local.replica_db_path)?; + let product_rows = trade_product::find_many( + &executor, + &ITradeProductFindMany { + filter: Some(trade_product_listing_addr_filter(listing_addr)), + }, ) - .map_err(|error| RuntimeError::Config(format!("invalid order decision SDK actor: {error}")))?; - let target_relays = order_decision_target_relays(config)?; - Ok(SdkOrderDecisionInput { - actor, - request_event: request.request_event.clone(), - request_event_ptr: order_decision_request_event_ptr(request, target_relays.as_slice()), - decision: payload, - target_relays, - }) -} + .map_err(|error| RuntimeError::Config(format!("resolve listing product state: {error:?}")))? + .results; -#[derive(Debug, Clone)] -struct SdkOrderDecisionInput { - actor: RadrootsActorContext, - request_event: SdkRadrootsNostrEvent, - request_event_ptr: RadrootsNostrEventPtr, - decision: RadrootsOrderDecision, - target_relays: Vec<String>, -} - -fn order_decision_request_event_ptr( - request: &ResolvedSellerOrderRequest, - target_relays: &[String], -) -> RadrootsNostrEventPtr { - RadrootsNostrEventPtr { - id: request.request_event_id.as_str().to_owned(), - relays: target_relays.first().cloned(), + match product_rows.len() { + 0 => Ok(None), + 1 => Ok(product_rows + .into_iter() + .next() + .map(ResolvedOrderEconomicsProduct::from_product)), + count => Err(RuntimeError::Config(format!( + "listing address `{listing_addr}` matched {count} active local listing rows" + ))), } } -fn order_decision_target_relays(config: &RuntimeConfig) -> Result<Vec<String>, RuntimeError> { - 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 Err(RuntimeError::Config( - "order decision requires at least one configured relay".to_owned(), - )); +fn resolve_active_listing_event_id( + config: &RuntimeConfig, + listing_addr: &str, + parsed: &ParsedListingAddress, +) -> Result<Option<String>, RuntimeError> { + if !config.local.replica_db_path.exists() { + return Ok(None); } - Ok(target_relays) -} -fn order_decision_relay_url_policy(target_relays: &[String]) -> SdkRelayUrlPolicy { - if target_relays - .iter() - .any(|relay_url| relay_url.starts_with("ws://")) - { - SdkRelayUrlPolicy::Localhost - } else { - SdkRelayUrlPolicy::Public - } -} + let executor = SqliteExecutor::open(&config.local.replica_db_path)?; + let product_rows = trade_product::find_many( + &executor, + &ITradeProductFindMany { + filter: Some(trade_product_listing_addr_filter(listing_addr)), + }, + ) + .map_err(|error| RuntimeError::Config(format!("resolve listing product state: {error:?}")))? + .results; -fn enqueue_order_decision_via_sdk( - config: &RuntimeConfig, - args: &OrderDecisionArgs, - request_context: ResolvedSellerOrderRequest, - resolution: SellerOrderRequestResolution, - signing: account::AccountSigningIdentity, - input: SdkOrderDecisionInput, - inventory: Option<OrderInventoryView>, -) -> Result<OrderDecisionView, CliSdkAdapterError> { - let target_relays = input.target_relays.clone(); - let policy = order_decision_relay_url_policy(target_relays.as_slice()); - let target_policy = SdkRelayTargetPolicy::try_explicit(target_relays.clone(), policy)?; - let mut request = OrderDecisionEnqueueRequest::new( - input.actor, - input.request_event_ptr, - input.decision, - target_policy, - ); - if let Some(idempotency_key) = args.idempotency_key.as_deref() { - request = request.try_with_idempotency_key(idempotency_key)?; + match product_rows.len() { + 0 => return Ok(None), + 1 => {} + count => { + return Err(RuntimeError::Config(format!( + "listing address `{listing_addr}` matched {count} active local listing rows" + ))); + } } - let session = CliSdkSession::connect(config)?; - session.block_on( - session - .sdk() - .orders() - .ingest_request_evidence(OrderRequestEvidenceIngestRequest::new(input.request_event)), - )?; - 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_decision(request, &signer))?; - let push = session.block_on( - session.sdk().sync().push_outbox( - PushOutboxRequest::new() - .with_limit(1) - .with_relay_url_policy(policy), - ), - )?; - Ok(sdk_enqueued_order_decision_view( - config, - args, - request_context, - resolution, - enqueue, - push, - target_relays, - inventory, - )) -} + let key = format!( + "{}:{}:{}", + parsed.kind, parsed.seller_pubkey, parsed.listing_id + ); + let state = nostr_event_head::find_one( + &executor, + &INostrEventHeadFindOne::On(INostrEventHeadFindOneArgs { + on: NostrEventHeadQueryBindValues::Key { key }, + }), + ) + .map_err(|error| RuntimeError::Config(format!("resolve listing event state: {error:?}")))? + .result; -fn cli_sdk_error_to_runtime(error: CliSdkAdapterError) -> RuntimeError { - match error { - CliSdkAdapterError::Runtime(error) => error, - CliSdkAdapterError::Sdk(error) => RuntimeError::Config(error.to_string()), + let Some(state) = state else { + return Ok(None); + }; + if !is_valid_event_id(state.last_event_id.as_str()) { + return Err(RuntimeError::Config(format!( + "listing address `{listing_addr}` has invalid latest listing event id in local replica" + ))); } -} -fn canonical_order_decision_payload( - args: &OrderDecisionArgs, - request: &ResolvedSellerOrderRequest, - signer_pubkey: &str, -) -> Result<RadrootsOrderDecision, RuntimeError> { - let payload = order_decision_payload_from_request(args, request)?; - canonicalize_order_decision_for_signer(payload, signer_pubkey) - .map_err(|error| RuntimeError::Config(format!("canonicalize order decision: {error}"))) + Ok(Some(state.last_event_id)) } -fn order_decision_payload_from_request( - args: &OrderDecisionArgs, - request: &ResolvedSellerOrderRequest, -) -> Result<RadrootsOrderDecision, RuntimeError> { - match args.decision { - OrderDecisionArg::Accept => Ok(accepted_order_decision_payload_from_request(request)), - OrderDecisionArg::Decline => { - let reason = args - .reason - .as_deref() - .map(str::trim) - .filter(|reason| !reason.is_empty()) - .ok_or_else(|| { - RuntimeError::Config("order decline requires a non-empty reason".to_owned()) - })?; - Ok(declined_order_decision_payload_from_request( - request, reason, - )) - } - } +#[derive(Debug, Clone)] +struct SharedListingProvenance { + event_id: String, + relays: Vec<String>, } -fn accepted_order_decision_payload_from_request( - request: &ResolvedSellerOrderRequest, -) -> RadrootsOrderDecision { - RadrootsOrderDecision { - order_id: request.order_id.clone(), - listing_addr: request.listing_addr.clone(), - buyer_pubkey: request.buyer_pubkey.clone(), - seller_pubkey: request.seller_pubkey.clone(), - decision: RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: request - .items - .iter() - .map(|item| RadrootsOrderInventoryCommitment { - bin_id: item.bin_id.clone(), - bin_count: item.bin_count, - }) - .collect(), - }, +fn listing_provenance_relays( + config: &RuntimeConfig, + listing_event_id: &str, + shared_provenance: Option<&SharedListingProvenance>, +) -> Result<Vec<String>, RuntimeError> { + let mut relays = Vec::<String>::new(); + if let Some(provenance) = shared_provenance + && provenance.event_id == listing_event_id + { + relays.extend(provenance.relays.iter().cloned()); } + relays.extend(relay_provenance_relays_for_scope( + config, + RelayIngestScope::MarketRefresh, + )?); + normalize_listing_relay_set(relays) + .map_err(|error| RuntimeError::Config(format!("listing provenance relays: {error}"))) } -fn declined_order_decision_payload_from_request( - request: &ResolvedSellerOrderRequest, - reason: &str, -) -> RadrootsOrderDecision { - RadrootsOrderDecision { - order_id: request.order_id.clone(), - listing_addr: request.listing_addr.clone(), - buyer_pubkey: request.buyer_pubkey.clone(), - seller_pubkey: request.seller_pubkey.clone(), - decision: RadrootsOrderDecisionOutcome::Declined { - reason: reason.to_owned(), - }, +fn resolve_shared_signed_listing_provenance( + config: &RuntimeConfig, + listing_addr: &str, + listing_event_id: Option<&str>, +) -> Result<Option<SharedListingProvenance>, RuntimeError> { + let mut candidates = list_shared_records_latest(config, ORDER_APP_RECORD_LIST_LIMIT)? + .into_iter() + .filter(|record| record.family == LocalRecordFamily::SignedEvent) + .filter(|record| record.status == LocalRecordStatus::Published) + .filter(|record| record.event_kind == Some(i64::from(KIND_LISTING))) + .filter(|record| record.listing_addr.as_deref() == Some(listing_addr)) + .filter(|record| { + listing_event_id.is_none() || record.event_id.as_deref() == listing_event_id + }) + .filter_map(|record| { + let event_id = record.event_id?; + if !is_valid_event_id(event_id.as_str()) { + return None; + } + let delivery = record.relay_delivery_json.as_ref()?; + let evidence = RelayDeliveryEvidence::from_json_value(delivery).ok()?; + let relays = listing_provenance_relays_from_delivery_evidence(evidence).ok()?; + if relays.is_empty() { + return None; + } + Some(SharedListingProvenance { event_id, relays }) + }) + .collect::<Vec<_>>(); + candidates.sort_by(|left, right| left.event_id.cmp(&right.event_id)); + candidates.dedup_by(|left, right| left.event_id == right.event_id); + if candidates.len() > 1 && listing_event_id.is_none() { + return Err(RuntimeError::Config(format!( + "listing address `{listing_addr}` has multiple published shared local listing events; run `radroots market refresh` or pass a current listing event id source" + ))); } + Ok(candidates.pop()) } -fn sdk_enqueued_order_decision_view( - config: &RuntimeConfig, - args: &OrderDecisionArgs, - request: ResolvedSellerOrderRequest, - resolution: SellerOrderRequestResolution, - enqueue: OrderDecisionReceipt, - push: PushOutboxReceipt, - target_relays: Vec<String>, - inventory: Option<OrderInventoryView>, -) -> OrderDecisionView { - let push_event = sdk_push_event_for_order_decision(&enqueue, &push); - let mut view = order_decision_base_view( - config, - args, - sdk_order_decision_state(args.decision, push_event).as_str(), - false, - ); - apply_order_decision_request(&mut view, &request); - view.event_id = Some(enqueue.signed_event_id.as_str().to_owned()); - view.event_kind = Some(KIND_ORDER_DECISION); - view.target_relays = push_event - .map(sdk_push_target_relays) - .unwrap_or(target_relays); - view.connected_relays = push_event - .map(sdk_push_connected_relays) - .unwrap_or_default(); - view.acknowledged_relays = push_event - .map(sdk_push_acknowledged_relays) - .unwrap_or_default(); - view.failed_relays = push_event.map(sdk_push_failed_relays).unwrap_or_default(); - view.fetched_count = resolution.fetched_count; - view.decoded_count = resolution.decoded_count; - view.skipped_count = resolution.skipped_count; - view.inventory = order_decision_inventory_for_view(args, &request, inventory); - view.reason = sdk_order_decision_reason(&enqueue.workflow, push_event); - view.actions = sdk_order_decision_actions(push_event); - view -} - -fn sdk_push_event_for_order_decision<'a>( - enqueue: &OrderDecisionReceipt, - push: &'a PushOutboxReceipt, -) -> Option<&'a PushOutboxEventReceipt> { - push.events - .iter() - .find(|event| event.event_id == enqueue.signed_event_id) -} - -fn sdk_order_decision_state( - decision: OrderDecisionArg, - push_event: Option<&PushOutboxEventReceipt>, -) -> String { - match push_event.map(|event| event.final_state) { - Some(PushOutboxEventState::Published) => decision.as_str(), - Some(PushOutboxEventState::PublishRetryable | PushOutboxEventState::FailedTerminal) => { - "unavailable" - } - Some(_) | None => "queued", - } - .to_owned() -} - -fn sdk_order_decision_reason( - enqueue: &OrderWorkflowEnqueueReceipt, - push_event: Option<&PushOutboxEventReceipt>, -) -> Option<String> { - match push_event.map(|event| event.final_state) { - Some(PushOutboxEventState::Published) => None, - Some(PushOutboxEventState::PublishRetryable) => Some(format!( - "{}; SDK relay publish did not reach accepted quorum; outbox event remains retryable; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - Some(PushOutboxEventState::FailedTerminal) => Some(format!( - "{}; SDK relay publish failed terminally; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - Some(state) => Some(format!( - "{}; SDK relay push left event in state `{state:?}`; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - None => Some(format!( - "{}; order decision queued in SDK outbox; no ready SDK outbox event was pushed; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - } +fn listing_provenance_relays_from_delivery_evidence( + evidence: RelayDeliveryEvidence, +) -> Result<Vec<String>, String> { + let relays = match evidence.state { + RelayDeliveryState::Acknowledged => evidence.acknowledged_relays, + RelayDeliveryState::Observed => evidence.observed_relays, + RelayDeliveryState::Pending | RelayDeliveryState::Failed => Vec::new(), + }; + normalize_listing_relay_set(relays) } -fn sdk_order_decision_actions(push_event: Option<&PushOutboxEventReceipt>) -> Vec<String> { - if !matches!( - push_event.map(|event| event.final_state), - Some(PushOutboxEventState::Published) - ) { - return sdk_order_push_recovery_actions(); +fn trade_product_listing_addr_filter(listing_addr: &str) -> ITradeProductFieldsFilter { + ITradeProductFieldsFilter { + id: None, + created_at: None, + updated_at: None, + key: None, + category: None, + title: None, + summary: None, + process: None, + lot: None, + profile: None, + year: None, + qty_amt: None, + qty_amt_exact: None, + qty_unit: None, + qty_label: None, + qty_avail: None, + price_amt: None, + price_amt_exact: None, + price_currency: None, + price_qty_amt: None, + price_qty_amt_exact: None, + price_qty_unit: None, + listing_addr: Some(listing_addr.to_owned()), + primary_bin_id: None, + verified_primary_bin_id: None, + notes: None, } - Vec::new() -} - -fn order_decision_binding_error_view( - config: &RuntimeConfig, - args: &OrderDecisionArgs, - request: ResolvedSellerOrderRequest, - resolution: SellerOrderRequestResolution, - error: ActorWriteBindingError, -) -> OrderDecisionView { - let (state, reason, actions) = order_actor_write_binding_error_parts(error); - let mut view = order_decision_base_view(config, args, state.as_str(), config.output.dry_run); - apply_order_decision_resolution(&mut view, &resolution); - apply_order_decision_request(&mut view, &request); - view.reason = Some(reason); - view.actions = actions; - view } -fn order_event_list_entry_from_event( - event: &RadrootsNostrEvent, - seller_pubkey: &str, -) -> Result<OrderEventListEntryView, RuntimeError> { - let event_kind = event_kind_u32(event); - if event_kind != KIND_ORDER_REQUEST { +fn order_economics_from_resolved_listing( + order_id: &str, + resolved_listing: Option<&ResolvedOrderListing>, + items: &[OrderDraftItem], + adjustments: &[crate::cli::global::OrderDraftAdjustmentArgs], +) -> Result<Option<RadrootsOrderEconomics>, RuntimeError> { + let Some(listing) = resolved_listing else { + return Ok(None); + }; + let Some(product) = listing.economics_product.as_ref() else { + return Ok(None); + }; + let Some(primary_bin_id) = product.primary_bin_id.as_deref().and_then(non_empty_ref) else { + return Ok(None); + }; + let Some(verified_primary_bin_id) = product + .verified_primary_bin_id + .as_deref() + .and_then(non_empty_ref) + else { return Err(RuntimeError::Config(format!( - "order event list received unexpected kind `{event_kind}`" + "listing_primary_bin_invalid: listing `{}` primary bin `{primary_bin_id}` is not verified in the current local replica", + listing.listing_addr + ))); + }; + if verified_primary_bin_id != primary_bin_id { + return Err(RuntimeError::Config(format!( + "listing_primary_bin_invalid: listing `{}` primary bin `{primary_bin_id}` does not match verified primary bin `{verified_primary_bin_id}` in the current local replica", + listing.listing_addr ))); } - - let event = radroots_event_from_nostr(event); - let envelope = order_request_from_event(&event) - .map_err(|error| RuntimeError::Config(format!("decode order request event: {error}")))?; - let context = - order_event_context_from_tags(RadrootsOrderEventType::OrderRequested, &event.tags) - .map_err(|error| RuntimeError::Config(format!("decode order request tags: {error}")))?; - - if context.counterparty_pubkey != seller_pubkey - || envelope.payload.seller_pubkey != seller_pubkey + if items.is_empty() + || items + .iter() + .any(|item| item.bin_id.as_str() != primary_bin_id) { - return Err(RuntimeError::Config( - "order request is not targeted at the selected seller".to_owned(), - )); + return Ok(None); } - let listing_event_id = context.listing_event.as_ref().map(|event| event.id.clone()); - let created_at_unix = u64::from(event.created_at); - - Ok(OrderEventListEntryView { - id: envelope.order_id.clone(), - state: "requested".to_owned(), - event_id: Some(event.id), - event_kind: Some(event.kind), - listing_lookup: None, - listing_addr: Some(envelope.listing_addr), - listing_event_id, - buyer_account_id: None, - buyer_pubkey: Some(envelope.payload.buyer_pubkey.to_string()), - seller_pubkey: Some(envelope.payload.seller_pubkey.to_string()), - item_count: Some(envelope.payload.items.len()), - created_at_unix: Some(created_at_unix), - submitted_at_unix: Some(created_at_unix), - updated_at_unix: created_at_unix, - job: None, - workflow: None, - issues: Vec::new(), - }) -} + let currency = parse_economics_currency(product.price_currency.as_str(), "price_currency")?; + let quantity_amount = + exact_non_negative_decimal(product.qty_amt_exact.as_deref(), "qty_amt_exact")?; + let quantity_unit = parse_economics_unit(product.qty_unit.as_str(), "qty_unit")?; + let price_amount = + exact_non_negative_decimal(product.price_amt_exact.as_deref(), "price_amt_exact")?; + let price_quantity_amount = exact_positive_decimal( + product.price_qty_amt_exact.as_deref(), + "price_qty_amt_exact", + )?; + let price_unit = parse_economics_unit(product.price_qty_unit.as_str(), "price_qty_unit")?; + let quantity_unit_in_price_units = + convert_unit_decimal(RadrootsCoreDecimal::ONE, quantity_unit, price_unit).map_err( + |error| { + RuntimeError::Config(format!( + "listing quantity unit and price unit are incompatible: {error}" + )) + }, + )?; + let unit_price_amount = (price_amount / price_quantity_amount) * quantity_unit_in_price_units; -fn order_request_filter( - seller_pubkey: &str, - order_id: Option<&str>, -) -> Result<RadrootsNostrFilter, RuntimeError> { - let filter = RadrootsNostrFilter::new() - .kind(radroots_nostr_kind(KIND_ORDER_REQUEST as u16)) - .limit(1_000); - let filter = radroots_nostr_filter_tag(filter, "p", vec![seller_pubkey.to_owned()]) - .map_err(|error| RuntimeError::Config(format!("build order event filter: {error}")))?; - if let Some(order_id) = order_id { - return radroots_nostr_filter_tag(filter, "d", vec![order_id.to_owned()]) - .map_err(|error| RuntimeError::Config(format!("build order event filter: {error}"))); + let mut subtotal_amount = RadrootsCoreDecimal::ZERO; + let mut economic_items = Vec::with_capacity(items.len()); + for item in items { + let line_amount = + unit_price_amount * quantity_amount * RadrootsCoreDecimal::from(item.bin_count); + subtotal_amount = subtotal_amount + line_amount; + economic_items.push(RadrootsOrderEconomicItem { + bin_id: protocol_inventory_bin_id(item.bin_id.as_str(), "order item bin_id")?, + bin_count: item.bin_count, + quantity_amount, + quantity_unit, + unit_price_amount, + unit_price_currency: currency, + line_subtotal: RadrootsCoreMoney::new(line_amount, currency), + }); } - Ok(filter) -} -fn listing_event_filter( - listing_addr: &ParsedListingAddress, -) -> Result<RadrootsNostrFilter, RuntimeError> { - let filter = RadrootsNostrFilter::new() - .kind(radroots_nostr_kind(KIND_LISTING as u16)) - .limit(100); - radroots_nostr_filter_tag(filter, "d", vec![listing_addr.listing_id.clone()]) - .map_err(|error| RuntimeError::Config(format!("build listing event filter: {error}"))) + let subtotal = RadrootsCoreMoney::new(subtotal_amount, currency); + let discounts = listing_discount_lines_from_product( + product, + &subtotal, + items, + quantity_amount, + quantity_unit, + )?; + let adjustments = basket_adjustment_lines(adjustments)?; + let zero = RadrootsCoreMoney::zero(currency); + let mut economics = RadrootsOrderEconomics { + quote_id: protocol_quote_id(format!("quote_{order_id}").as_str(), "quote_id")?, + quote_version: 1, + pricing_basis: RadrootsOrderPricingBasis::ListingEvent, + currency, + items: economic_items, + discounts, + adjustments, + subtotal: subtotal.clone(), + discount_total: zero.clone(), + adjustment_total: zero, + total: subtotal, + }; + economics.canonicalize(); + economics + .validate() + .map_err(|error| RuntimeError::Config(format!("build order economics: {error}")))?; + Ok(Some(economics)) } -fn order_listing_request_filter( - seller_pubkey: &str, - listing_addr: &str, -) -> Result<RadrootsNostrFilter, RuntimeError> { - let filter = RadrootsNostrFilter::new() - .kind(radroots_nostr_kind(KIND_ORDER_REQUEST as u16)) - .limit(1_000); - let filter = radroots_nostr_filter_tag(filter, "p", vec![seller_pubkey.to_owned()]) - .map_err(|error| RuntimeError::Config(format!("build order request filter: {error}")))?; - radroots_nostr_filter_tag(filter, "a", vec![listing_addr.to_owned()]) - .map_err(|error| RuntimeError::Config(format!("build order request filter: {error}"))) +fn listing_discount_lines_from_product( + product: &ResolvedOrderEconomicsProduct, + subtotal: &RadrootsCoreMoney, + items: &[OrderDraftItem], + quantity_amount: RadrootsCoreDecimal, + quantity_unit: RadrootsCoreUnit, +) -> Result<Vec<RadrootsOrderEconomicLine>, RuntimeError> { + let Some(notes) = product.notes.as_deref().and_then(non_empty_ref) else { + return Ok(Vec::new()); + }; + let parsed = serde_json::from_str::<ResolvedTradeProductNotes>(notes).map_err(|error| { + RuntimeError::Config(format!("listing discount metadata is invalid: {error}")) + })?; + let mut lines = Vec::new(); + for (index, discount) in parsed.listing_discounts.iter().enumerate() { + if !discount_applies(discount, items, quantity_amount, quantity_unit)? { + continue; + } + let amount = listing_discount_amount(discount, subtotal, items)?; + if amount.is_zero() { + return Err(RuntimeError::Config( + "listing discount amount must be greater than zero".to_owned(), + )); + } + lines.push(RadrootsOrderEconomicLine { + id: format!("listing_discount_{}", index + 1), + kind: RadrootsOrderEconomicLineKind::ListingDiscount, + actor: RadrootsOrderEconomicActor::Seller, + effect: RadrootsOrderEconomicEffect::Decrease, + amount, + reason: format!("listing discount {}", index + 1), + }); + } + Ok(lines) } -fn order_listing_decision_filter(listing_addr: &str) -> Result<RadrootsNostrFilter, RuntimeError> { - let filter = RadrootsNostrFilter::new() - .kind(radroots_nostr_kind(KIND_ORDER_DECISION as u16)) - .limit(1_000); - radroots_nostr_filter_tag(filter, "a", vec![listing_addr.to_owned()]) - .map_err(|error| RuntimeError::Config(format!("build order decision filter: {error}"))) +fn discount_applies( + discount: &RadrootsCoreDiscount, + items: &[OrderDraftItem], + quantity_amount: RadrootsCoreDecimal, + quantity_unit: RadrootsCoreUnit, +) -> Result<bool, RuntimeError> { + match &discount.threshold { + RadrootsCoreDiscountThreshold::BinCount { bin_id, min } => Ok(items + .iter() + .any(|item| item.bin_id == *bin_id && item.bin_count >= *min)), + RadrootsCoreDiscountThreshold::OrderQuantity { min } => { + let requested = items.iter().fold(RadrootsCoreDecimal::ZERO, |total, item| { + total + quantity_amount * RadrootsCoreDecimal::from(item.bin_count) + }); + let converted = + convert_unit_decimal(requested, quantity_unit, min.unit).map_err(|error| { + RuntimeError::Config(format!( + "listing discount quantity threshold is incompatible: {error}" + )) + })?; + Ok(converted >= min.amount) + } + } } -fn order_listing_revision_proposal_filter( - listing_addr: &str, -) -> Result<RadrootsNostrFilter, RuntimeError> { - let filter = RadrootsNostrFilter::new() - .kind(radroots_nostr_kind(KIND_ORDER_REVISION_PROPOSAL as u16)) - .limit(1_000); - radroots_nostr_filter_tag(filter, "a", vec![listing_addr.to_owned()]) - .map_err(|error| RuntimeError::Config(format!("build revision proposal filter: {error}"))) +fn listing_discount_amount( + discount: &RadrootsCoreDiscount, + subtotal: &RadrootsCoreMoney, + items: &[OrderDraftItem], +) -> Result<RadrootsCoreMoney, RuntimeError> { + match &discount.value { + RadrootsCoreDiscountValue::Percent(percent) => Ok(percent.of_money(subtotal)), + RadrootsCoreDiscountValue::MoneyPerBin(money) => { + if money.currency != subtotal.currency { + return Err(RuntimeError::Config( + "listing discount currency must match listing price currency".to_owned(), + )); + } + let multiplier = match &discount.scope { + RadrootsCoreDiscountScope::Bin => { + items.iter().map(|item| item.bin_count).sum::<u32>().max(1) + } + RadrootsCoreDiscountScope::OrderTotal => 1, + }; + Ok(money.mul_decimal(RadrootsCoreDecimal::from(multiplier))) + } + } } -fn order_listing_revision_decision_filter( - listing_addr: &str, -) -> Result<RadrootsNostrFilter, RuntimeError> { - let filter = RadrootsNostrFilter::new() - .kind(radroots_nostr_kind(KIND_ORDER_REVISION_DECISION as u16)) - .limit(1_000); - radroots_nostr_filter_tag(filter, "a", vec![listing_addr.to_owned()]) - .map_err(|error| RuntimeError::Config(format!("build revision decision filter: {error}"))) +fn basket_adjustment_lines( + adjustments: &[crate::cli::global::OrderDraftAdjustmentArgs], +) -> Result<Vec<RadrootsOrderEconomicLine>, RuntimeError> { + adjustments + .iter() + .map(|adjustment| { + let currency = + parse_economics_currency(adjustment.currency.as_str(), "adjustment_currency")?; + let amount = decimal_from_adjustment(adjustment.amount.as_str(), "adjustment_amount")?; + if amount.is_zero() { + return Err(RuntimeError::Config( + "basket adjustment amount must be greater than zero".to_owned(), + )); + } + let effect = match adjustment.effect.as_str() { + "increase" => RadrootsOrderEconomicEffect::Increase, + "decrease" => RadrootsOrderEconomicEffect::Decrease, + other => { + return Err(RuntimeError::Config(format!( + "basket adjustment effect `{other}` is invalid" + ))); + } + }; + if adjustment.id.trim().is_empty() { + return Err(RuntimeError::Config( + "basket adjustment id must not be empty".to_owned(), + )); + } + if adjustment.reason.trim().is_empty() { + return Err(RuntimeError::Config( + "basket adjustment reason must not be empty".to_owned(), + )); + } + Ok(RadrootsOrderEconomicLine { + id: adjustment.id.trim().to_owned(), + kind: RadrootsOrderEconomicLineKind::BasketAdjustment, + actor: RadrootsOrderEconomicActor::Buyer, + effect, + amount: RadrootsCoreMoney::new(amount, currency), + reason: adjustment.reason.trim().to_owned(), + }) + }) + .collect() } -fn order_listing_fulfillment_filter( - listing_addr: &str, -) -> Result<RadrootsNostrFilter, RuntimeError> { - let filter = RadrootsNostrFilter::new() - .kind(radroots_nostr_kind(KIND_ORDER_FULFILLMENT_UPDATE as u16)) - .limit(1_000); - radroots_nostr_filter_tag(filter, "a", vec![listing_addr.to_owned()]) - .map_err(|error| RuntimeError::Config(format!("build fulfillment filter: {error}"))) +fn parse_economics_currency( + value: &str, + field: &str, +) -> Result<RadrootsCoreCurrency, RuntimeError> { + value + .parse::<RadrootsCoreCurrency>() + .map_err(|error| RuntimeError::Config(format!("listing {field} is invalid: {error}"))) } -fn order_listing_cancellation_filter( - listing_addr: &str, -) -> Result<RadrootsNostrFilter, RuntimeError> { - let filter = RadrootsNostrFilter::new() - .kind(radroots_nostr_kind(KIND_ORDER_CANCELLATION as u16)) - .limit(1_000); - radroots_nostr_filter_tag(filter, "a", vec![listing_addr.to_owned()]) - .map_err(|error| RuntimeError::Config(format!("build cancellation filter: {error}"))) +fn parse_economics_unit(value: &str, field: &str) -> Result<RadrootsCoreUnit, RuntimeError> { + value + .parse::<RadrootsCoreUnit>() + .map_err(|error| RuntimeError::Config(format!("listing {field} is invalid: {error}"))) } -fn order_status_filter(order_id: &str) -> Result<RadrootsNostrFilter, RuntimeError> { - let filter = RadrootsNostrFilter::new() - .kinds([ - radroots_nostr_kind(KIND_ORDER_REQUEST as u16), - radroots_nostr_kind(KIND_ORDER_DECISION as u16), - radroots_nostr_kind(KIND_ORDER_REVISION_PROPOSAL as u16), - radroots_nostr_kind(KIND_ORDER_REVISION_DECISION as u16), - radroots_nostr_kind(KIND_ORDER_FULFILLMENT_UPDATE as u16), - radroots_nostr_kind(KIND_ORDER_CANCELLATION as u16), - radroots_nostr_kind(KIND_ORDER_RECEIPT as u16), - ]) - .limit(1_000); - radroots_nostr_filter_tag(filter, "d", vec![order_id.to_owned()]) - .map_err(|error| RuntimeError::Config(format!("build order status filter: {error}"))) +fn exact_non_negative_decimal( + value: Option<&str>, + field: &str, +) -> Result<RadrootsCoreDecimal, RuntimeError> { + let parsed = exact_decimal(value, field)?; + if parsed.is_sign_negative() { + return Err(RuntimeError::Config(format!( + "listing {field} must be non-negative" + ))); + } + Ok(parsed) } -fn event_kind_u32(event: &RadrootsNostrEvent) -> u32 { - u32::from(event.kind.as_u16()) -} - -fn order_evidence_from_relay_events(events: &[RadrootsNostrEvent]) -> Vec<SdkRadrootsNostrEvent> { - events.iter().map(radroots_event_from_nostr).collect() -} - -fn validate_scaffold_args(args: &OrderDraftCreateArgs) -> Result<(), RuntimeError> { - match (normalize_optional(args.bin_id.as_deref()), args.bin_count) { - (None, Some(_)) => Err(RuntimeError::Config( - "`--qty` requires `--bin` when creating an order draft".to_owned(), - )), - (Some(_), Some(0)) => Err(RuntimeError::Config( - "`--qty` must be greater than zero".to_owned(), - )), - (Some(_), None) | (Some(_), Some(_)) | (None, None) => Ok(()), +fn exact_positive_decimal( + value: Option<&str>, + field: &str, +) -> Result<RadrootsCoreDecimal, RuntimeError> { + let parsed = exact_non_negative_decimal(value, field)?; + if parsed.is_zero() { + return Err(RuntimeError::Config(format!( + "listing {field} must be greater than zero" + ))); } + Ok(parsed) } -fn resolve_order_listing( - config: &RuntimeConfig, - listing_lookup: Option<&str>, - explicit_listing_addr: Option<&str>, -) -> Result<Option<ResolvedOrderListing>, RuntimeError> { - if let Some(listing_addr) = explicit_listing_addr { - let parsed = parse_listing_addr(listing_addr).map_err(|error| { - RuntimeError::Config(format!("explicit listing_addr is invalid: {error}")) - })?; - if parsed.kind != KIND_LISTING { - return Err(RuntimeError::Config( - "explicit listing_addr must reference a public NIP-99 listing".to_owned(), - )); - } - let replica_listing_event_id = - resolve_active_listing_event_id(config, listing_addr, &parsed)?; - let shared_provenance = resolve_shared_signed_listing_provenance( - config, - listing_addr, - replica_listing_event_id.as_deref(), - )?; - let listing_event_id = replica_listing_event_id - .or_else(|| { - shared_provenance - .as_ref() - .map(|provenance| provenance.event_id.clone()) - }) - .unwrap_or_default(); - let listing_relays = listing_provenance_relays( - config, - listing_event_id.as_str(), - shared_provenance.as_ref(), - )?; - let economics_product = resolve_trade_product_by_listing_addr(config, listing_addr)?; - return Ok(Some(ResolvedOrderListing { - listing_addr: listing_addr.to_owned(), - listing_event_id, - listing_relays, - seller_pubkey: parsed.seller_pubkey, - economics_product, - })); - } - - let Some(listing_lookup) = listing_lookup else { - return Ok(None); +fn exact_decimal(value: Option<&str>, field: &str) -> Result<RadrootsCoreDecimal, RuntimeError> { + let Some(value) = value.and_then(non_empty_ref) else { + return Err(RuntimeError::Config(format!( + "listing {field} exact source is missing" + ))); }; + value + .parse::<RadrootsCoreDecimal>() + .map_err(|error| RuntimeError::Config(format!("listing {field} is invalid: {error}"))) +} - if !config.local.replica_db_path.exists() { +fn decimal_from_adjustment(value: &str, field: &str) -> Result<RadrootsCoreDecimal, RuntimeError> { + let parsed = value + .trim() + .parse::<RadrootsCoreDecimal>() + .map_err(|error| RuntimeError::Config(format!("basket {field} is invalid: {error}")))?; + if parsed.is_sign_negative() { return Err(RuntimeError::Config(format!( - "order listing lookup `{listing_lookup}` requires local market data; run `radroots store init` and `radroots market refresh` before creating an order from a listing" + "basket {field} must be non-negative" ))); } + Ok(parsed) +} - let db = ReplicaSql::new(SqliteExecutor::open(&config.local.replica_db_path)?); - let rows = db.trade_product_lookup(listing_lookup)?; - match rows.len() { - 0 => Err(RuntimeError::Config(format!( - "listing `{listing_lookup}` is not available in the local replica; run `radroots market refresh` or pass `--listing-addr`" - ))), - 1 => { - let row = rows.into_iter().next().expect("one row"); - let economics_product = ResolvedOrderEconomicsProduct::from_summary(&row); - let listing_addr = normalize_optional(row.listing_addr.as_deref()).ok_or_else(|| { - RuntimeError::Config(format!( - "listing `{listing_lookup}` is missing a canonical listing address; run `radroots market refresh` or pass `--listing-addr`" - )) - })?; - let parsed = parse_listing_addr(listing_addr.as_str()).map_err(|error| { - RuntimeError::Config(format!( - "listing `{listing_lookup}` has invalid listing_addr: {error}; run `radroots market refresh` or pass `--listing-addr`" - )) - })?; - if parsed.kind != KIND_LISTING { - return Err(RuntimeError::Config(format!( - "listing `{listing_lookup}` listing_addr must reference a public NIP-99 listing; run `radroots market refresh` or pass `--listing-addr`" - ))); - } - - let listing_event_id = resolve_active_listing_event_id( - config, - listing_addr.as_str(), - &parsed, - )? - .ok_or_else(|| { - RuntimeError::Config(format!( - "listing `{listing_lookup}` is missing the latest listing event pointer; run `radroots market refresh` before creating an order from this listing" - )) - })?; - let shared_provenance = resolve_shared_signed_listing_provenance( - config, - listing_addr.as_str(), - Some(listing_event_id.as_str()), - )?; - let listing_relays = listing_provenance_relays( - config, - listing_event_id.as_str(), - shared_provenance.as_ref(), - )?; - - Ok(Some(ResolvedOrderListing { - listing_addr, - listing_event_id, - listing_relays, - seller_pubkey: parsed.seller_pubkey, - economics_product: Some(economics_product), - })) - } - count => Err(RuntimeError::Config(format!( - "listing lookup `{listing_lookup}` matched {count} local listings; use a unique product key or pass `--listing-addr`" - ))), - } +fn view_from_loaded( + config: &RuntimeConfig, + loaded: LoadedOrderDraft, +) -> Result<OrderGetView, RuntimeError> { + view_from_loaded_with_source_issues(config, loaded, &[]) } -fn resolve_trade_product_by_listing_addr( +fn view_from_loaded_with_source_issues( config: &RuntimeConfig, - listing_addr: &str, -) -> Result<Option<ResolvedOrderEconomicsProduct>, RuntimeError> { - if !config.local.replica_db_path.exists() { - return Ok(None); - } + loaded: LoadedOrderDraft, + source_issues: &[OrderIssueView], +) -> Result<OrderGetView, RuntimeError> { + let OrderInspection { + state, + ready_for_submit, + listing_addr, + listing_event_id, + seller_pubkey, + buyer_custody, + buyer_write_capable, + issues, + } = inspect_document_with_source_issues(config, &loaded.document, source_issues)?; - let executor = SqliteExecutor::open(&config.local.replica_db_path)?; - let product_rows = trade_product::find_many( - &executor, - &ITradeProductFindMany { - filter: Some(trade_product_listing_addr_filter(listing_addr)), - }, - ) - .map_err(|error| RuntimeError::Config(format!("resolve listing product state: {error:?}")))? - .results; + let actions = actions_for_document(&loaded.document, loaded.file.as_path(), issues.as_slice()); - match product_rows.len() { - 0 => Ok(None), - 1 => Ok(product_rows - .into_iter() - .next() - .map(ResolvedOrderEconomicsProduct::from_product)), - count => Err(RuntimeError::Config(format!( - "listing address `{listing_addr}` matched {count} active local listing rows" - ))), - } + Ok(OrderGetView { + state, + source: ORDER_SOURCE.to_owned(), + lookup: loaded.document.order.order_id.clone(), + order_id: Some(loaded.document.order.order_id.clone()), + file: Some(loaded.file.display().to_string()), + listing_lookup: loaded.document.listing_lookup.clone(), + listing_addr, + listing_event_id, + listing_relays: order_listing_relays(&loaded.document), + 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 + .document + .order + .items + .iter() + .map(|item| OrderDraftItemView { + bin_id: item.bin_id.clone(), + bin_count: item.bin_count, + }) + .collect(), + economics: loaded.document.order.economics.clone(), + updated_at_unix: Some(loaded.updated_at_unix), + job: None, + workflow: None, + reason: None, + issues, + actions, + }) } -fn resolve_active_listing_event_id( +fn summary_from_loaded( config: &RuntimeConfig, - listing_addr: &str, - parsed: &ParsedListingAddress, -) -> Result<Option<String>, RuntimeError> { - if !config.local.replica_db_path.exists() { - return Ok(None); - } - - let executor = SqliteExecutor::open(&config.local.replica_db_path)?; - let product_rows = trade_product::find_many( - &executor, - &ITradeProductFindMany { - filter: Some(trade_product_listing_addr_filter(listing_addr)), - }, - ) - .map_err(|error| RuntimeError::Config(format!("resolve listing product state: {error:?}")))? - .results; + loaded: &LoadedOrderDraft, +) -> Result<OrderSummaryView, RuntimeError> { + summary_from_loaded_with_source_issues(config, loaded, &[]) +} - match product_rows.len() { - 0 => return Ok(None), - 1 => {} - count => { - return Err(RuntimeError::Config(format!( - "listing address `{listing_addr}` matched {count} active local listing rows" - ))); - } - } +fn summary_from_loaded_with_source_issues( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + source_issues: &[OrderIssueView], +) -> Result<OrderSummaryView, RuntimeError> { + let OrderInspection { + state, + ready_for_submit, + listing_addr, + listing_event_id, + seller_pubkey: _, + buyer_custody, + buyer_write_capable, + issues, + } = inspect_document_with_source_issues(config, &loaded.document, source_issues)?; - let key = format!( - "{}:{}:{}", - parsed.kind, parsed.seller_pubkey, parsed.listing_id - ); - let state = nostr_event_head::find_one( - &executor, - &INostrEventHeadFindOne::On(INostrEventHeadFindOneArgs { - on: NostrEventHeadQueryBindValues::Key { key }, - }), - ) - .map_err(|error| RuntimeError::Config(format!("resolve listing event state: {error:?}")))? - .result; + Ok(OrderSummaryView { + id: loaded.document.order.order_id.clone(), + state, + ready_for_submit, + file: loaded.file.display().to_string(), + listing_lookup: loaded.document.listing_lookup.clone(), + listing_addr, + listing_event_id, + listing_relays: order_listing_relays(&loaded.document), + 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, + }) +} - let Some(state) = state else { - return Ok(None); - }; - if !is_valid_event_id(state.last_event_id.as_str()) { - return Err(RuntimeError::Config(format!( - "listing address `{listing_addr}` has invalid latest listing event id in local replica" - ))); +fn summary_for_invalid_file(path: &Path, reason: String) -> OrderSummaryView { + let id = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("unknown") + .to_owned(); + OrderSummaryView { + id, + state: "error".to_owned(), + ready_for_submit: false, + file: path.display().to_string(), + listing_lookup: None, + listing_addr: None, + listing_event_id: None, + listing_relays: Vec::new(), + 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(), + job: None, + issues: vec![issue_with_code("invalid_order_draft", "draft", reason)], } +} - Ok(Some(state.last_event_id)) +fn app_order_local_records(config: &RuntimeConfig) -> Result<Vec<LocalEventRecord>, RuntimeError> { + let mut app_records = Vec::new(); + let mut before_cursor = None::<(i64, i64)>; + loop { + let shared_records = if let Some((before_change_seq, before_seq)) = before_cursor { + list_shared_records_before( + config, + before_change_seq, + before_seq, + ORDER_APP_RECORD_LIST_LIMIT, + )? + } else { + list_shared_records_latest(config, ORDER_APP_RECORD_LIST_LIMIT)? + }; + let Some(next_cursor) = shared_records + .last() + .map(|record| (record.change_seq, record.seq)) + else { + break; + }; + let has_more = shared_records.len() == ORDER_APP_RECORD_LIST_LIMIT as usize; + app_records.extend(shared_records.into_iter().filter(is_app_order_local_record)); + if !has_more { + break; + } + before_cursor = Some(next_cursor); + } + Ok(app_records) } -#[derive(Debug, Clone)] -struct SharedListingProvenance { - event_id: String, - relays: Vec<String>, +fn is_app_order_local_record(record: &LocalEventRecord) -> bool { + record.source_runtime == SourceRuntime::App + && record.family == LocalRecordFamily::LocalWork + && record.status == LocalRecordStatus::LocalSaved + && local_record_kind(record).as_deref() == Some(BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND) } -fn listing_provenance_relays( - config: &RuntimeConfig, - listing_event_id: &str, - shared_provenance: Option<&SharedListingProvenance>, -) -> Result<Vec<String>, RuntimeError> { - let mut relays = Vec::<String>::new(); - if let Some(provenance) = shared_provenance - && provenance.event_id == listing_event_id - { - relays.extend(provenance.relays.iter().cloned()); +fn current_app_order_record_entries( + mut records: Vec<LocalEventRecord>, +) -> Vec<AppOrderRecordListEntry> { + records.sort_by(|left, right| { + right + .change_seq + .cmp(&left.change_seq) + .then_with(|| right.seq.cmp(&left.seq)) + .then_with(|| left.record_id.cmp(&right.record_id)) + }); + + let mut entries = Vec::<AppOrderRecordListEntry>::new(); + let mut seen = HashMap::<String, usize>::new(); + for record in records { + let key = app_order_record_current_key(&record); + if let Some(index) = seen.get(&key).copied() { + entries[index].superseded_count += 1; + } else { + seen.insert(key, entries.len()); + entries.push(AppOrderRecordListEntry { + record, + superseded_count: 0, + }); + } } - relays.extend(relay_provenance_relays_for_scope( - config, - RelayIngestScope::MarketRefresh, - )?); - normalize_listing_relay_set(relays) - .map_err(|error| RuntimeError::Config(format!("listing provenance relays: {error}"))) + entries } -fn resolve_shared_signed_listing_provenance( +fn current_app_order_record_for( config: &RuntimeConfig, - listing_addr: &str, - listing_event_id: Option<&str>, -) -> Result<Option<SharedListingProvenance>, RuntimeError> { - let mut candidates = list_shared_records_latest(config, ORDER_APP_RECORD_LIST_LIMIT)? + record: &LocalEventRecord, +) -> Result<Option<LocalEventRecord>, RuntimeError> { + let key = app_order_record_current_key(record); + Ok(app_order_local_records(config)? .into_iter() - .filter(|record| record.family == LocalRecordFamily::SignedEvent) - .filter(|record| record.status == LocalRecordStatus::Published) - .filter(|record| record.event_kind == Some(i64::from(KIND_LISTING))) - .filter(|record| record.listing_addr.as_deref() == Some(listing_addr)) - .filter(|record| { - listing_event_id.is_none() || record.event_id.as_deref() == listing_event_id - }) - .filter_map(|record| { - let event_id = record.event_id?; - if !is_valid_event_id(event_id.as_str()) { - return None; - } - let delivery = record.relay_delivery_json.as_ref()?; - let evidence = RelayDeliveryEvidence::from_json_value(delivery).ok()?; - let relays = listing_provenance_relays_from_delivery_evidence(evidence).ok()?; - if relays.is_empty() { - return None; - } - Some(SharedListingProvenance { event_id, relays }) - }) - .collect::<Vec<_>>(); - candidates.sort_by(|left, right| left.event_id.cmp(&right.event_id)); - candidates.dedup_by(|left, right| left.event_id == right.event_id); - if candidates.len() > 1 && listing_event_id.is_none() { - return Err(RuntimeError::Config(format!( - "listing address `{listing_addr}` has multiple published shared local listing events; run `radroots market refresh` or pass a current listing event id source" - ))); + .filter(|candidate| app_order_record_current_key(candidate) == key) + .max_by(|left, right| { + left.change_seq + .cmp(&right.change_seq) + .then_with(|| left.seq.cmp(&right.seq)) + })) +} + +fn app_order_conflicting_record_ids_for( + config: &RuntimeConfig, + record: &LocalEventRecord, +) -> Result<Vec<String>, RuntimeError> { + if app_order_record_order_id(record).is_none() { + return Ok(Vec::new()); } - Ok(candidates.pop()) + let key = app_order_record_current_key(record); + let mut record_ids = app_order_local_records(config)? + .into_iter() + .filter(|candidate| candidate.record_id != record.record_id) + .filter(|candidate| app_order_record_current_key(candidate) == key) + .map(|candidate| candidate.record_id) + .collect::<Vec<_>>(); + record_ids.sort(); + record_ids.dedup(); + Ok(record_ids) } -fn listing_provenance_relays_from_delivery_evidence( - evidence: RelayDeliveryEvidence, -) -> Result<Vec<String>, String> { - let relays = match evidence.state { - RelayDeliveryState::Acknowledged => evidence.acknowledged_relays, - RelayDeliveryState::Observed => evidence.observed_relays, - RelayDeliveryState::Pending | RelayDeliveryState::Failed => Vec::new(), - }; - normalize_listing_relay_set(relays) +fn load_app_order_record_for_lookup( + config: &RuntimeConfig, + lookup: &str, +) -> Result<Option<LoadedAppOrderRecord>, RuntimeError> { + if let Some(record) = get_shared_record(config, lookup)? + && is_app_order_local_record(&record) + { + return load_app_order_record_from_record(config, record).map(Some); + } + for entry in current_app_order_record_entries(app_order_local_records(config)?) { + if app_order_record_order_id(&entry.record).as_deref() == Some(lookup) { + return load_app_order_record_from_record(config, entry.record).map(Some); + } + } + Ok(None) } -fn trade_product_listing_addr_filter(listing_addr: &str) -> ITradeProductFieldsFilter { - ITradeProductFieldsFilter { - id: None, - created_at: None, - updated_at: None, - key: None, - category: None, - title: None, - summary: None, - process: None, - lot: None, - profile: None, - year: None, - qty_amt: None, - qty_amt_exact: None, - qty_unit: None, - qty_label: None, - qty_avail: None, - price_amt: None, - price_amt_exact: None, - price_currency: None, - price_qty_amt: None, - price_qty_amt_exact: None, - price_qty_unit: None, - listing_addr: Some(listing_addr.to_owned()), - primary_bin_id: None, - verified_primary_bin_id: None, - notes: None, - } -} - -fn order_economics_from_resolved_listing( - order_id: &str, - resolved_listing: Option<&ResolvedOrderListing>, - items: &[OrderDraftItem], - adjustments: &[crate::cli::global::OrderDraftAdjustmentArgs], -) -> Result<Option<RadrootsOrderEconomics>, RuntimeError> { - let Some(listing) = resolved_listing else { - return Ok(None); - }; - let Some(product) = listing.economics_product.as_ref() else { - return Ok(None); +fn load_app_order_record_from_record( + config: &RuntimeConfig, + record: LocalEventRecord, +) -> Result<LoadedAppOrderRecord, RuntimeError> { + let mut source_issues = app_order_record_source_issues(config, &record)?; + let payload = record.local_work_json.clone().unwrap_or(Value::Null); + let document = match payload.get("document").cloned() { + Some(value) => match serde_json::from_value::<OrderDraftDocument>(value) { + Ok(document) => document, + Err(error) => { + source_issues.push(issue_with_code( + "invalid_app_order_record", + "document", + format!("app-authored order document cannot be decoded: {error}"), + )); + placeholder_app_order_document(&record) + } + }, + None => { + source_issues.push(issue_with_code( + "invalid_app_order_record", + "document", + "app-authored order record is missing document", + )); + placeholder_app_order_document(&record) + } }; - let Some(primary_bin_id) = product.primary_bin_id.as_deref().and_then(non_empty_ref) else { - return Ok(None); + let loaded = LoadedOrderDraft { + file: PathBuf::from(format!("shared-local-events/{}", record.record_id)), + updated_at_unix: u64::try_from(record.updated_at_ms / 1000).unwrap_or_default(), + document, }; - let Some(verified_primary_bin_id) = product - .verified_primary_bin_id - .as_deref() - .and_then(non_empty_ref) - else { - return Err(RuntimeError::Config(format!( - "listing_primary_bin_invalid: listing `{}` primary bin `{primary_bin_id}` is not verified in the current local replica", - listing.listing_addr - ))); + source_issues.extend(app_order_signed_evidence_issues(config, &loaded)?); + + Ok(LoadedAppOrderRecord { + loaded, + record, + source_issues, + }) +} + +fn app_order_record_source_issues( + config: &RuntimeConfig, + record: &LocalEventRecord, +) -> Result<Vec<OrderIssueView>, RuntimeError> { + let mut issues = Vec::new(); + if record.source_runtime != SourceRuntime::App { + issues.push(issue_with_code( + "app_order_unsupported", + "source_runtime", + "order record must come from radroots_app", + )); + } + if record.family != LocalRecordFamily::LocalWork { + issues.push(issue_with_code( + "app_order_unsupported", + "family", + "order record must be shared local work", + )); + } + if record.status != LocalRecordStatus::LocalSaved { + issues.push(issue_with_code( + "app_order_unsupported", + "status", + format!( + "order record status `{}` is not consumable as local saved work", + record.status.as_str() + ), + )); + } + let Some(payload) = record.local_work_json.as_ref() else { + issues.push(issue_with_code( + "invalid_app_order_record", + "local_work_json", + "app-authored order record is missing local work payload", + )); + return Ok(issues); }; - if verified_primary_bin_id != primary_bin_id { - return Err(RuntimeError::Config(format!( - "listing_primary_bin_invalid: listing `{}` primary bin `{primary_bin_id}` does not match verified primary bin `{verified_primary_bin_id}` in the current local replica", - listing.listing_addr - ))); + let current = payload["currentness"]["current"].as_bool() == Some(true); + if !current { + issues.push(issue_with_code( + "app_order_stale", + "currentness.current", + "app-authored order record is not marked current", + )); } - if items.is_empty() - || items - .iter() - .any(|item| item.bin_id.as_str() != primary_bin_id) + if payload["currentness"]["record_id"].as_str() != Some(record.record_id.as_str()) { + issues.push(issue_with_code( + "invalid_app_order_record", + "currentness.record_id", + "app-authored order record currentness id does not match the shared record id", + )); + } + if current { + match validate_supported_buyer_order_request_local_work_payload(payload) { + Ok(_) => {} + Err(error) => { + let support_state = payload["support_status"]["state"].as_str(); + let support_issues = payload["support_status"]["issues"] + .as_array() + .cloned() + .unwrap_or_default(); + if support_state == Some("unsupported") { + issues.push(issue_with_code( + "app_order_unsupported", + "support_status.state", + "app-authored order record is not marked supported", + )); + for support_issue in support_issues { + if let Some(support_issue) = support_issue.as_str() { + issues.push(issue_with_code( + "app_order_unsupported", + "support_status.issues", + format!("app order support issue: {support_issue}"), + )); + } + } + } else { + issues.push(issue_with_code( + "invalid_app_order_record", + "local_work_json", + error.to_string(), + )); + } + } + } + } + if let Some(current_record) = current_app_order_record_for(config, record)? + && current_record.record_id != record.record_id { - return Ok(None); + issues.push(issue_with_code( + "app_order_stale", + "record_id", + format!( + "app-authored local order record `{}` was superseded by `{}`", + record.record_id, current_record.record_id + ), + )); } + let conflicting_record_ids = app_order_conflicting_record_ids_for(config, record)?; + if !conflicting_record_ids.is_empty() { + issues.push(issue_with_code( + "app_order_conflict", + "order_id", + format!( + "app-authored order id conflicts with other shared records: {}", + conflicting_record_ids.join(", ") + ), + )); + } + Ok(issues) +} - let currency = parse_economics_currency(product.price_currency.as_str(), "price_currency")?; - let quantity_amount = - exact_non_negative_decimal(product.qty_amt_exact.as_deref(), "qty_amt_exact")?; - let quantity_unit = parse_economics_unit(product.qty_unit.as_str(), "qty_unit")?; - let price_amount = - exact_non_negative_decimal(product.price_amt_exact.as_deref(), "price_amt_exact")?; - let price_quantity_amount = exact_positive_decimal( - product.price_qty_amt_exact.as_deref(), - "price_qty_amt_exact", - )?; - let price_unit = parse_economics_unit(product.price_qty_unit.as_str(), "price_qty_unit")?; - let quantity_unit_in_price_units = - convert_unit_decimal(RadrootsCoreDecimal::ONE, quantity_unit, price_unit).map_err( - |error| { - RuntimeError::Config(format!( - "listing quantity unit and price unit are incompatible: {error}" - )) - }, - )?; - let unit_price_amount = (price_amount / price_quantity_amount) * quantity_unit_in_price_units; - - let mut subtotal_amount = RadrootsCoreDecimal::ZERO; - let mut economic_items = Vec::with_capacity(items.len()); - for item in items { - let line_amount = - unit_price_amount * quantity_amount * RadrootsCoreDecimal::from(item.bin_count); - subtotal_amount = subtotal_amount + line_amount; - economic_items.push(RadrootsOrderEconomicItem { - bin_id: protocol_inventory_bin_id(item.bin_id.as_str(), "order item bin_id")?, - bin_count: item.bin_count, - quantity_amount, - quantity_unit, - unit_price_amount, - unit_price_currency: currency, - line_subtotal: RadrootsCoreMoney::new(line_amount, currency), - }); +fn app_order_signed_evidence_issues( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, +) -> Result<Vec<OrderIssueView>, RuntimeError> { + let order_id = loaded.document.order.order_id.as_str(); + let candidate_records = visible_signed_order_request_records(config, order_id)?; + if candidate_records.is_empty() { + return Ok(Vec::new()); } - let subtotal = RadrootsCoreMoney::new(subtotal_amount, currency); - let discounts = listing_discount_lines_from_product( - product, - &subtotal, - items, - quantity_amount, - quantity_unit, - )?; - let adjustments = basket_adjustment_lines(adjustments)?; - let zero = RadrootsCoreMoney::zero(currency); - let mut economics = RadrootsOrderEconomics { - quote_id: protocol_quote_id(format!("quote_{order_id}").as_str(), "quote_id")?, - quote_version: 1, - pricing_basis: RadrootsOrderPricingBasis::ListingEvent, - currency, - items: economic_items, - discounts, - adjustments, - subtotal: subtotal.clone(), - discount_total: zero.clone(), - adjustment_total: zero, - total: subtotal, + let expected_payload = match canonical_order_request_payload_from_loaded( + loaded, + loaded.document.order.buyer_pubkey.as_str(), + ) { + Ok(payload) => payload, + Err(error) => { + let event_ids = candidate_records + .iter() + .map(signed_record_event_id) + .collect::<Vec<_>>(); + return Ok(vec![issue_with_events( + APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE, + "signed_event", + format!( + "signed order request evidence cannot be compared with local work: {error}" + ), + event_ids, + )]); + } }; - economics.canonicalize(); - economics - .validate() - .map_err(|error| RuntimeError::Config(format!("build order economics: {error}")))?; - Ok(Some(economics)) -} -fn listing_discount_lines_from_product( - product: &ResolvedOrderEconomicsProduct, - subtotal: &RadrootsCoreMoney, - items: &[OrderDraftItem], - quantity_amount: RadrootsCoreDecimal, - quantity_unit: RadrootsCoreUnit, -) -> Result<Vec<RadrootsOrderEconomicLine>, RuntimeError> { - let Some(notes) = product.notes.as_deref().and_then(non_empty_ref) else { - return Ok(Vec::new()); - }; - let parsed = serde_json::from_str::<ResolvedTradeProductNotes>(notes).map_err(|error| { - RuntimeError::Config(format!("listing discount metadata is invalid: {error}")) - })?; - let mut lines = Vec::new(); - for (index, discount) in parsed.listing_discounts.iter().enumerate() { - if !discount_applies(discount, items, quantity_amount, quantity_unit)? { - continue; - } - let amount = listing_discount_amount(discount, subtotal, items)?; - if amount.is_zero() { - return Err(RuntimeError::Config( - "listing discount amount must be greater than zero".to_owned(), - )); + let mut submitted_event_ids = Vec::new(); + let mut conflict_issues = Vec::new(); + for record in candidate_records { + let event_id = signed_record_event_id(&record); + match signed_order_request_from_record(&record) + .and_then(|event| order_submit_request_from_event(&event, loaded)) + { + Ok(request) + if order_submit_request_matches_draft(&request, loaded, &expected_payload) => + { + submitted_event_ids.push(request.request_event_id); + } + Ok(request) => conflict_issues.push(issue_with_events( + APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE, + "signed_event", + format!( + "signed order request event `{}` conflicts with the app-authored local order", + request.request_event_id + ), + vec![request.request_event_id], + )), + Err(error) => conflict_issues.push(issue_with_events( + APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE, + "signed_event", + format!("signed order request event `{event_id}` cannot be validated: {error}"), + vec![event_id], + )), } - lines.push(RadrootsOrderEconomicLine { - id: format!("listing_discount_{}", index + 1), - kind: RadrootsOrderEconomicLineKind::ListingDiscount, - actor: RadrootsOrderEconomicActor::Seller, - effect: RadrootsOrderEconomicEffect::Decrease, - amount, - reason: format!("listing discount {}", index + 1), - }); } - Ok(lines) -} -fn discount_applies( - discount: &RadrootsCoreDiscount, - items: &[OrderDraftItem], - quantity_amount: RadrootsCoreDecimal, - quantity_unit: RadrootsCoreUnit, -) -> Result<bool, RuntimeError> { - match &discount.threshold { - RadrootsCoreDiscountThreshold::BinCount { bin_id, min } => Ok(items - .iter() - .any(|item| item.bin_id == *bin_id && item.bin_count >= *min)), - RadrootsCoreDiscountThreshold::OrderQuantity { min } => { - let requested = items.iter().fold(RadrootsCoreDecimal::ZERO, |total, item| { - total + quantity_amount * RadrootsCoreDecimal::from(item.bin_count) - }); - let converted = - convert_unit_decimal(requested, quantity_unit, min.unit).map_err(|error| { - RuntimeError::Config(format!( - "listing discount quantity threshold is incompatible: {error}" - )) - })?; - Ok(converted >= min.amount) - } + conflict_issues.sort_by(|left, right| { + left.event_ids + .cmp(&right.event_ids) + .then_with(|| left.message.cmp(&right.message)) + }); + if !conflict_issues.is_empty() { + return Ok(conflict_issues); } -} -fn listing_discount_amount( - discount: &RadrootsCoreDiscount, - subtotal: &RadrootsCoreMoney, - items: &[OrderDraftItem], -) -> Result<RadrootsCoreMoney, RuntimeError> { - match &discount.value { - RadrootsCoreDiscountValue::Percent(percent) => Ok(percent.of_money(subtotal)), - RadrootsCoreDiscountValue::MoneyPerBin(money) => { - if money.currency != subtotal.currency { - return Err(RuntimeError::Config( - "listing discount currency must match listing price currency".to_owned(), - )); - } - let multiplier = match &discount.scope { - RadrootsCoreDiscountScope::Bin => { - items.iter().map(|item| item.bin_count).sum::<u32>().max(1) - } - RadrootsCoreDiscountScope::OrderTotal => 1, - }; - Ok(money.mul_decimal(RadrootsCoreDecimal::from(multiplier))) - } + submitted_event_ids.sort(); + submitted_event_ids.dedup(); + if submitted_event_ids.is_empty() { + Ok(Vec::new()) + } else { + Ok(vec![issue_with_events( + APP_ORDER_ALREADY_SUBMITTED_ISSUE, + "signed_event", + "app-authored local order already has matching signed order request evidence", + submitted_event_ids, + )]) } } -fn basket_adjustment_lines( - adjustments: &[crate::cli::global::OrderDraftAdjustmentArgs], -) -> Result<Vec<RadrootsOrderEconomicLine>, RuntimeError> { - adjustments - .iter() - .map(|adjustment| { - let currency = - parse_economics_currency(adjustment.currency.as_str(), "adjustment_currency")?; - let amount = decimal_from_adjustment(adjustment.amount.as_str(), "adjustment_amount")?; - if amount.is_zero() { - return Err(RuntimeError::Config( - "basket adjustment amount must be greater than zero".to_owned(), - )); - } - let effect = match adjustment.effect.as_str() { - "increase" => RadrootsOrderEconomicEffect::Increase, - "decrease" => RadrootsOrderEconomicEffect::Decrease, - other => { - return Err(RuntimeError::Config(format!( - "basket adjustment effect `{other}` is invalid" - ))); - } - }; - if adjustment.id.trim().is_empty() { - return Err(RuntimeError::Config( - "basket adjustment id must not be empty".to_owned(), - )); - } - if adjustment.reason.trim().is_empty() { - return Err(RuntimeError::Config( - "basket adjustment reason must not be empty".to_owned(), - )); - } - Ok(RadrootsOrderEconomicLine { - id: adjustment.id.trim().to_owned(), - kind: RadrootsOrderEconomicLineKind::BasketAdjustment, - actor: RadrootsOrderEconomicActor::Buyer, - effect, - amount: RadrootsCoreMoney::new(amount, currency), - reason: adjustment.reason.trim().to_owned(), - }) - }) - .collect() +fn visible_signed_order_request_records( + config: &RuntimeConfig, + order_id: &str, +) -> Result<Vec<LocalEventRecord>, RuntimeError> { + let mut records = Vec::new(); + let mut before_cursor = None::<(i64, i64)>; + loop { + let shared_records = if let Some((before_change_seq, before_seq)) = before_cursor { + list_shared_records_before( + config, + before_change_seq, + before_seq, + ORDER_APP_RECORD_LIST_LIMIT, + )? + } else { + list_shared_records_latest(config, ORDER_APP_RECORD_LIST_LIMIT)? + }; + let Some(next_cursor) = shared_records + .last() + .map(|record| (record.change_seq, record.seq)) + else { + break; + }; + let has_more = shared_records.len() == ORDER_APP_RECORD_LIST_LIMIT as usize; + records.extend( + shared_records + .into_iter() + .filter(|record| is_visible_signed_order_request_record(record, order_id)), + ); + if !has_more { + break; + } + before_cursor = Some(next_cursor); + } + Ok(records) } -fn parse_economics_currency( - value: &str, - field: &str, -) -> Result<RadrootsCoreCurrency, RuntimeError> { - value - .parse::<RadrootsCoreCurrency>() - .map_err(|error| RuntimeError::Config(format!("listing {field} is invalid: {error}"))) +fn is_visible_signed_order_request_record(record: &LocalEventRecord, order_id: &str) -> bool { + record.family == LocalRecordFamily::SignedEvent + && record.status == LocalRecordStatus::Published + && record.outbox_status == PublishOutboxStatus::Acknowledged + && record.event_kind == Some(i64::from(KIND_ORDER_REQUEST)) + && signed_record_tag_values(record, "d") + .iter() + .any(|value| value == order_id) } -fn parse_economics_unit(value: &str, field: &str) -> Result<RadrootsCoreUnit, RuntimeError> { - value - .parse::<RadrootsCoreUnit>() - .map_err(|error| RuntimeError::Config(format!("listing {field} is invalid: {error}"))) -} - -fn exact_non_negative_decimal( - value: Option<&str>, - field: &str, -) -> Result<RadrootsCoreDecimal, RuntimeError> { - let parsed = exact_decimal(value, field)?; - if parsed.is_sign_negative() { - return Err(RuntimeError::Config(format!( - "listing {field} must be non-negative" - ))); - } - Ok(parsed) -} - -fn exact_positive_decimal( - value: Option<&str>, - field: &str, -) -> Result<RadrootsCoreDecimal, RuntimeError> { - let parsed = exact_non_negative_decimal(value, field)?; - if parsed.is_zero() { - return Err(RuntimeError::Config(format!( - "listing {field} must be greater than zero" - ))); - } - Ok(parsed) +fn signed_order_request_from_record( + record: &LocalEventRecord, +) -> Result<RadrootsNostrEvent, RuntimeError> { + let raw_event_json = record.raw_event_json.as_ref().ok_or_else(|| { + RuntimeError::Config(format!( + "signed event record `{}` is missing raw_event_json", + record.record_id + )) + })?; + serde_json::from_value::<RadrootsNostrEvent>(raw_event_json.clone()).map_err(|error| { + RuntimeError::Config(format!( + "signed event record `{}` raw_event_json cannot be decoded: {error}", + record.record_id + )) + }) } -fn exact_decimal(value: Option<&str>, field: &str) -> Result<RadrootsCoreDecimal, RuntimeError> { - let Some(value) = value.and_then(non_empty_ref) else { - return Err(RuntimeError::Config(format!( - "listing {field} exact source is missing" - ))); - }; - value - .parse::<RadrootsCoreDecimal>() - .map_err(|error| RuntimeError::Config(format!("listing {field} is invalid: {error}"))) +fn signed_record_tag_values(record: &LocalEventRecord, key: &str) -> Vec<String> { + record + .event_tags_json + .as_ref() + .or(record + .raw_event_json + .as_ref() + .and_then(|event| event.get("tags"))) + .and_then(Value::as_array) + .map(|tags| { + tags.iter() + .filter_map(Value::as_array) + .filter_map(|tag| { + if tag.first().and_then(Value::as_str) == Some(key) { + tag.get(1).and_then(Value::as_str).map(str::to_owned) + } else { + None + } + }) + .collect::<Vec<_>>() + }) + .unwrap_or_default() } -fn decimal_from_adjustment(value: &str, field: &str) -> Result<RadrootsCoreDecimal, RuntimeError> { - let parsed = value - .trim() - .parse::<RadrootsCoreDecimal>() - .map_err(|error| RuntimeError::Config(format!("basket {field} is invalid: {error}")))?; - if parsed.is_sign_negative() { - return Err(RuntimeError::Config(format!( - "basket {field} must be non-negative" - ))); - } - Ok(parsed) +fn signed_record_event_id(record: &LocalEventRecord) -> String { + record + .event_id + .clone() + .unwrap_or_else(|| record.record_id.clone()) } -fn view_from_loaded( +fn source_and_document_issues( config: &RuntimeConfig, - loaded: LoadedOrderDraft, -) -> Result<OrderGetView, RuntimeError> { - view_from_loaded_with_source_issues(config, loaded, &[]) + app_order: &LoadedAppOrderRecord, +) -> Result<Vec<OrderIssueView>, RuntimeError> { + Ok(inspect_document_with_source_issues( + config, + &app_order.loaded.document, + app_order.source_issues.as_slice(), + )? + .issues) } -fn view_from_loaded_with_source_issues( +fn app_order_record_summary( config: &RuntimeConfig, - loaded: LoadedOrderDraft, - source_issues: &[OrderIssueView], -) -> Result<OrderGetView, RuntimeError> { - let OrderInspection { - state, - ready_for_submit, - listing_addr, - listing_event_id, - seller_pubkey, - buyer_custody, - buyer_write_capable, - issues, - } = inspect_document_with_source_issues(config, &loaded.document, source_issues)?; - - let actions = actions_for_document(&loaded.document, loaded.file.as_path(), issues.as_slice()); - - Ok(OrderGetView { - state, - source: ORDER_SOURCE.to_owned(), - lookup: loaded.document.order.order_id.clone(), - order_id: Some(loaded.document.order.order_id.clone()), - file: Some(loaded.file.display().to_string()), - listing_lookup: loaded.document.listing_lookup.clone(), - listing_addr, - listing_event_id, - listing_relays: order_listing_relays(&loaded.document), - 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 - .document - .order - .items - .iter() - .map(|item| OrderDraftItemView { - bin_id: item.bin_id.clone(), - bin_count: item.bin_count, - }) - .collect(), - economics: loaded.document.order.economics.clone(), - updated_at_unix: Some(loaded.updated_at_unix), - job: None, - workflow: None, - reason: None, - issues, + record: &LocalEventRecord, + superseded_count: usize, +) -> Result<OrderAppRecordSummaryView, RuntimeError> { + let record_kind = local_record_kind(record).unwrap_or_else(|| "unknown".to_owned()); + let app_order = load_app_order_record_from_record(config, record.clone())?; + let issues = source_and_document_issues(config, &app_order)?; + let exportable = issues.is_empty(); + let reason = issues.first().map(|issue| issue.message.clone()); + let document = &app_order.loaded.document; + let status = if app_order_issue_present(&issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { + "submitted".to_owned() + } else if app_order_issue_present(&issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { + "conflict".to_owned() + } else { + record.status.as_str().to_owned() + }; + let actions = if exportable { + vec![ + format!("radroots order get {}", document.order.order_id), + format!("radroots order app export {}", record.record_id), + format!( + "radroots --relay wss://relay.example.com order submit {}", + document.order.order_id + ), + ] + } else if app_order_issue_present(&issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { + vec![format!( + "radroots order status get {}", + document.order.order_id + )] + } else if app_order_issue_present(&issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { + vec![ + format!("radroots order status get {}", document.order.order_id), + "radroots order app list".to_owned(), + ] + } else { + Vec::new() + }; + Ok(OrderAppRecordSummaryView { + record_id: record.record_id.clone(), + seq: record.seq, + change_seq: record.change_seq, + superseded_count, + record_kind, + status, + source_runtime: record.source_runtime.as_str().to_owned(), + owner_account_id: record.owner_account_id.clone(), + owner_pubkey: record.owner_pubkey.clone(), + farm_id: record.farm_id.clone(), + listing_addr: record + .listing_addr + .clone() + .or_else(|| non_empty_string(app_order.loaded.document.order.listing_addr.clone())), + listing_relays: order_listing_relays(document), + order_id: non_empty_string(document.order.order_id.clone()), + buyer_account_id: buyer_account_id(document), + buyer_pubkey: non_empty_string(document.order.buyer_pubkey.clone()), + seller_pubkey: non_empty_string(document.order.seller_pubkey.clone()), + ready_for_submit: exportable, + exportable, + reason, actions, }) } -fn summary_from_loaded( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, -) -> Result<OrderSummaryView, RuntimeError> { - summary_from_loaded_with_source_issues(config, loaded, &[]) +fn app_order_record_current_key(record: &LocalEventRecord) -> String { + app_order_record_order_id(record) + .map(|order_id| format!("order:{order_id}")) + .unwrap_or_else(|| format!("record:{}", record.record_id)) } -fn summary_from_loaded_with_source_issues( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - source_issues: &[OrderIssueView], -) -> Result<OrderSummaryView, RuntimeError> { - let OrderInspection { - state, - ready_for_submit, - listing_addr, - listing_event_id, - seller_pubkey: _, - buyer_custody, - buyer_write_capable, - issues, - } = inspect_document_with_source_issues(config, &loaded.document, source_issues)?; +fn app_order_record_order_id(record: &LocalEventRecord) -> Option<String> { + record + .local_work_json + .as_ref() + .and_then(|payload| payload["document"]["order"]["order_id"].as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} - Ok(OrderSummaryView { - id: loaded.document.order.order_id.clone(), - state, - ready_for_submit, - file: loaded.file.display().to_string(), - listing_lookup: loaded.document.listing_lookup.clone(), - listing_addr, - listing_event_id, - listing_relays: order_listing_relays(&loaded.document), - 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 { - let id = path - .file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("unknown") - .to_owned(); - OrderSummaryView { - id, - state: "error".to_owned(), - ready_for_submit: false, - file: path.display().to_string(), +fn placeholder_app_order_document(record: &LocalEventRecord) -> OrderDraftDocument { + OrderDraftDocument { + version: 0, + kind: "invalid_app_order_record".to_owned(), + order: OrderDraft { + order_id: app_order_record_order_id(record).unwrap_or_else(|| record.record_id.clone()), + listing_addr: String::new(), + listing_event_id: String::new(), + listing_relays: Vec::new(), + buyer_pubkey: String::new(), + seller_pubkey: String::new(), + items: Vec::new(), + economics: None, + }, + buyer_actor: OrderDraftBuyerActor { + account_id: String::new(), + pubkey: String::new(), + source: String::new(), + }, listing_lookup: None, - listing_addr: None, - listing_event_id: None, - listing_relays: Vec::new(), - 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(), - job: None, - issues: vec![issue_with_code("invalid_order_draft", "draft", reason)], } } -fn app_order_local_records(config: &RuntimeConfig) -> Result<Vec<LocalEventRecord>, RuntimeError> { - let mut app_records = Vec::new(); - let mut before_cursor = None::<(i64, i64)>; - loop { - let shared_records = if let Some((before_change_seq, before_seq)) = before_cursor { - list_shared_records_before( - config, - before_change_seq, - before_seq, - ORDER_APP_RECORD_LIST_LIMIT, - )? - } else { - list_shared_records_latest(config, ORDER_APP_RECORD_LIST_LIMIT)? - }; - let Some(next_cursor) = shared_records - .last() - .map(|record| (record.change_seq, record.seq)) - else { - break; - }; - let has_more = shared_records.len() == ORDER_APP_RECORD_LIST_LIMIT as usize; - app_records.extend(shared_records.into_iter().filter(is_app_order_local_record)); - if !has_more { - break; - } - before_cursor = Some(next_cursor); +fn app_order_export_failure_state(issues: &[OrderIssueView]) -> &'static str { + if issues + .iter() + .any(|issue| issue.code == APP_ORDER_ALREADY_SUBMITTED_ISSUE) + { + "already_submitted" + } else if issues.iter().any(|issue| { + issue.code == "app_order_conflict" || issue.code == APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE + }) { + "conflict" + } else if issues.iter().any(|issue| issue.code == "app_order_stale") { + "stale" + } else if issues + .iter() + .any(|issue| issue.code == "invalid_app_order_record") + { + "invalid" + } else if issues + .iter() + .any(|issue| issue.code == "app_order_unsupported") + { + "unsupported" + } else { + "invalid" } - Ok(app_records) -} - -fn is_app_order_local_record(record: &LocalEventRecord) -> bool { - record.source_runtime == SourceRuntime::App - && record.family == LocalRecordFamily::LocalWork - && record.status == LocalRecordStatus::LocalSaved - && local_record_kind(record).as_deref() == Some(BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND) } -fn current_app_order_record_entries( - mut records: Vec<LocalEventRecord>, -) -> Vec<AppOrderRecordListEntry> { - records.sort_by(|left, right| { - right - .change_seq - .cmp(&left.change_seq) - .then_with(|| right.seq.cmp(&left.seq)) - .then_with(|| left.record_id.cmp(&right.record_id)) - }); - - let mut entries = Vec::<AppOrderRecordListEntry>::new(); - let mut seen = HashMap::<String, usize>::new(); - for record in records { - let key = app_order_record_current_key(&record); - if let Some(index) = seen.get(&key).copied() { - entries[index].superseded_count += 1; - } else { - seen.insert(key, entries.len()); - entries.push(AppOrderRecordListEntry { - record, - superseded_count: 0, - }); - } +fn app_order_export_failure_actions( + document: &OrderDraftDocument, + issues: &[OrderIssueView], +) -> Vec<String> { + if app_order_issue_present(issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { + vec![format!( + "radroots order status get {}", + document.order.order_id + )] + } else if app_order_issue_present(issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { + vec![ + format!("radroots order status get {}", document.order.order_id), + "radroots order app list".to_owned(), + ] + } else { + vec!["radroots order app list".to_owned()] } - entries } -fn current_app_order_record_for( +fn order_export_output_path( config: &RuntimeConfig, - record: &LocalEventRecord, -) -> Result<Option<LocalEventRecord>, RuntimeError> { - let key = app_order_record_current_key(record); - Ok(app_order_local_records(config)? - .into_iter() - .filter(|candidate| app_order_record_current_key(candidate) == key) - .max_by(|left, right| { - left.change_seq - .cmp(&right.change_seq) - .then_with(|| left.seq.cmp(&right.seq)) - })) + output: Option<&PathBuf>, + order_id: &str, +) -> PathBuf { + output + .cloned() + .unwrap_or_else(|| drafts_dir(config).join(format!("{order_id}.toml"))) } -fn app_order_conflicting_record_ids_for( - config: &RuntimeConfig, - record: &LocalEventRecord, -) -> Result<Vec<String>, RuntimeError> { - if app_order_record_order_id(record).is_none() { - return Ok(Vec::new()); +fn validate_order_export_output_target(output_path: &Path) -> Result<(), RuntimeError> { + if output_path.exists() { + return Err(RuntimeError::Config(format!( + "order draft output {} must not already exist", + output_path.display() + ))); } - let key = app_order_record_current_key(record); - let mut record_ids = app_order_local_records(config)? - .into_iter() - .filter(|candidate| candidate.record_id != record.record_id) - .filter(|candidate| app_order_record_current_key(candidate) == key) - .map(|candidate| candidate.record_id) - .collect::<Vec<_>>(); - record_ids.sort(); - record_ids.dedup(); - Ok(record_ids) + if let Some(parent) = output_path.parent() { + if parent.exists() && !parent.is_dir() { + return Err(RuntimeError::Config(format!( + "order draft parent {} is not a directory", + parent.display() + ))); + } + } + Ok(()) } -fn load_app_order_record_for_lookup( +fn local_record_kind(record: &LocalEventRecord) -> Option<String> { + record + .local_work_json + .as_ref() + .and_then(|payload| payload.get("record_kind")) + .and_then(Value::as_str) + .map(str::to_owned) +} + +fn inspect_document( config: &RuntimeConfig, - lookup: &str, -) -> Result<Option<LoadedAppOrderRecord>, RuntimeError> { - if let Some(record) = get_shared_record(config, lookup)? - && is_app_order_local_record(&record) - { - return load_app_order_record_from_record(config, record).map(Some); - } - for entry in current_app_order_record_entries(app_order_local_records(config)?) { - if app_order_record_order_id(&entry.record).as_deref() == Some(lookup) { - return load_app_order_record_from_record(config, entry.record).map(Some); - } - } - Ok(None) + document: &OrderDraftDocument, +) -> Result<OrderInspection, RuntimeError> { + inspect_document_with_source_issues(config, document, &[]) } -fn load_app_order_record_from_record( +fn inspect_document_with_source_issues( config: &RuntimeConfig, - record: LocalEventRecord, -) -> Result<LoadedAppOrderRecord, RuntimeError> { - let mut source_issues = app_order_record_source_issues(config, &record)?; - let payload = record.local_work_json.clone().unwrap_or(Value::Null); - let document = match payload.get("document").cloned() { - Some(value) => match serde_json::from_value::<OrderDraftDocument>(value) { - Ok(document) => document, - Err(error) => { - source_issues.push(issue_with_code( - "invalid_app_order_record", - "document", - format!("app-authored order document cannot be decoded: {error}"), - )); - placeholder_app_order_document(&record) - } - }, - None => { - source_issues.push(issue_with_code( - "invalid_app_order_record", - "document", - "app-authored order record is missing document", - )); - placeholder_app_order_document(&record) - } - }; - let loaded = LoadedOrderDraft { - file: PathBuf::from(format!("shared-local-events/{}", record.record_id)), - updated_at_unix: u64::try_from(record.updated_at_ms / 1000).unwrap_or_default(), - document, + document: &OrderDraftDocument, + source_issues: &[OrderIssueView], +) -> 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 + .as_deref() + .and_then(|value| parse_listing_addr(value).ok()); + let seller_pubkey = non_empty_string(document.order.seller_pubkey.clone()).or_else(|| { + parsed_listing_addr + .as_ref() + .map(|listing| listing.seller_pubkey.clone()) + }); + let mut issues = collect_issues(document); + let buyer_readiness = inspect_buyer_actor_readiness(config, document)?; + issues.extend(buyer_readiness.issues); + issues.extend(source_issues.iter().cloned()); + let ready_for_submit = issues.is_empty(); + let state = if app_order_issue_present(&issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { + "submitted".to_owned() + } else if app_order_issue_present(&issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { + "conflict".to_owned() + } else if ready_for_submit { + "ready".to_owned() + } else { + "draft".to_owned() }; - source_issues.extend(app_order_signed_evidence_issues(config, &loaded)?); - Ok(LoadedAppOrderRecord { - loaded, - record, - source_issues, + 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, }) } -fn app_order_record_source_issues( +#[derive(Debug, Clone)] +struct OrderBuyerActorReadiness { + account: Option<account::AccountRecordView>, + issues: Vec<OrderIssueView>, +} + +fn inspect_buyer_actor_readiness( config: &RuntimeConfig, - record: &LocalEventRecord, -) -> Result<Vec<OrderIssueView>, RuntimeError> { - let mut issues = Vec::new(); - if record.source_runtime != SourceRuntime::App { - issues.push(issue_with_code( - "app_order_unsupported", - "source_runtime", - "order record must come from radroots_app", - )); + 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(), + }); } - if record.family != LocalRecordFamily::LocalWork { + + let snapshot = account::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( - "app_order_unsupported", - "family", - "order record must be shared local work", + "account_mismatch", + "buyer_actor.pubkey", + format!( + "order buyer_actor pubkey `{buyer_pubkey}` does not match local account `{account_id}` pubkey `{account_pubkey}`" + ), )); } - if record.status != LocalRecordStatus::LocalSaved { + if !account.write_capable { issues.push(issue_with_code( - "app_order_unsupported", - "status", + "account_watch_only", + "buyer_actor.account_id", format!( - "order record status `{}` is not consumable as local saved work", - record.status.as_str() + "order buyer_actor account `{account_id}` is watch_only and cannot sign until a matching secret is attached" ), )); } - let Some(payload) = record.local_work_json.as_ref() else { - issues.push(issue_with_code( - "invalid_app_order_record", - "local_work_json", - "app-authored order record is missing local work payload", - )); - return Ok(issues); - }; - let current = payload["currentness"]["current"].as_bool() == Some(true); - if !current { - issues.push(issue_with_code( - "app_order_stale", - "currentness.current", - "app-authored order record is not marked current", - )); + + Ok(OrderBuyerActorReadiness { + account: Some(account), + issues, + }) +} + +fn collect_issues(document: &OrderDraftDocument) -> Vec<OrderIssueView> { + let mut issues = Vec::new(); + if document.version != 1 { + issues.push(issue("version", "version must be 1")); } - if payload["currentness"]["record_id"].as_str() != Some(record.record_id.as_str()) { - issues.push(issue_with_code( - "invalid_app_order_record", - "currentness.record_id", - "app-authored order record currentness id does not match the shared record id", + if document.kind != ORDER_DRAFT_KIND { + issues.push(issue("kind", format!("kind must be `{ORDER_DRAFT_KIND}`"))); + } + if !is_valid_order_id(document.order.order_id.as_str()) { + issues.push(issue( + "order.order_id", + "order_id must look like `ord_<base64url>` or a canonical UUID", )); } - if current { - match validate_supported_buyer_order_request_local_work_payload(payload) { - Ok(_) => {} - Err(error) => { - let support_state = payload["support_status"]["state"].as_str(); - let support_issues = payload["support_status"]["issues"] - .as_array() - .cloned() - .unwrap_or_default(); - if support_state == Some("unsupported") { - issues.push(issue_with_code( - "app_order_unsupported", - "support_status.state", - "app-authored order record is not marked supported", + + match normalize_optional(Some(document.order.listing_addr.as_str())) { + Some(listing_addr) => match parse_listing_addr(listing_addr.as_str()) { + Ok(parsed) => { + if parsed.kind != KIND_LISTING { + issues.push(issue( + "order.listing_addr", + "listing_addr must reference a public NIP-99 listing", )); - for support_issue in support_issues { - if let Some(support_issue) = support_issue.as_str() { - issues.push(issue_with_code( - "app_order_unsupported", - "support_status.issues", - format!("app order support issue: {support_issue}"), - )); - } + } + if let Some(seller_pubkey) = non_empty_string(document.order.seller_pubkey.clone()) + { + if seller_pubkey != parsed.seller_pubkey { + issues.push(issue( + "order.seller_pubkey", + "seller_pubkey must match listing_addr seller when both are set", + )); } - } else { - issues.push(issue_with_code( - "invalid_app_order_record", - "local_work_json", - error.to_string(), - )); } } + Err(error) => issues.push(issue( + "order.listing_addr", + format!("listing_addr is invalid: {error}"), + )), + }, + None => issues.push(issue( + "order.listing_addr", + "listing_addr is required before order submit", + )), + } + + match normalize_optional(Some(document.order.listing_event_id.as_str())) { + Some(listing_event_id) => { + if !is_valid_event_id(listing_event_id.as_str()) { + issues.push(issue( + "order.listing_event_id", + "listing_event_id must be a 64-character hex Nostr event id", + )); + } } + None => issues.push(issue( + "order.listing_event_id", + "latest active listing event id is required before order submit; run `radroots market refresh` and create the order from local market data", + )), } - if let Some(current_record) = current_app_order_record_for(config, record)? - && current_record.record_id != record.record_id - { - issues.push(issue_with_code( - "app_order_stale", - "record_id", - format!( - "app-authored local order record `{}` was superseded by `{}`", - record.record_id, current_record.record_id - ), + + match normalize_listing_relay_set(document.order.listing_relays.iter()) { + Ok(listing_relays) if listing_relays.is_empty() => issues.push(issue_with_code( + "listing_provenance_missing", + "order.listing_relays", + "listing relay provenance is required before order submit; run `radroots market refresh` and create the order from current local market data", + )), + Ok(_) => {} + Err(error) => issues.push(issue_with_code( + "listing_provenance_invalid", + "order.listing_relays", + format!("listing relay provenance is invalid: {error}"), + )), + } + + if document.order.items.is_empty() { + issues.push(issue( + "order.items", + "at least one order item is required before order submit", )); } - let conflicting_record_ids = app_order_conflicting_record_ids_for(config, record)?; - if !conflicting_record_ids.is_empty() { - issues.push(issue_with_code( - "app_order_conflict", - "order_id", - format!( - "app-authored order id conflicts with other shared records: {}", - conflicting_record_ids.join(", ") - ), - )); - } - Ok(issues) -} - -fn app_order_signed_evidence_issues( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, -) -> Result<Vec<OrderIssueView>, RuntimeError> { - let order_id = loaded.document.order.order_id.as_str(); - let candidate_records = visible_signed_order_request_records(config, order_id)?; - if candidate_records.is_empty() { - return Ok(Vec::new()); - } - - let expected_payload = match canonical_order_request_payload_from_loaded( - loaded, - loaded.document.order.buyer_pubkey.as_str(), - ) { - Ok(payload) => payload, - Err(error) => { - let event_ids = candidate_records - .iter() - .map(signed_record_event_id) - .collect::<Vec<_>>(); - return Ok(vec![issue_with_events( - APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE, - "signed_event", - format!( - "signed order request evidence cannot be compared with local work: {error}" - ), - event_ids, - )]); + for (index, item) in document.order.items.iter().enumerate() { + if item.bin_id.trim().is_empty() { + issues.push(issue( + format!("order.items[{index}].bin_id"), + "bin_id must not be empty", + )); } - }; + if item.bin_count == 0 { + issues.push(issue( + format!("order.items[{index}].bin_count"), + "bin_count must be greater than zero", + )); + } + } - let mut submitted_event_ids = Vec::new(); - let mut conflict_issues = Vec::new(); - for record in candidate_records { - let event_id = signed_record_event_id(&record); - match signed_order_request_from_record(&record) - .and_then(|event| order_submit_request_from_event(&event, loaded)) - { - Ok(request) - if order_submit_request_matches_draft(&request, loaded, &expected_payload) => - { - submitted_event_ids.push(request.request_event_id); + match &document.order.economics { + Some(economics) => { + if let Err(error) = economics.validate() { + issues.push(issue( + "order.economics", + format!("order economics is invalid: {error}"), + )); + } + if !order_items_match_economics(document.order.items.as_slice(), economics) { + issues.push(issue( + "order.economics", + "order economics must match the order item bin ids and counts", + )); } - Ok(request) => conflict_issues.push(issue_with_events( - APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE, - "signed_event", - format!( - "signed order request event `{}` conflicts with the app-authored local order", - request.request_event_id - ), - vec![request.request_event_id], - )), - Err(error) => conflict_issues.push(issue_with_events( - APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE, - "signed_event", - format!("signed order request event `{event_id}` cannot be validated: {error}"), - vec![event_id], - )), } + None => issues.push(issue( + "order.economics", + "quote economics is required before order submit; run `radroots basket quote create` from current local market data", + )), } - conflict_issues.sort_by(|left, right| { - left.event_ids - .cmp(&right.event_ids) - .then_with(|| left.message.cmp(&right.message)) - }); - if !conflict_issues.is_empty() { - return Ok(conflict_issues); + if document.buyer_actor.account_id.trim().is_empty() { + issues.push(issue( + "buyer_actor.account_id", + "buyer_actor account_id is required before order submit", + )); } - - submitted_event_ids.sort(); - submitted_event_ids.dedup(); - if submitted_event_ids.is_empty() { - Ok(Vec::new()) - } else { - Ok(vec![issue_with_events( - APP_ORDER_ALREADY_SUBMITTED_ISSUE, - "signed_event", - "app-authored local order already has matching signed order request evidence", - submitted_event_ids, - )]) + if document.buyer_actor.pubkey.trim().is_empty() { + issues.push(issue( + "buyer_actor.pubkey", + "buyer_actor pubkey is required before order submit", + )); } -} - -fn visible_signed_order_request_records( - config: &RuntimeConfig, - order_id: &str, -) -> Result<Vec<LocalEventRecord>, RuntimeError> { - let mut records = Vec::new(); - let mut before_cursor = None::<(i64, i64)>; - loop { - let shared_records = if let Some((before_change_seq, before_seq)) = before_cursor { - list_shared_records_before( - config, - before_change_seq, - before_seq, - ORDER_APP_RECORD_LIST_LIMIT, - )? - } else { - list_shared_records_latest(config, ORDER_APP_RECORD_LIST_LIMIT)? - }; - let Some(next_cursor) = shared_records - .last() - .map(|record| (record.change_seq, record.seq)) - else { - break; - }; - let has_more = shared_records.len() == ORDER_APP_RECORD_LIST_LIMIT as usize; - records.extend( - shared_records - .into_iter() - .filter(|record| is_visible_signed_order_request_record(record, order_id)), - ); - if !has_more { - break; - } - before_cursor = Some(next_cursor); + if document.buyer_actor.source.trim().is_empty() { + issues.push(issue( + "buyer_actor.source", + "buyer_actor source is required before order submit", + )); + } else if !matches!( + document.buyer_actor.source.as_str(), + ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT | ORDER_BUYER_ACTOR_SOURCE_REBIND + ) { + issues.push(issue( + "buyer_actor.source", + format!( + "unsupported buyer_actor source `{}`", + document.buyer_actor.source + ), + )); + } + if document.order.buyer_pubkey.trim().is_empty() { + issues.push(issue( + "order.buyer_pubkey", + "order buyer_pubkey is required before order submit", + )); + } else if !document + .order + .buyer_pubkey + .eq_ignore_ascii_case(document.buyer_actor.pubkey.as_str()) + { + issues.push(issue( + "order.buyer_pubkey", + "order buyer_pubkey must match buyer_actor pubkey", + )); } - Ok(records) -} -fn is_visible_signed_order_request_record(record: &LocalEventRecord, order_id: &str) -> bool { - record.family == LocalRecordFamily::SignedEvent - && record.status == LocalRecordStatus::Published - && record.outbox_status == PublishOutboxStatus::Acknowledged - && record.event_kind == Some(i64::from(KIND_ORDER_REQUEST)) - && signed_record_tag_values(record, "d") - .iter() - .any(|value| value == order_id) + issues } -fn signed_order_request_from_record( - record: &LocalEventRecord, -) -> Result<RadrootsNostrEvent, RuntimeError> { - let raw_event_json = record.raw_event_json.as_ref().ok_or_else(|| { - RuntimeError::Config(format!( - "signed event record `{}` is missing raw_event_json", - record.record_id - )) - })?; - serde_json::from_value::<RadrootsNostrEvent>(raw_event_json.clone()).map_err(|error| { - RuntimeError::Config(format!( - "signed event record `{}` raw_event_json cannot be decoded: {error}", - record.record_id - )) - }) +fn order_items_match_economics( + items: &[OrderDraftItem], + economics: &RadrootsOrderEconomics, +) -> bool { + let mut order_items = items + .iter() + .map(|item| (item.bin_id.as_str(), item.bin_count)) + .collect::<Vec<_>>(); + let mut economic_items = economics + .items + .iter() + .map(|item| (item.bin_id.as_str(), item.bin_count)) + .collect::<Vec<_>>(); + order_items.sort_unstable(); + economic_items.sort_unstable(); + order_items == economic_items } -fn signed_record_tag_values(record: &LocalEventRecord, key: &str) -> Vec<String> { - record - .event_tags_json - .as_ref() - .or(record - .raw_event_json - .as_ref() - .and_then(|event| event.get("tags"))) - .and_then(Value::as_array) - .map(|tags| { - tags.iter() - .filter_map(Value::as_array) - .filter_map(|tag| { - if tag.first().and_then(Value::as_str) == Some(key) { - tag.get(1).and_then(Value::as_str).map(str::to_owned) - } else { - None - } - }) - .collect::<Vec<_>>() - }) - .unwrap_or_default() -} - -fn signed_record_event_id(record: &LocalEventRecord) -> String { - record - .event_id - .clone() - .unwrap_or_else(|| record.record_id.clone()) -} - -fn source_and_document_issues( - config: &RuntimeConfig, - app_order: &LoadedAppOrderRecord, -) -> Result<Vec<OrderIssueView>, RuntimeError> { - Ok(inspect_document_with_source_issues( - config, - &app_order.loaded.document, - app_order.source_issues.as_slice(), - )? - .issues) -} - -fn app_order_record_summary( - config: &RuntimeConfig, - record: &LocalEventRecord, - superseded_count: usize, -) -> Result<OrderAppRecordSummaryView, RuntimeError> { - let record_kind = local_record_kind(record).unwrap_or_else(|| "unknown".to_owned()); - let app_order = load_app_order_record_from_record(config, record.clone())?; - let issues = source_and_document_issues(config, &app_order)?; - let exportable = issues.is_empty(); - let reason = issues.first().map(|issue| issue.message.clone()); - let document = &app_order.loaded.document; - let status = if app_order_issue_present(&issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { - "submitted".to_owned() - } else if app_order_issue_present(&issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { - "conflict".to_owned() - } else { - record.status.as_str().to_owned() - }; - let actions = if exportable { - vec![ - format!("radroots order get {}", document.order.order_id), - format!("radroots order app export {}", record.record_id), - format!( - "radroots --relay wss://relay.example.com order submit {}", - document.order.order_id - ), - ] - } else if app_order_issue_present(&issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { - vec![format!( +fn actions_for_document( + document: &OrderDraftDocument, + file: &Path, + issues: &[OrderIssueView], +) -> Vec<String> { + if app_order_issue_present(issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { + return vec![format!( "radroots order status get {}", document.order.order_id - )] - } else if app_order_issue_present(&issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { - vec![ + )]; + } + if app_order_issue_present(issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { + return vec![ format!("radroots order status get {}", document.order.order_id), "radroots order app list".to_owned(), - ] - } else { - Vec::new() - }; - Ok(OrderAppRecordSummaryView { - record_id: record.record_id.clone(), - seq: record.seq, - change_seq: record.change_seq, - superseded_count, - record_kind, - status, - source_runtime: record.source_runtime.as_str().to_owned(), - owner_account_id: record.owner_account_id.clone(), - owner_pubkey: record.owner_pubkey.clone(), - farm_id: record.farm_id.clone(), - listing_addr: record - .listing_addr - .clone() - .or_else(|| non_empty_string(app_order.loaded.document.order.listing_addr.clone())), - listing_relays: order_listing_relays(document), - order_id: non_empty_string(document.order.order_id.clone()), - buyer_account_id: buyer_account_id(document), - buyer_pubkey: non_empty_string(document.order.buyer_pubkey.clone()), - seller_pubkey: non_empty_string(document.order.seller_pubkey.clone()), - ready_for_submit: exportable, - exportable, - reason, - actions, - }) -} - -fn app_order_record_current_key(record: &LocalEventRecord) -> String { - app_order_record_order_id(record) - .map(|order_id| format!("order:{order_id}")) - .unwrap_or_else(|| format!("record:{}", record.record_id)) -} - -fn app_order_record_order_id(record: &LocalEventRecord) -> Option<String> { - record - .local_work_json - .as_ref() - .and_then(|payload| payload["document"]["order"]["order_id"].as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) -} - -fn placeholder_app_order_document(record: &LocalEventRecord) -> OrderDraftDocument { - OrderDraftDocument { - version: 0, - kind: "invalid_app_order_record".to_owned(), - order: OrderDraft { - order_id: app_order_record_order_id(record).unwrap_or_else(|| record.record_id.clone()), - listing_addr: String::new(), - listing_event_id: String::new(), - listing_relays: Vec::new(), - buyer_pubkey: String::new(), - seller_pubkey: String::new(), - items: Vec::new(), - economics: None, - }, - buyer_actor: OrderDraftBuyerActor { - account_id: String::new(), - pubkey: String::new(), - source: String::new(), - }, - listing_lookup: None, + ]; } -} -fn app_order_export_failure_state(issues: &[OrderIssueView]) -> &'static str { - if issues - .iter() - .any(|issue| issue.code == APP_ORDER_ALREADY_SUBMITTED_ISSUE) + let mut actions = Vec::new(); + actions.push(format!( + "edit {} and fill the remaining draft fields", + file.display() + )); + if document.buyer_actor.account_id.trim().is_empty() + || document.buyer_actor.pubkey.trim().is_empty() + || document.order.buyer_pubkey.trim().is_empty() + || !document + .order + .buyer_pubkey + .eq_ignore_ascii_case(document.buyer_actor.pubkey.as_str()) { - "already_submitted" - } else if issues.iter().any(|issue| { - issue.code == "app_order_conflict" || issue.code == APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE - }) { - "conflict" - } else if issues.iter().any(|issue| issue.code == "app_order_stale") { - "stale" - } else if issues + actions.push(format!( + "radroots order rebind {} <selector>", + document.order.order_id + )); + } + if issues .iter() - .any(|issue| issue.code == "invalid_app_order_record") + .any(|issue| issue.code == "account_unresolved") { - "invalid" - } else if issues + 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 == "app_order_unsupported") + .any(|issue| issue.code == "account_watch_only") { - "unsupported" - } else { - "invalid" + actions.push(format!( + "radroots account attach-secret {} <path>", + document.buyer_actor.account_id + )); + actions.push(format!("radroots order get {}", document.order.order_id)); } -} - -fn app_order_export_failure_actions( - document: &OrderDraftDocument, - issues: &[OrderIssueView], -) -> Vec<String> { - if app_order_issue_present(issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { - vec![format!( - "radroots order status get {}", + if issues.iter().any(|issue| issue.code == "account_mismatch") { + actions.push(format!( + "radroots order rebind {} <selector>", document.order.order_id - )] - } else if app_order_issue_present(issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { - vec![ - format!("radroots order status get {}", document.order.order_id), - "radroots order app list".to_owned(), - ] - } else { - vec!["radroots order app list".to_owned()] + )); } -} - -fn order_export_output_path( - config: &RuntimeConfig, - output: Option<&PathBuf>, - order_id: &str, -) -> PathBuf { - output - .cloned() - .unwrap_or_else(|| drafts_dir(config).join(format!("{order_id}.toml"))) -} - -fn validate_order_export_output_target(output_path: &Path) -> Result<(), RuntimeError> { - if output_path.exists() { - return Err(RuntimeError::Config(format!( - "order draft output {} must not already exist", - output_path.display() - ))); + if document.order.items.is_empty() + || issues + .iter() + .any(|issue| issue.field.starts_with("order.items[")) + { + actions.push(format!("radroots order get {}", document.order.order_id)); } - if let Some(parent) = output_path.parent() { - if parent.exists() && !parent.is_dir() { - return Err(RuntimeError::Config(format!( - "order draft parent {} is not a directory", - parent.display() - ))); + let mut deduped = Vec::new(); + for action in actions { + if !deduped.contains(&action) { + deduped.push(action); } } - Ok(()) + deduped } -fn local_record_kind(record: &LocalEventRecord) -> Option<String> { - record - .local_work_json - .as_ref() - .and_then(|payload| payload.get("record_kind")) - .and_then(Value::as_str) - .map(str::to_owned) +fn app_order_issue_present(issues: &[OrderIssueView], code: &str) -> bool { + issues.iter().any(|issue| issue.code == code) } -fn inspect_document( - config: &RuntimeConfig, - document: &OrderDraftDocument, -) -> Result<OrderInspection, RuntimeError> { - inspect_document_with_source_issues(config, document, &[]) +fn app_order_issue<'a>(issues: &'a [OrderIssueView], code: &str) -> Option<&'a OrderIssueView> { + issues.iter().find(|issue| issue.code == code) } -fn inspect_document_with_source_issues( +fn order_rebind_selector_error(selector: &str, error: RuntimeError) -> RuntimeError { + match error { + RuntimeError::Accounts(_) | RuntimeError::Account(_) => { + account::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, - document: &OrderDraftDocument, - source_issues: &[OrderIssueView], -) -> 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 - .as_deref() - .and_then(|value| parse_listing_addr(value).ok()); - let seller_pubkey = non_empty_string(document.order.seller_pubkey.clone()).or_else(|| { - parsed_listing_addr - .as_ref() - .map(|listing| listing.seller_pubkey.clone()) - }); - let mut issues = collect_issues(document); - let buyer_readiness = inspect_buyer_actor_readiness(config, document)?; - issues.extend(buyer_readiness.issues); - issues.extend(source_issues.iter().cloned()); - let ready_for_submit = issues.is_empty(); - let state = if app_order_issue_present(&issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { - "submitted".to_owned() - } else if app_order_issue_present(&issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { - "conflict".to_owned() - } else if ready_for_submit { - "ready".to_owned() - } else { - "draft".to_owned() - }; + loaded: &LoadedOrderDraft, +) -> Result<OrderRebindExistingRequestCheck, RuntimeError> { + if config.relay.urls.is_empty() { + return Ok(OrderRebindExistingRequestCheck { + state: "skipped_no_relays".to_owned(), + event_ids: Vec::new(), + }); + } - 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, + 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, }) } -#[derive(Debug, Clone)] -struct OrderBuyerActorReadiness { - account: Option<account::AccountRecordView>, - issues: Vec<OrderIssueView>, +fn resolve_initial_buyer_actor( + config: &RuntimeConfig, +) -> Result<OrderDraftBuyerActor, RuntimeError> { + let resolution = account::resolve_account_resolution(config)?; + let Some(account) = resolution.resolved_account else { + return Err(account::AccountRuntimeFailure::unresolved_with_detail( + account::unresolved_account_reason(config)?, + json!({ + "buyer_actor_source": ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT, + "actions": [ + "radroots account create", + "radroots account import <path>", + ], + }), + ) + .into()); + }; + Ok(OrderDraftBuyerActor { + account_id: account.record.account_id.to_string(), + pubkey: account.record.public_identity.public_key_hex, + source: ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(), + }) } -fn inspect_buyer_actor_readiness( +fn buyer_account_id(document: &OrderDraftDocument) -> Option<String> { + non_empty_string(document.buyer_actor.account_id.clone()) +} + +fn buyer_actor_source(document: &OrderDraftDocument) -> Option<String> { + non_empty_string(document.buyer_actor.source.clone()) +} + +fn load_local_order_draft_if_exists( 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(), + lookup: &str, +) -> Result<Option<LoadedOrderDraft>, RuntimeError> { + let file = draft_lookup_path(config, lookup); + if !file.exists() { + return Ok(None); + } + load_draft(file.as_path()) + .map(Some) + .map_err(RuntimeError::Config) +} + +fn order_status_actor_context( + config: &RuntimeConfig, + order_id: &str, +) -> Result<OrderDraftStatusActorContext, RuntimeError> { + if let Some(loaded) = load_local_order_draft_if_exists(config, order_id)? { + return Ok(OrderDraftStatusActorContext { + source: ORDER_ACTOR_CONTEXT_ORDER_DRAFT, + buyer_pubkey: non_empty_string(loaded.document.buyer_actor.pubkey.clone()) + .or_else(|| non_empty_string(loaded.document.order.buyer_pubkey.clone())), + seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey), + selected_account_pubkey: None, }); } - let snapshot = account::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 selected_account = account::resolve_account(config)?; + let Some(account) = selected_account else { + return Ok(OrderDraftStatusActorContext { + source: ORDER_ACTOR_CONTEXT_NETWORK_ONLY, + buyer_pubkey: None, + seller_pubkey: None, + selected_account_pubkey: None, }); }; - 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, + Ok(OrderDraftStatusActorContext { + source: ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT, + buyer_pubkey: None, + seller_pubkey: None, + selected_account_pubkey: Some(account.record.public_identity.public_key_hex), }) } -fn collect_issues(document: &OrderDraftDocument) -> Vec<OrderIssueView> { - let mut issues = Vec::new(); - if document.version != 1 { - issues.push(issue("version", "version must be 1")); - } - if document.kind != ORDER_DRAFT_KIND { - issues.push(issue("kind", format!("kind must be `{ORDER_DRAFT_KIND}`"))); - } - if !is_valid_order_id(document.order.order_id.as_str()) { - issues.push(issue( - "order.order_id", - "order_id must look like `ord_<base64url>` or a canonical UUID", - )); +fn order_event_list_actor_context( + config: &RuntimeConfig, + order_id: Option<&str>, +) -> Result<Option<OrderEventListActorContext>, RuntimeError> { + if let Some(order_id) = order_id + && let Some(loaded) = load_local_order_draft_if_exists(config, order_id)? + { + let seller_pubkey = + non_empty_string(loaded.document.order.seller_pubkey).ok_or_else(|| { + RuntimeError::Config(format!( + "local order draft `{order_id}` is missing seller_pubkey" + )) + })?; + return Ok(Some(OrderEventListActorContext { + source: ORDER_ACTOR_CONTEXT_ORDER_DRAFT, + seller_pubkey, + })); } - match normalize_optional(Some(document.order.listing_addr.as_str())) { - Some(listing_addr) => match parse_listing_addr(listing_addr.as_str()) { - Ok(parsed) => { - if parsed.kind != KIND_LISTING { - issues.push(issue( - "order.listing_addr", - "listing_addr must reference a public NIP-99 listing", - )); - } - if let Some(seller_pubkey) = non_empty_string(document.order.seller_pubkey.clone()) - { - if seller_pubkey != parsed.seller_pubkey { - issues.push(issue( - "order.seller_pubkey", - "seller_pubkey must match listing_addr seller when both are set", - )); - } - } - } - Err(error) => issues.push(issue( - "order.listing_addr", - format!("listing_addr is invalid: {error}"), - )), - }, - None => issues.push(issue( - "order.listing_addr", - "listing_addr is required before order submit", - )), - } + Ok( + account::resolve_account(config)?.map(|account| OrderEventListActorContext { + source: ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT, + seller_pubkey: account.record.public_identity.public_key_hex, + }), + ) +} - match normalize_optional(Some(document.order.listing_event_id.as_str())) { - Some(listing_event_id) => { - if !is_valid_event_id(listing_event_id.as_str()) { - issues.push(issue( - "order.listing_event_id", - "listing_event_id must be a 64-character hex Nostr event id", - )); - } - } - None => issues.push(issue( - "order.listing_event_id", - "latest active listing event id is required before order submit; run `radroots market refresh` and create the order from local market data", - )), - } +fn bound_buyer_write_context_if_exists( + config: &RuntimeConfig, + order_id: &str, +) -> Result<Option<OrderBoundBuyerWriteContext>, RuntimeError> { + let Some(loaded) = load_local_order_draft_if_exists(config, order_id)? else { + return Ok(None); + }; + let account = validate_bound_order_buyer_account(config, &loaded)?; + Ok(Some(OrderBoundBuyerWriteContext { loaded, account })) +} - match normalize_listing_relay_set(document.order.listing_relays.iter()) { - Ok(listing_relays) if listing_relays.is_empty() => issues.push(issue_with_code( - "listing_provenance_missing", - "order.listing_relays", - "listing relay provenance is required before order submit; run `radroots market refresh` and create the order from current local market data", - )), - Ok(_) => {} - Err(error) => issues.push(issue_with_code( - "listing_provenance_invalid", - "order.listing_relays", - format!("listing relay provenance is invalid: {error}"), - )), +fn order_buyer_write_actor_context( + config: &RuntimeConfig, + order_id: &str, +) -> Result<Option<OrderBuyerWriteActorContext>, RuntimeError> { + if let Some(bound) = bound_buyer_write_context_if_exists(config, order_id)? { + let selected_pubkey = bound.account.record.public_identity.public_key_hex.clone(); + let status_seller_pubkey = + non_empty_string(bound.loaded.document.order.seller_pubkey.clone()); + return Ok(Some(OrderBuyerWriteActorContext { + bound: Some(bound), + selected_pubkey: selected_pubkey.clone(), + status_buyer_pubkey: Some(selected_pubkey), + status_seller_pubkey, + status_context_source: ORDER_ACTOR_CONTEXT_ORDER_DRAFT, + })); } - if document.order.items.is_empty() { - issues.push(issue( - "order.items", - "at least one order item is required before order submit", - )); - } - for (index, item) in document.order.items.iter().enumerate() { - if item.bin_id.trim().is_empty() { - issues.push(issue( - format!("order.items[{index}].bin_id"), - "bin_id must not be empty", - )); - } - if item.bin_count == 0 { - issues.push(issue( - format!("order.items[{index}].bin_count"), - "bin_count must be greater than zero", - )); + Ok(account::resolve_account(config)?.map(|account| { + let selected_pubkey = account.record.public_identity.public_key_hex; + OrderBuyerWriteActorContext { + bound: None, + selected_pubkey: selected_pubkey.clone(), + status_buyer_pubkey: None, + status_seller_pubkey: None, + status_context_source: ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT, } + })) +} + +fn order_submit_listing_freshness_view( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, +) -> Result<Option<OrderSubmitView>, RuntimeError> { + if !config.local.replica_db_path.exists() { + return Ok(Some(order_submit_unconfigured_view( + config, + loaded, + args, + "order submit requires local market data to confirm the listing is still active; run `radroots store init` and `radroots market refresh` before submitting", + vec![issue( + "order.listing_addr", + "local replica database is missing; run `radroots store init` and `radroots market refresh` before submitting", + )], + vec![ + "radroots store init".to_owned(), + "radroots market refresh".to_owned(), + ], + ))); } - match &document.order.economics { - Some(economics) => { - if let Err(error) = economics.validate() { - issues.push(issue( - "order.economics", - format!("order economics is invalid: {error}"), - )); - } - if !order_items_match_economics(document.order.items.as_slice(), economics) { - issues.push(issue( - "order.economics", - "order economics must match the order item bin ids and counts", - )); - } + let listing_addr = loaded.document.order.listing_addr.as_str(); + let parsed = parse_listing_addr(listing_addr) + .map_err(|error| RuntimeError::Config(format!("order listing_addr is invalid: {error}")))?; + let active_event_id = match resolve_active_listing_event_id(config, listing_addr, &parsed)? { + Some(event_id) => event_id, + None => { + return Ok(Some(order_submit_unconfigured_view( + config, + loaded, + args, + "order listing is not active in the local replica; run `radroots market refresh` and create a new order from current market data", + vec![issue( + "order.listing_addr", + "listing is missing, archived, or superseded in the local replica", + )], + vec!["radroots market refresh".to_owned()], + ))); } - None => issues.push(issue( - "order.economics", - "quote economics is required before order submit; run `radroots basket quote create` from current local market data", - )), - } + }; - if document.buyer_actor.account_id.trim().is_empty() { - issues.push(issue( - "buyer_actor.account_id", - "buyer_actor account_id is required before order submit", - )); - } - if document.buyer_actor.pubkey.trim().is_empty() { - issues.push(issue( - "buyer_actor.pubkey", - "buyer_actor pubkey is required before order submit", - )); - } - if document.buyer_actor.source.trim().is_empty() { - issues.push(issue( - "buyer_actor.source", - "buyer_actor source is required before order submit", - )); - } else if !matches!( - document.buyer_actor.source.as_str(), - ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT | ORDER_BUYER_ACTOR_SOURCE_REBIND - ) { - issues.push(issue( - "buyer_actor.source", - format!( - "unsupported buyer_actor source `{}`", - document.buyer_actor.source - ), - )); - } - if document.order.buyer_pubkey.trim().is_empty() { - issues.push(issue( - "order.buyer_pubkey", - "order buyer_pubkey is required before order submit", - )); - } else if !document - .order - .buyer_pubkey - .eq_ignore_ascii_case(document.buyer_actor.pubkey.as_str()) - { - issues.push(issue( - "order.buyer_pubkey", - "order buyer_pubkey must match buyer_actor pubkey", - )); + if !active_event_id.eq_ignore_ascii_case(loaded.document.order.listing_event_id.as_str()) { + return Ok(Some(order_submit_unconfigured_view( + config, + loaded, + args, + "order listing event is no longer current in the local replica; run `radroots market refresh` and create a new order from current market data", + vec![issue( + "order.listing_event_id", + format!( + "draft listing_event_id does not match latest local listing event `{active_event_id}`" + ), + )], + vec!["radroots market refresh".to_owned()], + ))); } - issues -} - -fn order_items_match_economics( - items: &[OrderDraftItem], - economics: &RadrootsOrderEconomics, -) -> bool { - let mut order_items = items - .iter() - .map(|item| (item.bin_id.as_str(), item.bin_count)) - .collect::<Vec<_>>(); - let mut economic_items = economics - .items - .iter() - .map(|item| (item.bin_id.as_str(), item.bin_count)) - .collect::<Vec<_>>(); - order_items.sort_unstable(); - economic_items.sort_unstable(); - order_items == economic_items + Ok(None) } -fn actions_for_document( - document: &OrderDraftDocument, - file: &Path, - issues: &[OrderIssueView], -) -> Vec<String> { - if app_order_issue_present(issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { - return vec![format!( - "radroots order status get {}", - document.order.order_id - )]; - } - if app_order_issue_present(issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { - return vec![ - format!("radroots order status get {}", document.order.order_id), - "radroots order app list".to_owned(), - ]; - } - - let mut actions = Vec::new(); - actions.push(format!( - "edit {} and fill the remaining draft fields", - file.display() - )); - if document.buyer_actor.account_id.trim().is_empty() - || document.buyer_actor.pubkey.trim().is_empty() - || document.order.buyer_pubkey.trim().is_empty() - || !document - .order - .buyer_pubkey - .eq_ignore_ascii_case(document.buyer_actor.pubkey.as_str()) - { - actions.push(format!( - "radroots order rebind {} <selector>", - 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 - )); +fn order_submit_quantity_preflight_view( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, +) -> Result<Option<OrderSubmitView>, RuntimeError> { + if !config.local.replica_db_path.exists() { + return Ok(Some(order_submit_unconfigured_view( + config, + loaded, + args, + "order submit requires local market data to confirm current listing availability; run `radroots store init` and `radroots market refresh` before submitting", + vec![issue( + "order.listing_addr", + "local replica database is missing; run `radroots store init` and `radroots market refresh` before submitting", + )], + vec![ + "radroots store init".to_owned(), + "radroots market refresh".to_owned(), + ], + ))); } - 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)); + + let requested_count = + loaded + .document + .order + .items + .iter() + .enumerate() + .try_fold(0u64, |total, (index, item)| { + if item.bin_count == 0 { + return Err(RuntimeError::Config(format!( + "order item {index} quantity must be greater than zero" + ))); + } + total.checked_add(u64::from(item.bin_count)).ok_or_else(|| { + RuntimeError::Config("order quantity exceeds supported range".to_owned()) + }) + })?; + + let executor = SqliteExecutor::open(&config.local.replica_db_path)?; + let product_rows = trade_product::find_many( + &executor, + &ITradeProductFindMany { + filter: Some(trade_product_listing_addr_filter( + loaded.document.order.listing_addr.as_str(), + )), + }, + ) + .map_err(|error| RuntimeError::Config(format!("resolve listing product state: {error:?}")))? + .results; + + let product = match product_rows.as_slice() { + [product] => product, + [] => { + return Ok(Some(order_submit_unconfigured_view( + config, + loaded, + args, + "order listing is not active in the local replica; run `radroots market refresh` and create a new order from current market data", + vec![issue( + "order.listing_addr", + "listing is missing, archived, or superseded in the local replica", + )], + vec!["radroots market refresh".to_owned()], + ))); + } + _ => { + return Err(RuntimeError::Config(format!( + "listing address `{}` matched {} active local listing rows", + loaded.document.order.listing_addr, + product_rows.len() + ))); + } + }; + + let Some(primary_bin_id) = product.primary_bin_id.as_deref().and_then(non_empty_ref) else { + return Ok(Some(order_submit_invalid_quantity_view( + config, + loaded, + args, + "order listing bin identity is missing in the local replica", + vec![issue_with_code( + "listing_primary_bin_missing", + "inventory.primary_bin_id", + "current local replica listing primary bin is required before submit", + )], + ))); + }; + let Some(verified_primary_bin_id) = product + .verified_primary_bin_id + .as_deref() + .and_then(non_empty_ref) + else { + return Ok(Some(order_submit_invalid_quantity_view( + config, + loaded, + args, + "order listing bin identity is not verified in the local replica", + vec![issue_with_code( + "listing_primary_bin_invalid", + "inventory.primary_bin_id", + format!("current local replica primary bin `{primary_bin_id}` is not verified"), + )], + ))); + }; + if verified_primary_bin_id != primary_bin_id { + return Ok(Some(order_submit_invalid_quantity_view( + config, + loaded, + args, + "order listing bin identity is invalid in the local replica", + vec![issue_with_code( + "listing_primary_bin_invalid", + "inventory.primary_bin_id", + format!( + "current local replica primary bin `{primary_bin_id}` does not match verified primary bin `{verified_primary_bin_id}`" + ), + )], + ))); } - if issues.iter().any(|issue| issue.code == "account_mismatch") { - actions.push(format!( - "radroots order rebind {} <selector>", - document.order.order_id - )); + + let mut bin_issues = Vec::new(); + for (index, item) in loaded.document.order.items.iter().enumerate() { + if item.bin_id != primary_bin_id { + bin_issues.push(issue_with_code( + "order_bin_unknown", + format!("order.items[{index}].bin_id"), + format!( + "draft bin `{}` is not in the current local listing bin set; expected primary bin `{primary_bin_id}`", + item.bin_id + ), + )); + } } - if document.order.items.is_empty() - || issues - .iter() - .any(|issue| issue.field.starts_with("order.items[")) - { - actions.push(format!("radroots order get {}", document.order.order_id)); + if !bin_issues.is_empty() { + return Ok(Some(order_submit_invalid_quantity_view( + config, + loaded, + args, + "order draft references a bin outside the current local listing", + bin_issues, + ))); } - let mut deduped = Vec::new(); - for action in actions { - if !deduped.contains(&action) { - deduped.push(action); + + let available_count = match product.qty_avail { + Some(value) if value >= 0 => value as u64, + Some(value) => { + return Ok(Some(order_submit_invalid_quantity_view( + config, + loaded, + args, + "order listing availability is invalid in the local replica", + vec![issue_with_code( + "listing_inventory_availability_invalid", + "inventory.available", + format!("current local replica availability is negative: {value}"), + )], + ))); + } + None => { + return Ok(Some(order_submit_invalid_quantity_view( + config, + loaded, + args, + "order listing availability is missing in the local replica", + vec![issue_with_code( + "listing_inventory_availability_missing", + "inventory.available", + "current local replica listing availability is required before submit", + )], + ))); } + }; + + if requested_count > available_count { + return Ok(Some(order_submit_invalid_quantity_view( + config, + loaded, + args, + "order requested quantity exceeds current local listing availability", + vec![issue_with_code( + "order_quantity_exceeds_available", + "order.items", + format!( + "requested quantity {requested_count} exceeds current local replica available quantity {available_count}" + ), + )], + ))); } - deduped -} -fn app_order_issue_present(issues: &[OrderIssueView], code: &str) -> bool { - issues.iter().any(|issue| issue.code == code) + Ok(None) } -fn app_order_issue<'a>(issues: &'a [OrderIssueView], code: &str) -> Option<&'a OrderIssueView> { - issues.iter().find(|issue| issue.code == code) -} +fn order_submit_unconfigured_view( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, + reason: impl Into<String>, + issues: Vec<OrderIssueView>, + mut actions: Vec<String>, +) -> OrderSubmitView { + actions.push(format!( + "radroots order get {}", + loaded.document.order.order_id + )); -fn order_rebind_selector_error(selector: &str, error: RuntimeError) -> RuntimeError { - match error { - RuntimeError::Accounts(_) | RuntimeError::Account(_) => { - account::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, + OrderSubmitView { + state: "unconfigured".to_owned(), + source: ORDER_SOURCE.to_owned(), + order_id: loaded.document.order.order_id.clone(), + file: loaded.file.display().to_string(), + listing_lookup: loaded.document.listing_lookup.clone(), + listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), + listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), + listing_relays: order_listing_relays(&loaded.document), + 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, + dry_run: config.output.dry_run, + deduplicated: false, + target_relays: Vec::new(), + connected_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + idempotency_key: args.idempotency_key.clone(), + signer_mode: None, + signer_session_id: None, + requested_signer_session_id: None, + reason: Some(reason.into()), + job: None, + issues, + actions, } } -fn order_rebind_existing_request_check( +fn order_submit_app_signed_evidence_view( 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(), + args: &OrderSubmitArgs, + issues: &[OrderIssueView], +) -> Option<OrderSubmitView> { + if let Some(issue) = app_order_issue(issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { + return Some(OrderSubmitView { + 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(), + listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), + listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), + listing_relays: order_listing_relays(&loaded.document), + 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: issue.event_ids.first().cloned(), + event_kind: Some(KIND_ORDER_REQUEST), + dry_run: config.output.dry_run, + deduplicated: true, + target_relays: Vec::new(), + connected_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + idempotency_key: args.idempotency_key.clone(), + signer_mode: None, + signer_session_id: None, + requested_signer_session_id: None, + reason: Some( + "matching signed order request evidence already exists; publish skipped".to_owned(), + ), + job: None, + issues: vec![issue.clone()], + actions: 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(); + if app_order_issue_present(issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { + return Some(OrderSubmitView { + state: "invalid".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(), + listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), + listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), + listing_relays: order_listing_relays(&loaded.document), + 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_ORDER_REQUEST), + dry_run: config.output.dry_run, + deduplicated: false, + target_relays: Vec::new(), + connected_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + idempotency_key: args.idempotency_key.clone(), + signer_mode: None, + signer_session_id: None, + requested_signer_session_id: None, + reason: Some( + "signed order request evidence conflicts with the app-authored local order" + .to_owned(), + ), + job: None, + issues: issues.to_vec(), + actions: vec![format!( + "radroots order status get {}", + loaded.document.order.order_id + )], + }); + } - Ok(OrderRebindExistingRequestCheck { - state: if event_ids.is_empty() { - "clear".to_owned() - } else { - "blocked_existing_request".to_owned() - }, - event_ids, - }) + None } -fn resolve_initial_buyer_actor( +fn order_submit_invalid_quantity_view( config: &RuntimeConfig, -) -> Result<OrderDraftBuyerActor, RuntimeError> { - let resolution = account::resolve_account_resolution(config)?; - let Some(account) = resolution.resolved_account else { - return Err(account::AccountRuntimeFailure::unresolved_with_detail( - account::unresolved_account_reason(config)?, - json!({ - "buyer_actor_source": ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT, - "actions": [ - "radroots account create", - "radroots account import <path>", - ], - }), - ) - .into()); - }; - Ok(OrderDraftBuyerActor { - account_id: account.record.account_id.to_string(), - pubkey: account.record.public_identity.public_key_hex, - source: ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(), - }) -} - -fn buyer_account_id(document: &OrderDraftDocument) -> Option<String> { - non_empty_string(document.buyer_actor.account_id.clone()) -} - -fn buyer_actor_source(document: &OrderDraftDocument) -> Option<String> { - non_empty_string(document.buyer_actor.source.clone()) + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, + reason: impl Into<String>, + issues: Vec<OrderIssueView>, +) -> OrderSubmitView { + OrderSubmitView { + state: "invalid".to_owned(), + source: ORDER_SOURCE.to_owned(), + order_id: loaded.document.order.order_id.clone(), + file: loaded.file.display().to_string(), + listing_lookup: loaded.document.listing_lookup.clone(), + listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), + listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), + listing_relays: order_listing_relays(&loaded.document), + 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, + dry_run: config.output.dry_run, + deduplicated: false, + target_relays: Vec::new(), + connected_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + idempotency_key: args.idempotency_key.clone(), + signer_mode: None, + signer_session_id: None, + requested_signer_session_id: None, + reason: Some(reason.into()), + job: None, + issues, + actions: vec![ + "radroots market refresh".to_owned(), + format!("radroots order get {}", loaded.document.order.order_id), + ], + } } -fn load_local_order_draft_if_exists( +fn order_submit_listing_provenance_preflight_view( config: &RuntimeConfig, - lookup: &str, -) -> Result<Option<LoadedOrderDraft>, RuntimeError> { - let file = draft_lookup_path(config, lookup); - if !file.exists() { + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, +) -> Result<Option<OrderSubmitView>, 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}")))?; + if target_relays.is_empty() { + return Ok(None); + } + let reachable_relays = listing_relays + .iter() + .filter(|relay| target_relays.contains(relay)) + .cloned() + .collect::<Vec<_>>(); + if !reachable_relays.is_empty() { return Ok(None); } - load_draft(file.as_path()) - .map(Some) - .map_err(RuntimeError::Config) -} -fn order_status_actor_context( - config: &RuntimeConfig, - order_id: &str, -) -> Result<OrderDraftStatusActorContext, RuntimeError> { - if let Some(loaded) = load_local_order_draft_if_exists(config, order_id)? { - return Ok(OrderDraftStatusActorContext { - source: ORDER_ACTOR_CONTEXT_ORDER_DRAFT, - buyer_pubkey: non_empty_string(loaded.document.buyer_actor.pubkey.clone()) - .or_else(|| non_empty_string(loaded.document.order.buyer_pubkey.clone())), - seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey), - selected_account_pubkey: None, - }); - } - - let selected_account = account::resolve_account(config)?; - let Some(account) = selected_account else { - return Ok(OrderDraftStatusActorContext { - source: ORDER_ACTOR_CONTEXT_NETWORK_ONLY, - buyer_pubkey: None, - seller_pubkey: None, - selected_account_pubkey: None, - }); - }; - - Ok(OrderDraftStatusActorContext { - source: ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT, - buyer_pubkey: None, - seller_pubkey: None, - selected_account_pubkey: Some(account.record.public_identity.public_key_hex), - }) -} - -fn order_event_list_actor_context( - config: &RuntimeConfig, - order_id: Option<&str>, -) -> Result<Option<OrderEventListActorContext>, RuntimeError> { - if let Some(order_id) = order_id - && let Some(loaded) = load_local_order_draft_if_exists(config, order_id)? - { - let seller_pubkey = - non_empty_string(loaded.document.order.seller_pubkey).ok_or_else(|| { - RuntimeError::Config(format!( - "local order draft `{order_id}` is missing seller_pubkey" - )) - })?; - return Ok(Some(OrderEventListActorContext { - source: ORDER_ACTOR_CONTEXT_ORDER_DRAFT, - seller_pubkey, - })); - } - - Ok( - account::resolve_account(config)?.map(|account| OrderEventListActorContext { - source: ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT, - seller_pubkey: account.record.public_identity.public_key_hex, - }), - ) -} - -fn bound_buyer_write_context_if_exists( - config: &RuntimeConfig, - order_id: &str, -) -> Result<Option<OrderBoundBuyerWriteContext>, RuntimeError> { - let Some(loaded) = load_local_order_draft_if_exists(config, order_id)? else { - return Ok(None); - }; - let account = validate_bound_order_buyer_account(config, &loaded)?; - Ok(Some(OrderBoundBuyerWriteContext { loaded, account })) -} - -fn order_buyer_write_actor_context( - config: &RuntimeConfig, - order_id: &str, -) -> Result<Option<OrderBuyerWriteActorContext>, RuntimeError> { - if let Some(bound) = bound_buyer_write_context_if_exists(config, order_id)? { - let selected_pubkey = bound.account.record.public_identity.public_key_hex.clone(); - let status_seller_pubkey = - non_empty_string(bound.loaded.document.order.seller_pubkey.clone()); - return Ok(Some(OrderBuyerWriteActorContext { - bound: Some(bound), - selected_pubkey: selected_pubkey.clone(), - status_buyer_pubkey: Some(selected_pubkey), - status_seller_pubkey, - status_context_source: ORDER_ACTOR_CONTEXT_ORDER_DRAFT, - })); - } - - Ok(account::resolve_account(config)?.map(|account| { - let selected_pubkey = account.record.public_identity.public_key_hex; - OrderBuyerWriteActorContext { - bound: None, - selected_pubkey: selected_pubkey.clone(), - status_buyer_pubkey: None, - status_seller_pubkey: None, - status_context_source: ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT, - } - })) -} - -fn order_submit_listing_freshness_view( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &OrderSubmitArgs, -) -> Result<Option<OrderSubmitView>, RuntimeError> { - if !config.local.replica_db_path.exists() { - return Ok(Some(order_submit_unconfigured_view( - config, - loaded, - args, - "order submit requires local market data to confirm the listing is still active; run `radroots store init` and `radroots market refresh` before submitting", - vec![issue( - "order.listing_addr", - "local replica database is missing; run `radroots store init` and `radroots market refresh` before submitting", - )], - vec![ - "radroots store init".to_owned(), - "radroots market refresh".to_owned(), - ], - ))); - } - - let listing_addr = loaded.document.order.listing_addr.as_str(); - let parsed = parse_listing_addr(listing_addr) - .map_err(|error| RuntimeError::Config(format!("order listing_addr is invalid: {error}")))?; - let active_event_id = match resolve_active_listing_event_id(config, listing_addr, &parsed)? { - Some(event_id) => event_id, - None => { - return Ok(Some(order_submit_unconfigured_view( - config, - loaded, - args, - "order listing is not active in the local replica; run `radroots market refresh` and create a new order from current market data", - vec![issue( - "order.listing_addr", - "listing is missing, archived, or superseded in the local replica", - )], - vec!["radroots market refresh".to_owned()], - ))); - } - }; - - if !active_event_id.eq_ignore_ascii_case(loaded.document.order.listing_event_id.as_str()) { - return Ok(Some(order_submit_unconfigured_view( - config, - loaded, - args, - "order listing event is no longer current in the local replica; run `radroots market refresh` and create a new order from current market data", - vec![issue( - "order.listing_event_id", - format!( - "draft listing_event_id does not match latest local listing event `{active_event_id}`" - ), - )], - vec!["radroots market refresh".to_owned()], - ))); - } - - Ok(None) -} - -fn order_submit_quantity_preflight_view( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &OrderSubmitArgs, -) -> Result<Option<OrderSubmitView>, RuntimeError> { - if !config.local.replica_db_path.exists() { - return Ok(Some(order_submit_unconfigured_view( - config, - loaded, - args, - "order submit requires local market data to confirm current listing availability; run `radroots store init` and `radroots market refresh` before submitting", - vec![issue( - "order.listing_addr", - "local replica database is missing; run `radroots store init` and `radroots market refresh` before submitting", - )], - vec![ - "radroots store init".to_owned(), - "radroots market refresh".to_owned(), - ], - ))); - } - - let requested_count = - loaded - .document - .order - .items - .iter() - .enumerate() - .try_fold(0u64, |total, (index, item)| { - if item.bin_count == 0 { - return Err(RuntimeError::Config(format!( - "order item {index} quantity must be greater than zero" - ))); - } - total.checked_add(u64::from(item.bin_count)).ok_or_else(|| { - RuntimeError::Config("order quantity exceeds supported range".to_owned()) - }) - })?; - - let executor = SqliteExecutor::open(&config.local.replica_db_path)?; - let product_rows = trade_product::find_many( - &executor, - &ITradeProductFindMany { - filter: Some(trade_product_listing_addr_filter( - loaded.document.order.listing_addr.as_str(), - )), - }, - ) - .map_err(|error| RuntimeError::Config(format!("resolve listing product state: {error:?}")))? - .results; - - let product = match product_rows.as_slice() { - [product] => product, - [] => { - return Ok(Some(order_submit_unconfigured_view( - config, - loaded, - args, - "order listing is not active in the local replica; run `radroots market refresh` and create a new order from current market data", - vec![issue( - "order.listing_addr", - "listing is missing, archived, or superseded in the local replica", - )], - vec!["radroots market refresh".to_owned()], - ))); - } - _ => { - return Err(RuntimeError::Config(format!( - "listing address `{}` matched {} active local listing rows", - loaded.document.order.listing_addr, - product_rows.len() - ))); - } - }; - - let Some(primary_bin_id) = product.primary_bin_id.as_deref().and_then(non_empty_ref) else { - return Ok(Some(order_submit_invalid_quantity_view( - config, - loaded, - args, - "order listing bin identity is missing in the local replica", - vec![issue_with_code( - "listing_primary_bin_missing", - "inventory.primary_bin_id", - "current local replica listing primary bin is required before submit", - )], - ))); - }; - let Some(verified_primary_bin_id) = product - .verified_primary_bin_id - .as_deref() - .and_then(non_empty_ref) - else { - return Ok(Some(order_submit_invalid_quantity_view( - config, - loaded, - args, - "order listing bin identity is not verified in the local replica", - vec![issue_with_code( - "listing_primary_bin_invalid", - "inventory.primary_bin_id", - format!("current local replica primary bin `{primary_bin_id}` is not verified"), - )], - ))); - }; - if verified_primary_bin_id != primary_bin_id { - return Ok(Some(order_submit_invalid_quantity_view( - config, - loaded, - args, - "order listing bin identity is invalid in the local replica", - vec![issue_with_code( - "listing_primary_bin_invalid", - "inventory.primary_bin_id", - format!( - "current local replica primary bin `{primary_bin_id}` does not match verified primary bin `{verified_primary_bin_id}`" - ), - )], - ))); - } - - let mut bin_issues = Vec::new(); - for (index, item) in loaded.document.order.items.iter().enumerate() { - if item.bin_id != primary_bin_id { - bin_issues.push(issue_with_code( - "order_bin_unknown", - format!("order.items[{index}].bin_id"), - format!( - "draft bin `{}` is not in the current local listing bin set; expected primary bin `{primary_bin_id}`", - item.bin_id - ), - )); - } - } - if !bin_issues.is_empty() { - return Ok(Some(order_submit_invalid_quantity_view( - config, - loaded, - args, - "order draft references a bin outside the current local listing", - bin_issues, - ))); - } - - let available_count = match product.qty_avail { - Some(value) if value >= 0 => value as u64, - Some(value) => { - return Ok(Some(order_submit_invalid_quantity_view( - config, - loaded, - args, - "order listing availability is invalid in the local replica", - vec![issue_with_code( - "listing_inventory_availability_invalid", - "inventory.available", - format!("current local replica availability is negative: {value}"), - )], - ))); - } - None => { - return Ok(Some(order_submit_invalid_quantity_view( - config, - loaded, - args, - "order listing availability is missing in the local replica", - vec![issue_with_code( - "listing_inventory_availability_missing", - "inventory.available", - "current local replica listing availability is required before submit", - )], - ))); - } - }; - - if requested_count > available_count { - return Ok(Some(order_submit_invalid_quantity_view( - config, - loaded, - args, - "order requested quantity exceeds current local listing availability", - vec![issue_with_code( - "order_quantity_exceeds_available", - "order.items", - format!( - "requested quantity {requested_count} exceeds current local replica available quantity {available_count}" - ), - )], - ))); - } - - Ok(None) -} - -fn order_submit_unconfigured_view( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &OrderSubmitArgs, - reason: impl Into<String>, - issues: Vec<OrderIssueView>, - mut actions: Vec<String>, -) -> OrderSubmitView { - actions.push(format!( - "radroots order get {}", - loaded.document.order.order_id - )); - - OrderSubmitView { - state: "unconfigured".to_owned(), - source: ORDER_SOURCE.to_owned(), - order_id: loaded.document.order.order_id.clone(), - file: loaded.file.display().to_string(), - listing_lookup: loaded.document.listing_lookup.clone(), - listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), - listing_relays: order_listing_relays(&loaded.document), - 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, - dry_run: config.output.dry_run, - deduplicated: false, - target_relays: Vec::new(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - idempotency_key: args.idempotency_key.clone(), - signer_mode: None, - signer_session_id: None, - requested_signer_session_id: None, - reason: Some(reason.into()), - job: None, - issues, - actions, - } -} - -fn order_submit_app_signed_evidence_view( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &OrderSubmitArgs, - issues: &[OrderIssueView], -) -> Option<OrderSubmitView> { - if let Some(issue) = app_order_issue(issues, APP_ORDER_ALREADY_SUBMITTED_ISSUE) { - return Some(OrderSubmitView { - 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(), - listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), - listing_relays: order_listing_relays(&loaded.document), - 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: issue.event_ids.first().cloned(), - event_kind: Some(KIND_ORDER_REQUEST), - dry_run: config.output.dry_run, - deduplicated: true, - target_relays: Vec::new(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - idempotency_key: args.idempotency_key.clone(), - signer_mode: None, - signer_session_id: None, - requested_signer_session_id: None, - reason: Some( - "matching signed order request evidence already exists; publish skipped".to_owned(), - ), - job: None, - issues: vec![issue.clone()], - actions: Vec::new(), - }); - } - - if app_order_issue_present(issues, APP_ORDER_SIGNED_EVIDENCE_CONFLICT_ISSUE) { - return Some(OrderSubmitView { - state: "invalid".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(), - listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), - listing_relays: order_listing_relays(&loaded.document), - 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_ORDER_REQUEST), - dry_run: config.output.dry_run, - deduplicated: false, - target_relays: Vec::new(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - idempotency_key: args.idempotency_key.clone(), - signer_mode: None, - signer_session_id: None, - requested_signer_session_id: None, - reason: Some( - "signed order request evidence conflicts with the app-authored local order" - .to_owned(), - ), - job: None, - issues: issues.to_vec(), - actions: vec![format!( - "radroots order status get {}", - loaded.document.order.order_id - )], - }); - } - - None -} - -fn order_submit_invalid_quantity_view( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &OrderSubmitArgs, - reason: impl Into<String>, - issues: Vec<OrderIssueView>, -) -> OrderSubmitView { - OrderSubmitView { - state: "invalid".to_owned(), - source: ORDER_SOURCE.to_owned(), - order_id: loaded.document.order.order_id.clone(), - file: loaded.file.display().to_string(), - listing_lookup: loaded.document.listing_lookup.clone(), - listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), - listing_relays: order_listing_relays(&loaded.document), - 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, - dry_run: config.output.dry_run, - deduplicated: false, - target_relays: Vec::new(), - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - failed_relays: Vec::new(), - idempotency_key: args.idempotency_key.clone(), - signer_mode: None, - signer_session_id: None, - requested_signer_session_id: None, - reason: Some(reason.into()), - job: None, - issues, - actions: vec![ - "radroots market refresh".to_owned(), - format!("radroots order get {}", loaded.document.order.order_id), - ], - } -} - -fn order_submit_listing_provenance_preflight_view( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &OrderSubmitArgs, -) -> Result<Option<OrderSubmitView>, 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}")))?; - if target_relays.is_empty() { - return Ok(None); - } - let reachable_relays = listing_relays - .iter() - .filter(|relay| target_relays.contains(relay)) - .cloned() - .collect::<Vec<_>>(); - if !reachable_relays.is_empty() { - return Ok(None); - } - - let mut actions = listing_relays - .iter() - .map(|relay| { - format!( - "radroots --relay {} order submit {}", - relay, loaded.document.order.order_id - ) - }) - .collect::<Vec<_>>(); - actions.push(format!( - "radroots order get {}", - loaded.document.order.order_id - )); - Ok(Some(OrderSubmitView { - state: "unconfigured".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(), - listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), - listing_relays, - 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_ORDER_REQUEST), - dry_run: config.output.dry_run, - deduplicated: false, - target_relays, - connected_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, - requested_signer_session_id: None, - reason: Some( - "order submit requires at least one configured relay that is known to carry the listing" - .to_owned(), - ), - job: None, - issues: vec![issue_with_code( - "listing_relay_target_mismatch", - "order.listing_relays", - format!( - "configured relays must include one of the listing provenance relays: {}", - loaded.document.order.listing_relays.join(", ") - ), - )], - actions, - })) -} - -fn order_submit_market_freshness_view( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &OrderSubmitArgs, -) -> Result<Option<OrderSubmitView>, RuntimeError> { - if config.output.dry_run || config.relay.urls.is_empty() { - return Ok(None); - } - - let mut freshness = freshness_for_scope(config, RelayIngestScope::MarketRefresh)?; - if freshness_requires_refresh(&freshness) { - let _ = market_refresh(config)?; - freshness = freshness_for_scope(config, RelayIngestScope::MarketRefresh)?; - } - if !freshness_requires_refresh(&freshness) { - return Ok(None); - } - - Ok(Some(order_submit_unconfigured_view( - config, - loaded, - args, - "order submit requires a current market refresh before signing; run `radroots market refresh` with the relays you trust, then submit again", - vec![issue( - "order.listing_addr", - format!( - "local market freshness is `{}`; current listing state must be refreshed before order submit", - freshness.state - ), - )], - vec!["radroots market refresh".to_owned()], - ))) -} - -fn order_submit_existing_request_view_from_receipt( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &OrderSubmitArgs, - payload: &RadrootsOrderRequest, - receipt: DirectRelayFetchReceipt, -) -> Result<Option<OrderSubmitView>, RuntimeError> { - let DirectRelayFetchReceipt { - target_relays, - connected_relays, - failed_relays, - events, - } = receipt; - let mut requests = Vec::new(); - let mut candidate_issues = Vec::new(); - let candidate_context = OrderRequestCandidateContext { - order_id: loaded.document.order.order_id.as_str(), - seller_pubkey: Some(loaded.document.order.seller_pubkey.as_str()), - }; - - for event in events { - if !order_request_candidate_matches(&event, candidate_context) { - continue; - } - let event_id = event.id.to_string(); - match order_submit_request_from_event(&event, loaded) { - Ok(request) => requests.push(request), - Err(error) => candidate_issues.push(issue_with_events( - "invalid_request_candidate", - "request_event_id", - format!("request event `{event_id}` failed order submit preflight: {error}"), - vec![event_id], - )), - } - } - - requests.sort_by(|left, right| left.request_event_id.cmp(&right.request_event_id)); - candidate_issues.sort_by(|left, right| { - left.event_ids - .cmp(&right.event_ids) - .then_with(|| left.message.cmp(&right.message)) - }); - if !candidate_issues.is_empty() { - return Ok(Some(order_submit_invalid_existing_request_view( - config, - loaded, - args, - "visible order request candidates failed submit preflight validation", - candidate_issues, - target_relays, - failed_relays, - ))); - } - - let request_event_ids = requests - .iter() - .map(|request| request.request_event_id.clone()) - .collect::<Vec<_>>(); - - match requests.as_slice() { - [] => Ok(None), - [request] if order_submit_request_matches_draft(request, loaded, payload) => { - Ok(Some(order_submit_deduplicated_view( - config, - loaded, - args, - request, - target_relays, - connected_relays, - failed_relays, - ))) - } - [request] => Ok(Some(order_submit_invalid_existing_request_view( - config, - loaded, - args, - "visible order request event conflicts with the local order draft; refusing to publish a second request for the same order id", - vec![issue_with_events( - "existing_request_conflict", - "request_event_id", - format!( - "request event `{}` does not match the local order draft", - request.request_event_id - ), - vec![request.request_event_id.clone()], - )], - target_relays, - failed_relays, - ))), - _ => Ok(Some(order_submit_invalid_existing_request_view( - config, - loaded, - args, - "multiple visible order request events matched the local order id; refusing to publish another request", - vec![issue_with_events( - "multiple_request_candidates", - "request_event_id", - format!( - "matched {} request events for the same order id", - requests.len() - ), - request_event_ids, - )], - target_relays, - failed_relays, - ))), - } -} - -fn order_submit_request_from_event( - event: &RadrootsNostrEvent, - loaded: &LoadedOrderDraft, -) -> Result<ResolvedOrderSubmitRequest, RuntimeError> { - let event = radroots_event_from_nostr(event); - let envelope = order_request_from_event(&event) - .map_err(|error| RuntimeError::Config(format!("decode order request event: {error}")))?; - let context = - order_event_context_from_tags(RadrootsOrderEventType::OrderRequested, &event.tags) - .map_err(|error| RuntimeError::Config(format!("decode order request tags: {error}")))?; - - if envelope.order_id != loaded.document.order.order_id - || envelope.payload.order_id != loaded.document.order.order_id - { - return Err(RuntimeError::Config( - "order request does not match local order id".to_owned(), - )); - } - if context.counterparty_pubkey != envelope.payload.seller_pubkey { - return Err(RuntimeError::Config( - "order request p tag does not match seller_pubkey".to_owned(), - )); - } - let listing_addr = - parse_listing_addr(envelope.payload.listing_addr.as_str()).map_err(|error| { - RuntimeError::Config(format!("order request listing_addr is invalid: {error}")) - })?; - if listing_addr.seller_pubkey != envelope.payload.seller_pubkey { - return Err(RuntimeError::Config( - "order request listing address is outside seller authority".to_owned(), - )); - } - let payload = canonicalize_order_request_for_signer(envelope.payload, event.author.as_str()) - .map_err(|error| RuntimeError::Config(format!("canonicalize order request: {error}")))?; - let listing_event_id = context.listing_event.as_ref().map(|event| event.id.clone()); - - Ok(ResolvedOrderSubmitRequest { - request_event_id: event.id, - listing_event_id, - payload, - }) -} - -fn order_submit_request_matches_draft( - request: &ResolvedOrderSubmitRequest, - loaded: &LoadedOrderDraft, - payload: &RadrootsOrderRequest, -) -> bool { - request.payload == *payload - && request.listing_event_id.as_deref() - == Some(loaded.document.order.listing_event_id.as_str()) -} - -fn order_submit_deduplicated_view( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &OrderSubmitArgs, - request: &ResolvedOrderSubmitRequest, - target_relays: Vec<String>, - connected_relays: Vec<String>, - failed_relays: Vec<DirectRelayFailure>, -) -> OrderSubmitView { - OrderSubmitView { - 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(), - listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), - listing_relays: order_listing_relays(&loaded.document), - 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_ORDER_REQUEST), - dry_run: config.output.dry_run, - deduplicated: true, - target_relays, - connected_relays: connected_relays.clone(), - acknowledged_relays: connected_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( - "an identical order request is already visible on the configured relays; publish skipped" - .to_owned(), - ), - job: None, - issues: Vec::new(), - actions: Vec::new(), - } -} - -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(), - 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(), - listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), - listing_relays: order_listing_relays(&loaded.document), - 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(plan.expected_event_id.as_str().to_owned()), - event_kind: Some(KIND_ORDER_REQUEST), - dry_run: true, - deduplicated: false, - target_relays, - connected_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, - requested_signer_session_id: None, - reason: Some("dry run requested; SDK enqueue and relay push skipped".to_owned()), - job: None, - issues: Vec::new(), - actions: vec![format!( - "radroots order submit {}", - loaded.document.order.order_id - )], - } -} - -fn order_submit_invalid_existing_request_view( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &OrderSubmitArgs, - reason: impl Into<String>, - issues: Vec<OrderIssueView>, - target_relays: Vec<String>, - failed_relays: Vec<DirectRelayFailure>, -) -> OrderSubmitView { - OrderSubmitView { - state: "invalid".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(), - listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), - listing_relays: order_listing_relays(&loaded.document), - 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_ORDER_REQUEST), - dry_run: config.output.dry_run, - deduplicated: false, - target_relays, - connected_relays: Vec::new(), - acknowledged_relays: Vec::new(), - 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(reason.into()), - job: None, - issues, - actions: vec![format!( - "radroots order status get {}", - loaded.document.order.order_id - )], - } -} - -fn canonical_order_request_payload_from_loaded( - loaded: &LoadedOrderDraft, - signer_pubkey: &str, -) -> Result<RadrootsOrderRequest, RuntimeError> { - let economics = - loaded.document.order.economics.clone().ok_or_else(|| { - RuntimeError::Config("order draft is missing quote economics".to_owned()) - })?; - let items = loaded - .document - .order - .items - .iter() - .map(|item| { - Ok(RadrootsOrderItem { - bin_id: protocol_inventory_bin_id(item.bin_id.as_str(), "order item bin_id")?, - bin_count: item.bin_count, - }) - }) - .collect::<Result<Vec<_>, RuntimeError>>()?; - let payload = RadrootsOrderRequest { - order_id: protocol_order_id(loaded.document.order.order_id.as_str(), "order_id")?, - listing_addr: protocol_listing_addr( - loaded.document.order.listing_addr.as_str(), - "listing_addr", - )?, - buyer_pubkey: protocol_pubkey(loaded.document.order.buyer_pubkey.as_str(), "buyer_pubkey")?, - seller_pubkey: protocol_pubkey( - loaded.document.order.seller_pubkey.as_str(), - "seller_pubkey", - )?, - items, - economics, - }; - canonicalize_order_request_for_signer(payload, signer_pubkey) - .map_err(|error| RuntimeError::Config(format!("canonicalize order request: {error}"))) -} - -fn sdk_order_submit_input( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - signing: &account::AccountSigningIdentity, - payload: RadrootsOrderRequest, -) -> 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, - }) -} - -#[derive(Debug, Clone)] -struct SdkOrderSubmitInput { - actor: RadrootsActorContext, - listing_event: RadrootsNostrEventPtr, - order: RadrootsOrderRequest, - target_relays: Vec<String>, -} - -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}")))?; - Ok(RadrootsNostrEventPtr { - id: loaded.document.order.listing_event_id.clone(), - relays: listing_relays.first().cloned(), - }) -} - -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 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 prepare_order_submit_via_sdk( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &OrderSubmitArgs, - 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, - )) -} - -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(), - listing_lookup: loaded.document.listing_lookup.clone(), - listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), - listing_relays: order_listing_relays(&loaded.document), - 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(enqueue.signed_event_id.as_str().to_owned()), - event_kind: Some(KIND_ORDER_REQUEST), - dry_run: false, - 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: sdk_order_submit_reason(&enqueue.workflow, push_event), - job: None, - issues: 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( - enqueue: &OrderWorkflowEnqueueReceipt, - push_event: Option<&PushOutboxEventReceipt>, -) -> Option<String> { - match push_event.map(|event| event.final_state) { - Some(PushOutboxEventState::Published) => None, - Some(PushOutboxEventState::PublishRetryable) => Some(format!( - "{}; SDK relay publish did not reach accepted quorum; outbox event remains retryable; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - Some(PushOutboxEventState::FailedTerminal) => Some(format!( - "{}; SDK relay publish failed terminally; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - Some(state) => Some(format!( - "{}; SDK relay push left event in state `{state:?}`; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - None => Some(format!( - "{}; order submit queued in SDK outbox; no ready SDK outbox event was pushed; {}", - sdk_order_enqueue_summary(enqueue), - sdk_order_enqueue_retry_summary(enqueue) - )), - } -} - -fn sdk_order_submit_actions(push_event: Option<&PushOutboxEventReceipt>) -> Vec<String> { - if !matches!( - push_event.map(|event| event.final_state), - Some(PushOutboxEventState::Published) - ) { - return sdk_order_push_recovery_actions(); - } - 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( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - args: &OrderSubmitArgs, - error: ActorWriteBindingError, -) -> OrderSubmitView { - let (state, reason, actions) = order_actor_write_binding_error_parts(error); - - let mut actions = actions; - actions.push(format!( - "radroots order get {}", - loaded.document.order.order_id - )); - - OrderSubmitView { - state: state.clone(), - source: ORDER_SOURCE.to_owned(), - order_id: loaded.document.order.order_id.clone(), - file: loaded.file.display().to_string(), - listing_lookup: loaded.document.listing_lookup.clone(), - listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), - listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), - listing_relays: order_listing_relays(&loaded.document), - 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, - dry_run: config.output.dry_run, - deduplicated: false, - target_relays: Vec::new(), - connected_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, - requested_signer_session_id: None, - reason: Some(reason), - job: None, - issues: Vec::new(), - actions, - } -} - -fn validate_bound_order_buyer_account( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, -) -> Result<account::AccountRecordView, RuntimeError> { - let document = &loaded.document; - let account_id = document.buyer_actor.account_id.trim(); - let buyer_pubkey = document.buyer_actor.pubkey.trim(); - let snapshot = account::snapshot(config)?; - let Some(account) = snapshot - .accounts - .iter() - .find(|account| account.record.account_id.as_str() == account_id) - .cloned() - else { - return Err(account::AccountRuntimeFailure::unresolved_with_detail( - format!( - "order-bound buyer account `{account_id}` is not present in the local account store" - ), - order_buyer_failure_detail( - loaded, - json!({ - "actions": [ - "radroots account import <path>", - format!("radroots order rebind {} <selector>", document.order.order_id), - format!("radroots order get {}", document.order.order_id), - ], - }), - ), - ) - .into()); - }; - - let account_pubkey = account.record.public_identity.public_key_hex.as_str(); - if !account_pubkey.eq_ignore_ascii_case(buyer_pubkey) - || !document - .order - .buyer_pubkey - .eq_ignore_ascii_case(buyer_pubkey) - { - return Err(account::AccountRuntimeFailure::mismatch_with_detail( - format!( - "order-bound buyer account `{account_id}` does not match order buyer pubkey `{buyer_pubkey}`" - ), - order_buyer_failure_detail( - loaded, - json!({ - "attempted_buyer_account_id": account_id, - "attempted_buyer_pubkey": account_pubkey, - "actions": [ - format!("radroots order rebind {} <selector>", document.order.order_id), - format!("radroots order get {}", document.order.order_id), - ], - }), - ), - ) - .into()); - } - - if !account.write_capable { - return Err(account::AccountRuntimeFailure::watch_only_with_detail( - account_id, - order_buyer_failure_detail( - loaded, - json!({ - "actions": [ - format!("radroots account attach-secret {account_id} <path>"), - format!("radroots order get {}", document.order.order_id), - ], - }), - ), - ) - .into()); - } - - if let Some(selector) = config.account.selector.as_deref() { - let attempted = account::resolve_account_selector(config, selector).map_err(|_| { - account::AccountRuntimeFailure::unresolved_with_detail( - format!("account override `{selector}` did not resolve to a local buyer account"), - order_buyer_failure_detail( - loaded, - json!({ - "attempted_buyer_account_id": selector, - "actions": [ - "radroots account list", - format!("radroots order get {}", document.order.order_id), - ], - }), - ), - ) - })?; - if attempted.record.account_id.as_str() != account_id { - let attempted_pubkey = attempted.record.public_identity.public_key_hex.as_str(); - return Err(account::AccountRuntimeFailure::mismatch_with_detail( - format!( - "account override `{}` cannot retarget order `{}` bound to buyer account `{account_id}`", - attempted.record.account_id, document.order.order_id - ), - order_buyer_failure_detail( - loaded, - json!({ - "attempted_buyer_account_id": attempted.record.account_id.to_string(), - "attempted_buyer_pubkey": attempted_pubkey, - "actions": [ - format!("radroots --account-id {account_id} order submit {}", document.order.order_id), - format!("radroots order rebind {} <selector>", document.order.order_id), - format!("radroots order get {}", document.order.order_id), - ], - }), - ), - ) - .into()); - } - } - - Ok(account) -} - -fn order_buyer_failure_detail( - loaded: &LoadedOrderDraft, - mut extra: serde_json::Value, -) -> serde_json::Value { - let mut detail = json!({ - "buyer_actor_source": loaded.document.buyer_actor.source.as_str(), - "order_buyer_account_id": loaded.document.buyer_actor.account_id.as_str(), - "order_buyer_pubkey": loaded.document.buyer_actor.pubkey.as_str(), - "order_file": loaded.file.display().to_string(), - "order_id": loaded.document.order.order_id.as_str(), - }); - if let (Some(detail), Some(extra)) = (detail.as_object_mut(), extra.as_object_mut()) { - for (key, value) in std::mem::take(extra) { - detail.insert(key, value); - } - } - detail -} - -fn resolve_local_order_signing_identity( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, -) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> { - resolve_local_order_bound_buyer_signing_identity(config, loaded, "order submit") -} - -fn resolve_local_order_bound_buyer_signing_identity( - config: &RuntimeConfig, - loaded: &LoadedOrderDraft, - action: &str, -) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> { - if !matches!(config.signer.backend, SignerBackend::Local) { - return Err(ActorWriteBindingError::Unconfigured(format!( - "{action} requires signer mode `local`" - ))); - } - let account_id = loaded.document.buyer_actor.account_id.trim(); - let buyer_pubkey = loaded.document.buyer_actor.pubkey.trim(); - let signing = account::resolve_local_signing_identity_for_account(config, account_id) - .map_err(ActorWriteBindingError::from_runtime)?; - let selected_pubkey = signing - .account - .record - .public_identity - .public_key_hex - .as_str(); - if !selected_pubkey.eq_ignore_ascii_case(buyer_pubkey) { - return Err(ActorWriteBindingError::Account( - account::AccountRuntimeFailure::mismatch_with_detail( - format!( - "account mismatch: order-bound buyer account `{account_id}` pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" - ), - order_buyer_failure_detail( - loaded, - json!({ - "attempted_buyer_account_id": signing.account.record.account_id.to_string(), - "attempted_buyer_pubkey": selected_pubkey, - "actions": [ - format!("radroots order rebind {} <selector>", loaded.document.order.order_id), - format!("radroots order get {}", loaded.document.order.order_id), - ], - }), - ), - ), - )); - } - Ok(signing) -} - -fn resolve_local_order_decision_signing_identity( - config: &RuntimeConfig, - seller_pubkey: &str, - decision: OrderDecisionArg, -) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> { - if !matches!(config.signer.backend, SignerBackend::Local) { - return Err(ActorWriteBindingError::Unconfigured(format!( - "order {} requires signer mode `local`", - decision.command() - ))); - } - let signing = account::resolve_local_signing_identity(config) - .map_err(ActorWriteBindingError::from_runtime)?; - let selected_pubkey = signing - .account - .record - .public_identity - .public_key_hex - .as_str(); - if !selected_pubkey.eq_ignore_ascii_case(seller_pubkey) { - return Err(ActorWriteBindingError::Account( - account::AccountRuntimeFailure::mismatch(format!( - "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order seller_pubkey `{seller_pubkey}`" - )), - )); - } - Ok(signing) -} - -fn resolve_local_order_fulfillment_signing_identity( - config: &RuntimeConfig, - seller_pubkey: &str, -) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> { - if !matches!(config.signer.backend, SignerBackend::Local) { - return Err(ActorWriteBindingError::Unconfigured( - "order fulfillment update requires signer mode `local`".to_owned(), - )); - } - let signing = account::resolve_local_signing_identity(config) - .map_err(ActorWriteBindingError::from_runtime)?; - let selected_pubkey = signing - .account - .record - .public_identity - .public_key_hex - .as_str(); - if !selected_pubkey.eq_ignore_ascii_case(seller_pubkey) { - return Err(ActorWriteBindingError::Account( - account::AccountRuntimeFailure::mismatch(format!( - "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order seller_pubkey `{seller_pubkey}`" - )), - )); - } - Ok(signing) -} - -fn resolve_local_order_cancellation_signing_identity( - config: &RuntimeConfig, - buyer_pubkey: &str, -) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> { - if !matches!(config.signer.backend, SignerBackend::Local) { - return Err(ActorWriteBindingError::Unconfigured( - "order cancel requires signer mode `local`".to_owned(), - )); - } - let signing = account::resolve_local_signing_identity(config) - .map_err(ActorWriteBindingError::from_runtime)?; - let selected_pubkey = signing - .account - .record - .public_identity - .public_key_hex - .as_str(); - if !selected_pubkey.eq_ignore_ascii_case(buyer_pubkey) { - return Err(ActorWriteBindingError::Account( - account::AccountRuntimeFailure::mismatch(format!( - "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" - )), - )); - } - Ok(signing) -} - -fn resolve_local_order_receipt_signing_identity( - config: &RuntimeConfig, - buyer_pubkey: &str, -) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> { - if !matches!(config.signer.backend, SignerBackend::Local) { - return Err(ActorWriteBindingError::Unconfigured( - "order receipt record requires signer mode `local`".to_owned(), - )); - } - let signing = account::resolve_local_signing_identity(config) - .map_err(ActorWriteBindingError::from_runtime)?; - let selected_pubkey = signing - .account - .record - .public_identity - .public_key_hex - .as_str(); - if !selected_pubkey.eq_ignore_ascii_case(buyer_pubkey) { - return Err(ActorWriteBindingError::Account( - account::AccountRuntimeFailure::mismatch(format!( - "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" - )), - )); - } - Ok(signing) -} - -fn resolve_local_order_payment_signing_identity( - config: &RuntimeConfig, - buyer_pubkey: &str, -) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> { - if !matches!(config.signer.backend, SignerBackend::Local) { - return Err(ActorWriteBindingError::Unconfigured( - "order payment record requires signer mode `local`".to_owned(), - )); - } - let signing = account::resolve_local_signing_identity(config) - .map_err(ActorWriteBindingError::from_runtime)?; - let selected_pubkey = signing - .account - .record - .public_identity - .public_key_hex - .as_str(); - if !selected_pubkey.eq_ignore_ascii_case(buyer_pubkey) { - return Err(ActorWriteBindingError::Account( - account::AccountRuntimeFailure::mismatch(format!( - "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" - )), - )); - } - Ok(signing) -} - -fn resolve_local_order_settlement_signing_identity( - config: &RuntimeConfig, - seller_pubkey: &str, -) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> { - if !matches!(config.signer.backend, SignerBackend::Local) { - return Err(ActorWriteBindingError::Unconfigured( - "order settlement decision requires signer mode `local`".to_owned(), - )); - } - let signing = account::resolve_local_signing_identity(config) - .map_err(ActorWriteBindingError::from_runtime)?; - let selected_pubkey = signing - .account - .record - .public_identity - .public_key_hex - .as_str(); - if !selected_pubkey.eq_ignore_ascii_case(seller_pubkey) { - return Err(ActorWriteBindingError::Account( - account::AccountRuntimeFailure::mismatch(format!( - "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order seller_pubkey `{seller_pubkey}`" - )), - )); - } - Ok(signing) -} - -fn resolve_local_order_revision_decision_signing_identity( - config: &RuntimeConfig, - buyer_pubkey: &str, - args: &OrderRevisionDecisionArgs, -) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> { - if !matches!(config.signer.backend, SignerBackend::Local) { - return Err(ActorWriteBindingError::Unconfigured(format!( - "order revision {} requires signer mode `local`", - args.decision.command() - ))); - } - let signing = account::resolve_local_signing_identity(config) - .map_err(ActorWriteBindingError::from_runtime)?; - let selected_pubkey = signing - .account - .record - .public_identity - .public_key_hex - .as_str(); - if !selected_pubkey.eq_ignore_ascii_case(buyer_pubkey) { - return Err(ActorWriteBindingError::Account( - account::AccountRuntimeFailure::mismatch(format!( - "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" - )), - )); - } - Ok(signing) -} - -fn parse_fulfillment_state(state: &str) -> Result<RadrootsOrderFulfillmentState, String> { - match state.trim() { - "accepted_not_fulfilled" => Ok(RadrootsOrderFulfillmentState::AcceptedNotFulfilled), - "preparing" => Ok(RadrootsOrderFulfillmentState::Preparing), - "ready_for_pickup" => Ok(RadrootsOrderFulfillmentState::ReadyForPickup), - "out_for_delivery" => Ok(RadrootsOrderFulfillmentState::OutForDelivery), - "delivered" => Ok(RadrootsOrderFulfillmentState::Delivered), - "seller_cancelled" => Ok(RadrootsOrderFulfillmentState::SellerCancelled), - other => Err(format!( - "unsupported fulfillment state `{other}`; expected preparing, ready_for_pickup, out_for_delivery, delivered, or seller_cancelled" - )), - } -} - -fn fulfillment_state_name(state: RadrootsOrderFulfillmentState) -> &'static str { - match state { - RadrootsOrderFulfillmentState::AcceptedNotFulfilled => "accepted_not_fulfilled", - RadrootsOrderFulfillmentState::Preparing => "preparing", - RadrootsOrderFulfillmentState::ReadyForPickup => "ready_for_pickup", - RadrootsOrderFulfillmentState::OutForDelivery => "out_for_delivery", - RadrootsOrderFulfillmentState::Delivered => "delivered", - RadrootsOrderFulfillmentState::SellerCancelled => "seller_cancelled", - } -} - -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> { - let contents = fs::read_to_string(path) - .map_err(|error| format!("read order draft {}: {error}", path.display()))?; - let document = toml::from_str::<OrderDraftDocument>(contents.as_str()) - .map_err(|error| format!("parse order draft {}: {error}", path.display()))?; - Ok(LoadedOrderDraft { - file: path.to_path_buf(), - updated_at_unix: modified_unix(path).unwrap_or_default(), - document, - }) -} - -fn save_draft(path: &Path, draft: &OrderDraftDocument) -> Result<(), RuntimeError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(path, scaffold_contents(draft)?)?; - Ok(()) -} - -fn scaffold_contents(draft: &OrderDraftDocument) -> Result<String, RuntimeError> { - let toml = toml::to_string_pretty(draft) - .map_err(|error| RuntimeError::Config(format!("render order draft: {error}")))?; - Ok(format!( - "# radroots order draft v1\n# fill listing_addr and any missing order items before submit\n\n{toml}" - )) -} - -fn drafts_dir(config: &RuntimeConfig) -> PathBuf { - config.paths.app_data_root.join(ORDERS_DIR) -} - -fn draft_lookup_path(config: &RuntimeConfig, lookup: &str) -> PathBuf { - let candidate = PathBuf::from(lookup); - if candidate.is_absolute() || lookup.contains(std::path::MAIN_SEPARATOR) { - return candidate; - } - let file_name = if lookup.ends_with(".toml") { - lookup.to_owned() - } else { - format!("{lookup}.toml") - }; - drafts_dir(config).join(file_name) -} - -#[derive(Debug, Clone)] -struct ParsedListingAddress { - kind: u32, - seller_pubkey: String, - listing_id: String, -} - -fn parse_listing_addr(raw: &str) -> Result<ParsedListingAddress, String> { - let parsed = RadrootsListingAddress::parse(raw).map_err(|error| error.to_string())?; - let (kind, rest) = parsed - .as_str() - .split_once(':') - .ok_or_else(|| "listing address has invalid format".to_owned())?; - let (seller_pubkey, listing_id) = rest - .split_once(':') - .ok_or_else(|| "listing address has invalid format".to_owned())?; - let kind = kind - .parse::<u32>() - .map_err(|_| "listing address kind is invalid".to_owned())?; - Ok(ParsedListingAddress { - kind, - seller_pubkey: seller_pubkey.to_owned(), - listing_id: listing_id.to_owned(), - }) -} - -fn issue(field: impl Into<String>, message: impl Into<String>) -> OrderIssueView { - let field = field.into(); - issue_with_code(validation_issue_code(&field), field, message) -} - -fn issue_with_code( - code: impl Into<String>, - field: impl Into<String>, - message: impl Into<String>, -) -> OrderIssueView { - OrderIssueView { - code: code.into(), - field: field.into(), - message: message.into(), - event_ids: Vec::new(), - } -} - -fn issue_with_events( - code: impl Into<String>, - field: impl Into<String>, - message: impl Into<String>, - event_ids: Vec<impl ToString>, -) -> OrderIssueView { - let mut event_ids = event_ids - .into_iter() - .map(|event_id| event_id.to_string()) - .collect::<Vec<_>>(); - event_ids.sort(); - event_ids.dedup(); - OrderIssueView { - code: code.into(), - field: field.into(), - message: message.into(), - event_ids, - } -} - -fn validation_issue_code(field: &str) -> String { - let mut code = String::new(); - let mut previous_separator = false; - for character in field.chars() { - if character.is_ascii_alphanumeric() { - code.push(character.to_ascii_lowercase()); - previous_separator = false; - } else if !previous_separator { - code.push('_'); - previous_separator = true; - } - } - let code = code.trim_matches('_'); - if code.is_empty() { - "validation_failed".to_owned() - } else { - format!("{code}_invalid") - } -} - -fn normalize_optional(value: Option<&str>) -> Option<String> { - let value = value?; - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_owned()) - } -} - -fn normalize_listing_relay_set<I, S>(values: I) -> Result<Vec<String>, String> -where - I: IntoIterator<Item = S>, - S: AsRef<str>, -{ - normalize_relay_urls(values).map_err(|error| error.to_string()) -} - -fn order_listing_relays(document: &OrderDraftDocument) -> Vec<String> { - normalize_listing_relay_set(document.order.listing_relays.iter()) - .unwrap_or_else(|_| document.order.listing_relays.clone()) -} - -fn non_empty_string(value: String) -> Option<String> { - if value.trim().is_empty() { - None - } else { - Some(value) - } -} - -fn non_empty_ref(value: &str) -> Option<&str> { - if value.trim().is_empty() { - None - } else { - Some(value) - } -} - -fn modified_unix(path: &Path) -> Option<u64> { - let modified = fs::metadata(path).ok()?.modified().ok()?; - modified - .duration_since(UNIX_EPOCH) - .ok() - .map(|value| value.as_secs()) -} - -fn now_unix() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|value| value.as_secs()) - .unwrap_or_default() -} - -fn next_order_id() -> String { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or_default(); - let counter = ORDER_COUNTER.fetch_add(1, Ordering::Relaxed) as u128; - format!( - "ord_{}", - encode_base64url_no_pad((nanos ^ counter).to_be_bytes()) - ) -} - -fn next_revision_id() -> String { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or_default(); - let counter = ORDER_COUNTER.fetch_add(1, Ordering::Relaxed) as u128; - format!( - "rev_{}", - encode_base64url_no_pad((nanos ^ counter).to_be_bytes()) - ) -} - -fn is_valid_order_id(value: &str) -> bool { - if let Some(encoded) = value.strip_prefix("ord_") { - return encoded.len() == 22 && is_d_tag_base64url(encoded); - } - is_canonical_uuid(value) -} - -fn is_canonical_uuid(value: &str) -> bool { - if value.len() != 36 { - return false; - } - for (index, character) in value.chars().enumerate() { - if matches!(index, 8 | 13 | 18 | 23) { - if character != '-' { - return false; - } - } else if !character.is_ascii_hexdigit() { - return false; - } - } - true -} - -fn is_valid_event_id(value: &str) -> bool { - value.len() == 64 && value.chars().all(|ch| ch.is_ascii_hexdigit()) -} - -fn encode_base64url_no_pad(bytes: [u8; 16]) -> String { - const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - let mut output = String::with_capacity(22); - let mut index = 0usize; - while index + 3 <= bytes.len() { - let block = ((bytes[index] as u32) << 16) - | ((bytes[index + 1] as u32) << 8) - | (bytes[index + 2] as u32); - output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); - output.push(ALPHABET[(block & 0x3f) as usize] as char); - index += 3; - } - let remaining = bytes.len() - index; - if remaining == 1 { - let block = (bytes[index] as u32) << 16; - output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); - } else if remaining == 2 { - let block = ((bytes[index] as u32) << 16) | ((bytes[index + 1] as u32) << 8); - output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); - } - output -} - -#[derive(Debug, Clone)] -struct OrderInspection { - state: String, - ready_for_submit: bool, - listing_addr: Option<String>, - listing_event_id: Option<String>, - seller_pubkey: Option<String>, - buyer_custody: Option<String>, - buyer_write_capable: Option<bool>, - issues: Vec<OrderIssueView>, -} - -impl From<OrderGetView> for OrderNewView { - fn from(view: OrderGetView) -> Self { - Self { - state: "draft_created".to_owned(), - source: view.source, - order_id: view.order_id.unwrap_or_default(), - file: view.file.unwrap_or_default(), - listing_lookup: view.listing_lookup, - listing_addr: view.listing_addr, - listing_event_id: view.listing_event_id, - listing_relays: view.listing_relays, - 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, - economics: view.economics, - issues: view.issues, - actions: view.actions, - } - } -} - -#[cfg(test)] -mod tests { - use std::path::{Path, PathBuf}; - - use radroots_core::{ - 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_REQUEST, - KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_ORDER_SETTLEMENT_DECISION, - }; - use radroots_events::order::{ - RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, - RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderEventType, - RadrootsOrderFulfillmentState, RadrootsOrderFulfillmentUpdate, - RadrootsOrderInventoryCommitment, RadrootsOrderItem, RadrootsOrderPaymentMethod, - RadrootsOrderPaymentRecord, RadrootsOrderPricingBasis, RadrootsOrderReceipt, - RadrootsOrderRequest, RadrootsOrderRevisionDecision, RadrootsOrderRevisionOutcome, - RadrootsOrderRevisionProposal, RadrootsOrderSettlementDecision, - RadrootsOrderSettlementOutcome, - }; - use radroots_events_codec::order::{ - order_cancellation_event_build, order_decision_event_build, order_decision_from_event, - order_event_context_from_tags, order_fulfillment_update_event_build, - order_payment_record_event_build, order_receipt_event_build, order_request_event_build, - order_revision_decision_event_build, order_revision_proposal_event_build, - order_settlement_decision_event_build, - }; - use radroots_identity::RadrootsIdentity; - use radroots_nostr::prelude::{radroots_event_from_nostr, radroots_nostr_build_event}; - use radroots_runtime_paths::RadrootsMigrationReport; - use radroots_sdk::{ - OrderPaymentHandoffKind, OrderPaymentStateKind, OrderSettlementStateKind, - OrderStatusEligibility, OrderStatusEvidenceSummary, OrderStatusKind, - OrderStatusNextActionKind, OrderStatusReceipt, OrderSubmitPlan, OrderWorkflowKind, - OrderWorkflowPlan, RadrootsSdkTimestamp, SdkOrderStatusIssue, SdkOrderStatusIssueKind, - SdkOrderStatusSource, - }; - use radroots_secret_vault::RadrootsSecretBackend; - use radroots_trade::order::{ - RadrootsListingInventoryAccountingInputs, RadrootsListingInventoryBinAvailability, - RadrootsOrderCancellationRecord, RadrootsOrderDecisionRecord, - RadrootsOrderFulfillmentRecord, RadrootsOrderReceiptRecord, - RadrootsOrderRevisionDecisionRecord, RadrootsOrderRevisionProposalRecord, - canonicalize_order_decision_for_signer, reduce_listing_inventory_accounting, - }; - use tempfile::tempdir; - - use super::{ - LoadedOrderDraft, ORDER_ACTOR_CONTEXT_NETWORK_ONLY, ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT, - ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT, ORDER_DRAFT_KIND, ORDER_SUBMIT_SOURCE, - OrderDraft, OrderDraftBuyerActor, OrderDraftDocument, OrderDraftItem, OrderStatusContext, - RelayDeliveryEvidence, ResolvedOrderEconomicsProduct, ResolvedOrderListing, - ResolvedSellerOrderRequest, SellerOrderRequestResolution, - accepted_order_decision_payload_from_request, active_request_record_from_resolved, - canonical_order_request_payload_from_loaded, collect_issues, - declined_order_decision_payload_from_request, inspect_document, - listing_provenance_relays_from_delivery_evidence, next_order_id, - order_accept_inventory_preflight_view_from_projection, order_cancellation_dry_run_view, - order_cancellation_event_parts, order_cancellation_payload_from_status, - order_cancellation_preflight_view_from_status, order_decision_dry_run_view, - 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_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_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, - order_revision_preflight_view_from_status, order_revision_proposals_from_events, - order_settlement_dry_run_view, order_settlement_event_parts, - order_settlement_payload_from_status, order_settlement_preflight_view_from_status, - 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_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, - OrderFulfillmentArgs, OrderPaymentArgs, OrderReceiptArgs, OrderRevisionDecisionArg, - OrderRevisionDecisionArgs, OrderRevisionProposeArgs, OrderSettlementArgs, - OrderSettlementDecisionArg, OrderSubmitArgs, - }; - use crate::runtime::account; - use crate::runtime::config::{ - AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, - LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, - PathsConfig, PublishConfig, PublishMode, PublishModeSource, RelayConfig, RelayConfigSource, - RelayPublishPolicy, RpcConfig, RuntimeConfig, SignerBackend, SignerConfig, Verbosity, - }; - use crate::runtime::direct_relay::DirectRelayFetchReceipt; - - fn test_order_id(value: &str) -> RadrootsOrderId { - value.parse().expect("valid order id") - } - - fn test_listing_addr(value: &str) -> RadrootsListingAddress { - value.parse().expect("valid listing address") - } - - fn test_inventory_bin_id(value: &str) -> RadrootsInventoryBinId { - value.parse().expect("valid inventory bin id") - } - - fn test_order_quote_id(value: &str) -> RadrootsOrderQuoteId { - value.parse().expect("valid order quote id") - } - - fn test_order_revision_id(value: &str) -> RadrootsOrderRevisionId { - value.parse().expect("valid order revision id") - } - - fn test_economics_digest(value: &str) -> RadrootsEconomicsDigest { - value.parse().expect("valid economics digest") - } - - fn test_event_id(value: &str) -> RadrootsEventId { - value.parse().expect("valid event id") - } - - fn test_event_id_char(value: char) -> RadrootsEventId { - 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"); - let expected_event_id = test_event_id_char('3'); - let workflow_kind = OrderWorkflowKind::Submit; - OrderSubmitPlan { - workflow: OrderWorkflowPlan { - kind: workflow_kind, - operation_kind: workflow_kind.operation_kind(), - contract_id: workflow_kind.contract_id(), - expected_event_id: expected_event_id.clone(), - created_at: RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), - }, - 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, - frozen_draft, - created_at: RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000), - } - } - - fn test_pubkey(value: &str) -> RadrootsPublicKey { - value.parse().expect("valid public key") - } - - #[test] - fn generated_order_id_uses_stable_prefix() { - let order_id = next_order_id(); - assert!(order_id.starts_with("ord_")); - assert_eq!(order_id.len(), 26); - } - - #[test] - fn order_draft_kind_constant_is_stable() { - let document = OrderDraftDocument { - version: 1, - kind: ORDER_DRAFT_KIND.to_owned(), - order: OrderDraft { - order_id: "ord_AAAAAAAAAAAAAAAAAAAAAg".to_owned(), - listing_addr: "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg".to_owned(), - listing_event_id: "1".repeat(64), - listing_relays: Vec::new(), - buyer_pubkey: "a".repeat(64), - seller_pubkey: "b".repeat(64), - items: vec![OrderDraftItem { - bin_id: "bin-1".to_owned(), - bin_count: 2, - }], - economics: Some(sample_order_economics( - "ord_AAAAAAAAAAAAAAAAAAAAAg", - "bin-1", - 2, - )), - }, - buyer_actor: OrderDraftBuyerActor { - account_id: "acct_demo".to_owned(), - pubkey: "a".repeat(64), - source: ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(), - }, - listing_lookup: Some("fresh-eggs".to_owned()), - }; - - let rendered = toml::to_string_pretty(&document).expect("render draft"); - assert!(rendered.contains("kind = \"order_draft_v1\"")); - assert!(rendered.contains("order_id = \"ord_AAAAAAAAAAAAAAAAAAAAAg\"")); - assert!(rendered.contains("listing_event_id")); - } - - #[test] - fn order_economics_applies_listing_discounts_and_basket_adjustments() { - let listing = ResolvedOrderListing { - listing_addr: "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".to_owned(), - listing_event_id: "1".repeat(64), - listing_relays: vec!["ws://relay.test".to_owned()], - seller_pubkey: "seller".to_owned(), - economics_product: Some(ResolvedOrderEconomicsProduct { - qty_amt_exact: Some("1".to_owned()), - qty_unit: "each".to_owned(), - price_amt_exact: Some("10".to_owned()), - price_currency: "USD".to_owned(), - price_qty_amt_exact: Some("1".to_owned()), - price_qty_unit: "each".to_owned(), - primary_bin_id: Some("bin-1".to_owned()), - verified_primary_bin_id: Some("bin-1".to_owned()), - notes: Some( - serde_json::json!({ - "listing_discounts": [{ - "scope": "bin", - "threshold": { - "kind": "bin_count", - "amount": { "bin_id": "bin-1", "min": 1 } - }, - "value": { - "kind": "percent", - "amount": { "value": "10" } - } - }] - }) - .to_string(), - ), - }), - }; - let items = vec![OrderDraftItem { - bin_id: "bin-1".to_owned(), - bin_count: 2, - }]; - let adjustments = vec![OrderDraftAdjustmentArgs { - id: "adj_delivery".to_owned(), - effect: "increase".to_owned(), - amount: "2".to_owned(), - currency: "USD".to_owned(), - reason: "delivery".to_owned(), - }]; - - let economics = order_economics_from_resolved_listing( - "ord_AAAAAAAAAAAAAAAAAAAAAg", - Some(&listing), - items.as_slice(), - adjustments.as_slice(), - ) - .expect("economics") - .expect("economics present"); - - assert_eq!( - economics.subtotal, - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(20), RadrootsCoreCurrency::USD) - ); - assert_eq!(economics.discounts.len(), 1); - assert_eq!( - economics.discounts[0].amount, - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(2), RadrootsCoreCurrency::USD) - ); - assert_eq!(economics.adjustments.len(), 1); - assert_eq!(economics.adjustments[0].id, "adj_delivery"); - assert_eq!( - economics.adjustments[0].amount, - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(2), RadrootsCoreCurrency::USD) - ); - assert_eq!( - economics.total, - RadrootsCoreMoney::new(RadrootsCoreDecimal::from(20), RadrootsCoreCurrency::USD) - ); - } - - #[test] - fn order_economics_uses_exact_listing_values_over_display_projection() { - let listing = ResolvedOrderListing { - listing_addr: "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".to_owned(), - listing_event_id: "1".repeat(64), - listing_relays: vec!["ws://relay.test".to_owned()], - seller_pubkey: "seller".to_owned(), - economics_product: Some(ResolvedOrderEconomicsProduct { - qty_amt_exact: Some("0.5".to_owned()), - qty_unit: "each".to_owned(), - price_amt_exact: Some("10.25".to_owned()), - price_currency: "USD".to_owned(), - price_qty_amt_exact: Some("1".to_owned()), - price_qty_unit: "each".to_owned(), - primary_bin_id: Some("bin-1".to_owned()), - verified_primary_bin_id: Some("bin-1".to_owned()), - notes: None, - }), - }; - let items = vec![OrderDraftItem { - bin_id: "bin-1".to_owned(), - bin_count: 2, - }]; - - let economics = order_economics_from_resolved_listing( - "ord_AAAAAAAAAAAAAAAAAAAAAg", - Some(&listing), - items.as_slice(), - &[], - ) - .expect("economics") - .expect("economics present"); - - assert_eq!( - economics.subtotal, - RadrootsCoreMoney::new( - "10.25".parse::<RadrootsCoreDecimal>().unwrap(), - RadrootsCoreCurrency::USD - ) - ); - } - - #[test] - fn order_economics_fails_when_exact_listing_source_is_missing() { - let listing = ResolvedOrderListing { - listing_addr: "30402:seller:AAAAAAAAAAAAAAAAAAAAAg".to_owned(), - listing_event_id: "1".repeat(64), - listing_relays: vec!["ws://relay.test".to_owned()], - seller_pubkey: "seller".to_owned(), - economics_product: Some(ResolvedOrderEconomicsProduct { - qty_amt_exact: None, - qty_unit: "kg".to_owned(), - price_amt_exact: Some("3.25".to_owned()), - price_currency: "USD".to_owned(), - price_qty_amt_exact: Some("1".to_owned()), - price_qty_unit: "kg".to_owned(), - primary_bin_id: Some("bin-a".to_owned()), - verified_primary_bin_id: Some("bin-a".to_owned()), - notes: None, - }), - }; - let items = vec![OrderDraftItem { - bin_id: "bin-a".to_owned(), - bin_count: 1, - }]; - - let error = order_economics_from_resolved_listing( - "ord_AAAAAAAAAAAAAAAAAAAAAg", - Some(&listing), - items.as_slice(), - &[], - ) - .expect_err("missing exact source should fail"); - - assert!(matches!( - error, - crate::runtime::RuntimeError::Config(message) - if message.contains("listing qty_amt_exact exact source is missing") - )); - } - - #[test] - fn order_draft_requires_listing_event_id_for_submit_readiness() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let buyer = account::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(), - order: OrderDraft { - order_id: "ord_AAAAAAAAAAAAAAAAAAAAAg".to_owned(), - listing_addr: "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg".to_owned(), - listing_event_id: String::new(), - listing_relays: Vec::new(), - buyer_pubkey: buyer_pubkey.clone(), - seller_pubkey: "deadbeef".to_owned(), - items: vec![OrderDraftItem { - bin_id: "bin-1".to_owned(), - bin_count: 2, - }], - economics: Some(sample_order_economics( - "ord_AAAAAAAAAAAAAAAAAAAAAg", - "bin-1", - 2, - )), - }, - buyer_actor: OrderDraftBuyerActor { - 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(&config, &document).expect("inspect order draft"); - assert_eq!(inspection.state, "draft"); - assert!(!inspection.ready_for_submit); - assert!( - collect_issues(&document) - .iter() - .any(|issue| issue.field == "order.listing_event_id") - ); - } - - #[test] - fn order_draft_requires_listing_relays_for_submit_readiness() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let buyer = account::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(), - order: OrderDraft { - order_id: "ord_AAAAAAAAAAAAAAAAAAAAAg".to_owned(), - listing_addr: "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg".to_owned(), - listing_event_id: "1".repeat(64), - listing_relays: Vec::new(), - buyer_pubkey: buyer_pubkey.clone(), - seller_pubkey: "deadbeef".to_owned(), - items: vec![OrderDraftItem { - bin_id: "bin-1".to_owned(), - bin_count: 2, - }], - economics: Some(sample_order_economics( - "ord_AAAAAAAAAAAAAAAAAAAAAg", - "bin-1", - 2, - )), - }, - buyer_actor: OrderDraftBuyerActor { - 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(&config, &document).expect("inspect order draft"); - - assert_eq!(inspection.state, "draft"); - assert!(!inspection.ready_for_submit); - assert!( - collect_issues(&document) - .iter() - .any(|issue| issue.code == "listing_provenance_missing" - && issue.field == "order.listing_relays") - ); - } - - #[test] - fn order_request_event_decodes_to_history_entry() { - let buyer = RadrootsIdentity::generate(); - let seller = RadrootsIdentity::generate(); - let buyer_pubkey = buyer.public_key_hex(); - let seller_pubkey = seller.public_key_hex(); - let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); - let listing_event_id = "1".repeat(64); - let payload = RadrootsOrderRequest { - order_id: test_order_id("ord_AAAAAAAAAAAAAAAAAAAAAg"), - listing_addr: test_listing_addr(listing_addr.as_str()), - buyer_pubkey: test_pubkey(buyer_pubkey.as_str()), - seller_pubkey: test_pubkey(seller_pubkey.as_str()), - items: vec![RadrootsOrderItem { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - economics: sample_order_economics("ord_AAAAAAAAAAAAAAAAAAAAAg", "bin-1", 2), - }; - let parts = order_request_event_build( - &RadrootsNostrEventPtr { - id: listing_event_id.clone(), - relays: None, - }, - &payload, - ) - .expect("order request parts"); - let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .expect("nostr event builder") - .sign_with_keys(buyer.keys()) - .expect("signed order request"); - - let entry = order_event_list_entry_from_event(&event, seller_pubkey.as_str()) - .expect("history entry"); - - assert_eq!(entry.id, "ord_AAAAAAAAAAAAAAAAAAAAAg"); - assert_eq!(entry.state, "requested"); - assert_eq!(entry.event_kind, Some(3422)); - assert_eq!(entry.listing_addr.as_deref(), Some(listing_addr.as_str())); - assert_eq!( - entry.listing_event_id.as_deref(), - Some(listing_event_id.as_str()) - ); - assert_eq!(entry.buyer_pubkey.as_deref(), Some(buyer_pubkey.as_str())); - assert_eq!(entry.seller_pubkey.as_deref(), Some(seller_pubkey.as_str())); - assert_eq!(entry.item_count, Some(1)); - } - - #[test] - fn order_request_filter_includes_order_id_d_tag_when_provided() { - let filter = order_request_filter("a", Some("ord_AAAAAAAAAAAAAAAAAAAAAg")) - .expect("order request filter"); - let value = serde_json::to_value(filter).expect("filter json"); - - assert_eq!(value["kinds"][0], 3422); - assert_eq!(value["#p"][0], "a"); - assert_eq!(value["#d"][0], "ord_AAAAAAAAAAAAAAAAAAAAAg"); - } - - #[test] - fn order_status_filter_includes_only_initial_active_lifecycle_kinds() { - let filter = order_status_filter("ord_AAAAAAAAAAAAAAAAAAAAAg").expect("status filter"); - let value = serde_json::to_value(filter).expect("filter json"); - let kinds = value["kinds"].as_array().expect("kinds array"); - - assert!(kinds.contains(&serde_json::json!(3422))); - assert!(kinds.contains(&serde_json::json!(3423))); - assert!(kinds.contains(&serde_json::json!(3424))); - assert!(kinds.contains(&serde_json::json!(3425))); - assert!(kinds.contains(&serde_json::json!(3433))); - assert!(kinds.contains(&serde_json::json!(3432))); - assert!(kinds.contains(&serde_json::json!(3434))); - assert!(!kinds.contains(&serde_json::json!(3435))); - assert!(!kinds.contains(&serde_json::json!(3436))); - assert_eq!(value["#d"][0], "ord_AAAAAAAAAAAAAAAAAAAAAg"); - } - - #[test] - fn order_revision_payload_updates_items_and_economics() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event.clone()], - }, - ); - let args = revision_args_for_fixture(&fixture, 3); - - let payload = - order_revision_payload_from_status(&args, &status_view).expect("revision payload"); - let parts = - order_revision_event_parts(&status_view, &payload).expect("revision event parts"); - let context = order_event_context_from_tags( - RadrootsOrderEventType::OrderRevisionProposed, - &parts.tags, - ) - .expect("revision context"); - let request_event_id = fixture.request_event.id.to_string(); - let decision_event_id = decision_event.id.to_string(); - - assert_eq!(payload.items[0].bin_id, "bin-1"); - assert_eq!(payload.items[0].bin_count, 3); - assert_eq!(payload.economics.items[0].bin_count, 3); - assert_eq!(payload.economics.quote_version, 2); - assert!(payload.economics.quote_id.starts_with("revision_rev_")); - assert_eq!(payload.reason, "update count"); - assert_eq!(parts.kind, KIND_ORDER_REVISION_PROPOSAL); - assert_eq!( - context.root_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!( - context.prev_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - } - - #[test] - fn order_revision_decision_payload_uses_pending_proposal_chain() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let revision_event = signed_order_revision_proposal_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - 3, - ); - let revision_event_id = revision_event.id.to_string(); - let candidates = - order_revision_proposals_from_events(fixture.order_id.as_str(), &[revision_event]); - let proposal = candidates.records.first().expect("revision proposal"); - let args = revision_decision_args_for_fixture( - &fixture, - proposal.payload.revision_id.as_str(), - OrderRevisionDecisionArg::Accept, - ); - - let payload = order_revision_decision_payload_from_proposal(&args, proposal) - .expect("revision decision payload"); - let parts = - order_revision_decision_event_parts(&payload).expect("revision decision event parts"); - let context = order_event_context_from_tags( - RadrootsOrderEventType::OrderRevisionDecision, - &parts.tags, - ) - .expect("revision decision context"); - - assert_eq!(payload.revision_id, proposal.payload.revision_id); - assert_eq!(payload.prev_event_id, revision_event_id); - assert_eq!(parts.kind, KIND_ORDER_REVISION_DECISION); - let request_event_id = fixture.request_event.id.to_string(); - assert_eq!( - context.root_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!( - context.prev_event_id.as_deref(), - Some(revision_event_id.as_str()) - ); - } - - #[test] - fn order_revision_decision_preflight_allows_selected_buyer_pending_proposal() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let revision_event = signed_order_revision_proposal_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - 3, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - revision_event.clone(), - ], - }, - ); - let candidates = - order_revision_proposals_from_events(fixture.order_id.as_str(), &[revision_event]); - let args = revision_decision_args_for_fixture( - &fixture, - "rev_test", - OrderRevisionDecisionArg::Accept, - ); - - let view = order_revision_decision_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.buyer_pubkey.as_str(), - &candidates, - ); - - assert!(view.is_none()); - } - - #[test] - fn order_revision_decision_preflight_rejects_selected_non_buyer_account() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let revision_event = signed_order_revision_proposal_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - 3, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - revision_event.clone(), - ], - }, - ); - let candidates = - order_revision_proposals_from_events(fixture.order_id.as_str(), &[revision_event]); - let args = revision_decision_args_for_fixture( - &fixture, - "rev_test", - OrderRevisionDecisionArg::Accept, - ); - - let view = order_revision_decision_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.seller_pubkey.as_str(), - &candidates, - ) - .expect("non buyer revision decision preflight"); - - assert_eq!(view.state, "invalid"); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("selected account is not buyer") - ); - } - - #[test] - fn order_status_from_receipt_applies_accepted_revision_decision() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let revision_event = signed_order_revision_proposal_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - 3, - ); - let revision_decision_event = signed_order_revision_decision_event( - &fixture.buyer, - &revision_event, - RadrootsOrderRevisionOutcome::Accepted, - ); - let revision_decision_event_id = revision_decision_event.id.to_string(); - - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - revision_event, - revision_decision_event, - ], - }, - ); - - assert_eq!(status_view.state, "accepted"); - assert_eq!( - status_view.last_event_id.as_deref(), - Some(revision_decision_event_id.as_str()) - ); - assert_eq!( - status_view.agreement_event_id.as_deref(), - Some(revision_decision_event_id.as_str()) - ); - assert_eq!( - status_view - .economics - .as_ref() - .expect("current economics") - .items[0] - .bin_count, - 3 - ); - } - - #[test] - fn order_status_from_receipt_preserves_agreement_after_declined_revision_decision() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let decision_event_id = decision_event.id.to_string(); - let revision_event = signed_order_revision_proposal_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - 3, - ); - let revision_decision_event = signed_order_revision_decision_event( - &fixture.buyer, - &revision_event, - RadrootsOrderRevisionOutcome::Declined { - reason: "keep original order".to_owned(), - }, - ); - let revision_decision_event_id = revision_decision_event.id.to_string(); - - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - revision_event, - revision_decision_event, - ], - }, - ); - - assert_eq!(status_view.state, "accepted"); - assert_eq!( - status_view.last_event_id.as_deref(), - Some(revision_decision_event_id.as_str()) - ); - assert_eq!( - status_view.agreement_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert_eq!( - status_view - .economics - .as_ref() - .expect("current economics") - .items[0] - .bin_count, - 2 - ); - } - - #[test] - fn order_revision_preflight_rejects_selected_non_seller_account() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event], - }, - ); - let args = revision_args_for_fixture(&fixture, 3); - let candidates = order_revision_proposals_from_events(fixture.order_id.as_str(), &[]); - - let view = order_revision_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.buyer_pubkey.as_str(), - &candidates, - ) - .expect("non seller revision preflight"); - - assert_eq!(view.state, "invalid"); - assert!(view.event_id.is_none()); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("selected account is not seller") - ); - } - - #[test] - fn order_revision_preflight_ignores_deferred_payment_events() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let payment_event = signed_payment_recorded_event( - &fixture.buyer, - &fixture.request_event, - &decision_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event, payment_event], - }, - ); - let args = revision_args_for_fixture(&fixture, 3); - let candidates = order_revision_proposals_from_events(fixture.order_id.as_str(), &[]); - - let view = order_revision_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.seller_pubkey.as_str(), - &candidates, - ); - - assert!(view.is_none()); - assert_eq!(status_view.fetched_count, 3); - assert_eq!(status_view.decoded_count, 2); - assert_eq!(status_view.skipped_count, 1); - assert!(status_view.payment.is_none()); - } - - #[test] - fn order_revision_preflight_rejects_declined_order() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Declined { - reason: "out of stock".to_owned(), - }, - ); - let decision_event_id = decision_event.id.to_string(); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event], - }, - ); - let args = revision_args_for_fixture(&fixture, 3); - let candidates = order_revision_proposals_from_events(fixture.order_id.as_str(), &[]); - - let view = order_revision_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.seller_pubkey.as_str(), - &candidates, - ) - .expect("declined revision proposal preflight"); - - assert_eq!(view.state, "declined"); - assert_eq!( - view.disposition(), - crate::view::runtime::CommandDisposition::ValidationFailed - ); - assert_eq!( - view.decision_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert!(view.event_id.is_none()); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("was declined") - ); - } - - #[test] - fn order_revision_preflight_rejects_pending_revision_candidate() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let revision_event = signed_order_revision_proposal_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - 3, - ); - let revision_event_id = revision_event.id.to_string(); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - revision_event.clone(), - ], - }, - ); - let args = revision_args_for_fixture(&fixture, 1); - let candidates = - order_revision_proposals_from_events(fixture.order_id.as_str(), &[revision_event]); - - let view = order_revision_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.seller_pubkey.as_str(), - &candidates, - ) - .expect("pending revision preflight"); - - assert_eq!(view.state, "forked"); - assert_eq!(view.event_id.as_deref(), Some(revision_event_id.as_str())); - assert_eq!(view.event_kind, Some(KIND_ORDER_REVISION_PROPOSAL)); - assert_eq!(view.issues.len(), 1); - assert_eq!(view.issues[0].code, "pending_revision_exists"); - assert_eq!(view.issues[0].event_ids, vec![revision_event_id]); - } - - #[test] - fn order_revision_inventory_preflight_rejects_unavailable_increase() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event], - }, - ); - let args = revision_args_for_fixture(&fixture, 3); - let payload = - order_revision_payload_from_status(&args, &status_view).expect("revision payload"); - - let view = order_revision_inventory_preflight_view(&config, &args, &status_view, &payload) - .expect("unavailable inventory preflight"); - - assert_eq!(view.state, "invalid"); - assert_eq!( - view.revision_id.as_deref(), - Some(payload.revision_id.as_str()) - ); - assert!( - view.issues - .iter() - .any(|issue| issue.code == "revision_inventory_unavailable") - ); - assert!(view.event_id.is_none()); - } - - #[test] - fn order_submit_existing_request_preflight_deduplicates_identical_request() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let loaded = loaded_order_draft_for_fixture(&fixture); - let payload = - canonical_order_request_payload_from_loaded(&loaded, fixture.buyer_pubkey.as_str()) - .expect("canonical order request payload"); - let event_id = fixture.request_event.id.to_string(); - let args = OrderSubmitArgs { - key: fixture.order_id.clone(), - idempotency_key: Some("idem-submit".to_owned()), - }; - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone()], - }; - - let view = order_submit_existing_request_view_from_receipt( - &config, &loaded, &args, &payload, receipt, - ) - .expect("submit existing request preflight") - .expect("deduplicated view"); - - assert_eq!(view.state, "submitted"); - assert_eq!(view.deduplicated, true); - assert_eq!(view.event_id.as_deref(), Some(event_id.as_str())); - assert_eq!(view.event_kind, Some(3422)); - assert_eq!(view.target_relays, vec!["ws://relay.test"]); - assert_eq!(view.acknowledged_relays, vec!["ws://relay.test"]); - assert_eq!(view.idempotency_key.as_deref(), Some("idem-submit")); - } - - #[test] - fn order_submit_dry_run_view_preserves_preflighted_no_publish_fields() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.output.dry_run = true; - 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, - 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.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()); - assert_eq!(view.signer_mode.as_deref(), Some("local")); - assert_eq!(view.idempotency_key.as_deref(), Some("idem-dry-submit")); - assert_eq!(view.listing_relays, vec!["ws://relay.test"]); - } - - #[test] - fn order_submit_listing_provenance_preflight_rejects_disjoint_relay_targets() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.output.dry_run = true; - config.relay.urls = vec!["ws://relay-b.test".to_owned()]; - let fixture = order_status_fixture(); - let mut loaded = loaded_order_draft_for_fixture(&fixture); - loaded.document.order.listing_relays = vec!["ws://relay-a.test".to_owned()]; - let args = OrderSubmitArgs { - key: fixture.order_id.clone(), - idempotency_key: Some("idem-provenance".to_owned()), - }; - - let view = order_submit_listing_provenance_preflight_view(&config, &loaded, &args) - .expect("provenance preflight") - .expect("preflight rejection"); - - assert_eq!(view.state, "unconfigured"); - assert_eq!(view.target_relays, vec!["ws://relay-b.test"]); - assert_eq!(view.listing_relays, vec!["ws://relay-a.test"]); - assert_eq!(view.event_kind, Some(3422)); - assert_eq!(view.issues[0].code, "listing_relay_target_mismatch"); - assert!(view.event_id.is_none()); - assert!(view.connected_relays.is_empty()); - assert!(view.acknowledged_relays.is_empty()); - } - - #[test] - fn order_submit_listing_provenance_preflight_accepts_matching_relay_target() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec![ - "ws://relay-b.test".to_owned(), - "ws://relay-a.test".to_owned(), - ]; - let fixture = order_status_fixture(); - let mut loaded = loaded_order_draft_for_fixture(&fixture); - loaded.document.order.listing_relays = vec!["ws://relay-a.test".to_owned()]; - let args = OrderSubmitArgs { - key: fixture.order_id.clone(), - idempotency_key: None, - }; - - let view = order_submit_listing_provenance_preflight_view(&config, &loaded, &args) - .expect("provenance preflight"); - - assert!(view.is_none()); - } - - #[test] - 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-a.test".to_owned()]; - let args = OrderSubmitArgs { - key: fixture.order_id.clone(), - idempotency_key: None, - }; - - 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_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())); - } - - #[test] - fn listing_provenance_relays_use_observed_relays_without_acknowledgement() { - let evidence = RelayDeliveryEvidence::observed( - ["ws://target.test"], - ["ws://connected.test"], - ["ws://observed.test"], - Vec::new(), - ) - .expect("observed evidence"); - - let relays = - listing_provenance_relays_from_delivery_evidence(evidence).expect("listing relays"); - - assert_eq!(relays, vec!["ws://observed.test"]); - } - - #[test] - fn listing_provenance_relays_ignore_connected_relays_when_observed_relay_is_unknown() { - let evidence = RelayDeliveryEvidence::observed( - ["ws://target.test"], - ["ws://connected.test"], - Vec::<String>::new(), - Vec::new(), - ) - .expect("observed evidence"); - - let relays = - listing_provenance_relays_from_delivery_evidence(evidence).expect("listing relays"); - - assert!(relays.is_empty()); - } - - #[test] - fn order_submit_dry_run_deduplicates_identical_visible_request() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.output.dry_run = true; - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let loaded = loaded_order_draft_for_fixture(&fixture); - let payload = - canonical_order_request_payload_from_loaded(&loaded, fixture.buyer_pubkey.as_str()) - .expect("canonical order request payload"); - let event_id = fixture.request_event.id.to_string(); - let args = OrderSubmitArgs { - key: fixture.order_id.clone(), - idempotency_key: Some("idem-dry-dedupe".to_owned()), - }; - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone()], - }; - - let view = order_submit_existing_request_view_from_receipt( - &config, &loaded, &args, &payload, receipt, - ) - .expect("submit existing request preflight") - .expect("deduplicated view"); - - assert_eq!(view.state, "submitted"); - assert_eq!(view.dry_run, true); - assert_eq!(view.deduplicated, true); - assert_eq!(view.event_id.as_deref(), Some(event_id.as_str())); - assert_eq!(view.event_kind, Some(3422)); - assert_eq!(view.acknowledged_relays, vec!["ws://relay.test"]); - assert_eq!(view.idempotency_key.as_deref(), Some("idem-dry-dedupe")); - } - - #[test] - fn order_submit_existing_request_preflight_rejects_changed_request() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let loaded = loaded_order_draft_for_fixture(&fixture); - let payload = - canonical_order_request_payload_from_loaded(&loaded, fixture.buyer_pubkey.as_str()) - .expect("canonical order request payload"); - let changed_event = signed_order_request_event( - &fixture.buyer, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - "2".repeat(64).as_str(), - ); - let changed_event_id = changed_event.id.to_string(); - let args = OrderSubmitArgs { - key: fixture.order_id.clone(), - idempotency_key: None, - }; - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![changed_event], - }; - - let view = order_submit_existing_request_view_from_receipt( - &config, &loaded, &args, &payload, receipt, - ) - .expect("submit existing request preflight") - .expect("invalid view"); - - assert_eq!(view.state, "invalid"); - assert_eq!(view.deduplicated, false); - assert_eq!(view.issues.len(), 1); - assert_eq!(view.issues[0].code, "existing_request_conflict"); - assert_eq!(view.issues[0].event_ids, vec![changed_event_id]); - assert_eq!( - view.actions, - vec![format!("radroots order status get {}", fixture.order_id)] - ); - } - - #[test] - fn order_submit_existing_request_preflight_rejects_changed_economics() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let loaded = loaded_order_draft_for_fixture(&fixture); - let payload = - canonical_order_request_payload_from_loaded(&loaded, fixture.buyer_pubkey.as_str()) - .expect("canonical order request payload"); - let changed_event = signed_order_request_event_with_economics( - &fixture.buyer, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - fixture.listing_event_id.as_str(), - sample_order_economics_with_unit_price(fixture.order_id.as_str(), "bin-1", 2, 7), - ); - let changed_event_id = changed_event.id.to_string(); - let args = OrderSubmitArgs { - key: fixture.order_id.clone(), - idempotency_key: None, - }; - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![changed_event], - }; - - let view = order_submit_existing_request_view_from_receipt( - &config, &loaded, &args, &payload, receipt, - ) - .expect("submit existing request preflight") - .expect("invalid view"); - - assert_eq!(view.state, "invalid"); - assert_eq!(view.deduplicated, false); - assert_eq!(view.issues.len(), 1); - assert_eq!(view.issues[0].code, "existing_request_conflict"); - assert_eq!(view.issues[0].event_ids, vec![changed_event_id]); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("conflicts with the local order draft") - ); - } - - #[test] - fn order_event_list_counts_decoded_before_order_id_narrowing() { - let seller = RadrootsIdentity::generate(); - let other_seller = RadrootsIdentity::generate(); - let buyer = RadrootsIdentity::generate(); - let seller_pubkey = seller.public_key_hex(); - let other_seller_pubkey = other_seller.public_key_hex(); - let buyer_pubkey = buyer.public_key_hex(); - let first_order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg"; - let second_order_id = "ord_AAAAAAAAAAAAAAAAAAAAAw"; - let first_listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); - let second_listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAw"); - let other_listing_addr = format!("30402:{other_seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); - let listing_event_id = "1".repeat(64); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - signed_order_request_event( - &buyer, - first_order_id, - first_listing_addr.as_str(), - buyer_pubkey.as_str(), - seller_pubkey.as_str(), - listing_event_id.as_str(), - ), - signed_order_request_event( - &buyer, - second_order_id, - second_listing_addr.as_str(), - buyer_pubkey.as_str(), - seller_pubkey.as_str(), - listing_event_id.as_str(), - ), - signed_order_request_event( - &buyer, - "ord_AAAAAAAAAAAAAAAAAAAABA", - other_listing_addr.as_str(), - buyer_pubkey.as_str(), - other_seller_pubkey.as_str(), - listing_event_id.as_str(), - ), - ], - }; - - let event_list = order_event_list_from_receipt( - seller_pubkey, - Some(first_order_id), - ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT, - receipt, - ); - - assert_eq!(event_list.fetched_count, 3); - assert_eq!(event_list.decoded_count, 2); - assert_eq!(event_list.skipped_count, 1); - assert_eq!(event_list.count, 1); - assert_eq!(event_list.orders[0].id, first_order_id); - } - - #[test] - fn seller_order_request_resolution_matches_selected_seller_order() { - let seller = RadrootsIdentity::generate(); - let buyer = RadrootsIdentity::generate(); - let seller_pubkey = seller.public_key_hex(); - let buyer_pubkey = buyer.public_key_hex(); - let order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg"; - let listing_event_id = "1".repeat(64); - let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); - let event = signed_order_request_event( - &buyer, - order_id, - listing_addr.as_str(), - buyer_pubkey.as_str(), - seller_pubkey.as_str(), - listing_event_id.as_str(), - ); - let event_id = event.id.to_string(); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![event], - }; - - let resolution = - seller_order_request_resolution_from_receipt(seller_pubkey.as_str(), order_id, receipt) - .expect("seller order request resolution"); - - assert_eq!(resolution.fetched_count, 1); - assert_eq!(resolution.decoded_count, 1); - assert_eq!(resolution.skipped_count, 0); - assert_eq!(resolution.requests.len(), 1); - assert_eq!(resolution.requests[0].request_event_id, event_id); - assert_eq!(resolution.requests[0].order_id, order_id); - assert_eq!( - resolution.requests[0].listing_event_id.as_deref(), - Some(listing_event_id.as_str()) - ); - assert_eq!(resolution.requests[0].listing_addr, listing_addr); - assert_eq!(resolution.requests[0].buyer_pubkey, buyer_pubkey); - assert_eq!(resolution.requests[0].seller_pubkey, seller_pubkey); - assert_eq!(resolution.requests[0].items.len(), 1); - } - - #[test] - fn accepted_order_decision_payload_derives_inventory_commitments() { - let seller = RadrootsIdentity::generate(); - let buyer = RadrootsIdentity::generate(); - let seller_pubkey = seller.public_key_hex(); - let buyer_pubkey = buyer.public_key_hex(); - let order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg"; - let listing_event_id = "1".repeat(64); - let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![signed_order_request_event( - &buyer, - order_id, - listing_addr.as_str(), - buyer_pubkey.as_str(), - seller_pubkey.as_str(), - listing_event_id.as_str(), - )], - }; - let resolution = - seller_order_request_resolution_from_receipt(seller_pubkey.as_str(), order_id, receipt) - .expect("seller order request resolution"); - let request = resolution - .requests - .first() - .expect("resolved request") - .clone(); - - let payload = accepted_order_decision_payload_from_request(&request); - - assert_eq!(payload.order_id, order_id); - assert_eq!(payload.listing_addr, listing_addr); - assert_eq!(payload.buyer_pubkey, buyer_pubkey); - assert_eq!(payload.seller_pubkey, seller_pubkey); - let RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments, - } = payload.decision - else { - panic!("expected accepted decision"); - }; - assert_eq!(inventory_commitments.len(), 1); - assert_eq!(inventory_commitments[0].bin_id, "bin-1"); - assert_eq!(inventory_commitments[0].bin_count, 2); - } - - #[test] - fn accepted_order_decision_event_uses_request_chain_tags() { - let seller = RadrootsIdentity::generate(); - let buyer = RadrootsIdentity::generate(); - let seller_pubkey = seller.public_key_hex(); - let buyer_pubkey = buyer.public_key_hex(); - let order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg"; - let listing_event_id = "1".repeat(64); - let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![signed_order_request_event( - &buyer, - order_id, - listing_addr.as_str(), - buyer_pubkey.as_str(), - seller_pubkey.as_str(), - listing_event_id.as_str(), - )], - }; - let resolution = - seller_order_request_resolution_from_receipt(seller_pubkey.as_str(), order_id, receipt) - .expect("seller order request resolution"); - let request = resolution - .requests - .first() - .expect("resolved request") - .clone(); - let payload = accepted_order_decision_payload_from_request(&request); - let payload = canonicalize_order_decision_for_signer(payload, seller_pubkey.as_str()) - .expect("canonical decision payload"); - let parts = order_decision_event_build( - &request.request_event_id, - &request.request_event_id, - &payload, - ) - .expect("decision event parts"); - - assert_eq!(parts.kind, KIND_ORDER_DECISION); - let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .expect("nostr event builder") - .sign_with_keys(seller.keys()) - .expect("signed order decision"); - let event = radroots_event_from_nostr(&event); - let envelope = order_decision_from_event(&event).expect("decoded decision event"); - let context = - order_event_context_from_tags(RadrootsOrderEventType::OrderDecision, &event.tags) - .expect("decision event context"); - - assert_eq!(envelope.order_id, order_id); - assert_eq!(envelope.payload.seller_pubkey, seller_pubkey); - assert_eq!(envelope.payload.buyer_pubkey, buyer_pubkey); - assert_eq!( - context.root_event_id.as_deref(), - Some(request.request_event_id.as_str()) - ); - assert_eq!( - context.prev_event_id.as_deref(), - Some(request.request_event_id.as_str()) - ); - } - - #[test] - fn declined_order_decision_payload_uses_decline_reason() { - let seller = RadrootsIdentity::generate(); - let buyer = RadrootsIdentity::generate(); - let seller_pubkey = seller.public_key_hex(); - let buyer_pubkey = buyer.public_key_hex(); - let order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg"; - let listing_event_id = "1".repeat(64); - let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![signed_order_request_event( - &buyer, - order_id, - listing_addr.as_str(), - buyer_pubkey.as_str(), - seller_pubkey.as_str(), - listing_event_id.as_str(), - )], - }; - let resolution = - seller_order_request_resolution_from_receipt(seller_pubkey.as_str(), order_id, receipt) - .expect("seller order request resolution"); - let request = resolution - .requests - .first() - .expect("resolved request") - .clone(); - - let payload = declined_order_decision_payload_from_request(&request, "out of stock"); - - assert_eq!(payload.order_id, order_id); - assert_eq!(payload.listing_addr, listing_addr); - assert_eq!(payload.buyer_pubkey, buyer_pubkey); - assert_eq!(payload.seller_pubkey, seller_pubkey); - let RadrootsOrderDecisionOutcome::Declined { reason } = payload.decision else { - panic!("expected declined decision"); - }; - assert_eq!(reason, "out of stock"); - } - - #[test] - fn declined_order_decision_event_uses_request_chain_tags() { - let seller = RadrootsIdentity::generate(); - let buyer = RadrootsIdentity::generate(); - let seller_pubkey = seller.public_key_hex(); - let buyer_pubkey = buyer.public_key_hex(); - let order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg"; - let listing_event_id = "1".repeat(64); - let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![signed_order_request_event( - &buyer, - order_id, - listing_addr.as_str(), - buyer_pubkey.as_str(), - seller_pubkey.as_str(), - listing_event_id.as_str(), - )], - }; - let resolution = - seller_order_request_resolution_from_receipt(seller_pubkey.as_str(), order_id, receipt) - .expect("seller order request resolution"); - let request = resolution - .requests - .first() - .expect("resolved request") - .clone(); - let payload = declined_order_decision_payload_from_request(&request, " out of stock "); - let payload = canonicalize_order_decision_for_signer(payload, seller_pubkey.as_str()) - .expect("canonical decision payload"); - let parts = order_decision_event_build( - &request.request_event_id, - &request.request_event_id, - &payload, - ) - .expect("decision event parts"); - - assert_eq!(parts.kind, KIND_ORDER_DECISION); - let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .expect("nostr event builder") - .sign_with_keys(seller.keys()) - .expect("signed order decision"); - let event = radroots_event_from_nostr(&event); - let envelope = order_decision_from_event(&event).expect("decoded decision event"); - let context = - order_event_context_from_tags(RadrootsOrderEventType::OrderDecision, &event.tags) - .expect("decision event context"); - - assert_eq!(envelope.order_id, order_id); - assert_eq!(envelope.payload.seller_pubkey, seller_pubkey); - assert_eq!(envelope.payload.buyer_pubkey, buyer_pubkey); - let RadrootsOrderDecisionOutcome::Declined { reason } = envelope.payload.decision else { - panic!("expected declined decision"); - }; - assert_eq!(reason, "out of stock"); - assert_eq!( - context.root_event_id.as_deref(), - Some(request.request_event_id.as_str()) - ); - assert_eq!( - context.prev_event_id.as_deref(), - Some(request.request_event_id.as_str()) - ); - } - - #[test] - fn order_status_from_receipt_reports_missing() { - let order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg"; - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: Vec::new(), - }; - - let view = order_status_from_receipt(order_id, receipt); - - assert_eq!(view.state, "missing"); - assert_eq!(view.order_id, order_id); - assert_eq!(view.fetched_count, 0); - assert_eq!(view.decoded_count, 0); - assert_eq!(view.skipped_count, 0); - assert!(view.request_event_id.is_none()); - assert!(view.economics.is_none()); - assert!(view.fulfillment.is_none()); - assert!(view.sdk_receipt.is_none()); - assert!(view.reducer_issues.is_empty()); - } - - #[test] - fn sdk_order_status_view_reports_found_local_projection() { - let request_event_id = test_event_id_char('1'); - let decision_event_id = test_event_id_char('2'); - let receipt = OrderStatusReceipt { - order_id: test_order_id("ord_AAAAAAAAAAAAAAAAAAAAAg"), - source: SdkOrderStatusSource::LocalEventStore, - found: true, - event_count: 2, - limit_applied: 500, - status: OrderStatusKind::Accepted, - fulfillment_status: None, - payment_state: OrderPaymentStateKind::NotRecorded, - settlement_state: OrderSettlementStateKind::NotRequired, - lifecycle_terminal: false, - evidence: OrderStatusEvidenceSummary { - event_count: 2, - limit_applied: 500, - has_request: true, - has_decision: true, - has_agreement: true, - has_pending_revision: false, - has_fulfillment: false, - has_cancellation: false, - has_receipt: false, - has_issues: false, - }, - eligibility: OrderStatusEligibility { - can_decide: false, - can_propose_revision: true, - can_decide_revision: false, - can_cancel: true, - can_update_fulfillment: true, - can_record_receipt: false, - }, - payment_handoff: OrderPaymentHandoffKind::InPersonOrOffPlatformPending, - next_action: OrderStatusNextActionKind::ArrangeInPersonOrOffPlatformPayment, - event_ids: vec![request_event_id.clone(), decision_event_id.clone()], - request_event_id: Some(request_event_id.clone()), - decision_event_id: Some(decision_event_id.clone()), - agreement_event_id: Some(decision_event_id.clone()), - pending_revision_event_id: None, - fulfillment_event_id: None, - cancellation_event_id: None, - receipt_event_id: None, - last_event_id: Some(decision_event_id.clone()), - issues: Vec::new(), - }; - - let view = sdk_order_status_view(receipt); - - assert_eq!(view.state, "accepted"); - assert_eq!(view.source, "SDK local order projection"); - assert_eq!(view.actor_context_source, "sdk_local_projection"); - assert_eq!( - view.request_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!( - view.decision_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert_eq!( - view.agreement_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert_eq!( - view.last_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert_eq!(view.fetched_count, 0); - assert_eq!(view.decoded_count, 2); - assert_eq!(view.skipped_count, 0); - assert!(view.target_relays.is_empty()); - assert!(view.connected_relays.is_empty()); - assert!(view.failed_relays.is_empty()); - assert!(view.reducer_issues.is_empty()); - let sdk_receipt = view.sdk_receipt.as_ref().expect("sdk receipt"); - assert_eq!( - sdk_receipt.payment_handoff, - "in_person_or_off_platform_pending" - ); - assert_eq!( - sdk_receipt.next_action, - "arrange_in_person_or_off_platform_payment" - ); - assert_eq!(sdk_receipt.evidence.event_count, 2); - assert!(sdk_receipt.evidence.has_request); - assert!(sdk_receipt.evidence.has_decision); - assert!(sdk_receipt.evidence.has_agreement); - assert!(!sdk_receipt.evidence.has_issues); - assert!(!sdk_receipt.eligibility.can_decide); - assert!(sdk_receipt.eligibility.can_propose_revision); - assert!(sdk_receipt.eligibility.can_cancel); - assert!(sdk_receipt.eligibility.can_update_fulfillment); - let lifecycle = view.lifecycle.expect("lifecycle"); - assert_eq!(lifecycle.phase, "accepted"); - assert!(!lifecycle.terminal); - assert!(!lifecycle.settlement_required); - } - - #[test] - fn sdk_order_status_view_uses_sdk_agreement_event_id() { - let request_event_id = test_event_id_char('1'); - let decision_event_id = test_event_id_char('2'); - let agreement_event_id = test_event_id_char('3'); - let receipt = OrderStatusReceipt { - order_id: test_order_id("ord_AAAAAAAAAAAAAAAAAAAAAg"), - source: SdkOrderStatusSource::LocalEventStore, - found: true, - event_count: 3, - limit_applied: 500, - status: OrderStatusKind::Accepted, - fulfillment_status: None, - payment_state: OrderPaymentStateKind::NotRecorded, - settlement_state: OrderSettlementStateKind::NotRequired, - lifecycle_terminal: false, - evidence: OrderStatusEvidenceSummary { - event_count: 3, - limit_applied: 500, - has_request: true, - has_decision: true, - has_agreement: true, - has_pending_revision: false, - has_fulfillment: false, - has_cancellation: false, - has_receipt: false, - has_issues: false, - }, - eligibility: OrderStatusEligibility { - can_decide: false, - can_propose_revision: true, - can_decide_revision: false, - can_cancel: true, - can_update_fulfillment: true, - can_record_receipt: false, - }, - payment_handoff: OrderPaymentHandoffKind::InPersonOrOffPlatformPending, - next_action: OrderStatusNextActionKind::ArrangeInPersonOrOffPlatformPayment, - event_ids: vec![ - request_event_id.clone(), - decision_event_id.clone(), - agreement_event_id.clone(), - ], - request_event_id: Some(request_event_id), - decision_event_id: Some(decision_event_id.clone()), - agreement_event_id: Some(agreement_event_id.clone()), - pending_revision_event_id: None, - fulfillment_event_id: None, - cancellation_event_id: None, - receipt_event_id: None, - last_event_id: Some(agreement_event_id.clone()), - issues: Vec::new(), - }; - - let view = sdk_order_status_view(receipt); - - assert_eq!( - view.decision_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert_eq!( - view.agreement_event_id.as_deref(), - Some(agreement_event_id.as_str()) - ); - assert_eq!( - view.last_event_id.as_deref(), - Some(agreement_event_id.as_str()) - ); - let payment = view.payment.expect("payment"); - assert_eq!( - payment.agreement_event_id.as_deref(), - Some(agreement_event_id.as_str()) - ); - } - - #[test] - fn sdk_order_status_view_maps_stable_issue_codes() { - let request_event_id = test_event_id_char('1'); - let fork_event_id = test_event_id_char('3'); - let receipt = OrderStatusReceipt { - order_id: test_order_id("ord_AAAAAAAAAAAAAAAAAAAAAg"), - source: SdkOrderStatusSource::LocalEventStore, - found: true, - event_count: 2, - limit_applied: 500, - status: OrderStatusKind::Invalid, - fulfillment_status: None, - payment_state: OrderPaymentStateKind::NotRecorded, - settlement_state: OrderSettlementStateKind::NotRequired, - lifecycle_terminal: false, - evidence: OrderStatusEvidenceSummary { - event_count: 2, - limit_applied: 500, - has_request: false, - has_decision: false, - has_agreement: false, - has_pending_revision: false, - has_fulfillment: false, - has_cancellation: false, - has_receipt: false, - has_issues: true, - }, - eligibility: OrderStatusEligibility { - can_decide: false, - can_propose_revision: false, - can_decide_revision: false, - can_cancel: false, - can_update_fulfillment: false, - can_record_receipt: false, - }, - payment_handoff: OrderPaymentHandoffKind::Invalid, - next_action: OrderStatusNextActionKind::InspectEvidenceIssues, - event_ids: vec![request_event_id, fork_event_id.clone()], - request_event_id: None, - decision_event_id: None, - agreement_event_id: None, - pending_revision_event_id: None, - fulfillment_event_id: None, - cancellation_event_id: None, - receipt_event_id: None, - last_event_id: Some(fork_event_id.clone()), - issues: vec![SdkOrderStatusIssue { - kind: SdkOrderStatusIssueKind::MultipleRequests, - event_ids: vec![fork_event_id.clone()], - }], - }; - - let view = sdk_order_status_view(receipt); - - assert_eq!(view.state, "invalid"); - assert_eq!( - view.reason.as_deref(), - Some( - "local SDK order events for `ord_AAAAAAAAAAAAAAAAAAAAAg` failed reducer validation" - ) - ); - assert_eq!(view.reducer_issues.len(), 1); - assert_eq!(view.reducer_issues[0].code, "multiple_requests"); - assert_eq!(view.reducer_issues[0].field, "sdk_order_status"); - assert_eq!( - view.reducer_issues[0].event_ids, - vec![fork_event_id.to_string()] - ); - let sdk_receipt = view.sdk_receipt.expect("sdk receipt"); - assert_eq!(sdk_receipt.payment_handoff, "invalid"); - assert_eq!(sdk_receipt.next_action, "inspect_evidence_issues"); - assert!(sdk_receipt.evidence.has_issues); - assert!(!sdk_receipt.eligibility.can_decide); - assert!(!sdk_receipt.eligibility.can_update_fulfillment); - } - - #[test] - fn order_status_from_receipt_reports_requested() { - let fixture = order_status_fixture(); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone()], - }; - - let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); - let request_event_id = fixture.request_event.id.to_string(); - - assert_eq!(view.state, "requested"); - assert_eq!( - view.request_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert!(view.decision_event_id.is_none()); - assert_eq!( - view.listing_addr.as_deref(), - Some(fixture.listing_addr.as_str()) - ); - assert_eq!( - view.listing_event_id.as_deref(), - Some(fixture.listing_event_id.as_str()) - ); - assert_eq!( - view.buyer_pubkey.as_deref(), - Some(fixture.buyer_pubkey.as_str()) - ); - assert_eq!( - view.seller_pubkey.as_deref(), - Some(fixture.seller_pubkey.as_str()) - ); - assert_eq!( - view.economics, - Some(sample_order_economics( - fixture.order_id.as_str(), - "bin-1", - 2 - )) - ); - assert_eq!(view.decoded_count, 1); - assert_eq!(view.skipped_count, 0); - assert!(view.fulfillment.is_none()); - } - - #[test] - fn order_status_with_selected_seller_skips_wrong_seller_same_order_request() { - let fixture = order_status_fixture(); - let other_seller = RadrootsIdentity::generate(); - let other_seller_pubkey = other_seller.public_key_hex(); - let other_listing_addr = format!("30402:{other_seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAw"); - let other_request_event = signed_order_request_event( - &fixture.buyer, - fixture.order_id.as_str(), - other_listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - other_seller_pubkey.as_str(), - "2".repeat(64).as_str(), - ); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), other_request_event], - }; - let expected_request_event_id = fixture.request_event.id.to_string(); - - let view = order_status_from_receipt_with_context( - OrderStatusContext { - order_id: fixture.order_id.as_str(), - buyer_pubkey: None, - seller_pubkey: None, - selected_account_pubkey: Some(fixture.seller_pubkey.as_str()), - actor_context_source: ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT, - }, - receipt, - ); - - assert_eq!(view.state, "requested"); - assert_eq!(view.decoded_count, 1); - assert_eq!(view.skipped_count, 1); - assert_eq!( - view.request_event_id.as_deref(), - Some(expected_request_event_id.as_str()) - ); - assert_eq!( - view.economics, - Some(sample_order_economics( - fixture.order_id.as_str(), - "bin-1", - 2 - )) - ); - assert!(view.reducer_issues.is_empty()); - } - - #[test] - fn order_status_with_selected_seller_ignores_malformed_wrong_seller_candidate() { - let fixture = order_status_fixture(); - let other_seller = RadrootsIdentity::generate(); - let other_seller_pubkey = other_seller.public_key_hex(); - let other_listing_addr = format!("30402:{other_seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAw"); - let invalid_event = signed_malformed_order_request_event( - &fixture.buyer, - fixture.order_id.as_str(), - other_listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - other_seller_pubkey.as_str(), - "2".repeat(64).as_str(), - ); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), invalid_event], - }; - - let view = order_status_from_receipt_with_context( - OrderStatusContext { - order_id: fixture.order_id.as_str(), - buyer_pubkey: None, - seller_pubkey: None, - selected_account_pubkey: Some(fixture.seller_pubkey.as_str()), - actor_context_source: ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT, - }, - receipt, - ); - - assert_eq!(view.state, "requested"); - assert_eq!(view.decoded_count, 1); - assert_eq!(view.skipped_count, 1); - assert!(view.reducer_issues.is_empty()); - } - - #[test] - fn order_fulfillment_preflight_uses_selected_seller_context() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let other_seller = RadrootsIdentity::generate(); - let other_seller_pubkey = other_seller.public_key_hex(); - let other_listing_addr = format!("30402:{other_seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAw"); - let other_request_event = signed_order_request_event( - &fixture.buyer, - fixture.order_id.as_str(), - other_listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - other_seller_pubkey.as_str(), - "2".repeat(64).as_str(), - ); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let unscoped_reduction = order_status_reduction_from_receipt_with_context( - OrderStatusContext { - order_id: fixture.order_id.as_str(), - buyer_pubkey: None, - seller_pubkey: None, - selected_account_pubkey: None, - actor_context_source: ORDER_ACTOR_CONTEXT_NETWORK_ONLY, - }, - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - other_request_event.clone(), - decision_event.clone(), - ], - }, - ); - let scoped_reduction = order_status_reduction_from_receipt_with_context( - OrderStatusContext { - order_id: fixture.order_id.as_str(), - buyer_pubkey: None, - seller_pubkey: None, - selected_account_pubkey: Some(fixture.seller_pubkey.as_str()), - actor_context_source: ORDER_ACTOR_CONTEXT_RESOLVED_ACCOUNT, - }, - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - other_request_event, - decision_event, - ], - }, - ); - let args = fulfillment_args_for_fixture(&fixture, "ready_for_pickup"); - - assert_eq!(unscoped_reduction.view.state, "invalid"); - assert_eq!(scoped_reduction.view.state, "accepted"); - assert_eq!(scoped_reduction.view.decoded_count, 2); - assert_eq!(scoped_reduction.view.skipped_count, 1); - assert!( - order_fulfillment_preflight_view_from_status( - &config, - &args, - &scoped_reduction.view, - scoped_reduction.fulfillment_status, - scoped_reduction.fulfillment_event_id.as_deref(), - ) - .is_none() - ); - } - - #[test] - fn order_decision_dry_run_view_preserves_ready_preflight_without_publish_fields() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.output.dry_run = true; - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let resolution = request_resolution_for_fixture(&fixture); - let request = resolution.requests[0].clone(); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone()], - }, - ); - let args = OrderDecisionArgs { - key: fixture.order_id.clone(), - decision: OrderDecisionArg::Accept, - reason: None, - idempotency_key: Some("idem_dry_run".to_owned()), - }; - - let view = order_decision_dry_run_view(&config, &args, &request, &status_view, None); - - assert_eq!(view.state, "dry_run"); - assert_eq!(view.dry_run, true); - assert_eq!(view.order_id, fixture.order_id); - assert_eq!( - view.request_event_id.as_deref(), - Some(request.request_event_id.as_str()) - ); - assert_eq!( - view.root_event_id.as_deref(), - Some(request.request_event_id.as_str()) - ); - assert_eq!( - view.prev_event_id.as_deref(), - Some(request.request_event_id.as_str()) - ); - assert_eq!( - view.listing_addr.as_deref(), - Some(fixture.listing_addr.as_str()) - ); - assert_eq!(view.event_id, None); - assert_eq!(view.event_kind, None); - assert!(view.acknowledged_relays.is_empty()); - assert_eq!(view.target_relays, vec!["ws://relay.test"]); - assert_eq!(view.connected_relays, vec!["ws://relay.test"]); - assert_eq!(view.fetched_count, 1); - assert_eq!(view.decoded_count, 1); - assert_eq!(view.skipped_count, 0); - assert_eq!(view.idempotency_key.as_deref(), Some("idem_dry_run")); - assert_eq!( - view.actions, - vec![format!("radroots order status get {}", fixture.order_id)] - ); - } - - #[test] - fn order_decline_dry_run_view_preserves_ready_preflight_without_publish_fields() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.output.dry_run = true; - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let resolution = request_resolution_for_fixture(&fixture); - let request = resolution.requests[0].clone(); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone()], - }, - ); - let args = OrderDecisionArgs { - key: fixture.order_id.clone(), - decision: OrderDecisionArg::Decline, - reason: Some(" out of stock ".to_owned()), - idempotency_key: Some("idem_decline_dry_run".to_owned()), - }; - - let view = order_decision_dry_run_view(&config, &args, &request, &status_view, None); - - assert_eq!(view.state, "dry_run"); - assert_eq!(view.decision, "declined"); - assert_eq!(view.dry_run, true); - assert_eq!( - view.reason.as_deref(), - Some( - "dry run requested; seller order decision publication skipped with reason `out of stock`" - ) - ); - assert_eq!( - view.request_event_id.as_deref(), - Some(request.request_event_id.as_str()) - ); - assert_eq!( - view.root_event_id.as_deref(), - Some(request.request_event_id.as_str()) - ); - assert_eq!( - view.prev_event_id.as_deref(), - Some(request.request_event_id.as_str()) - ); - assert_eq!( - view.listing_addr.as_deref(), - Some(fixture.listing_addr.as_str()) - ); - assert_eq!(view.event_id, None); - assert_eq!(view.event_kind, None); - assert!(view.acknowledged_relays.is_empty()); - assert_eq!(view.target_relays, vec!["ws://relay.test"]); - assert_eq!(view.connected_relays, vec!["ws://relay.test"]); - assert_eq!(view.fetched_count, 1); - assert_eq!(view.decoded_count, 1); - assert_eq!(view.skipped_count, 0); - assert_eq!( - view.idempotency_key.as_deref(), - Some("idem_decline_dry_run") - ); - } - - #[test] - fn order_status_from_receipt_reports_accepted() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event.clone()], - }; - - let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); - let request_event_id = fixture.request_event.id.to_string(); - let decision_event_id = decision_event.id.to_string(); - - assert_eq!(view.state, "accepted"); - assert_eq!( - view.economics, - Some(sample_order_economics( - fixture.order_id.as_str(), - "bin-1", - 2 - )) - ); - assert_eq!( - view.decision_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert_eq!( - view.last_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert_eq!( - view.listing_event_id.as_deref(), - Some(fixture.listing_event_id.as_str()) - ); - let inventory = view.inventory.as_ref().expect("inventory view"); - assert_eq!(inventory.state, "reserved"); - assert_eq!(inventory.commitment_valid, true); - assert_eq!( - inventory.listing_event_id.as_deref(), - Some(fixture.listing_event_id.as_str()) - ); - assert_eq!(inventory.bins.len(), 1); - assert_eq!(inventory.bins[0].bin_id, "bin-1"); - assert_eq!(inventory.bins[0].committed_count, 2); - let fulfillment = view.fulfillment.as_ref().expect("fulfillment view"); - assert_eq!(fulfillment.state, "accepted_not_fulfilled"); - assert_eq!(fulfillment.event_id, None); - assert_eq!( - fulfillment.root_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!( - fulfillment.prev_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert_eq!(fulfillment.terminal, false); - assert_eq!(fulfillment.inventory_released, false); - assert!(fulfillment.issues.is_empty()); - assert!(view.reducer_issues.is_empty()); - assert_eq!(view.decoded_count, 2); - } - - #[test] - fn order_status_from_receipt_reports_requested_cancellation() { - let fixture = order_status_fixture(); - let cancellation_event = signed_order_cancellation_event( - &fixture.buyer, - &fixture.request_event, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - "buyer cancelled", - ); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), cancellation_event.clone()], - }; - - let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); - let request_event_id = fixture.request_event.id.to_string(); - let cancellation_event_id = cancellation_event.id.to_string(); - let lifecycle = view.lifecycle.as_ref().expect("lifecycle view"); - let cancellation = lifecycle.cancellation.as_ref().expect("cancellation view"); - let inventory = view.inventory.as_ref().expect("inventory view"); - - assert_eq!( - u32::from(cancellation_event.kind.as_u16()), - KIND_ORDER_CANCELLATION - ); - assert_eq!(view.state, "cancelled"); - assert_eq!( - view.last_event_id.as_deref(), - Some(cancellation_event_id.as_str()) - ); - assert_eq!(lifecycle.phase, "cancelled"); - assert_eq!(lifecycle.terminal, true); - assert_eq!( - lifecycle.event_id.as_deref(), - Some(cancellation_event_id.as_str()) - ); - assert_eq!( - lifecycle.root_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!( - lifecycle.prev_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!(lifecycle.settlement_required, false); - assert_eq!(lifecycle.settlement_reason, None); - assert_eq!(cancellation.event_id, cancellation_event_id); - assert_eq!( - cancellation.root_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!( - cancellation.prev_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!(cancellation.reason.as_deref(), Some("buyer cancelled")); - assert_eq!(inventory.state, "not_reserved"); - assert_eq!(inventory.commitment_valid, true); - assert!(view.reducer_issues.is_empty()); - } - - #[test] - fn order_status_from_receipt_reports_accepted_cancellation() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let cancellation_event = signed_order_cancellation_event( - &fixture.buyer, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - "buyer cannot collect", - ); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event.clone(), - cancellation_event.clone(), - ], - }; - - let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); - let decision_event_id = decision_event.id.to_string(); - let cancellation_event_id = cancellation_event.id.to_string(); - let lifecycle = view.lifecycle.as_ref().expect("lifecycle view"); - let inventory = view.inventory.as_ref().expect("inventory view"); - - assert_eq!(view.state, "cancelled"); - assert_eq!( - view.decision_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert_eq!(inventory.state, "released"); - assert_eq!(inventory.commitment_valid, true); - assert_eq!(lifecycle.phase, "cancelled"); - assert_eq!(lifecycle.terminal, true); - assert_eq!( - lifecycle.event_id.as_deref(), - Some(cancellation_event_id.as_str()) - ); - assert_eq!( - lifecycle.prev_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert_eq!(lifecycle.settlement_required, false); - assert_eq!(lifecycle.settlement_reason, None); - assert!(view.reducer_issues.is_empty()); - } - - #[test] - fn order_status_from_receipt_reports_request_cancellation_decision_fork() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let cancellation_event = signed_order_cancellation_event( - &fixture.buyer, - &fixture.request_event, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - "buyer cancelled", - ); - let mut expected_event_ids = vec![ - decision_event.id.to_string(), - cancellation_event.id.to_string(), - ]; - expected_event_ids.sort(); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - cancellation_event, - ], - }; - - let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); - let lifecycle = view.lifecycle.as_ref().expect("lifecycle view"); - - assert_eq!(view.state, "invalid"); - assert_eq!(lifecycle.phase, "invalid"); - assert_eq!(lifecycle.terminal, true); - assert_eq!(view.reducer_issues.len(), 1); - assert_eq!(view.reducer_issues[0].code, "forked_lifecycle"); - assert_eq!(view.reducer_issues[0].event_ids, expected_event_ids); - assert_eq!(lifecycle.issues[0].code, "forked_lifecycle"); - } - - #[test] - fn order_cancellation_event_parts_chain_from_request_or_decision() { - let fixture = order_status_fixture(); - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let args = cancel_args_for_fixture(&fixture, "buyer cancelled"); - let requested_status = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone()], - }, - ); - - assert!( - order_cancellation_preflight_view_from_status( - &config, - &args, - &requested_status, - fixture.buyer_pubkey.as_str() - ) - .is_none() - ); - let requested_payload = order_cancellation_payload_from_status(&args, &requested_status) - .expect("requested cancellation payload"); - let requested_parts = order_cancellation_event_parts(&requested_status, &requested_payload) - .expect("requested cancellation parts"); - let request_event_id = fixture.request_event.id.to_string(); - let requested_context = order_event_context_from_tags( - RadrootsOrderEventType::OrderCancelled, - &requested_parts.tags, - ) - .expect("requested cancellation context"); - - assert_eq!(requested_parts.kind, KIND_ORDER_CANCELLATION); - assert_eq!( - requested_context.root_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!( - requested_context.prev_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let accepted_status = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event.clone()], - }, - ); - - assert!( - order_cancellation_preflight_view_from_status( - &config, - &args, - &accepted_status, - fixture.buyer_pubkey.as_str() + let mut actions = listing_relays + .iter() + .map(|relay| { + format!( + "radroots --relay {} order submit {}", + relay, loaded.document.order.order_id ) - .is_none() - ); - let accepted_payload = order_cancellation_payload_from_status(&args, &accepted_status) - .expect("accepted cancellation payload"); - let accepted_parts = order_cancellation_event_parts(&accepted_status, &accepted_payload) - .expect("accepted cancellation parts"); - let decision_event_id = decision_event.id.to_string(); - let accepted_context = order_event_context_from_tags( - RadrootsOrderEventType::OrderCancelled, - &accepted_parts.tags, - ) - .expect("accepted cancellation context"); - - assert_eq!(accepted_parts.kind, KIND_ORDER_CANCELLATION); - assert_eq!( - accepted_context.root_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!( - accepted_context.prev_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - } - - #[test] - fn order_cancellation_dry_run_view_preserves_preflight_without_publish_fields() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.output.dry_run = true; - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event.clone()], - }, - ); - let args = OrderCancelArgs { - key: fixture.order_id.clone(), - reason: "buyer cancelled".to_owned(), - idempotency_key: Some("idem_cancel".to_owned()), - }; - - let view = order_cancellation_dry_run_view(&config, &args, &status_view); - let request_event_id = fixture.request_event.id.to_string(); - let decision_event_id = decision_event.id.to_string(); - - assert_eq!(view.state, "dry_run"); - assert_eq!(view.dry_run, true); - assert_eq!( - view.root_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!( - view.prev_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert_eq!(view.event_id, None); - assert_eq!(view.event_kind, None); - assert_eq!(view.target_relays, vec!["ws://relay.test"]); - assert_eq!(view.connected_relays, vec!["ws://relay.test"]); - assert_eq!(view.fetched_count, 2); - assert_eq!(view.decoded_count, 2); - assert_eq!(view.idempotency_key.as_deref(), Some("idem_cancel")); - } - - #[test] - fn order_cancellation_preflight_rejects_fulfilled_order() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::ReadyForPickup, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - fulfillment_event.clone(), - ], - }, - ); - let args = cancel_args_for_fixture(&fixture, "buyer cancelled"); - - let view = order_cancellation_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.buyer_pubkey.as_str(), - ) - .expect("fulfilled cancellation preflight"); - - assert_eq!(view.state, "fulfilled"); - assert_eq!(view.event_id, None); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("already has seller fulfillment") - ); - } - - #[test] - fn order_cancellation_preflight_ignores_deferred_payment_events() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let payment_event = signed_payment_recorded_event( - &fixture.buyer, - &fixture.request_event, - &decision_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event, payment_event], - }, - ); - let args = cancel_args_for_fixture(&fixture, "buyer cancelled"); - - let view = order_cancellation_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.buyer_pubkey.as_str(), - ); - - assert!(view.is_none()); - assert_eq!(status_view.fetched_count, 3); - assert_eq!(status_view.decoded_count, 2); - assert_eq!(status_view.skipped_count, 1); - assert!(status_view.payment.is_none()); - } - - #[test] - fn order_cancellation_preflight_rejects_selected_non_buyer_account() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone()], - }, - ); - let args = cancel_args_for_fixture(&fixture, "buyer cancelled"); - - let view = order_cancellation_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.seller_pubkey.as_str(), - ) - .expect("non buyer cancellation preflight"); - - assert_eq!(view.state, "invalid"); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("selected account is not buyer") - ); - assert!(view.event_id.is_none()); - } - - #[test] - fn order_cancellation_preflight_rejects_completed_order_as_terminal() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::Delivered, - ); - let receipt_event = signed_buyer_receipt_event( - &fixture.buyer, - &fixture.request_event, - &fulfillment_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - true, - None, - ); - let receipt_event_id = receipt_event.id.to_string(); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - fulfillment_event, - receipt_event, - ], - }, - ); - let args = cancel_args_for_fixture(&fixture, "buyer cancelled"); - - let view = order_cancellation_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.buyer_pubkey.as_str(), - ) - .expect("completed cancellation preflight"); - - assert_eq!(view.state, "terminal"); - assert_eq!( - view.prev_event_id.as_deref(), - Some(receipt_event_id.as_str()) - ); - assert_eq!(view.event_id, None); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("already terminal") - ); - } - - #[test] - fn order_status_from_receipt_reports_completed_buyer_receipt() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::Delivered, - ); - let receipt_event = signed_buyer_receipt_event( - &fixture.buyer, - &fixture.request_event, - &fulfillment_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - true, - None, - ); - let view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - fulfillment_event.clone(), - receipt_event.clone(), - ], - }, - ); - let receipt_event_id = receipt_event.id.to_string(); - let fulfillment_event_id = fulfillment_event.id.to_string(); - let lifecycle = view.lifecycle.as_ref().expect("lifecycle view"); - let receipt = lifecycle.receipt.as_ref().expect("receipt view"); - let inventory = view.inventory.as_ref().expect("inventory view"); - let fulfillment = view.fulfillment.as_ref().expect("fulfillment view"); - - assert_eq!(u32::from(receipt_event.kind.as_u16()), KIND_ORDER_RECEIPT); - assert_eq!(view.state, "completed"); - assert_eq!( - view.last_event_id.as_deref(), - Some(receipt_event_id.as_str()) - ); - assert_eq!(inventory.state, "reserved"); - assert_eq!(fulfillment.state, "delivered"); - assert_eq!( - fulfillment.event_id.as_deref(), - Some(fulfillment_event_id.as_str()) - ); - assert_eq!(lifecycle.phase, "completed"); - assert_eq!(lifecycle.terminal, true); - assert_eq!( - lifecycle.event_id.as_deref(), - Some(receipt_event_id.as_str()) - ); - assert_eq!( - lifecycle.prev_event_id.as_deref(), - Some(fulfillment_event_id.as_str()) - ); - assert_eq!(lifecycle.settlement_required, false); - assert_eq!(lifecycle.settlement_reason, None); - assert_eq!(receipt.event_id, receipt_event_id); - assert_eq!( - receipt.prev_event_id.as_deref(), - Some(fulfillment_event_id.as_str()) - ); - assert_eq!(receipt.received, true); - assert_eq!(receipt.issue, None); - assert_eq!(receipt.received_at, Some(1_777_665_600)); - assert!(view.reducer_issues.is_empty()); - } - - #[test] - fn order_status_from_receipt_reports_disputed_buyer_receipt() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::ReadyForPickup, - ); - let receipt_event = signed_buyer_receipt_event( - &fixture.buyer, - &fixture.request_event, - &fulfillment_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - false, - Some("damaged items"), - ); - let view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - fulfillment_event.clone(), - receipt_event.clone(), - ], - }, - ); - let receipt_event_id = receipt_event.id.to_string(); - let fulfillment_event_id = fulfillment_event.id.to_string(); - let lifecycle = view.lifecycle.as_ref().expect("lifecycle view"); - let receipt = lifecycle.receipt.as_ref().expect("receipt view"); - let fulfillment = view.fulfillment.as_ref().expect("fulfillment view"); - - assert_eq!(view.state, "disputed"); - assert_eq!(fulfillment.state, "ready_for_pickup"); - assert_eq!( - fulfillment.event_id.as_deref(), - Some(fulfillment_event_id.as_str()) - ); - assert_eq!(lifecycle.phase, "disputed"); - assert_eq!(lifecycle.terminal, true); - assert_eq!( - lifecycle.event_id.as_deref(), - Some(receipt_event_id.as_str()) - ); - assert_eq!(lifecycle.settlement_required, false); - assert_eq!(lifecycle.settlement_reason, None); - assert_eq!(receipt.received, false); - assert_eq!(receipt.issue.as_deref(), Some("damaged items")); - assert_eq!(receipt.received_at, Some(1_777_665_600)); - assert!(view.reducer_issues.is_empty()); - } - - #[test] - fn order_status_from_receipt_reports_receipt_fulfillment_fork() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let ready_fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::ReadyForPickup, - ); - let delivered_fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &ready_fulfillment_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::Delivered, - ); - let receipt_event = signed_buyer_receipt_event( - &fixture.buyer, - &fixture.request_event, - &ready_fulfillment_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - true, - None, - ); - let mut expected_event_ids = vec![ - delivered_fulfillment_event.id.to_string(), - receipt_event.id.to_string(), - ]; - expected_event_ids.sort(); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - ready_fulfillment_event, - delivered_fulfillment_event, - receipt_event, - ], - }; + }) + .collect::<Vec<_>>(); + actions.push(format!( + "radroots order get {}", + loaded.document.order.order_id + )); + Ok(Some(OrderSubmitView { + state: "unconfigured".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(), + listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), + listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), + listing_relays, + 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_ORDER_REQUEST), + dry_run: config.output.dry_run, + deduplicated: false, + target_relays, + connected_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, + requested_signer_session_id: None, + reason: Some( + "order submit requires at least one configured relay that is known to carry the listing" + .to_owned(), + ), + job: None, + issues: vec![issue_with_code( + "listing_relay_target_mismatch", + "order.listing_relays", + format!( + "configured relays must include one of the listing provenance relays: {}", + loaded.document.order.listing_relays.join(", ") + ), + )], + actions, + })) +} - let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); - let lifecycle = view.lifecycle.as_ref().expect("lifecycle view"); - - assert_eq!(view.state, "invalid"); - assert_eq!(lifecycle.phase, "invalid"); - assert_eq!(lifecycle.terminal, true); - assert_eq!(view.reducer_issues.len(), 1); - assert_eq!(view.reducer_issues[0].code, "forked_lifecycle"); - assert_eq!(view.reducer_issues[0].event_ids, expected_event_ids); - assert_eq!(lifecycle.issues[0].code, "forked_lifecycle"); - } - - #[test] - fn order_receipt_event_parts_chain_from_latest_eligible_fulfillment() { - let fixture = order_status_fixture(); - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::ReadyForPickup, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - fulfillment_event.clone(), - ], - }, - ); - let args = receipt_args_for_fixture(&fixture, true, None); +fn order_submit_market_freshness_view( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, +) -> Result<Option<OrderSubmitView>, RuntimeError> { + if config.output.dry_run || config.relay.urls.is_empty() { + return Ok(None); + } - assert!( - order_receipt_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.buyer_pubkey.as_str() - ) - .is_none() - ); - let payload = - order_receipt_payload_from_status(&args, &status_view).expect("receipt payload"); - let parts = order_receipt_event_parts(&status_view, &payload).expect("receipt parts"); - let request_event_id = fixture.request_event.id.to_string(); - let fulfillment_event_id = fulfillment_event.id.to_string(); - let context = - order_event_context_from_tags(RadrootsOrderEventType::BuyerReceipt, &parts.tags) - .expect("receipt context"); - - assert_eq!(payload.received, true); - assert!(payload.received_at > 0); - assert_eq!(parts.kind, KIND_ORDER_RECEIPT); - assert_eq!( - context.root_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!( - context.prev_event_id.as_deref(), - Some(fulfillment_event_id.as_str()) - ); + let mut freshness = freshness_for_scope(config, RelayIngestScope::MarketRefresh)?; + if freshness_requires_refresh(&freshness) { + let _ = market_refresh(config)?; + freshness = freshness_for_scope(config, RelayIngestScope::MarketRefresh)?; + } + if !freshness_requires_refresh(&freshness) { + return Ok(None); } - #[test] - fn order_receipt_dry_run_view_preserves_preflight_without_publish_fields() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.output.dry_run = true; - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::Delivered, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - fulfillment_event.clone(), - ], - }, - ); - let args = OrderReceiptArgs { - key: fixture.order_id.clone(), - received: false, - issue: Some("damaged items".to_owned()), - idempotency_key: Some("idem_receipt".to_owned()), - }; - let payload = - order_receipt_payload_from_status(&args, &status_view).expect("receipt payload"); - - let view = order_receipt_dry_run_view(&config, &args, &status_view, &payload); - let request_event_id = fixture.request_event.id.to_string(); - let fulfillment_event_id = fulfillment_event.id.to_string(); - - assert_eq!(view.state, "dry_run"); - assert_eq!(view.dry_run, true); - assert_eq!( - view.root_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!( - view.prev_event_id.as_deref(), - Some(fulfillment_event_id.as_str()) - ); - assert_eq!(view.event_id, None); - assert_eq!(view.event_kind, None); - assert_eq!(view.received, false); - assert_eq!(view.issue.as_deref(), Some("damaged items")); - assert_eq!(view.received_at, Some(payload.received_at)); - assert_eq!(view.target_relays, vec!["ws://relay.test"]); - assert_eq!(view.connected_relays, vec!["ws://relay.test"]); - assert_eq!(view.fetched_count, 3); - assert_eq!(view.decoded_count, 3); - assert_eq!(view.idempotency_key.as_deref(), Some("idem_receipt")); - } - - #[test] - fn order_payment_event_parts_bind_current_agreement_terms() { - let fixture = order_status_fixture(); - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let status_view = order_status_from_receipt_with_deferred_payment( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event.clone()], - }, - ); - let args = payment_args_for_fixture(&fixture); + Ok(Some(order_submit_unconfigured_view( + config, + loaded, + args, + "order submit requires a current market refresh before signing; run `radroots market refresh` with the relays you trust, then submit again", + vec![issue( + "order.listing_addr", + format!( + "local market freshness is `{}`; current listing state must be refreshed before order submit", + freshness.state + ), + )], + vec!["radroots market refresh".to_owned()], + ))) +} - assert!( - order_payment_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.buyer_pubkey.as_str() - ) - .is_none() - ); - let payload = - order_payment_payload_from_status(&args, &status_view).expect("payment payload"); - let parts = order_payment_event_parts(&status_view, &payload).expect("payment parts"); - let request_event_id = fixture.request_event.id.to_string(); - let decision_event_id = decision_event.id.to_string(); - let context = - order_event_context_from_tags(RadrootsOrderEventType::PaymentRecorded, &parts.tags) - .expect("payment context"); - - assert_eq!(parts.kind, KIND_ORDER_PAYMENT_RECORD); - assert_eq!( - context.root_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!( - context.prev_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert_eq!(payload.agreement_event_id, decision_event_id); - assert_eq!(payload.quote_id, format!("quote_{}", fixture.order_id)); - assert_eq!(payload.quote_version, 1); - assert_eq!(payload.amount, RadrootsCoreDecimal::from(12u32)); - assert_eq!(payload.currency, RadrootsCoreCurrency::USD); - assert_eq!(payload.method, RadrootsOrderPaymentMethod::ManualTransfer); - assert_eq!(payload.reference.as_deref(), Some("memo-1")); - assert_eq!(payload.paid_at, Some(1_777_666_000)); - } - - #[test] - fn order_payment_dry_run_view_preserves_payment_payload_without_event_id() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.output.dry_run = true; - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let status_view = order_status_from_receipt_with_deferred_payment( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event.clone()], - }, - ); - let mut args = payment_args_for_fixture(&fixture); - args.idempotency_key = Some("idem_payment".to_owned()); - let payload = - order_payment_payload_from_status(&args, &status_view).expect("payment payload"); - - let view = order_payment_dry_run_view(&config, &args, &status_view, &payload); - let request_event_id = fixture.request_event.id.to_string(); - let decision_event_id = decision_event.id.to_string(); - - assert_eq!(view.state, "dry_run"); - assert_eq!(view.dry_run, true); - assert_eq!( - view.root_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!( - view.prev_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert_eq!( - view.agreement_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert_eq!(view.event_id, None); - assert_eq!(view.event_kind, None); - assert_eq!(view.amount, Some(RadrootsCoreDecimal::from(12u32))); - assert_eq!(view.currency, Some(RadrootsCoreCurrency::USD)); - assert_eq!( - view.method, - Some(RadrootsOrderPaymentMethod::ManualTransfer) - ); - assert_eq!(view.reference.as_deref(), Some("memo-1")); - assert_eq!(view.paid_at, Some(1_777_666_000)); - assert_eq!(view.target_relays, vec!["ws://relay.test"]); - assert_eq!(view.connected_relays, vec!["ws://relay.test"]); - assert_eq!(view.fetched_count, 2); - assert_eq!(view.decoded_count, 2); - assert_eq!(view.idempotency_key.as_deref(), Some("idem_payment")); - } - - #[test] - fn order_payment_preflight_rejects_selected_non_buyer_account() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event], - }, - ); - let args = payment_args_for_fixture(&fixture); +fn order_submit_existing_request_view_from_receipt( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, + payload: &RadrootsOrderRequest, + receipt: DirectRelayFetchReceipt, +) -> Result<Option<OrderSubmitView>, RuntimeError> { + let DirectRelayFetchReceipt { + target_relays, + connected_relays, + failed_relays, + events, + } = receipt; + let mut requests = Vec::new(); + let mut candidate_issues = Vec::new(); + let candidate_context = OrderRequestCandidateContext { + order_id: loaded.document.order.order_id.as_str(), + seller_pubkey: Some(loaded.document.order.seller_pubkey.as_str()), + }; - let view = order_payment_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.seller_pubkey.as_str(), - ) - .expect("non buyer payment preflight"); + for event in events { + if !order_request_candidate_matches(&event, candidate_context) { + continue; + } + let event_id = event.id.to_string(); + match order_submit_request_from_event(&event, loaded) { + Ok(request) => requests.push(request), + Err(error) => candidate_issues.push(issue_with_events( + "invalid_request_candidate", + "request_event_id", + format!("request event `{event_id}` failed order submit preflight: {error}"), + vec![event_id], + )), + } + } - assert_eq!(view.state, "invalid"); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("selected account is not buyer") - ); + requests.sort_by(|left, right| left.request_event_id.cmp(&right.request_event_id)); + candidate_issues.sort_by(|left, right| { + left.event_ids + .cmp(&right.event_ids) + .then_with(|| left.message.cmp(&right.message)) + }); + if !candidate_issues.is_empty() { + return Ok(Some(order_submit_invalid_existing_request_view( + config, + loaded, + args, + "visible order request candidates failed submit preflight validation", + candidate_issues, + target_relays, + failed_relays, + ))); } - #[test] - fn order_payment_preflight_rejects_amount_mismatch() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event], - }, - ); - let mut args = payment_args_for_fixture(&fixture); - args.amount = "11.99".to_owned(); + let request_event_ids = requests + .iter() + .map(|request| request.request_event_id.clone()) + .collect::<Vec<_>>(); - let view = order_payment_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.buyer_pubkey.as_str(), - ) - .expect("amount mismatch preflight"); - - assert_eq!(view.state, "invalid"); - assert_eq!(view.amount, Some("11.99".parse().expect("decimal"))); - assert_eq!(view.issues.len(), 1); - assert_eq!(view.issues[0].code, "payment_amount_mismatch"); - } - - #[test] - fn order_payment_preflight_rejects_cancelled_order() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let cancel_event = signed_order_cancellation_event( - &fixture.buyer, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - "changed plans", - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event, cancel_event], - }, - ); - let args = payment_args_for_fixture(&fixture); + match requests.as_slice() { + [] => Ok(None), + [request] if order_submit_request_matches_draft(request, loaded, payload) => { + Ok(Some(order_submit_deduplicated_view( + config, + loaded, + args, + request, + target_relays, + connected_relays, + failed_relays, + ))) + } + [request] => Ok(Some(order_submit_invalid_existing_request_view( + config, + loaded, + args, + "visible order request event conflicts with the local order draft; refusing to publish a second request for the same order id", + vec![issue_with_events( + "existing_request_conflict", + "request_event_id", + format!( + "request event `{}` does not match the local order draft", + request.request_event_id + ), + vec![request.request_event_id.clone()], + )], + target_relays, + failed_relays, + ))), + _ => Ok(Some(order_submit_invalid_existing_request_view( + config, + loaded, + args, + "multiple visible order request events matched the local order id; refusing to publish another request", + vec![issue_with_events( + "multiple_request_candidates", + "request_event_id", + format!( + "matched {} request events for the same order id", + requests.len() + ), + request_event_ids, + )], + target_relays, + failed_relays, + ))), + } +} - let view = order_payment_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.buyer_pubkey.as_str(), - ) - .expect("cancelled payment preflight"); +fn order_submit_request_from_event( + event: &RadrootsNostrEvent, + loaded: &LoadedOrderDraft, +) -> Result<ResolvedOrderSubmitRequest, RuntimeError> { + let event = radroots_event_from_nostr(event); + let envelope = order_request_from_event(&event) + .map_err(|error| RuntimeError::Config(format!("decode order request event: {error}")))?; + let context = + order_event_context_from_tags(RadrootsOrderEventType::OrderRequested, &event.tags) + .map_err(|error| RuntimeError::Config(format!("decode order request tags: {error}")))?; - assert_eq!(view.state, "cancelled"); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("was cancelled") - ); + if envelope.order_id != loaded.document.order.order_id + || envelope.payload.order_id != loaded.document.order.order_id + { + return Err(RuntimeError::Config( + "order request does not match local order id".to_owned(), + )); + } + if context.counterparty_pubkey != envelope.payload.seller_pubkey { + return Err(RuntimeError::Config( + "order request p tag does not match seller_pubkey".to_owned(), + )); + } + let listing_addr = + parse_listing_addr(envelope.payload.listing_addr.as_str()).map_err(|error| { + RuntimeError::Config(format!("order request listing_addr is invalid: {error}")) + })?; + if listing_addr.seller_pubkey != envelope.payload.seller_pubkey { + return Err(RuntimeError::Config( + "order request listing address is outside seller authority".to_owned(), + )); } + let payload = canonicalize_order_request_for_signer(envelope.payload, event.author.as_str()) + .map_err(|error| RuntimeError::Config(format!("canonicalize order request: {error}")))?; + let listing_event_id = context.listing_event.as_ref().map(|event| event.id.clone()); - #[test] - fn order_payment_preflight_skips_existing_recorded_payment() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let payment_event = signed_payment_recorded_event( - &fixture.buyer, - &fixture.request_event, - &decision_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - ); - let payment_event_id = payment_event.id.to_string(); - let status_view = order_status_from_receipt_with_deferred_payment( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event, payment_event], - }, - ); - let args = payment_args_for_fixture(&fixture); + Ok(ResolvedOrderSubmitRequest { + request_event_id: event.id, + listing_event_id, + payload, + }) +} - let view = order_payment_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.buyer_pubkey.as_str(), - ) - .expect("recorded payment preflight"); +fn order_submit_request_matches_draft( + request: &ResolvedOrderSubmitRequest, + loaded: &LoadedOrderDraft, + payload: &RadrootsOrderRequest, +) -> bool { + request.payload == *payload + && request.listing_event_id.as_deref() + == Some(loaded.document.order.listing_event_id.as_str()) +} - assert_eq!(view.state, "recorded"); - assert_eq!(view.event_id.as_deref(), Some(payment_event_id.as_str())); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("already has payment state") - ); +fn order_submit_deduplicated_view( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, + request: &ResolvedOrderSubmitRequest, + target_relays: Vec<String>, + connected_relays: Vec<String>, + failed_relays: Vec<DirectRelayFailure>, +) -> OrderSubmitView { + OrderSubmitView { + 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(), + listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), + listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), + listing_relays: order_listing_relays(&loaded.document), + 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_ORDER_REQUEST), + dry_run: config.output.dry_run, + deduplicated: true, + target_relays, + connected_relays: connected_relays.clone(), + acknowledged_relays: connected_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( + "an identical order request is already visible on the configured relays; publish skipped" + .to_owned(), + ), + job: None, + issues: Vec::new(), + actions: Vec::new(), } +} - #[test] - fn order_payment_preflight_rejects_second_different_recorded_payment() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let payment_event = signed_payment_recorded_event( - &fixture.buyer, - &fixture.request_event, - &decision_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - ); - let status_view = order_status_from_receipt_with_deferred_payment( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event, payment_event], - }, - ); - let mut args = payment_args_for_fixture(&fixture); - args.reference = Some("different memo".to_owned()); - - let view = order_payment_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.buyer_pubkey.as_str(), - ) - .expect("different payment preflight"); +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(), + 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(), + listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), + listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), + listing_relays: order_listing_relays(&loaded.document), + 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(plan.expected_event_id.as_str().to_owned()), + event_kind: Some(KIND_ORDER_REQUEST), + dry_run: true, + deduplicated: false, + target_relays, + connected_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, + requested_signer_session_id: None, + reason: Some("dry run requested; SDK enqueue and relay push skipped".to_owned()), + job: None, + issues: Vec::new(), + actions: vec![format!( + "radroots order submit {}", + loaded.document.order.order_id + )], + } +} - assert_eq!(view.state, "invalid"); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("already has a different unrejected payment") - ); - assert_eq!(view.issues.len(), 1); - assert_eq!(view.issues[0].code, "duplicate_payment_attempt"); - } - - #[test] - fn order_status_from_receipt_ignores_recorded_payment_axis() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let payment_event = signed_payment_recorded_event( - &fixture.buyer, - &fixture.request_event, - &decision_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - ); - let view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event.clone(), - payment_event, - ], - }, - ); +fn order_submit_invalid_existing_request_view( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, + reason: impl Into<String>, + issues: Vec<OrderIssueView>, + target_relays: Vec<String>, + failed_relays: Vec<DirectRelayFailure>, +) -> OrderSubmitView { + OrderSubmitView { + state: "invalid".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(), + listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), + listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), + listing_relays: order_listing_relays(&loaded.document), + 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_ORDER_REQUEST), + dry_run: config.output.dry_run, + deduplicated: false, + target_relays, + connected_relays: Vec::new(), + acknowledged_relays: Vec::new(), + 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(reason.into()), + job: None, + issues, + actions: vec![format!( + "radroots order status get {}", + loaded.document.order.order_id + )], + } +} - assert_eq!(view.state, "accepted"); - assert_eq!(view.fetched_count, 3); - assert_eq!(view.decoded_count, 2); - assert_eq!(view.skipped_count, 1); - assert!(view.payment.is_none()); - assert!(view.reducer_issues.is_empty()); - } - - #[test] - fn order_settlement_event_parts_bind_recorded_payment_terms() { - let fixture = order_status_fixture(); - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let payment_event = signed_payment_recorded_event( - &fixture.buyer, - &fixture.request_event, - &decision_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - ); - let payment_event_id = payment_event.id.to_string(); - let status_view = order_status_from_receipt_with_deferred_payment( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event.clone(), - payment_event, - ], - }, - ); - let args = settlement_args_for_fixture( - &fixture, - payment_event_id.as_str(), - OrderSettlementDecisionArg::Accept, - ); +fn canonical_order_request_payload_from_loaded( + loaded: &LoadedOrderDraft, + signer_pubkey: &str, +) -> Result<RadrootsOrderRequest, RuntimeError> { + let economics = + loaded.document.order.economics.clone().ok_or_else(|| { + RuntimeError::Config("order draft is missing quote economics".to_owned()) + })?; + let items = loaded + .document + .order + .items + .iter() + .map(|item| { + Ok(RadrootsOrderItem { + bin_id: protocol_inventory_bin_id(item.bin_id.as_str(), "order item bin_id")?, + bin_count: item.bin_count, + }) + }) + .collect::<Result<Vec<_>, RuntimeError>>()?; + let payload = RadrootsOrderRequest { + order_id: protocol_order_id(loaded.document.order.order_id.as_str(), "order_id")?, + listing_addr: protocol_listing_addr( + loaded.document.order.listing_addr.as_str(), + "listing_addr", + )?, + buyer_pubkey: protocol_pubkey(loaded.document.order.buyer_pubkey.as_str(), "buyer_pubkey")?, + seller_pubkey: protocol_pubkey( + loaded.document.order.seller_pubkey.as_str(), + "seller_pubkey", + )?, + items, + economics, + }; + canonicalize_order_request_for_signer(payload, signer_pubkey) + .map_err(|error| RuntimeError::Config(format!("canonicalize order request: {error}"))) +} - assert!( - order_settlement_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.seller_pubkey.as_str() - ) - .is_none() - ); - let payload = - order_settlement_payload_from_status(&args, &status_view).expect("settlement payload"); - let parts = order_settlement_event_parts(&status_view, &payload).expect("settlement parts"); - let request_event_id = fixture.request_event.id.to_string(); - let decision_event_id = decision_event.id.to_string(); - let context = - order_event_context_from_tags(RadrootsOrderEventType::SettlementDecision, &parts.tags) - .expect("settlement context"); - - assert_eq!(parts.kind, KIND_ORDER_SETTLEMENT_DECISION); - assert_eq!( - context.root_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!( - context.prev_event_id.as_deref(), - Some(payment_event_id.as_str()) - ); - assert_eq!(payload.previous_event_id, payment_event_id); - assert_eq!(payload.agreement_event_id, decision_event_id); - assert_eq!(payload.payment_event_id, payload.previous_event_id); - assert_eq!(payload.amount, RadrootsCoreDecimal::from(12u32)); - assert_eq!(payload.currency, RadrootsCoreCurrency::USD); - assert_eq!(payload.decision, RadrootsOrderSettlementOutcome::Accepted); - assert_eq!(payload.reason, None); - } - - #[test] - fn order_settlement_dry_run_view_preserves_rejection_payload_without_event_id() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.output.dry_run = true; - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let payment_event = signed_payment_recorded_event( - &fixture.buyer, - &fixture.request_event, - &decision_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - ); - let payment_event_id = payment_event.id.to_string(); - let status_view = order_status_from_receipt_with_deferred_payment( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event, payment_event], - }, - ); - let mut args = settlement_args_for_fixture( - &fixture, - payment_event_id.as_str(), - OrderSettlementDecisionArg::Reject, - ); - args.idempotency_key = Some("idem_settlement".to_owned()); - let payload = - order_settlement_payload_from_status(&args, &status_view).expect("settlement payload"); +fn sdk_order_submit_input( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + signing: &account::AccountSigningIdentity, + payload: RadrootsOrderRequest, +) -> 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)?; - let view = order_settlement_dry_run_view(&config, &args, &status_view, &payload); + Ok(SdkOrderSubmitInput { + actor, + listing_event, + order: payload, + target_relays, + }) +} - assert_eq!(view.state, "dry_run"); - assert_eq!(view.dry_run, true); - assert_eq!( - view.prev_event_id.as_deref(), - Some(payment_event_id.as_str()) - ); - assert_eq!( - view.payment_event_id.as_deref(), - Some(payment_event_id.as_str()) - ); - assert_eq!(view.event_id, None); - assert_eq!(view.event_kind, None); - assert_eq!(view.amount, Some(RadrootsCoreDecimal::from(12u32))); - assert_eq!(view.currency, Some(RadrootsCoreCurrency::USD)); - assert_eq!( - view.decision, - Some(RadrootsOrderSettlementOutcome::Rejected) - ); - assert_eq!( - view.settlement_reason.as_deref(), - Some("reference mismatch") - ); - assert_eq!(view.target_relays, vec!["ws://relay.test"]); - assert_eq!(view.connected_relays, vec!["ws://relay.test"]); - assert_eq!(view.fetched_count, 3); - assert_eq!(view.decoded_count, 3); - assert_eq!(view.idempotency_key.as_deref(), Some("idem_settlement")); - } - - #[test] - fn order_settlement_preflight_rejects_selected_non_seller_account() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let payment_event = signed_payment_recorded_event( - &fixture.buyer, - &fixture.request_event, - &decision_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - ); - let payment_event_id = payment_event.id.to_string(); - let status_view = order_status_from_receipt_with_deferred_payment( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event, payment_event], - }, - ); - let args = settlement_args_for_fixture( - &fixture, - payment_event_id.as_str(), - OrderSettlementDecisionArg::Accept, - ); +#[derive(Debug, Clone)] +struct SdkOrderSubmitInput { + actor: RadrootsActorContext, + listing_event: RadrootsNostrEventPtr, + order: RadrootsOrderRequest, + target_relays: Vec<String>, +} - let view = order_settlement_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.buyer_pubkey.as_str(), - ) - .expect("non seller settlement preflight"); +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}")))?; + Ok(RadrootsNostrEventPtr { + id: loaded.document.order.listing_event_id.clone(), + relays: listing_relays.first().cloned(), + }) +} - assert_eq!(view.state, "invalid"); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("selected account is not seller") - ); +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()) +} - #[test] - fn order_settlement_preflight_rejects_stale_payment_event_id() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let payment_event = signed_payment_recorded_event( - &fixture.buyer, - &fixture.request_event, - &decision_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - ); - let status_view = order_status_from_receipt_with_deferred_payment( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event, payment_event], - }, - ); - let args = settlement_args_for_fixture( - &fixture, - "2".repeat(64).as_str(), - OrderSettlementDecisionArg::Accept, - ); - - let view = order_settlement_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.seller_pubkey.as_str(), - ) - .expect("stale settlement preflight"); - - assert_eq!(view.state, "invalid"); - assert_eq!(view.issues.len(), 1); - assert_eq!(view.issues[0].code, "stale_payment_event"); - } - - #[test] - fn order_settlement_preflight_rejects_duplicate_decision() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let payment_event = signed_payment_recorded_event( - &fixture.buyer, - &fixture.request_event, - &decision_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - ); - let settlement_event = signed_settlement_decision_event( - &fixture.seller, - &fixture.request_event, - &payment_event, - RadrootsOrderSettlementOutcome::Accepted, - ); - let payment_event_id = payment_event.id.to_string(); - let status_view = order_status_from_receipt_with_deferred_payment( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - payment_event, - settlement_event, - ], - }, - ); - let args = settlement_args_for_fixture( - &fixture, - payment_event_id.as_str(), - OrderSettlementDecisionArg::Accept, - ); +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 + } +} - let view = order_settlement_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.seller_pubkey.as_str(), - ) - .expect("duplicate settlement preflight"); +fn prepare_order_submit_via_sdk( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, + 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, + )) +} - assert_eq!(view.state, "already_decided"); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("already has settlement state") - ); +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)?; } - #[test] - fn order_status_from_receipt_ignores_accepted_settlement_axis() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let payment_event = signed_payment_recorded_event( - &fixture.buyer, - &fixture.request_event, - &decision_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - ); - let settlement_event = signed_settlement_decision_event( - &fixture.seller, - &fixture.request_event, - &payment_event, - RadrootsOrderSettlementOutcome::Accepted, - ); - let view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - payment_event, - settlement_event, - ], - }, - ); - - assert_eq!(view.state, "accepted"); - assert_eq!(view.fetched_count, 4); - assert_eq!(view.decoded_count, 2); - assert_eq!(view.skipped_count, 2); - assert!(view.payment.is_none()); - assert!(view.reducer_issues.is_empty()); - } - - #[test] - fn deferred_payment_status_helper_reports_rejected_settlement_axis() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let payment_event = signed_payment_recorded_event( - &fixture.buyer, - &fixture.request_event, - &decision_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - ); - let settlement_event = signed_settlement_decision_event( - &fixture.seller, - &fixture.request_event, - &payment_event, - RadrootsOrderSettlementOutcome::Rejected, - ); - let view = order_status_from_receipt_with_deferred_payment( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - payment_event, - settlement_event, - ], - }, - ); - let payment = view.payment.as_ref().expect("payment view"); - - assert_eq!(payment.state, "rejected"); - assert_eq!(payment.settlement_state, "rejected"); - assert_eq!(payment.reason.as_deref(), Some("reference mismatch")); - assert!(view.reducer_issues.is_empty()); - } - - #[test] - fn order_receipt_preflight_rejects_ineligible_fulfillment() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::Preparing, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - fulfillment_event, - ], - }, - ); - let args = receipt_args_for_fixture(&fixture, true, None); + 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, + )) +} - let view = order_receipt_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.buyer_pubkey.as_str(), - ) - .expect("ineligible receipt preflight"); +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) +} - assert_eq!(view.state, "invalid"); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("no eligible seller fulfillment") - ); - assert!(view.event_id.is_none()); - } - - #[test] - fn order_receipt_preflight_rejects_selected_non_buyer_account() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::Delivered, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - fulfillment_event, - ], - }, - ); - let args = receipt_args_for_fixture(&fixture, true, None); +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(), + listing_lookup: loaded.document.listing_lookup.clone(), + listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), + listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), + listing_relays: order_listing_relays(&loaded.document), + 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(enqueue.signed_event_id.as_str().to_owned()), + event_kind: Some(KIND_ORDER_REQUEST), + dry_run: false, + 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: sdk_order_submit_reason(&enqueue.workflow, push_event), + job: None, + issues: Vec::new(), + actions: sdk_order_submit_actions(push_event), + } +} - let view = order_receipt_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.seller_pubkey.as_str(), - ) - .expect("non buyer receipt preflight"); +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) +} - assert_eq!(view.state, "invalid"); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("selected account is not buyer") - ); - assert!(view.event_id.is_none()); - } - - #[test] - fn order_receipt_preflight_rejects_existing_terminal_receipt() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::Delivered, - ); - let receipt_event = signed_buyer_receipt_event( - &fixture.buyer, - &fixture.request_event, - &fulfillment_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - true, - None, - ); - let receipt_event_id = receipt_event.id.to_string(); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - fulfillment_event, - receipt_event, - ], - }, - ); - let args = receipt_args_for_fixture(&fixture, true, None); +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() +} - let view = order_receipt_preflight_view_from_status( - &config, - &args, - &status_view, - fixture.buyer_pubkey.as_str(), - ) - .expect("terminal receipt preflight"); +fn sdk_order_submit_reason( + enqueue: &OrderWorkflowEnqueueReceipt, + push_event: Option<&PushOutboxEventReceipt>, +) -> Option<String> { + match push_event.map(|event| event.final_state) { + Some(PushOutboxEventState::Published) => None, + Some(PushOutboxEventState::PublishRetryable) => Some(format!( + "{}; SDK relay publish did not reach accepted quorum; outbox event remains retryable; {}", + sdk_order_enqueue_summary(enqueue), + sdk_order_enqueue_retry_summary(enqueue) + )), + Some(PushOutboxEventState::FailedTerminal) => Some(format!( + "{}; SDK relay publish failed terminally; {}", + sdk_order_enqueue_summary(enqueue), + sdk_order_enqueue_retry_summary(enqueue) + )), + Some(state) => Some(format!( + "{}; SDK relay push left event in state `{state:?}`; {}", + sdk_order_enqueue_summary(enqueue), + sdk_order_enqueue_retry_summary(enqueue) + )), + None => Some(format!( + "{}; order submit queued in SDK outbox; no ready SDK outbox event was pushed; {}", + sdk_order_enqueue_summary(enqueue), + sdk_order_enqueue_retry_summary(enqueue) + )), + } +} - assert_eq!(view.state, "terminal"); - assert_eq!(view.event_id.as_deref(), Some(receipt_event_id.as_str())); - assert_eq!(view.event_kind, Some(KIND_ORDER_RECEIPT)); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("already terminal") - ); +fn sdk_order_submit_actions(push_event: Option<&PushOutboxEventReceipt>) -> Vec<String> { + if !matches!( + push_event.map(|event| event.final_state), + Some(PushOutboxEventState::Published) + ) { + return sdk_order_push_recovery_actions(); } + Vec::new() +} - #[test] - fn order_status_from_receipt_reports_latest_fulfillment_as_last_event() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::ReadyForPickup, - ); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event.clone(), - fulfillment_event.clone(), - ], - }; +fn sdk_push_target_relays(event: &PushOutboxEventReceipt) -> Vec<String> { + event + .relays + .iter() + .map(|relay| relay.relay_url.clone()) + .collect() +} - let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); - let request_event_id = fixture.request_event.id.to_string(); - let decision_event_id = decision_event.id.to_string(); - let fulfillment_event_id = fulfillment_event.id.to_string(); +fn sdk_push_connected_relays(event: &PushOutboxEventReceipt) -> Vec<String> { + event + .relays + .iter() + .filter(|relay| relay.attempted) + .map(|relay| relay.relay_url.clone()) + .collect() +} - assert_eq!( - u32::from(fulfillment_event.kind.as_u16()), - KIND_ORDER_FULFILLMENT_UPDATE - ); - assert_eq!(view.state, "accepted"); - assert_eq!( - view.last_event_id.as_deref(), - Some(fulfillment_event_id.as_str()) - ); - let fulfillment = view.fulfillment.as_ref().expect("fulfillment view"); - assert_eq!(fulfillment.state, "ready_for_pickup"); - assert_eq!( - fulfillment.event_id.as_deref(), - Some(fulfillment_event_id.as_str()) - ); - assert_eq!( - fulfillment.root_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!( - fulfillment.prev_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert_eq!(fulfillment.terminal, false); - assert_eq!(fulfillment.inventory_released, false); - assert!(fulfillment.issues.is_empty()); - assert_eq!(view.decoded_count, 3); - assert!(view.reducer_issues.is_empty()); - } - - #[test] - fn order_status_from_receipt_reports_seller_cancelled_inventory_release_flag() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::SellerCancelled, - ); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - fulfillment_event.clone(), - ], - }; +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() +} - let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); - let fulfillment_event_id = fulfillment_event.id.to_string(); - let fulfillment = view.fulfillment.as_ref().expect("fulfillment view"); +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() +} - assert_eq!(view.state, "accepted"); - assert_eq!(fulfillment.state, "seller_cancelled"); - assert_eq!( - fulfillment.event_id.as_deref(), - Some(fulfillment_event_id.as_str()) - ); - assert_eq!(fulfillment.terminal, true); - assert_eq!(fulfillment.inventory_released, true); - assert!(fulfillment.issues.is_empty()); - } - - #[test] - fn order_status_from_receipt_exposes_forked_fulfillment_issues() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let first_fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::Preparing, - ); - let second_fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::ReadyForPickup, - ); - let mut expected_event_ids = vec![ - first_fulfillment_event.id.to_string(), - second_fulfillment_event.id.to_string(), - ]; - expected_event_ids.sort(); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - first_fulfillment_event, - second_fulfillment_event, - ], - }; +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", + } +} - let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); - let fulfillment = view.fulfillment.as_ref().expect("fulfillment view"); - - assert_eq!(view.state, "invalid"); - assert_eq!(fulfillment.state, "invalid"); - assert_eq!(fulfillment.issues.len(), 1); - assert_eq!(fulfillment.issues[0].code, "forked_fulfillments"); - assert_eq!(fulfillment.issues[0].event_ids, expected_event_ids); - } - - #[test] - fn order_fulfillment_dry_run_view_chains_from_latest_visible_event() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.output.dry_run = true; - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::Preparing, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - fulfillment_event.clone(), - ], - }, - ); - let args = OrderFulfillmentArgs { - key: fixture.order_id.clone(), - state: "ready_for_pickup".to_owned(), - idempotency_key: Some("idem_fulfillment".to_owned()), - }; +fn order_binding_error_view( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + args: &OrderSubmitArgs, + error: ActorWriteBindingError, +) -> OrderSubmitView { + let (state, reason, actions) = order_actor_write_binding_error_parts(error); - let view = order_fulfillment_dry_run_view( - &config, - &args, - &status_view, - RadrootsOrderFulfillmentState::ReadyForPickup, - ); - let request_event_id = fixture.request_event.id.to_string(); - let fulfillment_event_id = fulfillment_event.id.to_string(); - - assert_eq!(view.state, "dry_run"); - assert_eq!(view.fulfillment_state, "ready_for_pickup"); - assert_eq!( - view.root_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert_eq!( - view.prev_event_id.as_deref(), - Some(fulfillment_event_id.as_str()) - ); - assert_eq!(view.event_id, None); - assert_eq!(view.event_kind, None); - assert_eq!(view.target_relays, vec!["ws://relay.test"]); - assert_eq!(view.connected_relays, vec!["ws://relay.test"]); - assert_eq!(view.fetched_count, 3); - assert_eq!(view.decoded_count, 3); - assert_eq!(view.idempotency_key.as_deref(), Some("idem_fulfillment")); - } - - #[test] - fn order_fulfillment_preflight_rejects_terminal_fulfillment_state() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::Delivered, - ); - let fulfillment_event_id = fulfillment_event.id.to_string(); - let reduction = order_status_reduction_from_receipt_with_context( - OrderStatusContext { - order_id: fixture.order_id.as_str(), - buyer_pubkey: None, - seller_pubkey: None, - selected_account_pubkey: None, - actor_context_source: ORDER_ACTOR_CONTEXT_NETWORK_ONLY, - }, - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - fulfillment_event, - ], - }, - ); - let args = OrderFulfillmentArgs { - key: fixture.order_id.clone(), - state: "ready_for_pickup".to_owned(), - idempotency_key: None, - }; + let mut actions = actions; + actions.push(format!( + "radroots order get {}", + loaded.document.order.order_id + )); + + OrderSubmitView { + state: state.clone(), + source: ORDER_SOURCE.to_owned(), + order_id: loaded.document.order.order_id.clone(), + file: loaded.file.display().to_string(), + listing_lookup: loaded.document.listing_lookup.clone(), + listing_addr: non_empty_string(loaded.document.order.listing_addr.clone()), + listing_event_id: non_empty_string(loaded.document.order.listing_event_id.clone()), + listing_relays: order_listing_relays(&loaded.document), + 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, + dry_run: config.output.dry_run, + deduplicated: false, + target_relays: Vec::new(), + connected_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, + requested_signer_session_id: None, + reason: Some(reason), + job: None, + issues: Vec::new(), + actions, + } +} - let view = order_fulfillment_preflight_view_from_status( - &config, - &args, - &reduction.view, - reduction.fulfillment_status, - reduction.fulfillment_event_id.as_deref(), +fn validate_bound_order_buyer_account( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, +) -> Result<account::AccountRecordView, RuntimeError> { + let document = &loaded.document; + let account_id = document.buyer_actor.account_id.trim(); + let buyer_pubkey = document.buyer_actor.pubkey.trim(); + let snapshot = account::snapshot(config)?; + let Some(account) = snapshot + .accounts + .iter() + .find(|account| account.record.account_id.as_str() == account_id) + .cloned() + else { + return Err(account::AccountRuntimeFailure::unresolved_with_detail( + format!( + "order-bound buyer account `{account_id}` is not present in the local account store" + ), + order_buyer_failure_detail( + loaded, + json!({ + "actions": [ + "radroots account import <path>", + format!("radroots order rebind {} <selector>", document.order.order_id), + format!("radroots order get {}", document.order.order_id), + ], + }), + ), ) - .expect("terminal fulfillment preflight"); + .into()); + }; - assert_eq!(view.state, "invalid"); - let fulfillment = reduction - .view - .fulfillment - .as_ref() - .expect("fulfillment view"); - assert_eq!(fulfillment.state, "delivered"); - assert_eq!(fulfillment.terminal, true); - assert_eq!(fulfillment.inventory_released, false); - assert_eq!(view.issues[0].code, "fulfillment_unsupported_transition"); - assert_eq!(view.issues[0].event_ids, vec![fulfillment_event_id]); - assert!(view.event_id.is_none()); - } - - #[test] - fn order_fulfillment_preflight_rejects_completed_order_as_terminal() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::Delivered, - ); - let receipt_event = signed_buyer_receipt_event( - &fixture.buyer, - &fixture.request_event, - &fulfillment_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - true, - None, - ); - let receipt_event_id = receipt_event.id.to_string(); - let reduction = order_status_reduction_from_receipt_with_context( - OrderStatusContext { - order_id: fixture.order_id.as_str(), - buyer_pubkey: None, - seller_pubkey: None, - selected_account_pubkey: None, - actor_context_source: ORDER_ACTOR_CONTEXT_NETWORK_ONLY, - }, - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - fulfillment_event, - receipt_event, - ], - }, - ); - let args = OrderFulfillmentArgs { - key: fixture.order_id.clone(), - state: "ready_for_pickup".to_owned(), - idempotency_key: None, - }; + let account_pubkey = account.record.public_identity.public_key_hex.as_str(); + if !account_pubkey.eq_ignore_ascii_case(buyer_pubkey) + || !document + .order + .buyer_pubkey + .eq_ignore_ascii_case(buyer_pubkey) + { + return Err(account::AccountRuntimeFailure::mismatch_with_detail( + format!( + "order-bound buyer account `{account_id}` does not match order buyer pubkey `{buyer_pubkey}`" + ), + order_buyer_failure_detail( + loaded, + json!({ + "attempted_buyer_account_id": account_id, + "attempted_buyer_pubkey": account_pubkey, + "actions": [ + format!("radroots order rebind {} <selector>", document.order.order_id), + format!("radroots order get {}", document.order.order_id), + ], + }), + ), + ) + .into()); + } - let view = order_fulfillment_preflight_view_from_status( - &config, - &args, - &reduction.view, - reduction.fulfillment_status, - reduction.fulfillment_event_id.as_deref(), + if !account.write_capable { + return Err(account::AccountRuntimeFailure::watch_only_with_detail( + account_id, + order_buyer_failure_detail( + loaded, + json!({ + "actions": [ + format!("radroots account attach-secret {account_id} <path>"), + format!("radroots order get {}", document.order.order_id), + ], + }), + ), ) - .expect("completed fulfillment preflight"); - - assert_eq!(view.state, "terminal"); - assert_eq!( - view.disposition(), - crate::view::runtime::CommandDisposition::ValidationFailed - ); - assert_eq!( - view.prev_event_id.as_deref(), - Some(receipt_event_id.as_str()) - ); - assert!(view.event_id.is_none()); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("already terminal") - ); + .into()); } - #[test] - fn order_fulfillment_preflight_rejects_missing_order() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: Vec::new(), - }, - ); - let args = fulfillment_args_for_fixture(&fixture, "ready_for_pickup"); - - let view = - order_fulfillment_preflight_view_from_status(&config, &args, &status_view, None, None) - .expect("missing fulfillment preflight"); - - assert_eq!(view.state, "missing"); - assert_eq!(view.event_id, None); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("no active order events") - ); - assert_eq!( - view.actions, - vec![format!("radroots order status get {}", fixture.order_id)] - ); + if let Some(selector) = config.account.selector.as_deref() { + let attempted = account::resolve_account_selector(config, selector).map_err(|_| { + account::AccountRuntimeFailure::unresolved_with_detail( + format!("account override `{selector}` did not resolve to a local buyer account"), + order_buyer_failure_detail( + loaded, + json!({ + "attempted_buyer_account_id": selector, + "actions": [ + "radroots account list", + format!("radroots order get {}", document.order.order_id), + ], + }), + ), + ) + })?; + if attempted.record.account_id.as_str() != account_id { + let attempted_pubkey = attempted.record.public_identity.public_key_hex.as_str(); + return Err(account::AccountRuntimeFailure::mismatch_with_detail( + format!( + "account override `{}` cannot retarget order `{}` bound to buyer account `{account_id}`", + attempted.record.account_id, document.order.order_id + ), + order_buyer_failure_detail( + loaded, + json!({ + "attempted_buyer_account_id": attempted.record.account_id.to_string(), + "attempted_buyer_pubkey": attempted_pubkey, + "actions": [ + format!("radroots --account-id {account_id} order submit {}", document.order.order_id), + format!("radroots order rebind {} <selector>", document.order.order_id), + format!("radroots order get {}", document.order.order_id), + ], + }), + ), + ) + .into()); + } } - #[test] - fn order_fulfillment_preflight_rejects_requested_order() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone()], - }, - ); - let args = fulfillment_args_for_fixture(&fixture, "ready_for_pickup"); - - let view = - order_fulfillment_preflight_view_from_status(&config, &args, &status_view, None, None) - .expect("requested fulfillment preflight"); + Ok(account) +} - assert_eq!(view.state, "requested"); - let request_event_id = fixture.request_event.id.to_string(); - assert_eq!( - view.request_event_id.as_deref(), - Some(request_event_id.as_str()) - ); - assert!(view.event_id.is_none()); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("has no accepted seller decision") - ); +fn order_buyer_failure_detail( + loaded: &LoadedOrderDraft, + mut extra: serde_json::Value, +) -> serde_json::Value { + let mut detail = json!({ + "buyer_actor_source": loaded.document.buyer_actor.source.as_str(), + "order_buyer_account_id": loaded.document.buyer_actor.account_id.as_str(), + "order_buyer_pubkey": loaded.document.buyer_actor.pubkey.as_str(), + "order_file": loaded.file.display().to_string(), + "order_id": loaded.document.order.order_id.as_str(), + }); + if let (Some(detail), Some(extra)) = (detail.as_object_mut(), extra.as_object_mut()) { + for (key, value) in std::mem::take(extra) { + detail.insert(key, value); + } } + detail +} - #[test] - fn order_fulfillment_preflight_rejects_declined_order() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Declined { - reason: "out of stock".to_owned(), - }, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event.clone()], - }, - ); - let args = fulfillment_args_for_fixture(&fixture, "ready_for_pickup"); - - let view = - order_fulfillment_preflight_view_from_status(&config, &args, &status_view, None, None) - .expect("declined fulfillment preflight"); - - assert_eq!(view.state, "declined"); - let decision_event_id = decision_event.id.to_string(); - assert_eq!( - view.decision_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - assert!(view.event_id.is_none()); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("was declined") - ); - } +fn resolve_local_order_signing_identity( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, +) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> { + resolve_local_order_bound_buyer_signing_identity(config, loaded, "order submit") +} - #[test] - fn order_fulfillment_preflight_rejects_invalid_order_state() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let accepted_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let declined_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Declined { - reason: "out of stock".to_owned(), - }, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - accepted_event, - declined_event, - ], - }, - ); - let args = fulfillment_args_for_fixture(&fixture, "ready_for_pickup"); - - let view = - order_fulfillment_preflight_view_from_status(&config, &args, &status_view, None, None) - .expect("invalid fulfillment preflight"); - - assert_eq!(view.state, "invalid"); - assert!(view.event_id.is_none()); - assert_eq!(view.issues.len(), 1); - assert_eq!(view.issues[0].code, "conflicting_decisions"); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("failed reducer validation") - ); +fn resolve_local_order_bound_buyer_signing_identity( + config: &RuntimeConfig, + loaded: &LoadedOrderDraft, + action: &str, +) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> { + if !matches!(config.signer.backend, SignerBackend::Local) { + return Err(ActorWriteBindingError::Unconfigured(format!( + "{action} requires signer mode `local`" + ))); } - - #[test] - fn order_fulfillment_signing_rejects_selected_non_seller_account() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - account::create_or_migrate_default_account(&config).expect("create selected account"); - let fixture = order_status_fixture(); - - let error = resolve_local_order_fulfillment_signing_identity( - &config, - fixture.seller_pubkey.as_str(), - ) - .expect_err("non seller account rejected"); - - let reason = error.reason(); - assert!(reason.contains("cannot sign order seller_pubkey")); - } - - #[test] - fn order_status_from_receipt_rejects_wrong_decision_counterparty() { - let fixture = order_status_fixture(); - let wrong_buyer = RadrootsIdentity::generate(); - let decision_event = signed_order_decision_event_with_counterparty( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - wrong_buyer.public_key_hex().as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let decision_event_id = decision_event.id.to_string(); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event], - }; - - let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); - - assert_eq!(view.state, "invalid"); - let issue = view - .reducer_issues - .iter() - .find(|issue| issue.code == "decision_counterparty_mismatch") - .expect("decision counterparty mismatch issue"); - assert_eq!(issue.field, "buyer_pubkey"); - assert_eq!(issue.event_ids, vec![decision_event_id]); - let inventory = view.inventory.as_ref().expect("inventory view"); - assert_eq!(inventory.state, "invalid"); - assert_eq!(inventory.issues[0].code, "decision_counterparty_mismatch"); - } - - #[test] - fn order_decision_preflight_rejects_existing_decision() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let resolution = request_resolution_for_fixture(&fixture); - let request = resolution.requests[0].clone(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let decision_event_id = decision_event.id.to_string(); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event], - }, - ); - let args = OrderDecisionArgs { - key: fixture.order_id.clone(), - decision: OrderDecisionArg::Decline, - reason: Some("out of stock".to_owned()), - idempotency_key: None, - }; - - let view = order_decision_preflight_view_from_status( - &config, - &args, - &request, - &resolution, - &status_view, - ) - .expect("existing decision preflight view"); - - assert_eq!(view.state, "already_decided"); - assert_eq!(view.event_id.as_deref(), Some(decision_event_id.as_str())); - assert_eq!(view.event_kind, Some(KIND_ORDER_DECISION)); - assert_eq!( - view.request_event_id.as_deref(), - Some(request.request_event_id.as_str()) - ); - assert_eq!(view.target_relays, vec!["ws://relay.test"]); - assert_eq!(view.connected_relays, vec!["ws://relay.test"]); - assert_eq!(view.fetched_count, 2); - assert_eq!(view.decoded_count, 2); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("already has a visible `accepted` seller decision") - ); + let account_id = loaded.document.buyer_actor.account_id.trim(); + let buyer_pubkey = loaded.document.buyer_actor.pubkey.trim(); + let signing = account::resolve_local_signing_identity_for_account(config, account_id) + .map_err(ActorWriteBindingError::from_runtime)?; + let selected_pubkey = signing + .account + .record + .public_identity + .public_key_hex + .as_str(); + if !selected_pubkey.eq_ignore_ascii_case(buyer_pubkey) { + return Err(ActorWriteBindingError::Account( + account::AccountRuntimeFailure::mismatch_with_detail( + format!( + "account mismatch: order-bound buyer account `{account_id}` pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" + ), + order_buyer_failure_detail( + loaded, + json!({ + "attempted_buyer_account_id": signing.account.record.account_id.to_string(), + "attempted_buyer_pubkey": selected_pubkey, + "actions": [ + format!("radroots order rebind {} <selector>", loaded.document.order.order_id), + format!("radroots order get {}", loaded.document.order.order_id), + ], + }), + ), + ), + )); } + Ok(signing) +} - #[test] - fn order_decision_preflight_rejects_completed_order_as_terminal() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let resolution = request_resolution_for_fixture(&fixture); - let request = resolution.requests[0].clone(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let decision_event_id = decision_event.id.to_string(); - let fulfillment_event = signed_fulfillment_update_event( - &fixture.seller, - &fixture.request_event, - &decision_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderFulfillmentState::Delivered, - ); - let receipt_event = signed_buyer_receipt_event( - &fixture.buyer, - &fixture.request_event, - &fulfillment_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - true, - None, - ); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - decision_event, - fulfillment_event, - receipt_event, - ], - }, - ); - let args = OrderDecisionArgs { - key: fixture.order_id.clone(), - decision: OrderDecisionArg::Decline, - reason: Some("out of stock".to_owned()), - idempotency_key: None, - }; - - let view = order_decision_preflight_view_from_status( - &config, - &args, - &request, - &resolution, - &status_view, - ) - .expect("terminal decision preflight view"); - - assert_eq!(view.state, "terminal"); - assert_eq!( - view.disposition(), - crate::view::runtime::CommandDisposition::ValidationFailed - ); - assert_eq!(view.event_id.as_deref(), Some(decision_event_id.as_str())); - assert_eq!(view.event_kind, Some(KIND_ORDER_DECISION)); - assert!( - view.reason - .as_deref() - .expect("reason") - .contains("already terminal") - ); +fn resolve_local_order_decision_signing_identity( + config: &RuntimeConfig, + seller_pubkey: &str, + decision: OrderDecisionArg, +) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> { + if !matches!(config.signer.backend, SignerBackend::Local) { + return Err(ActorWriteBindingError::Unconfigured(format!( + "order {} requires signer mode `local`", + decision.command() + ))); } + let signing = account::resolve_local_signing_identity(config) + .map_err(ActorWriteBindingError::from_runtime)?; + let selected_pubkey = signing + .account + .record + .public_identity + .public_key_hex + .as_str(); + if !selected_pubkey.eq_ignore_ascii_case(seller_pubkey) { + return Err(ActorWriteBindingError::Account( + account::AccountRuntimeFailure::mismatch(format!( + "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order seller_pubkey `{seller_pubkey}`" + )), + )); + } + Ok(signing) +} - #[test] - fn order_accept_inventory_preflight_rejects_over_reserved_projection() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let resolution = request_resolution_for_fixture(&fixture); - let request = resolution.requests[0].clone(); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone()], - }, - ); - let existing_order_id = "ord_AAAAAAAAAAAAAAAAAAAAAw"; - let existing_request_event = signed_order_request_event( - &fixture.buyer, - existing_order_id, - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - fixture.listing_event_id.as_str(), - ); - let existing_request = ResolvedSellerOrderRequest { - request_event: radroots_event_from_nostr(&existing_request_event), - request_event_id: test_event_id(existing_request_event.id.to_string().as_str()), - listing_event_id: Some(fixture.listing_event_id.clone()), - order_id: test_order_id(existing_order_id), - listing_addr: test_listing_addr(fixture.listing_addr.as_str()), - buyer_pubkey: test_pubkey(fixture.buyer_pubkey.as_str()), - seller_pubkey: test_pubkey(fixture.seller_pubkey.as_str()), - items: vec![RadrootsOrderItem { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - economics: sample_order_economics(existing_order_id, "bin-1", 2), - }; - let existing_decision_payload = - accepted_order_decision_payload_from_request(&existing_request); - let existing_decision_payload = canonicalize_order_decision_for_signer( - existing_decision_payload, - fixture.seller_pubkey.as_str(), - ) - .expect("canonical existing decision"); - let projection = reduce_listing_inventory_accounting( - &test_listing_addr(fixture.listing_addr.as_str()), - &test_event_id(fixture.listing_event_id.as_str()), - RadrootsListingInventoryAccountingInputs { - bins: vec![RadrootsListingInventoryBinAvailability { - bin_id: test_inventory_bin_id("bin-1"), - available_count: 2, - }], - requests: vec![ - active_request_record_from_resolved(&existing_request), - active_request_record_from_resolved(&request), - ], - decisions: vec![ - RadrootsOrderDecisionRecord { - event_id: test_event_id_char('2'), - author_pubkey: test_pubkey(fixture.seller_pubkey.as_str()), - counterparty_pubkey: test_pubkey(fixture.buyer_pubkey.as_str()), - root_event_id: existing_request.request_event_id.clone(), - prev_event_id: existing_request.request_event_id.clone(), - payload: existing_decision_payload, - }, - proposed_accept_decision_record(&request).expect("proposed accept decision"), - ], - revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(), - revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(), - fulfillments: Vec::<RadrootsOrderFulfillmentRecord>::new(), - cancellations: Vec::<RadrootsOrderCancellationRecord>::new(), - receipts: Vec::<RadrootsOrderReceiptRecord>::new(), - }, - ); - let args = OrderDecisionArgs { - key: fixture.order_id.clone(), - decision: OrderDecisionArg::Accept, - reason: None, - idempotency_key: None, - }; - - let view = order_accept_inventory_preflight_view_from_projection( - &config, - &args, - &request, - &resolution, - &status_view, - projection, - ) - .invalid_view - .expect("invalid inventory preflight view"); - - assert_eq!(view.state, "invalid"); - assert_eq!(view.issues.len(), 1); - assert_eq!(view.issues[0].code, "listing_inventory_over_reserved"); - assert!(view.event_id.is_none()); - } - - #[test] - fn order_accept_inventory_preflight_counts_seller_cancelled_release() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let fixture = order_status_fixture(); - let resolution = request_resolution_for_fixture(&fixture); - let request = resolution.requests[0].clone(); - let status_view = order_status_from_receipt( - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone()], - }, - ); - let existing_order_id = "ord_AAAAAAAAAAAAAAAAAAAAAw"; - let existing_request_event = signed_order_request_event( - &fixture.buyer, - existing_order_id, - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - fixture.listing_event_id.as_str(), - ); - let existing_request = ResolvedSellerOrderRequest { - request_event: radroots_event_from_nostr(&existing_request_event), - request_event_id: test_event_id(existing_request_event.id.to_string().as_str()), - listing_event_id: Some(fixture.listing_event_id.clone()), - order_id: test_order_id(existing_order_id), - listing_addr: test_listing_addr(fixture.listing_addr.as_str()), - buyer_pubkey: test_pubkey(fixture.buyer_pubkey.as_str()), - seller_pubkey: test_pubkey(fixture.seller_pubkey.as_str()), - items: vec![RadrootsOrderItem { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - economics: sample_order_economics(existing_order_id, "bin-1", 2), - }; - let existing_decision_payload = - accepted_order_decision_payload_from_request(&existing_request); - let existing_decision_payload = canonicalize_order_decision_for_signer( - existing_decision_payload, - fixture.seller_pubkey.as_str(), - ) - .expect("canonical existing decision"); - let existing_decision_event_id = test_event_id_char('2'); - let projection = reduce_listing_inventory_accounting( - &test_listing_addr(fixture.listing_addr.as_str()), - &test_event_id(fixture.listing_event_id.as_str()), - RadrootsListingInventoryAccountingInputs { - bins: vec![RadrootsListingInventoryBinAvailability { - bin_id: test_inventory_bin_id("bin-1"), - available_count: 2, - }], - requests: vec![ - active_request_record_from_resolved(&existing_request), - active_request_record_from_resolved(&request), - ], - decisions: vec![ - RadrootsOrderDecisionRecord { - event_id: existing_decision_event_id.clone(), - author_pubkey: test_pubkey(fixture.seller_pubkey.as_str()), - counterparty_pubkey: test_pubkey(fixture.buyer_pubkey.as_str()), - root_event_id: existing_request.request_event_id.clone(), - prev_event_id: existing_request.request_event_id.clone(), - payload: existing_decision_payload, - }, - proposed_accept_decision_record(&request).expect("proposed accept decision"), - ], - revision_proposals: Vec::<RadrootsOrderRevisionProposalRecord>::new(), - revision_decisions: Vec::<RadrootsOrderRevisionDecisionRecord>::new(), - fulfillments: vec![RadrootsOrderFulfillmentRecord { - event_id: test_event_id_char('3'), - author_pubkey: test_pubkey(fixture.seller_pubkey.as_str()), - counterparty_pubkey: test_pubkey(fixture.buyer_pubkey.as_str()), - root_event_id: existing_request.request_event_id.clone(), - prev_event_id: existing_decision_event_id, - payload: RadrootsOrderFulfillmentUpdate { - order_id: existing_request.order_id.clone(), - listing_addr: existing_request.listing_addr.clone(), - buyer_pubkey: existing_request.buyer_pubkey.clone(), - seller_pubkey: existing_request.seller_pubkey.clone(), - status: RadrootsOrderFulfillmentState::SellerCancelled, - }, - }], - cancellations: Vec::<RadrootsOrderCancellationRecord>::new(), - receipts: Vec::<RadrootsOrderReceiptRecord>::new(), - }, - ); - let args = OrderDecisionArgs { - key: fixture.order_id.clone(), - decision: OrderDecisionArg::Accept, - reason: None, - idempotency_key: None, - }; - - let preflight = order_accept_inventory_preflight_view_from_projection( - &config, - &args, - &request, - &resolution, - &status_view, - projection, - ); - let inventory = preflight.inventory.expect("valid inventory preflight"); - - assert!(preflight.invalid_view.is_none()); - assert_eq!(inventory.state, "reserved"); - assert_eq!(inventory.commitment_valid, true); - assert_eq!(inventory.bins.len(), 1); - assert_eq!(inventory.bins[0].committed_count, 2); - assert_eq!(inventory.bins[0].remaining_count, Some(0)); - assert!(inventory.issues.is_empty()); - } - - #[test] - fn order_status_from_receipt_reports_mismatched_commitment_inventory_invalid() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 1, - }], - }, - ); - let decision_event_id = decision_event.id.to_string(); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event], - }; - - let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); +fn resolve_local_order_revision_signing_identity( + config: &RuntimeConfig, + seller_pubkey: &str, +) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> { + if !matches!(config.signer.backend, SignerBackend::Local) { + return Err(ActorWriteBindingError::Unconfigured( + "order revision propose requires signer mode `local`".to_owned(), + )); + } + let signing = account::resolve_local_signing_identity(config) + .map_err(ActorWriteBindingError::from_runtime)?; + let selected_pubkey = signing + .account + .record + .public_identity + .public_key_hex + .as_str(); + if !selected_pubkey.eq_ignore_ascii_case(seller_pubkey) { + return Err(ActorWriteBindingError::Account( + account::AccountRuntimeFailure::mismatch(format!( + "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order seller_pubkey `{seller_pubkey}`" + )), + )); + } + Ok(signing) +} - assert_eq!(view.state, "invalid"); - let issue = view - .reducer_issues - .iter() - .find(|issue| issue.code == "decision_inventory_commitment_mismatch") - .expect("commitment mismatch issue"); - assert_eq!(issue.event_ids, vec![decision_event_id]); - let inventory = view.inventory.as_ref().expect("inventory view"); - assert_eq!(inventory.state, "invalid"); - assert_eq!(inventory.commitment_valid, false); - assert_eq!( - inventory.issues[0].code, - "decision_inventory_commitment_mismatch" - ); +fn resolve_local_order_cancellation_signing_identity( + config: &RuntimeConfig, + buyer_pubkey: &str, +) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> { + if !matches!(config.signer.backend, SignerBackend::Local) { + return Err(ActorWriteBindingError::Unconfigured( + "order cancel requires signer mode `local`".to_owned(), + )); + } + let signing = account::resolve_local_signing_identity(config) + .map_err(ActorWriteBindingError::from_runtime)?; + let selected_pubkey = signing + .account + .record + .public_identity + .public_key_hex + .as_str(); + if !selected_pubkey.eq_ignore_ascii_case(buyer_pubkey) { + return Err(ActorWriteBindingError::Account( + account::AccountRuntimeFailure::mismatch(format!( + "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" + )), + )); } + Ok(signing) +} - #[test] - fn order_status_from_receipt_reports_declined() { - let fixture = order_status_fixture(); - let decision_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Declined { - reason: "out of stock".to_owned(), - }, - ); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), decision_event.clone()], - }; +fn resolve_local_order_revision_decision_signing_identity( + config: &RuntimeConfig, + buyer_pubkey: &str, + args: &OrderRevisionDecisionArgs, +) -> Result<account::AccountSigningIdentity, ActorWriteBindingError> { + if !matches!(config.signer.backend, SignerBackend::Local) { + return Err(ActorWriteBindingError::Unconfigured(format!( + "order revision {} requires signer mode `local`", + args.decision.command() + ))); + } + let signing = account::resolve_local_signing_identity(config) + .map_err(ActorWriteBindingError::from_runtime)?; + let selected_pubkey = signing + .account + .record + .public_identity + .public_key_hex + .as_str(); + if !selected_pubkey.eq_ignore_ascii_case(buyer_pubkey) { + return Err(ActorWriteBindingError::Account( + account::AccountRuntimeFailure::mismatch(format!( + "account mismatch: resolved account pubkey `{selected_pubkey}` cannot sign order buyer_pubkey `{buyer_pubkey}`" + )), + )); + } + Ok(signing) +} - let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); - let decision_event_id = decision_event.id.to_string(); +fn relay_failures(failures: Vec<DirectRelayFailure>) -> Vec<RelayFailureView> { + failures + .into_iter() + .map(|failure| RelayFailureView { + relay: failure.relay, + reason: failure.reason, + }) + .collect() +} - assert_eq!(view.state, "declined"); - assert_eq!( - view.decision_event_id.as_deref(), - Some(decision_event_id.as_str()) - ); - let inventory = view.inventory.as_ref().expect("inventory view"); - assert_eq!(inventory.state, "not_reserved"); - assert_eq!(inventory.commitment_valid, true); - assert!(inventory.bins.is_empty()); - assert!(view.economics.is_none()); - assert!(view.fulfillment.is_none()); - assert!(view.reducer_issues.is_empty()); - assert_eq!(view.decoded_count, 2); - } - - #[test] - fn order_status_from_receipt_reports_conflicting_decisions_invalid() { - let fixture = order_status_fixture(); - let accepted_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Accepted { - inventory_commitments: vec![RadrootsOrderInventoryCommitment { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - }, - ); - let declined_event = signed_order_decision_event( - &fixture.seller, - &fixture.request_event, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - RadrootsOrderDecisionOutcome::Declined { - reason: "out of stock".to_owned(), - }, - ); - let mut expected_event_ids = - vec![accepted_event.id.to_string(), declined_event.id.to_string()]; - expected_event_ids.sort(); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![ - fixture.request_event.clone(), - accepted_event, - declined_event, - ], - }; +fn load_draft(path: &Path) -> Result<LoadedOrderDraft, String> { + let contents = fs::read_to_string(path) + .map_err(|error| format!("read order draft {}: {error}", path.display()))?; + let document = toml::from_str::<OrderDraftDocument>(contents.as_str()) + .map_err(|error| format!("parse order draft {}: {error}", path.display()))?; + Ok(LoadedOrderDraft { + file: path.to_path_buf(), + updated_at_unix: modified_unix(path).unwrap_or_default(), + document, + }) +} - let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); +fn save_draft(path: &Path, draft: &OrderDraftDocument) -> Result<(), RuntimeError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, scaffold_contents(draft)?)?; + Ok(()) +} - assert_eq!(view.state, "invalid"); - assert_eq!(view.decoded_count, 3); - assert!(view.decision_event_id.is_none()); - let issue = view - .reducer_issues - .iter() - .find(|issue| issue.code == "conflicting_decisions") - .expect("conflicting decision issue"); - assert_eq!(issue.field, "decision_event_id"); - assert_eq!( - issue.message, - "active order reducer reported conflicting decisions" - ); - assert_eq!(issue.event_ids, expected_event_ids); - } - - #[test] - fn order_status_from_receipt_reports_invalid_same_order_request_candidate() { - let fixture = order_status_fixture(); - let invalid_event = signed_malformed_order_request_event( - &fixture.buyer, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - "2".repeat(64).as_str(), - ); - let invalid_event_id = invalid_event.id.to_string(); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), invalid_event], - }; +fn scaffold_contents(draft: &OrderDraftDocument) -> Result<String, RuntimeError> { + let toml = toml::to_string_pretty(draft) + .map_err(|error| RuntimeError::Config(format!("render order draft: {error}")))?; + Ok(format!( + "# radroots order draft v1\n# fill listing_addr and any missing order items before submit\n\n{toml}" + )) +} - let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); +fn drafts_dir(config: &RuntimeConfig) -> PathBuf { + config.paths.app_data_root.join(ORDERS_DIR) +} - assert_eq!(view.state, "invalid"); - assert_eq!(view.decoded_count, 1); - assert_eq!(view.skipped_count, 1); - assert_eq!( - view.reason.as_deref(), - Some( - "active order request candidates for `ord_AAAAAAAAAAAAAAAAAAAAAg` failed status validation" - ) - ); - let issue = view - .reducer_issues - .iter() - .find(|issue| issue.code == "invalid_request_candidate") - .expect("invalid request candidate issue"); - assert_eq!(issue.field, "request_event_id"); - assert_eq!(issue.event_ids, vec![invalid_event_id]); - } - - #[test] - fn order_status_from_receipt_reports_multiple_request_candidates_invalid() { - let fixture = order_status_fixture(); - let second_request_event = signed_order_request_event( - &fixture.buyer, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - "2".repeat(64).as_str(), - ); - let mut expected_event_ids = vec![ - fixture.request_event.id.to_string(), - second_request_event.id.to_string(), - ]; - expected_event_ids.sort(); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), second_request_event], - }; +fn draft_lookup_path(config: &RuntimeConfig, lookup: &str) -> PathBuf { + let candidate = PathBuf::from(lookup); + if candidate.is_absolute() || lookup.contains(std::path::MAIN_SEPARATOR) { + return candidate; + } + let file_name = if lookup.ends_with(".toml") { + lookup.to_owned() + } else { + format!("{lookup}.toml") + }; + drafts_dir(config).join(file_name) +} - let view = order_status_from_receipt(fixture.order_id.as_str(), receipt); +#[derive(Debug, Clone)] +struct ParsedListingAddress { + kind: u32, + seller_pubkey: String, + listing_id: String, +} - assert_eq!(view.state, "invalid"); - assert_eq!(view.decoded_count, 2); - assert_eq!(view.skipped_count, 0); - let issue = view - .reducer_issues - .iter() - .find(|issue| issue.code == "multiple_requests") - .expect("multiple request issue"); - assert_eq!(issue.field, "request_event_id"); - assert_eq!(issue.event_ids, expected_event_ids); - } - - #[test] - fn seller_order_request_resolution_skips_wrong_seller_request() { - let selected_seller = RadrootsIdentity::generate(); - let other_seller = RadrootsIdentity::generate(); - let buyer = RadrootsIdentity::generate(); - let selected_seller_pubkey = selected_seller.public_key_hex(); - let other_seller_pubkey = other_seller.public_key_hex(); - let buyer_pubkey = buyer.public_key_hex(); - let order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg"; - let listing_event_id = "1".repeat(64); - let listing_addr = format!("30402:{other_seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![signed_order_request_event( - &buyer, - order_id, - listing_addr.as_str(), - buyer_pubkey.as_str(), - other_seller_pubkey.as_str(), - listing_event_id.as_str(), - )], - }; +fn parse_listing_addr(raw: &str) -> Result<ParsedListingAddress, String> { + let parsed = RadrootsListingAddress::parse(raw).map_err(|error| error.to_string())?; + let (kind, rest) = parsed + .as_str() + .split_once(':') + .ok_or_else(|| "listing address has invalid format".to_owned())?; + let (seller_pubkey, listing_id) = rest + .split_once(':') + .ok_or_else(|| "listing address has invalid format".to_owned())?; + let kind = kind + .parse::<u32>() + .map_err(|_| "listing address kind is invalid".to_owned())?; + Ok(ParsedListingAddress { + kind, + seller_pubkey: seller_pubkey.to_owned(), + listing_id: listing_id.to_owned(), + }) +} - let resolution = seller_order_request_resolution_from_receipt( - selected_seller_pubkey.as_str(), - order_id, - receipt, - ) - .expect("seller order request resolution"); - - assert_eq!(resolution.fetched_count, 1); - assert_eq!(resolution.decoded_count, 0); - assert_eq!(resolution.skipped_count, 1); - assert!(resolution.requests.is_empty()); - } - - #[test] - fn seller_order_request_resolution_skips_listing_outside_seller_authority() { - let seller = RadrootsIdentity::generate(); - let listing_seller = RadrootsIdentity::generate(); - let buyer = RadrootsIdentity::generate(); - let seller_pubkey = seller.public_key_hex(); - let listing_seller_pubkey = listing_seller.public_key_hex(); - let buyer_pubkey = buyer.public_key_hex(); - let order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg"; - let listing_event_id = "1".repeat(64); - let listing_addr = format!("30402:{listing_seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![signed_order_request_event( - &buyer, - order_id, - listing_addr.as_str(), - buyer_pubkey.as_str(), - seller_pubkey.as_str(), - listing_event_id.as_str(), - )], - }; +fn issue(field: impl Into<String>, message: impl Into<String>) -> OrderIssueView { + let field = field.into(); + issue_with_code(validation_issue_code(&field), field, message) +} - let resolution = - seller_order_request_resolution_from_receipt(seller_pubkey.as_str(), order_id, receipt) - .expect("seller order request resolution"); - - assert_eq!(resolution.fetched_count, 1); - assert_eq!(resolution.decoded_count, 0); - assert_eq!(resolution.skipped_count, 1); - assert!(resolution.requests.is_empty()); - } - - #[test] - fn seller_order_request_resolution_reports_invalid_same_order_candidate() { - let fixture = order_status_fixture(); - let invalid_event = signed_malformed_order_request_event( - &fixture.buyer, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - "2".repeat(64).as_str(), - ); - let invalid_event_id = invalid_event.id.to_string(); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), invalid_event], - }; +fn issue_with_code( + code: impl Into<String>, + field: impl Into<String>, + message: impl Into<String>, +) -> OrderIssueView { + OrderIssueView { + code: code.into(), + field: field.into(), + message: message.into(), + event_ids: Vec::new(), + } +} - let resolution = seller_order_request_resolution_from_receipt( - fixture.seller_pubkey.as_str(), - fixture.order_id.as_str(), - receipt, - ) - .expect("seller order request resolution"); - - assert_eq!(resolution.fetched_count, 2); - assert_eq!(resolution.decoded_count, 1); - assert_eq!(resolution.skipped_count, 1); - assert_eq!(resolution.requests.len(), 1); - assert_eq!(resolution.candidate_issues.len(), 1); - assert_eq!( - resolution.candidate_issues[0].code, - "invalid_request_candidate" - ); - assert_eq!( - resolution.candidate_issues[0].event_ids, - vec![invalid_event_id.clone()] - ); +fn issue_with_events( + code: impl Into<String>, + field: impl Into<String>, + message: impl Into<String>, + event_ids: Vec<impl ToString>, +) -> OrderIssueView { + let mut event_ids = event_ids + .into_iter() + .map(|event_id| event_id.to_string()) + .collect::<Vec<_>>(); + event_ids.sort(); + event_ids.dedup(); + OrderIssueView { + code: code.into(), + field: field.into(), + message: message.into(), + event_ids, + } +} - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.output.dry_run = true; - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let args = OrderDecisionArgs { - key: fixture.order_id.clone(), - decision: OrderDecisionArg::Accept, - reason: None, - idempotency_key: None, - }; - let view = order_decision_view_from_resolution( - &config, - &args, - fixture.seller_pubkey.clone(), - resolution, - ); +fn validation_issue_code(field: &str) -> String { + let mut code = String::new(); + let mut previous_separator = false; + for character in field.chars() { + if character.is_ascii_alphanumeric() { + code.push(character.to_ascii_lowercase()); + previous_separator = false; + } else if !previous_separator { + code.push('_'); + previous_separator = true; + } + } + let code = code.trim_matches('_'); + if code.is_empty() { + "validation_failed".to_owned() + } else { + format!("{code}_invalid") + } +} - assert_eq!(view.state, "invalid"); - assert_eq!(view.issues[0].code, "invalid_request_candidate"); - assert_eq!(view.issues[0].event_ids, vec![invalid_event_id]); - assert!(view.event_id.is_none()); - } - - #[test] - fn seller_order_request_resolution_reports_multiple_same_order_candidates_invalid() { - let fixture = order_status_fixture(); - let second_request_event = signed_order_request_event( - &fixture.buyer, - fixture.order_id.as_str(), - fixture.listing_addr.as_str(), - fixture.buyer_pubkey.as_str(), - fixture.seller_pubkey.as_str(), - "2".repeat(64).as_str(), - ); - let mut expected_event_ids = vec![ - fixture.request_event.id.to_string(), - second_request_event.id.to_string(), - ]; - expected_event_ids.sort(); - let receipt = DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone(), second_request_event], - }; +fn normalize_optional(value: Option<&str>) -> Option<String> { + let value = value?; + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } +} - let resolution = seller_order_request_resolution_from_receipt( - fixture.seller_pubkey.as_str(), - fixture.order_id.as_str(), - receipt, - ) - .expect("seller order request resolution"); - - assert_eq!(resolution.fetched_count, 2); - assert_eq!(resolution.decoded_count, 2); - assert_eq!(resolution.skipped_count, 0); - assert_eq!(resolution.requests.len(), 2); - - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.output.dry_run = true; - config.relay.urls = vec!["ws://relay.test".to_owned()]; - let args = OrderDecisionArgs { - key: fixture.order_id.clone(), - decision: OrderDecisionArg::Accept, - reason: None, - idempotency_key: None, - }; - let view = order_decision_view_from_resolution( - &config, - &args, - fixture.seller_pubkey.clone(), - resolution, - ); +fn normalize_listing_relay_set<I, S>(values: I) -> Result<Vec<String>, String> +where + I: IntoIterator<Item = S>, + S: AsRef<str>, +{ + normalize_relay_urls(values).map_err(|error| error.to_string()) +} - assert_eq!(view.state, "invalid"); - assert_eq!(view.issues.len(), 1); - assert_eq!(view.issues[0].code, "multiple_request_candidates"); - assert_eq!(view.issues[0].field, "request_event_id"); - assert_eq!(view.issues[0].event_ids, expected_event_ids); - assert!(view.event_id.is_none()); - } - - struct OrderStatusFixture { - buyer: RadrootsIdentity, - seller: RadrootsIdentity, - order_id: String, - listing_addr: String, - listing_event_id: String, - buyer_pubkey: String, - seller_pubkey: String, - request_event: radroots_nostr::prelude::RadrootsNostrEvent, - } - - fn order_status_fixture() -> OrderStatusFixture { - let seller = RadrootsIdentity::generate(); - let buyer = RadrootsIdentity::generate(); - let seller_pubkey = seller.public_key_hex(); - let buyer_pubkey = buyer.public_key_hex(); - let order_id = "ord_AAAAAAAAAAAAAAAAAAAAAg".to_owned(); - let listing_event_id = "1".repeat(64); - let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAg"); - let request_event = signed_order_request_event( - &buyer, - order_id.as_str(), - listing_addr.as_str(), - buyer_pubkey.as_str(), - seller_pubkey.as_str(), - listing_event_id.as_str(), - ); +fn order_listing_relays(document: &OrderDraftDocument) -> Vec<String> { + normalize_listing_relay_set(document.order.listing_relays.iter()) + .unwrap_or_else(|_| document.order.listing_relays.clone()) +} - OrderStatusFixture { - buyer, - seller, - order_id, - listing_addr, - listing_event_id, - buyer_pubkey, - seller_pubkey, - request_event, - } +fn non_empty_string(value: String) -> Option<String> { + if value.trim().is_empty() { + None + } else { + Some(value) } +} - fn sample_order_economics( - order_id: &str, - bin_id: &str, - bin_count: u32, - ) -> RadrootsOrderEconomics { - sample_order_economics_with_unit_price(order_id, bin_id, bin_count, 6) - } - - fn sample_order_economics_with_unit_price( - order_id: &str, - bin_id: &str, - bin_count: u32, - unit_price: u32, - ) -> RadrootsOrderEconomics { - let currency = RadrootsCoreCurrency::USD; - let unit_price_amount = RadrootsCoreDecimal::from(unit_price); - let line_amount = unit_price_amount * RadrootsCoreDecimal::from(bin_count); - RadrootsOrderEconomics { - quote_id: test_order_quote_id(format!("quote_{order_id}").as_str()), - quote_version: 1, - pricing_basis: RadrootsOrderPricingBasis::ListingEvent, - currency, - items: vec![RadrootsOrderEconomicItem { - bin_id: test_inventory_bin_id(bin_id), - bin_count, - quantity_amount: RadrootsCoreDecimal::ONE, - quantity_unit: RadrootsCoreUnit::Each, - unit_price_amount, - unit_price_currency: currency, - line_subtotal: RadrootsCoreMoney::new(line_amount, currency), - }], - discounts: Vec::new(), - adjustments: Vec::new(), - subtotal: RadrootsCoreMoney::new(line_amount, currency), - discount_total: RadrootsCoreMoney::zero(currency), - adjustment_total: RadrootsCoreMoney::zero(currency), - total: RadrootsCoreMoney::new(line_amount, currency), - } +fn non_empty_ref(value: &str) -> Option<&str> { + if value.trim().is_empty() { + None + } else { + Some(value) } +} - fn loaded_order_draft_for_fixture(fixture: &OrderStatusFixture) -> LoadedOrderDraft { - LoadedOrderDraft { - file: PathBuf::from(format!("{}.toml", fixture.order_id)), - updated_at_unix: 0, - document: OrderDraftDocument { - version: 1, - kind: ORDER_DRAFT_KIND.to_owned(), - order: OrderDraft { - order_id: fixture.order_id.clone(), - listing_addr: fixture.listing_addr.clone(), - listing_event_id: fixture.listing_event_id.clone(), - listing_relays: vec!["ws://relay.test".to_owned()], - buyer_pubkey: fixture.buyer_pubkey.clone(), - seller_pubkey: fixture.seller_pubkey.clone(), - items: vec![OrderDraftItem { - bin_id: "bin-1".to_owned(), - bin_count: 2, - }], - economics: Some(sample_order_economics( - fixture.order_id.as_str(), - "bin-1", - 2, - )), - }, - buyer_actor: OrderDraftBuyerActor { - account_id: "acct_test".to_owned(), - pubkey: fixture.buyer_pubkey.clone(), - source: ORDER_BUYER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(), - }, - listing_lookup: Some("test-listing".to_owned()), - }, - } - } +fn modified_unix(path: &Path) -> Option<u64> { + let modified = fs::metadata(path).ok()?.modified().ok()?; + modified + .duration_since(UNIX_EPOCH) + .ok() + .map(|value| value.as_secs()) +} - fn request_resolution_for_fixture( - fixture: &OrderStatusFixture, - ) -> SellerOrderRequestResolution { - seller_order_request_resolution_from_receipt( - fixture.seller_pubkey.as_str(), - fixture.order_id.as_str(), - DirectRelayFetchReceipt { - target_relays: vec!["ws://relay.test".to_owned()], - connected_relays: vec!["ws://relay.test".to_owned()], - failed_relays: Vec::new(), - events: vec![fixture.request_event.clone()], - }, - ) - .expect("seller order request resolution") - } +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_secs()) + .unwrap_or_default() +} - fn fulfillment_args_for_fixture( - fixture: &OrderStatusFixture, - state: &str, - ) -> OrderFulfillmentArgs { - OrderFulfillmentArgs { - key: fixture.order_id.clone(), - state: state.to_owned(), - idempotency_key: None, - } - } +fn next_order_id() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let counter = ORDER_COUNTER.fetch_add(1, Ordering::Relaxed) as u128; + format!( + "ord_{}", + encode_base64url_no_pad((nanos ^ counter).to_be_bytes()) + ) +} - fn cancel_args_for_fixture(fixture: &OrderStatusFixture, reason: &str) -> OrderCancelArgs { - OrderCancelArgs { - key: fixture.order_id.clone(), - reason: reason.to_owned(), - idempotency_key: None, - } - } +fn next_revision_id() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let counter = ORDER_COUNTER.fetch_add(1, Ordering::Relaxed) as u128; + format!( + "rev_{}", + encode_base64url_no_pad((nanos ^ counter).to_be_bytes()) + ) +} - fn receipt_args_for_fixture( - fixture: &OrderStatusFixture, - received: bool, - issue: Option<&str>, - ) -> OrderReceiptArgs { - OrderReceiptArgs { - key: fixture.order_id.clone(), - received, - issue: issue.map(str::to_owned), - idempotency_key: None, - } +fn is_valid_order_id(value: &str) -> bool { + if let Some(encoded) = value.strip_prefix("ord_") { + return encoded.len() == 22 && is_d_tag_base64url(encoded); } + is_canonical_uuid(value) +} - fn payment_args_for_fixture(fixture: &OrderStatusFixture) -> OrderPaymentArgs { - OrderPaymentArgs { - key: fixture.order_id.clone(), - amount: "12".to_owned(), - currency: "USD".to_owned(), - method: "manual_transfer".to_owned(), - reference: Some("memo-1".to_owned()), - paid_at: Some(1_777_666_000), - idempotency_key: None, - } +fn is_canonical_uuid(value: &str) -> bool { + if value.len() != 36 { + return false; } - - fn settlement_args_for_fixture( - fixture: &OrderStatusFixture, - payment_event_id: &str, - decision: OrderSettlementDecisionArg, - ) -> OrderSettlementArgs { - OrderSettlementArgs { - key: fixture.order_id.clone(), - payment_event_id: payment_event_id.to_owned(), - decision, - reason: if decision == OrderSettlementDecisionArg::Reject { - Some("reference mismatch".to_owned()) - } else { - None - }, - idempotency_key: None, + for (index, character) in value.chars().enumerate() { + if matches!(index, 8 | 13 | 18 | 23) { + if character != '-' { + return false; + } + } else if !character.is_ascii_hexdigit() { + return false; } } + true +} - fn revision_args_for_fixture( - fixture: &OrderStatusFixture, - bin_count: u32, - ) -> OrderRevisionProposeArgs { - OrderRevisionProposeArgs { - key: fixture.order_id.clone(), - reason: "update count".to_owned(), - bin_id: Some("bin-1".to_owned()), - bin_count: Some(bin_count), - adjustment_id: None, - adjustment_effect: None, - adjustment_amount: None, - adjustment_currency: None, - adjustment_reason: None, - idempotency_key: None, - } - } +fn is_valid_event_id(value: &str) -> bool { + value.len() == 64 && value.chars().all(|ch| ch.is_ascii_hexdigit()) +} - fn revision_decision_args_for_fixture( - fixture: &OrderStatusFixture, - revision_id: &str, - decision: OrderRevisionDecisionArg, - ) -> OrderRevisionDecisionArgs { - OrderRevisionDecisionArgs { - key: fixture.order_id.clone(), - revision_id: revision_id.to_owned(), - decision, - reason: if decision == OrderRevisionDecisionArg::Decline { - Some("keep original order".to_owned()) - } else { - None - }, - idempotency_key: None, - } +fn encode_base64url_no_pad(bytes: [u8; 16]) -> String { + const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut output = String::with_capacity(22); + let mut index = 0usize; + while index + 3 <= bytes.len() { + let block = ((bytes[index] as u32) << 16) + | ((bytes[index + 1] as u32) << 8) + | (bytes[index + 2] as u32); + output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); + output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); + output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); + output.push(ALPHABET[(block & 0x3f) as usize] as char); + index += 3; } - - fn sample_config(root: &Path) -> RuntimeConfig { - let data = root.join("data"); - let logs = root.join("logs"); - let secrets = root.join("secrets"); - RuntimeConfig { - output: OutputConfig { - format: OutputFormat::Human, - verbosity: Verbosity::Normal, - color: true, - dry_run: false, - }, - interaction: InteractionConfig { - input_enabled: true, - assume_yes: false, - stdin_tty: false, - stdout_tty: false, - prompts_allowed: false, - confirmations_allowed: false, - }, - paths: PathsConfig { - profile: "interactive_user".into(), - profile_source: "test".into(), - allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], - root_source: "test".into(), - repo_local_root: None, - repo_local_root_source: None, - subordinate_path_override_source: "runtime_config".into(), - app_namespace: "apps/cli".into(), - shared_accounts_namespace: "shared/accounts".into(), - shared_identities_namespace: "shared/identities".into(), - app_config_path: root.join("config/apps/cli/config.toml"), - workspace_config_path: None, - app_data_root: data.join("apps/cli"), - app_logs_root: logs.join("apps/cli"), - shared_accounts_data_root: data.join("shared/accounts"), - shared_accounts_secrets_root: secrets.join("shared/accounts"), - default_identity_path: secrets.join("shared/identities/default.json"), - }, - migration: MigrationConfig { - report: RadrootsMigrationReport::empty(), - }, - logging: LoggingConfig { - filter: "info".into(), - directory: None, - stdout: false, - }, - account: AccountConfig { - selector: None, - store_path: data.join("shared/accounts/store.json"), - secrets_dir: secrets.join("shared/accounts"), - secret_backend: RadrootsSecretBackend::EncryptedFile, - secret_fallback: None, - }, - account_secret_contract: AccountSecretContractConfig { - default_backend: "host_vault".into(), - default_fallback: Some("encrypted_file".into()), - allowed_backends: vec!["host_vault".into(), "encrypted_file".into()], - host_vault_policy: Some("desktop".into()), - uses_protected_store: true, - }, - identity: IdentityConfig { - path: secrets.join("shared/identities/default.json"), - }, - signer: SignerConfig { - backend: SignerBackend::Local, - }, - publish: PublishConfig { - mode: PublishMode::NostrRelay, - source: PublishModeSource::Defaults, - }, - relay: RelayConfig { - urls: Vec::new(), - publish_policy: RelayPublishPolicy::Any, - source: RelayConfigSource::Defaults, - }, - local: LocalConfig { - root: data.join("apps/cli/replica"), - replica_db_path: data.join("apps/cli/replica/replica.sqlite"), - backups_dir: data.join("apps/cli/replica/backups"), - exports_dir: data.join("apps/cli/replica/exports"), - }, - myc: MycConfig { - executable: PathBuf::from("myc"), - status_timeout_ms: 2_000, - }, - hyf: HyfConfig { - enabled: false, - executable: PathBuf::from("hyfd"), - }, - rpc: RpcConfig { - url: "http://127.0.0.1:7070".into(), - bridge_bearer_token: None, - }, - rhi: crate::runtime::config::RhiConfig { - trusted_worker_pubkeys: Vec::new(), - }, - capability_bindings: Vec::new(), - } + let remaining = bytes.len() - index; + if remaining == 1 { + let block = (bytes[index] as u32) << 16; + output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); + output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); + } else if remaining == 2 { + let block = ((bytes[index] as u32) << 16) | ((bytes[index + 1] as u32) << 8); + output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); + output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); + output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); } + output +} - fn signed_order_decision_event( - seller: &RadrootsIdentity, - request_event: &radroots_nostr::prelude::RadrootsNostrEvent, - order_id: &str, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, - decision: RadrootsOrderDecisionOutcome, - ) -> radroots_nostr::prelude::RadrootsNostrEvent { - signed_order_decision_event_with_counterparty( - seller, - request_event, - order_id, - listing_addr, - buyer_pubkey, - seller_pubkey, - buyer_pubkey, - decision, - ) - } +#[derive(Debug, Clone)] +struct OrderInspection { + state: String, + ready_for_submit: bool, + 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>, +} - fn signed_order_decision_event_with_counterparty( - seller: &RadrootsIdentity, - request_event: &radroots_nostr::prelude::RadrootsNostrEvent, - order_id: &str, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, - counterparty_pubkey: &str, - decision: RadrootsOrderDecisionOutcome, - ) -> radroots_nostr::prelude::RadrootsNostrEvent { - let payload = RadrootsOrderDecision { - order_id: test_order_id(order_id), - listing_addr: test_listing_addr(listing_addr), - buyer_pubkey: test_pubkey(buyer_pubkey), - seller_pubkey: test_pubkey(seller_pubkey), - decision, - }; - let payload = canonicalize_order_decision_for_signer(payload, seller_pubkey) - .expect("canonical order decision"); - let request_event_id = test_event_id(request_event.id.to_string().as_str()); - let parts = order_decision_event_build(&request_event_id, &request_event_id, &payload) - .expect("order decision parts"); - let mut tags = parts.tags; - for tag in tags.iter_mut() { - if tag.first().map(String::as_str) == Some("p") && tag.len() > 1 { - tag[1] = counterparty_pubkey.to_owned(); - } +impl From<OrderGetView> for OrderNewView { + fn from(view: OrderGetView) -> Self { + Self { + state: "draft_created".to_owned(), + source: view.source, + order_id: view.order_id.unwrap_or_default(), + file: view.file.unwrap_or_default(), + listing_lookup: view.listing_lookup, + listing_addr: view.listing_addr, + listing_event_id: view.listing_event_id, + listing_relays: view.listing_relays, + 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, + economics: view.economics, + issues: view.issues, + actions: view.actions, } - radroots_nostr_build_event(parts.kind, parts.content, tags) - .expect("nostr event builder") - .sign_with_keys(seller.keys()) - .expect("signed order decision") - } - - fn signed_order_revision_proposal_event( - seller: &RadrootsIdentity, - request_event: &radroots_nostr::prelude::RadrootsNostrEvent, - decision_event: &radroots_nostr::prelude::RadrootsNostrEvent, - order_id: &str, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, - bin_count: u32, - ) -> radroots_nostr::prelude::RadrootsNostrEvent { - let mut economics = sample_order_economics(order_id, "bin-1", bin_count); - economics.quote_id = test_order_quote_id("revision_rev_test"); - economics.quote_version = 2; - economics.canonicalize(); - let payload = RadrootsOrderRevisionProposal { - revision_id: test_order_revision_id("rev_test"), - order_id: test_order_id(order_id), - listing_addr: test_listing_addr(listing_addr), - buyer_pubkey: test_pubkey(buyer_pubkey), - seller_pubkey: test_pubkey(seller_pubkey), - root_event_id: test_event_id(request_event.id.to_string().as_str()), - prev_event_id: test_event_id(decision_event.id.to_string().as_str()), - items: vec![RadrootsOrderItem { - bin_id: test_inventory_bin_id("bin-1"), - bin_count, - }], - economics, - reason: "update count".to_owned(), - }; - let parts = order_revision_proposal_event_build( - &payload.root_event_id, - &payload.prev_event_id, - &payload, - ) - .expect("revision proposal parts"); - radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .expect("nostr event builder") - .sign_with_keys(seller.keys()) - .expect("signed order revision proposal") - } - - fn signed_order_revision_decision_event( - buyer: &RadrootsIdentity, - proposal_event: &radroots_nostr::prelude::RadrootsNostrEvent, - decision: RadrootsOrderRevisionOutcome, - ) -> radroots_nostr::prelude::RadrootsNostrEvent { - let proposal = radroots_event_from_nostr(proposal_event); - let envelope = radroots_events_codec::order::order_revision_proposal_from_event(&proposal) - .expect("decoded revision proposal"); - let payload = RadrootsOrderRevisionDecision { - revision_id: envelope.payload.revision_id.clone(), - order_id: envelope.payload.order_id.clone(), - listing_addr: envelope.payload.listing_addr.clone(), - buyer_pubkey: envelope.payload.buyer_pubkey.clone(), - seller_pubkey: envelope.payload.seller_pubkey.clone(), - root_event_id: envelope.payload.root_event_id.clone(), - prev_event_id: test_event_id(proposal_event.id.to_string().as_str()), - decision, - }; - let parts = order_revision_decision_event_build( - &payload.root_event_id, - &payload.prev_event_id, - &payload, - ) - .expect("revision decision parts"); - radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .expect("nostr event builder") - .sign_with_keys(buyer.keys()) - .expect("signed order revision decision") - } - - fn signed_fulfillment_update_event( - seller: &RadrootsIdentity, - request_event: &radroots_nostr::prelude::RadrootsNostrEvent, - prev_event: &radroots_nostr::prelude::RadrootsNostrEvent, - order_id: &str, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, - status: RadrootsOrderFulfillmentState, - ) -> radroots_nostr::prelude::RadrootsNostrEvent { - let payload = RadrootsOrderFulfillmentUpdate { - order_id: test_order_id(order_id), - listing_addr: test_listing_addr(listing_addr), - buyer_pubkey: test_pubkey(buyer_pubkey), - seller_pubkey: test_pubkey(seller_pubkey), - status, - }; - let request_event_id = test_event_id(request_event.id.to_string().as_str()); - let prev_event_id = test_event_id(prev_event.id.to_string().as_str()); - let parts = - order_fulfillment_update_event_build(&request_event_id, &prev_event_id, &payload) - .expect("fulfillment update parts"); - radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .expect("nostr event builder") - .sign_with_keys(seller.keys()) - .expect("signed fulfillment update") - } - - fn signed_order_cancellation_event( - buyer: &RadrootsIdentity, - request_event: &radroots_nostr::prelude::RadrootsNostrEvent, - prev_event: &radroots_nostr::prelude::RadrootsNostrEvent, - order_id: &str, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, - reason: &str, - ) -> radroots_nostr::prelude::RadrootsNostrEvent { - let payload = RadrootsOrderCancellation { - order_id: test_order_id(order_id), - listing_addr: test_listing_addr(listing_addr), - buyer_pubkey: test_pubkey(buyer_pubkey), - seller_pubkey: test_pubkey(seller_pubkey), - reason: reason.to_owned(), - }; - let request_event_id = test_event_id(request_event.id.to_string().as_str()); - let prev_event_id = test_event_id(prev_event.id.to_string().as_str()); - let parts = order_cancellation_event_build(&request_event_id, &prev_event_id, &payload) - .expect("order cancellation parts"); - radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .expect("nostr event builder") - .sign_with_keys(buyer.keys()) - .expect("signed order cancellation") - } - - fn signed_buyer_receipt_event( - buyer: &RadrootsIdentity, - request_event: &radroots_nostr::prelude::RadrootsNostrEvent, - prev_event: &radroots_nostr::prelude::RadrootsNostrEvent, - order_id: &str, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, - received: bool, - issue: Option<&str>, - ) -> radroots_nostr::prelude::RadrootsNostrEvent { - let payload = RadrootsOrderReceipt { - order_id: test_order_id(order_id), - listing_addr: test_listing_addr(listing_addr), - buyer_pubkey: test_pubkey(buyer_pubkey), - seller_pubkey: test_pubkey(seller_pubkey), - received, - issue: issue.map(str::to_owned), - received_at: 1_777_665_600, - }; - let request_event_id = test_event_id(request_event.id.to_string().as_str()); - let prev_event_id = test_event_id(prev_event.id.to_string().as_str()); - let parts = order_receipt_event_build(&request_event_id, &prev_event_id, &payload) - .expect("buyer receipt parts"); - radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .expect("nostr event builder") - .sign_with_keys(buyer.keys()) - .expect("signed buyer receipt") - } - - fn signed_payment_recorded_event( - buyer: &RadrootsIdentity, - request_event: &radroots_nostr::prelude::RadrootsNostrEvent, - prev_event: &radroots_nostr::prelude::RadrootsNostrEvent, - agreement_event: &radroots_nostr::prelude::RadrootsNostrEvent, - order_id: &str, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, - ) -> radroots_nostr::prelude::RadrootsNostrEvent { - let economics = sample_order_economics(order_id, "bin-1", 2); - let payload = RadrootsOrderPaymentRecord { - order_id: test_order_id(order_id), - listing_addr: test_listing_addr(listing_addr), - buyer_pubkey: test_pubkey(buyer_pubkey), - seller_pubkey: test_pubkey(seller_pubkey), - root_event_id: test_event_id(request_event.id.to_string().as_str()), - previous_event_id: test_event_id(prev_event.id.to_string().as_str()), - agreement_event_id: test_event_id(agreement_event.id.to_string().as_str()), - quote_id: economics.quote_id.clone(), - quote_version: economics.quote_version, - economics_digest: test_economics_digest( - radroots_trade::order::radroots_order_economics_digest(&economics) - .expect("economics digest") - .as_str(), - ), - amount: economics.total.amount, - currency: economics.total.currency, - method: RadrootsOrderPaymentMethod::ManualTransfer, - reference: Some("memo-1".to_owned()), - paid_at: Some(1_777_666_000), - }; - let parts = order_payment_record_event_build( - &payload.root_event_id, - &payload.previous_event_id, - &payload, - ) - .expect("payment recorded parts"); - radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .expect("nostr event builder") - .sign_with_keys(buyer.keys()) - .expect("signed payment recorded") - } - - fn signed_settlement_decision_event( - seller: &RadrootsIdentity, - request_event: &radroots_nostr::prelude::RadrootsNostrEvent, - payment_event: &radroots_nostr::prelude::RadrootsNostrEvent, - decision: RadrootsOrderSettlementOutcome, - ) -> radroots_nostr::prelude::RadrootsNostrEvent { - let payment = radroots_event_from_nostr(payment_event); - let envelope = radroots_events_codec::order::order_payment_record_from_event(&payment) - .expect("decoded payment"); - let payload = RadrootsOrderSettlementDecision { - order_id: envelope.payload.order_id.clone(), - listing_addr: envelope.payload.listing_addr.clone(), - seller_pubkey: envelope.payload.seller_pubkey.clone(), - buyer_pubkey: envelope.payload.buyer_pubkey.clone(), - root_event_id: test_event_id(request_event.id.to_string().as_str()), - previous_event_id: test_event_id(payment_event.id.to_string().as_str()), - agreement_event_id: envelope.payload.agreement_event_id.clone(), - payment_event_id: test_event_id(payment_event.id.to_string().as_str()), - quote_id: envelope.payload.quote_id.clone(), - quote_version: envelope.payload.quote_version, - economics_digest: envelope.payload.economics_digest.clone(), - amount: envelope.payload.amount, - currency: envelope.payload.currency, - decision, - reason: (decision == RadrootsOrderSettlementOutcome::Rejected) - .then(|| "reference mismatch".to_owned()), - }; - let parts = order_settlement_decision_event_build( - &payload.root_event_id, - &payload.previous_event_id, - &payload, - ) - .expect("settlement decision parts"); - radroots_nostr_build_event(parts.kind, parts.content, parts.tags) - .expect("nostr event builder") - .sign_with_keys(seller.keys()) - .expect("signed settlement decision") - } - - fn signed_malformed_order_request_event( - buyer: &RadrootsIdentity, - order_id: &str, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, - listing_event_id: &str, - ) -> radroots_nostr::prelude::RadrootsNostrEvent { - let payload = RadrootsOrderRequest { - order_id: test_order_id(order_id), - listing_addr: test_listing_addr(listing_addr), - buyer_pubkey: test_pubkey(buyer_pubkey), - seller_pubkey: test_pubkey(seller_pubkey), - items: vec![RadrootsOrderItem { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - economics: sample_order_economics(order_id, "bin-1", 2), - }; - let parts = order_request_event_build( - &RadrootsNostrEventPtr { - id: listing_event_id.to_owned(), - relays: None, - }, - &payload, - ) - .expect("order request parts"); - radroots_nostr_build_event(parts.kind, "not-json".to_owned(), parts.tags) - .expect("nostr event builder") - .sign_with_keys(buyer.keys()) - .expect("signed malformed order request") - } - - fn signed_order_request_event( - buyer: &RadrootsIdentity, - order_id: &str, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, - listing_event_id: &str, - ) -> radroots_nostr::prelude::RadrootsNostrEvent { - signed_order_request_event_with_economics( - buyer, - order_id, - listing_addr, - buyer_pubkey, - seller_pubkey, - listing_event_id, - sample_order_economics(order_id, "bin-1", 2), - ) - } - - fn signed_order_request_event_with_economics( - buyer: &RadrootsIdentity, - order_id: &str, - listing_addr: &str, - buyer_pubkey: &str, - seller_pubkey: &str, - listing_event_id: &str, - economics: RadrootsOrderEconomics, - ) -> radroots_nostr::prelude::RadrootsNostrEvent { - let payload = RadrootsOrderRequest { - order_id: test_order_id(order_id), - listing_addr: test_listing_addr(listing_addr), - buyer_pubkey: test_pubkey(buyer_pubkey), - seller_pubkey: test_pubkey(seller_pubkey), - items: vec![RadrootsOrderItem { - bin_id: test_inventory_bin_id("bin-1"), - bin_count: 2, - }], - economics, - }; - let parts = 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") } } diff --git a/src/runtime/order/sdk_status.rs b/src/runtime/order/sdk_status.rs @@ -1,15 +1,13 @@ use radroots_events::ids::RadrootsEventId; use radroots_sdk::{ - OrderFulfillmentStatusKind, OrderPaymentHandoffKind, OrderPaymentStateKind, - OrderSettlementStateKind, OrderStatusEligibility, OrderStatusEvidenceSummary, OrderStatusKind, - OrderStatusNextActionKind, OrderStatusReceipt, SdkOrderStatusIssue, + OrderStatusEligibility, OrderStatusEvidenceSummary, OrderStatusKind, OrderStatusNextActionKind, + OrderStatusReceipt, SdkOrderStatusIssue, }; use crate::view::runtime::{ OrderIssueView, OrderStatusEligibilityView, OrderStatusEvidenceSummaryView, - OrderStatusFulfillmentView, OrderStatusLifecycleCancellationView, - OrderStatusLifecycleReceiptView, OrderStatusLifecycleView, OrderStatusPaymentView, - OrderStatusSdkReceiptView, OrderStatusView, + OrderStatusLifecycleCancellationView, OrderStatusLifecycleView, OrderStatusSdkReceiptView, + OrderStatusView, }; use super::{ORDER_ACTOR_CONTEXT_SDK_LOCAL, ORDER_STATUS_SDK_SOURCE}; @@ -22,12 +20,7 @@ pub(super) fn sdk_order_status_view(receipt: OrderStatusReceipt) -> OrderStatusV .map(sdk_order_status_issue_view) .collect::<Vec<_>>(); let reason = sdk_order_status_reason(receipt.status, receipt.order_id.as_str()); - let fulfillment = sdk_order_status_fulfillment_view(&receipt, reducer_issues.as_slice()); let lifecycle = sdk_order_status_lifecycle_view(&receipt, reducer_issues.as_slice()); - let payment = Some(sdk_order_status_payment_view( - &receipt, - reducer_issues.as_slice(), - )); let sdk_receipt = Some(sdk_order_status_receipt_view(&receipt)); OrderStatusView { @@ -39,16 +32,14 @@ pub(super) fn sdk_order_status_view(receipt: OrderStatusReceipt) -> OrderStatusV decision_event_id: sdk_event_id_string(receipt.decision_event_id.as_ref()), agreement_event_id: sdk_order_status_agreement_event_id(&receipt), listing_event_id: None, - listing_addr: None, - buyer_pubkey: None, - seller_pubkey: None, - economics: None, + listing_addr: receipt.listing_addr.as_ref().map(ToString::to_string), + buyer_pubkey: receipt.buyer_pubkey.as_ref().map(ToString::to_string), + seller_pubkey: receipt.seller_pubkey.as_ref().map(ToString::to_string), + economics: receipt.economics.clone(), last_event_id: sdk_event_id_string(receipt.last_event_id.as_ref()), revision: None, inventory: None, - fulfillment, lifecycle: Some(lifecycle), - payment, sdk_receipt, reducer_issues, target_relays: Vec::new(), @@ -64,7 +55,6 @@ pub(super) fn sdk_order_status_view(receipt: OrderStatusReceipt) -> OrderStatusV fn sdk_order_status_receipt_view(receipt: &OrderStatusReceipt) -> OrderStatusSdkReceiptView { OrderStatusSdkReceiptView { - payment_handoff: sdk_payment_handoff(receipt.payment_handoff).to_owned(), next_action: sdk_status_next_action(receipt.next_action).to_owned(), evidence: sdk_status_evidence_view(&receipt.evidence), eligibility: sdk_status_eligibility_view(&receipt.eligibility), @@ -81,9 +71,7 @@ fn sdk_status_evidence_view( has_decision: evidence.has_decision, has_agreement: evidence.has_agreement, has_pending_revision: evidence.has_pending_revision, - has_fulfillment: evidence.has_fulfillment, has_cancellation: evidence.has_cancellation, - has_receipt: evidence.has_receipt, has_issues: evidence.has_issues, } } @@ -94,27 +82,6 @@ fn sdk_status_eligibility_view(eligibility: &OrderStatusEligibility) -> OrderSta can_propose_revision: eligibility.can_propose_revision, can_decide_revision: eligibility.can_decide_revision, can_cancel: eligibility.can_cancel, - can_update_fulfillment: eligibility.can_update_fulfillment, - can_record_receipt: eligibility.can_record_receipt, - } -} - -fn sdk_payment_handoff(kind: OrderPaymentHandoffKind) -> &'static str { - match kind { - OrderPaymentHandoffKind::NotReady => "not_ready", - OrderPaymentHandoffKind::NotRequired => "not_required", - OrderPaymentHandoffKind::InPersonOrOffPlatformPending => { - "in_person_or_off_platform_pending" - } - OrderPaymentHandoffKind::InPersonOrOffPlatformRecorded => { - "in_person_or_off_platform_recorded" - } - OrderPaymentHandoffKind::InPersonOrOffPlatformSettled => { - "in_person_or_off_platform_settled" - } - OrderPaymentHandoffKind::Rejected => "rejected", - OrderPaymentHandoffKind::Invalid => "invalid", - _ => "unknown", } } @@ -123,12 +90,7 @@ fn sdk_status_next_action(kind: OrderStatusNextActionKind) -> &'static str { OrderStatusNextActionKind::NoLocalOrder => "no_local_order", OrderStatusNextActionKind::InspectEvidenceIssues => "inspect_evidence_issues", OrderStatusNextActionKind::AwaitSellerDecision => "await_seller_decision", - OrderStatusNextActionKind::ArrangeInPersonOrOffPlatformPayment => { - "arrange_in_person_or_off_platform_payment" - } OrderStatusNextActionKind::DecideRevision => "decide_revision", - OrderStatusNextActionKind::FulfillOrder => "fulfill_order", - OrderStatusNextActionKind::RecordReceipt => "record_receipt", OrderStatusNextActionKind::Terminal => "terminal", _ => "unknown", } @@ -141,8 +103,6 @@ fn sdk_order_status_state(status: OrderStatusKind) -> &'static str { OrderStatusKind::Accepted => "accepted", OrderStatusKind::Declined => "declined", OrderStatusKind::Cancelled => "cancelled", - OrderStatusKind::Completed => "completed", - OrderStatusKind::Disputed => "disputed", OrderStatusKind::Invalid => "invalid", _ => "unknown", } @@ -162,74 +122,6 @@ fn sdk_order_status_agreement_event_id(receipt: &OrderStatusReceipt) -> Option<S sdk_event_id_string(receipt.agreement_event_id.as_ref()) } -fn sdk_order_status_fulfillment_view( - receipt: &OrderStatusReceipt, - issues: &[OrderIssueView], -) -> Option<OrderStatusFulfillmentView> { - let fulfillment_issues = issues - .iter() - .filter(|issue| { - issue.code.starts_with("fulfillment_") || issue.code == "forked_fulfillments" - }) - .cloned() - .collect::<Vec<_>>(); - if !fulfillment_issues.is_empty() { - return Some(OrderStatusFulfillmentView { - state: "invalid".to_owned(), - event_id: sdk_event_id_string(receipt.fulfillment_event_id.as_ref()), - root_event_id: sdk_event_id_string(receipt.request_event_id.as_ref()), - prev_event_id: sdk_event_id_string(receipt.decision_event_id.as_ref()), - terminal: false, - inventory_released: false, - issues: fulfillment_issues, - }); - } - let fulfillment_status = receipt.fulfillment_status?; - Some(OrderStatusFulfillmentView { - state: sdk_fulfillment_status_state(fulfillment_status).to_owned(), - event_id: sdk_event_id_string(receipt.fulfillment_event_id.as_ref()), - root_event_id: sdk_event_id_string(receipt.request_event_id.as_ref()), - prev_event_id: sdk_event_id_string(receipt.decision_event_id.as_ref()), - terminal: matches!( - fulfillment_status, - OrderFulfillmentStatusKind::Delivered | OrderFulfillmentStatusKind::SellerCancelled - ), - inventory_released: matches!( - fulfillment_status, - OrderFulfillmentStatusKind::SellerCancelled - ), - issues: Vec::new(), - }) -} - -fn sdk_order_status_payment_view( - receipt: &OrderStatusReceipt, - issues: &[OrderIssueView], -) -> OrderStatusPaymentView { - let payment_issues = issues - .iter() - .filter(|issue| issue.code.starts_with("payment_") || issue.code.starts_with("settlement_")) - .cloned() - .collect::<Vec<_>>(); - OrderStatusPaymentView { - state: sdk_payment_state(receipt.payment_state).to_owned(), - settlement_state: sdk_settlement_state(receipt.settlement_state).to_owned(), - payment_event_id: None, - settlement_event_id: None, - agreement_event_id: sdk_order_status_agreement_event_id(receipt), - quote_id: None, - quote_version: None, - economics_digest: None, - amount: None, - currency: None, - method: None, - reference: None, - paid_at: None, - reason: None, - issues: payment_issues, - } -} - fn sdk_order_status_lifecycle_view( receipt: &OrderStatusReceipt, issues: &[OrderIssueView], @@ -242,19 +134,6 @@ fn sdk_order_status_lifecycle_view( reason: None, } }); - let receipt_view = - receipt - .receipt_event_id - .as_ref() - .map(|event_id| OrderStatusLifecycleReceiptView { - event_id: event_id.to_string(), - root_event_id: sdk_event_id_string(receipt.request_event_id.as_ref()), - prev_event_id: sdk_event_id_string(receipt.fulfillment_event_id.as_ref()), - received: matches!(receipt.status, OrderStatusKind::Completed), - issue: None, - received_at: None, - }); - OrderStatusLifecycleView { phase: sdk_order_status_lifecycle_phase(receipt).to_owned(), terminal: receipt.lifecycle_terminal, @@ -262,12 +141,6 @@ fn sdk_order_status_lifecycle_view( root_event_id: sdk_event_id_string(receipt.request_event_id.as_ref()), prev_event_id: None, cancellation, - receipt: receipt_view, - settlement_required: !matches!( - receipt.settlement_state, - OrderSettlementStateKind::NotRequired - ), - settlement_reason: None, issues: issues.to_vec(), } } @@ -276,60 +149,14 @@ fn sdk_order_status_lifecycle_phase(receipt: &OrderStatusReceipt) -> &'static st match receipt.status { OrderStatusKind::Missing => "missing", OrderStatusKind::Requested => "requested", - OrderStatusKind::Accepted => match receipt.fulfillment_status { - Some(OrderFulfillmentStatusKind::Preparing) - | Some(OrderFulfillmentStatusKind::OutForDelivery) => "fulfillment_in_progress", - Some( - OrderFulfillmentStatusKind::ReadyForPickup - | OrderFulfillmentStatusKind::Delivered - | OrderFulfillmentStatusKind::SellerCancelled, - ) => "fulfilled", - Some(OrderFulfillmentStatusKind::AcceptedNotFulfilled) | None => "accepted", - Some(_) => "accepted", - }, + OrderStatusKind::Accepted => "accepted", OrderStatusKind::Declined => "declined", OrderStatusKind::Cancelled => "cancelled", - OrderStatusKind::Completed => "completed", - OrderStatusKind::Disputed => "disputed", OrderStatusKind::Invalid => "invalid", _ => "unknown", } } -fn sdk_fulfillment_status_state(status: OrderFulfillmentStatusKind) -> &'static str { - match status { - OrderFulfillmentStatusKind::AcceptedNotFulfilled => "accepted_not_fulfilled", - OrderFulfillmentStatusKind::Preparing => "preparing", - OrderFulfillmentStatusKind::ReadyForPickup => "ready_for_pickup", - OrderFulfillmentStatusKind::OutForDelivery => "out_for_delivery", - OrderFulfillmentStatusKind::Delivered => "delivered", - OrderFulfillmentStatusKind::SellerCancelled => "seller_cancelled", - _ => "unknown", - } -} - -fn sdk_payment_state(state: OrderPaymentStateKind) -> &'static str { - match state { - OrderPaymentStateKind::NotRecorded => "not_recorded", - OrderPaymentStateKind::Recorded => "recorded", - OrderPaymentStateKind::Settled => "settled", - OrderPaymentStateKind::Rejected => "rejected", - OrderPaymentStateKind::Invalid => "invalid", - _ => "unknown", - } -} - -fn sdk_settlement_state(state: OrderSettlementStateKind) -> &'static str { - match state { - OrderSettlementStateKind::NotRequired => "not_required", - OrderSettlementStateKind::Pending => "pending", - OrderSettlementStateKind::Accepted => "accepted", - OrderSettlementStateKind::Rejected => "rejected", - OrderSettlementStateKind::Invalid => "invalid", - _ => "unknown", - } -} - fn sdk_order_status_issue_view(issue: &SdkOrderStatusIssue) -> OrderIssueView { let code = issue.code(); OrderIssueView { diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs @@ -379,11 +379,10 @@ mod tests { required_tokens: &[ "legacy_order_preflight_relay_status", "fetch_events_from_relays", - "publish_parts_with_identity", ], - owner: "order.payment-settlement.direct-publish", - reason: "payment and settlement writes remain non-migrated while order request, decision, revision, cancellation, fulfillment, and receipt writes use SDK", - lifecycle: "retain until payment and settlement behavior is either SDK-backed or retired", + owner: "order.status.relay-read", + reason: "bounded order status and preflight reads still inspect configured relays outside SDK local storage", + lifecycle: "retain until order relay reads migrate to SDK-backed query APIs", }, LegacyDirectRelayConsumer { path: "src/runtime/sync.rs", @@ -454,7 +453,6 @@ mod tests { "OrderStatusReceipt", "OrderStatusView", "OrderStatusLifecycleView", - "OrderStatusPaymentView", "OrderStatusSdkReceiptView", ], }, @@ -486,20 +484,16 @@ mod tests { label: "order lifecycle", path: "src/runtime/order.rs", start: "fn publish_order_revision(", - end: "fn publish_order_payment(", + end: "fn sdk_order_lifecycle_actor(", required_tokens: &[ "prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new", "prepare_revision_decision(OrderRevisionDecisionPrepareRequest::new", - "prepare_fulfillment_update(OrderFulfillmentUpdatePrepareRequest::new", "prepare_cancellation(OrderCancellationPrepareRequest::new", - "prepare_receipt_record(OrderReceiptRecordPrepareRequest::new", - "ingest_evidence(OrderEvidenceIngestRequest::new", + "ingest_order_evidence_events(&session, evidence_events)?", "enqueue_revision_proposal(request, &signer)", "enqueue_revision_decision(request, &signer)", - "enqueue_fulfillment_update(request, &signer)", "enqueue_cancellation(request, &signer)", - "enqueue_receipt_record(request, &signer)", - "push_outbox(", + "push_one_sdk_outbox_event(&session, policy)?", ], }, MigratedCliPathGuard { diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -7,8 +7,7 @@ use crate::view::runtime::{ SignerWriteKindReadinessView, }; use radroots_events::kinds::{ - KIND_FARM, KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, - KIND_ORDER_FULFILLMENT_UPDATE, KIND_ORDER_RECEIPT, KIND_ORDER_REQUEST, + KIND_FARM, KIND_LISTING, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, KIND_PROFILE, }; use radroots_nostr_accounts::prelude::RadrootsNostrAccountStatus; @@ -293,7 +292,7 @@ fn deferred_myc_binding_status() -> SignerBindingStatusView { } } -fn cli_write_kinds() -> [CliWriteKind; 14] { +fn cli_write_kinds() -> [CliWriteKind; 12] { [ CliWriteKind { command: "sync.push", @@ -343,14 +342,6 @@ fn cli_write_kinds() -> [CliWriteKind; 14] { command: "order.revision.decline", event_kind: KIND_ORDER_REVISION_DECISION, }, - CliWriteKind { - command: "order.fulfillment.update", - event_kind: KIND_ORDER_FULFILLMENT_UPDATE, - }, - CliWriteKind { - command: "order.receipt.record", - event_kind: KIND_ORDER_RECEIPT, - }, ] } @@ -393,9 +384,8 @@ fn local_availability(value: RadrootsNostrLocalSignerAvailability) -> &'static s #[cfg(test)] mod tests { use super::{ - KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_FULFILLMENT_UPDATE, - KIND_ORDER_RECEIPT, KIND_ORDER_REQUEST, KIND_ORDER_REVISION_DECISION, - KIND_ORDER_REVISION_PROPOSAL, cli_write_kinds, + KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, KIND_ORDER_REQUEST, + KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, cli_write_kinds, }; const RESERVED_ORDER_KIND_3431: u32 = 3431; @@ -422,8 +412,6 @@ mod tests { "order.revision.propose", "order.revision.accept", "order.revision.decline", - "order.fulfillment.update", - "order.receipt.record", ] ); assert!(!commands.contains(&"signer.status.get")); @@ -475,26 +463,12 @@ mod tests { } #[test] - fn order_follow_on_readiness_uses_order_kinds() { + fn order_cancel_readiness_uses_order_cancellation_kind() { let cancel = cli_write_kinds() .into_iter() .find(|kind| kind.command == "order.cancel") .expect("order cancel readiness"); assert_eq!(cancel.event_kind, KIND_ORDER_CANCELLATION); assert_ne!(cancel.event_kind, RESERVED_ORDER_KIND_3431); - - let fulfillment = cli_write_kinds() - .into_iter() - .find(|kind| kind.command == "order.fulfillment.update") - .expect("order fulfillment readiness"); - assert_eq!(fulfillment.event_kind, KIND_ORDER_FULFILLMENT_UPDATE); - assert_ne!(fulfillment.event_kind, RESERVED_ORDER_KIND_3431); - - let receipt = cli_write_kinds() - .into_iter() - .find(|kind| kind.command == "order.receipt.record") - .expect("order receipt readiness"); - assert_eq!(receipt.event_kind, KIND_ORDER_RECEIPT); - assert_ne!(receipt.event_kind, RESERVED_ORDER_KIND_3431); } } diff --git a/src/view/runtime.rs b/src/view/runtime.rs @@ -2,14 +2,11 @@ use std::process::ExitCode; -use radroots_core::{RadrootsCoreCurrency, RadrootsCoreDecimal}; use radroots_events::farm::RadrootsFarm; use radroots_events::ids::RadrootsListingAddress; use radroots_events::kinds::KIND_LISTING; use radroots_events::listing::RadrootsListingLocation; -use radroots_events::order::{ - RadrootsOrderEconomics, RadrootsOrderPaymentMethod, RadrootsOrderSettlementOutcome, -}; +use radroots_events::order::RadrootsOrderEconomics; use radroots_events::profile::RadrootsProfile; use radroots_nostr_accounts::prelude::RadrootsNostrAccountRecord; use serde::Serialize; @@ -1862,73 +1859,6 @@ impl OrderDecisionView { } #[derive(Debug, Clone, Serialize)] -pub struct OrderFulfillmentView { - pub state: String, - pub source: String, - pub order_id: String, - pub fulfillment_state: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub request_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub decision_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub root_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub prev_event_id: 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, skip_serializing_if = "Vec::is_empty")] - pub target_relays: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub connected_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(default)] - pub fetched_count: usize, - #[serde(default)] - pub decoded_count: usize, - #[serde(default)] - pub skipped_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub idempotency_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_mode: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<OrderIssueView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl OrderFulfillmentView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "missing" => CommandDisposition::NotFound, - "invalid" | "requested" | "declined" | "terminal" | "forked" => { - CommandDisposition::ValidationFailed - } - "unconfigured" => CommandDisposition::Unconfigured, - "unavailable" => CommandDisposition::ExternalUnavailable, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] pub struct OrderCancellationView { pub state: String, pub source: String, @@ -1997,79 +1927,6 @@ impl OrderCancellationView { } #[derive(Debug, Clone, Serialize)] -pub struct OrderReceiptView { - pub state: String, - pub source: String, - pub order_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub request_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub decision_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub fulfillment_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub root_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub prev_event_id: 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>, - pub received: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub issue: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub received_at: Option<u64>, - #[serde(default)] - pub dry_run: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub target_relays: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub connected_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(default)] - pub fetched_count: usize, - #[serde(default)] - pub decoded_count: usize, - #[serde(default)] - pub skipped_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub idempotency_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_mode: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<OrderIssueView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl OrderReceiptView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "missing" => CommandDisposition::NotFound, - "invalid" | "requested" | "declined" | "cancelled" | "terminal" | "forked" => { - CommandDisposition::ValidationFailed - } - "unconfigured" => CommandDisposition::Unconfigured, - "unavailable" => CommandDisposition::ExternalUnavailable, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] pub struct OrderRevisionProposalView { pub state: String, pub source: String, @@ -2219,169 +2076,6 @@ impl OrderRevisionDecisionView { } #[derive(Debug, Clone, Serialize)] -pub struct OrderPaymentView { - pub state: String, - pub source: String, - pub order_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub request_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub agreement_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub root_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub prev_event_id: 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(skip_serializing_if = "Option::is_none")] - pub quote_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub quote_version: Option<u32>, - #[serde(skip_serializing_if = "Option::is_none")] - pub economics_digest: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub amount: Option<RadrootsCoreDecimal>, - #[serde(skip_serializing_if = "Option::is_none")] - pub currency: Option<RadrootsCoreCurrency>, - #[serde(skip_serializing_if = "Option::is_none")] - pub method: Option<RadrootsOrderPaymentMethod>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reference: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub paid_at: Option<u64>, - #[serde(default)] - pub dry_run: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub target_relays: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub connected_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(default)] - pub fetched_count: usize, - #[serde(default)] - pub decoded_count: usize, - #[serde(default)] - pub skipped_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub idempotency_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_mode: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<OrderIssueView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl OrderPaymentView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "missing" => CommandDisposition::NotFound, - "invalid" | "requested" | "declined" | "cancelled" | "terminal" | "forked" => { - CommandDisposition::ValidationFailed - } - "unconfigured" => CommandDisposition::Unconfigured, - "unavailable" => CommandDisposition::ExternalUnavailable, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderSettlementView { - pub state: String, - pub source: String, - pub order_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub buyer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub request_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub agreement_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub root_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub prev_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub payment_event_id: 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(skip_serializing_if = "Option::is_none")] - pub quote_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub quote_version: Option<u32>, - #[serde(skip_serializing_if = "Option::is_none")] - pub economics_digest: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub amount: Option<RadrootsCoreDecimal>, - #[serde(skip_serializing_if = "Option::is_none")] - pub currency: Option<RadrootsCoreCurrency>, - #[serde(skip_serializing_if = "Option::is_none")] - pub decision: Option<RadrootsOrderSettlementOutcome>, - #[serde(skip_serializing_if = "Option::is_none")] - pub settlement_reason: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default)] - pub dry_run: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub target_relays: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub connected_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(default)] - pub fetched_count: usize, - #[serde(default)] - pub decoded_count: usize, - #[serde(default)] - pub skipped_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub idempotency_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_mode: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<OrderIssueView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl OrderSettlementView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "missing" => CommandDisposition::NotFound, - "invalid" | "requested" | "declined" | "cancelled" | "not_recorded" | "settled" - | "already_decided" => CommandDisposition::ValidationFailed, - "unconfigured" => CommandDisposition::Unconfigured, - "unavailable" => CommandDisposition::ExternalUnavailable, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] pub struct OrderStatusView { pub state: String, pub source: String, @@ -2409,12 +2103,7 @@ pub struct OrderStatusView { pub revision: Option<OrderStatusRevisionView>, #[serde(skip_serializing_if = "Option::is_none")] pub inventory: Option<OrderInventoryView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub fulfillment: Option<OrderStatusFulfillmentView>, - #[serde(skip_serializing_if = "Option::is_none")] pub lifecycle: Option<OrderStatusLifecycleView>, - #[serde(skip_serializing_if = "inactive_status_payment")] - pub payment: Option<OrderStatusPaymentView>, #[serde(skip_serializing_if = "Option::is_none")] pub sdk_receipt: Option<OrderStatusSdkReceiptView>, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -2439,7 +2128,6 @@ pub struct OrderStatusView { #[derive(Debug, Clone, Serialize)] pub struct OrderStatusSdkReceiptView { - pub payment_handoff: String, pub next_action: String, pub evidence: OrderStatusEvidenceSummaryView, pub eligibility: OrderStatusEligibilityView, @@ -2453,9 +2141,7 @@ pub struct OrderStatusEvidenceSummaryView { pub has_decision: bool, pub has_agreement: bool, pub has_pending_revision: bool, - pub has_fulfillment: bool, pub has_cancellation: bool, - pub has_receipt: bool, pub has_issues: bool, } @@ -2465,8 +2151,6 @@ pub struct OrderStatusEligibilityView { pub can_propose_revision: bool, pub can_decide_revision: bool, pub can_cancel: bool, - pub can_update_fulfillment: bool, - pub can_record_receipt: bool, } #[derive(Debug, Clone, Serialize)] @@ -2489,65 +2173,6 @@ pub struct OrderStatusRevisionView { } #[derive(Debug, Clone, Serialize)] -pub struct OrderStatusFulfillmentView { - pub state: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub root_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub prev_event_id: Option<String>, - #[serde(default)] - pub terminal: bool, - #[serde(default)] - pub inventory_released: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<OrderIssueView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OrderStatusPaymentView { - pub state: String, - pub settlement_state: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub payment_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub settlement_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub agreement_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub quote_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub quote_version: Option<u32>, - #[serde(skip_serializing_if = "Option::is_none")] - pub economics_digest: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub amount: Option<RadrootsCoreDecimal>, - #[serde(skip_serializing_if = "Option::is_none")] - pub currency: Option<RadrootsCoreCurrency>, - #[serde(skip_serializing_if = "Option::is_none")] - pub method: Option<RadrootsOrderPaymentMethod>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reference: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub paid_at: Option<u64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<OrderIssueView>, -} - -fn inactive_status_payment(payment: &Option<OrderStatusPaymentView>) -> bool { - payment.as_ref().is_none_or(|payment| { - payment.state == "not_recorded" - && payment.settlement_state == "not_required" - && payment.payment_event_id.is_none() - && payment.settlement_event_id.is_none() - && payment.issues.is_empty() - }) -} - -#[derive(Debug, Clone, Serialize)] pub struct OrderStatusLifecycleView { pub phase: String, #[serde(default)] @@ -2560,12 +2185,6 @@ pub struct OrderStatusLifecycleView { pub prev_event_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub cancellation: Option<OrderStatusLifecycleCancellationView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub receipt: Option<OrderStatusLifecycleReceiptView>, - #[serde(default)] - pub settlement_required: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub settlement_reason: Option<String>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub issues: Vec<OrderIssueView>, } @@ -2582,20 +2201,6 @@ pub struct OrderStatusLifecycleCancellationView { } #[derive(Debug, Clone, Serialize)] -pub struct OrderStatusLifecycleReceiptView { - pub event_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub root_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub prev_event_id: Option<String>, - pub received: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub issue: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub received_at: Option<u64>, -} - -#[derive(Debug, Clone, Serialize)] pub struct OrderInventoryView { pub state: String, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -2237,102 +2237,6 @@ fn local_order_failure_envelopes_are_structured_and_actionable() { } #[test] -fn local_order_fulfillment_update_requires_configured_relay_before_publish_preflight() { - let sandbox = RadrootsCliSandbox::new(); - sandbox.json_success(&["--format", "json", "account", "create"]); - - let (output, value) = sandbox.json_output(&[ - "--format", - "json", - "--approval-token", - "approve", - "order", - "fulfillment", - "update", - "ord_missing_relay", - "--state", - "ready_for_pickup", - ]); - - assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(3)); - assert_eq!(value["operation_id"], "order.fulfillment.update"); - assert_eq!(value["result"], serde_json::Value::Null); - assert_eq!(value["errors"][0]["code"], "operation_unavailable"); - assert_eq!(value["errors"][0]["detail"]["class"], "operation"); - assert_contains( - &value["errors"][0]["message"], - "requires at least one configured relay", - ); - assert_no_removed_command_reference(&value, &["order", "fulfillment", "update"]); - assert_no_daemon_runtime_reference(&value, &["order", "fulfillment", "update"]); -} - -#[test] -fn local_order_fulfillment_update_requires_selected_seller_account_before_relay_fetch() { - let sandbox = RadrootsCliSandbox::new(); - - let (output, value) = sandbox.json_output(&[ - "--format", - "json", - "--relay", - "ws://127.0.0.1:9", - "--approval-token", - "approve", - "order", - "fulfillment", - "update", - "ord_no_account", - "--state", - "ready_for_pickup", - ]); - - assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(3)); - assert_eq!(value["operation_id"], "order.fulfillment.update"); - assert_eq!(value["result"], serde_json::Value::Null); - assert_eq!(value["errors"][0]["code"], "operation_unavailable"); - assert_eq!(value["errors"][0]["detail"]["class"], "operation"); - assert_contains( - &value["errors"][0]["message"], - "requires a selected seller account", - ); - assert_no_removed_command_reference(&value, &["order", "fulfillment", "update"]); - assert_no_daemon_runtime_reference(&value, &["order", "fulfillment", "update"]); -} - -#[test] -fn local_order_fulfillment_update_dry_run_attempts_configured_direct_relay() { - let sandbox = RadrootsCliSandbox::new(); - sandbox.json_success(&["--format", "json", "account", "create"]); - let relay = "ws://127.0.0.1:9"; - - let (output, value) = sandbox.json_output(&[ - "--format", - "json", - "--dry-run", - "--relay", - relay, - "order", - "fulfillment", - "update", - "ord_direct_fulfillment", - "--state", - "ready_for_pickup", - ]); - - assert!(!output.status.success()); - assert_eq!(value["dry_run"], true); - assert_direct_relay_connection_failure( - &value, - "order.fulfillment.update", - &["order", "fulfillment", "update"], - ); - assert_eq!(value["errors"][0]["detail"]["state"], "unavailable"); - assert_eq!(value["errors"][0]["detail"]["target_relays"][0], relay); -} - -#[test] fn watch_only_farm_publish_dry_run_fails_as_account_watch_only() { let sandbox = RadrootsCliSandbox::new(); let public_identity = identity_public(13); diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1920,86 +1920,6 @@ fn seller_order_decision_and_status_commands_are_public() { "order.status.get", ["--format", "json", "order", "status", "get", "ord_public"].as_slice(), ), - ( - "order.fulfillment.update", - [ - "--format", - "json", - "--dry-run", - "order", - "fulfillment", - "update", - "ord_public", - "--state", - "ready_for_pickup", - ] - .as_slice(), - ), - ( - "order.receipt.record", - [ - "--format", - "json", - "--dry-run", - "order", - "receipt", - "record", - "ord_public", - "--received", - ] - .as_slice(), - ), - ( - "order.payment.record", - [ - "--format", - "json", - "--dry-run", - "order", - "payment", - "record", - "ord_public", - "--amount", - "12", - "--currency", - "USD", - "--method", - "cash", - ] - .as_slice(), - ), - ( - "order.settlement.accept", - [ - "--format", - "json", - "--dry-run", - "order", - "settlement", - "accept", - "ord_public", - "--payment-event-id", - "1", - ] - .as_slice(), - ), - ( - "order.settlement.reject", - [ - "--format", - "json", - "--dry-run", - "order", - "settlement", - "reject", - "ord_public", - "--payment-event-id", - "1", - "--reason", - "reference mismatch", - ] - .as_slice(), - ), ] { let output = radroots() .args(args) @@ -2025,196 +1945,22 @@ fn seller_order_decision_and_status_commands_are_public() { } #[test] -fn payment_commands_return_not_implemented_before_mutation_preflight() { - let sandbox = RadrootsCliSandbox::new(); - - for (operation_id, args) in [ - ( - "order.payment.record", - [ - "--format", - "json", - "order", - "payment", - "record", - "ord_pending", - ] - .as_slice(), - ), - ( - "order.payment.record", - [ - "--format", - "json", - "--relay", - "not-a-url", - "order", - "payment", - "record", - "ord_pending", - "--method", - "card", - ] - .as_slice(), - ), - ( - "order.payment.record", - [ - "--format", - "json", - "--offline", - "order", - "payment", - "record", - "ord_pending", - ] - .as_slice(), - ), - ( - "order.settlement.accept", - [ - "--format", - "json", - "order", - "settlement", - "accept", - "ord_pending", - ] - .as_slice(), - ), - ( - "order.settlement.accept", - [ - "--format", - "json", - "--relay", - "not-a-url", - "order", - "settlement", - "accept", - "ord_pending", - ] - .as_slice(), - ), - ( - "order.settlement.accept", - [ - "--format", - "json", - "--online", - "--relay", - "not-a-url", - "order", - "settlement", - "accept", - "ord_pending", - ] - .as_slice(), - ), - ( - "order.settlement.reject", - [ - "--format", - "json", - "order", - "settlement", - "reject", - "ord_pending", - ] - .as_slice(), - ), - ( - "order.settlement.reject", - [ - "--format", - "json", - "--offline", - "--dry-run", - "order", - "settlement", - "reject", - "ord_pending", - ] - .as_slice(), - ), - ] { - let (output, value) = sandbox.json_output(args); - - assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(3)); - assert_eq!(value["operation_id"], operation_id); - assert_eq!(value["result"], Value::Null); - assert_eq!(value["errors"][0]["code"], "not_implemented"); - assert_eq!(value["errors"][0]["exit_code"], 3); - let message = value["errors"][0]["message"].as_str().expect("message"); - assert!(message.contains("not implemented")); - assert!(message.contains("future phase")); - assert!(!message.contains("approval_token")); - assert!(!message.contains("relay")); - } -} - -#[test] -fn payment_commands_return_not_implemented_for_ndjson_output() { - let sandbox = RadrootsCliSandbox::new(); - - for (operation_id, args) in [ - ( - "order.payment.record", - [ - "--format", - "ndjson", - "order", - "payment", - "record", - "ord_pending", - ] - .as_slice(), - ), - ( - "order.settlement.accept", - [ - "--format", - "ndjson", - "order", - "settlement", - "accept", - "ord_pending", - ] - .as_slice(), - ), - ( - "order.settlement.reject", - [ - "--format", - "ndjson", - "order", - "settlement", - "reject", - "ord_pending", - ] - .as_slice(), - ), +fn removed_order_post_agreement_subcommands_are_rejected_publicly() { + for args in [ + ["order", "fulfillment", "update", "ord_public"].as_slice(), + ["order", "receipt", "record", "ord_public"].as_slice(), + ["order", "payment", "record", "ord_public"].as_slice(), + ["order", "settlement", "accept", "ord_public"].as_slice(), + ["order", "settlement", "reject", "ord_public"].as_slice(), ] { - let output = sandbox.command().args(args).output().expect("run command"); - let frames = ndjson_from_stdout(&output); - let error_frame = frames.last().expect("error frame"); - let message = error_frame["errors"][0]["message"] - .as_str() - .expect("message"); + let output = radroots() + .args(args) + .output() + .expect("run removed order command"); - assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(3)); - assert_eq!(error_frame["operation_id"], operation_id); - assert_eq!(error_frame["frame_type"], "error"); - assert_eq!(error_frame["errors"][0]["code"], "not_implemented"); - assert_eq!(error_frame["errors"][0]["exit_code"], 3); - assert!(message.contains("not implemented")); - assert!(message.contains("future phase")); - assert!(!message.contains("ndjson")); - assert!(!message.contains("relay")); - assert!(!message.contains("approval")); - assert!(!message.contains("signer")); + assert!(!output.status.success(), "`{args:?}` should be rejected"); + let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); + assert!(stderr.contains("unrecognized subcommand")); } } @@ -2747,20 +2493,6 @@ fn machine_output_exposes_status_format_resource_and_reason_code() { account["result"]["account"]["id"] ); - let (deferred_output, deferred) = sandbox.json_output(&[ - "--format", - "json", - "order", - "payment", - "record", - "ord_pending", - ]); - assert_eq!(deferred_output.status.code(), Some(3)); - assert_eq!(deferred["status"], "error"); - assert_eq!(deferred["output_format"], "json"); - assert_eq!(deferred["reason_code"], "not_implemented"); - assert_eq!(deferred["errors"][0]["reason_code"], "not_implemented"); - let output = sandbox .command() .args(["--format", "json", "--dry-run", "workspace", "get"]) @@ -2774,24 +2506,17 @@ fn machine_output_exposes_status_format_resource_and_reason_code() { let ndjson_output = sandbox .command() - .args([ - "--format", - "ndjson", - "order", - "payment", - "record", - "ord_pending", - ]) + .args(["--format", "ndjson", "--dry-run", "workspace", "get"]) .output() - .expect("run deferred ndjson"); - assert_eq!(ndjson_output.status.code(), Some(3)); + .expect("run invalid ndjson"); + assert_eq!(ndjson_output.status.code(), Some(2)); let frames = ndjson_from_stdout(&ndjson_output); assert_eq!(frames[0]["payload"]["status"], "error"); assert_eq!(frames[0]["payload"]["output_format"], "ndjson"); assert_eq!(frames[1]["payload"]["status"], "error"); assert_eq!(frames[1]["payload"]["output_format"], "ndjson"); - assert_eq!(frames[1]["payload"]["reason_code"], "not_implemented"); - assert_eq!(frames[1]["errors"][0]["reason_code"], "not_implemented"); + assert_eq!(frames[1]["payload"]["reason_code"], "invalid_input"); + assert_eq!(frames[1]["errors"][0]["reason_code"], "invalid_input"); } #[test] @@ -2884,35 +2609,6 @@ fn offline_forbids_external_network_operations() { ] .as_slice(), ), - ( - "order.fulfillment.update", - [ - "--format", - "json", - "--offline", - "order", - "fulfillment", - "update", - "ord_offline_fulfillment", - "--state", - "ready_for_pickup", - ] - .as_slice(), - ), - ( - "order.receipt.record", - [ - "--format", - "json", - "--offline", - "order", - "receipt", - "record", - "ord_offline_receipt", - "--received", - ] - .as_slice(), - ), ] { let output = radroots() .args(args) @@ -3207,38 +2903,6 @@ fn offline_rejects_order_decision_dry_run() { ] .as_slice(), ), - ( - "order.fulfillment.update", - [ - "--format", - "json", - "--offline", - "--dry-run", - "order", - "fulfillment", - "update", - "ord_offline_decision", - "--state", - "ready_for_pickup", - ] - .as_slice(), - ), - ( - "order.receipt.record", - [ - "--format", - "json", - "--offline", - "--dry-run", - "order", - "receipt", - "record", - "ord_offline_decision", - "--issue", - "damaged items", - ] - .as_slice(), - ), ] { let output = radroots() .args(args) @@ -3389,35 +3053,6 @@ fn online_requires_relay_for_external_network_operations() { ] .as_slice(), ), - ( - "order.fulfillment.update", - [ - "--format", - "json", - "--online", - "order", - "fulfillment", - "update", - "ord_missing", - "--state", - "ready_for_pickup", - ] - .as_slice(), - ), - ( - "order.receipt.record", - [ - "--format", - "json", - "--online", - "order", - "receipt", - "record", - "ord_missing", - "--received", - ] - .as_slice(), - ), ] { let output = radroots() .args(args) @@ -6259,23 +5894,6 @@ fn required_approval_token_rejects_absent_empty_and_whitespace_values() { "keep original order", ], ); - assert_required_approval_token_rejected( - &sandbox, - "order.fulfillment.update", - &[ - "order", - "fulfillment", - "update", - "ord_pending_fulfillment", - "--state", - "ready_for_pickup", - ], - ); - assert_required_approval_token_rejected( - &sandbox, - "order.receipt.record", - &["order", "receipt", "record", "ord_pending", "--received"], - ); } fn assert_required_approval_token_rejected( @@ -6302,34 +5920,6 @@ fn assert_required_approval_token_rejected( } #[test] -fn order_fulfillment_update_requires_state_before_approval() { - let sandbox = RadrootsCliSandbox::new(); - - let (output, value) = sandbox.json_output(&[ - "--format", - "json", - "order", - "fulfillment", - "update", - "ord_missing_state", - ]); - - assert_eq!(output.status.code(), Some(2)); - assert_eq!(value["operation_id"], "order.fulfillment.update"); - assert_eq!(value["result"], Value::Null); - assert_eq!(value["errors"][0]["code"], "invalid_input"); - assert_eq!(value["errors"][0]["exit_code"], 2); - assert!( - value["errors"][0]["message"] - .as_str() - .expect("message") - .contains("state") - ); - assert_no_removed_command_reference(&value, &["order", "fulfillment", "update"]); - assert_no_daemon_runtime_reference(&value, &["order", "fulfillment", "update"]); -} - -#[test] fn order_submit_missing_order_returns_not_found_while_read_view_stays_successful() { let sandbox = RadrootsCliSandbox::new(); @@ -7306,23 +6896,6 @@ fn buyer_side_order_writes_reject_conflicting_account_override_for_local_draft() "changed plans", ], ), - ( - "order.receipt.record", - vec![ - "--format", - "json", - "--dry-run", - "--account-id", - drift_account_id, - "--relay", - "ws://127.0.0.1:9", - "order", - "receipt", - "record", - order_id.as_str(), - "--received", - ], - ), ] { let (output, value) = sandbox.json_output(command.as_slice());