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:
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());