commit 54bc3375cbf9094bc938ec796424b329b5bedf12
parent 8061cf6cf2668d77081011d5e359af2f772ab14f
Author: triesap <tyson@radroots.org>
Date: Tue, 28 Apr 2026 16:20:12 +0000
cli: expose order decision operations
- add order accept decline and status parser surfaces
- wire registry adapter service and main dispatch contracts
- add decision and status runtime output placeholders
- cover approval parser network and signer readiness behavior
Diffstat:
10 files changed, 653 insertions(+), 25 deletions(-)
diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs
@@ -1204,6 +1204,106 @@ impl OrderSubmitView {
}
#[derive(Debug, Clone, Serialize)]
+pub struct OrderDecisionView {
+ 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>,
+ pub decision: 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 acknowledged_relays: Vec<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub failed_relays: Vec<RelayFailureView>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub idempotency_key: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ 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 OrderDecisionView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "missing" => CommandDisposition::NotFound,
+ "unconfigured" => CommandDisposition::Unconfigured,
+ "unavailable" => CommandDisposition::ExternalUnavailable,
+ "error" => CommandDisposition::InternalError,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+pub struct OrderStatusView {
+ pub state: String,
+ pub source: String,
+ pub order_id: 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 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 last_event_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub reducer_issues: Vec<OrderIssueView>,
+ #[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 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 reason: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub actions: Vec<String>,
+}
+
+impl OrderStatusView {
+ pub fn disposition(&self) -> CommandDisposition {
+ match self.state.as_str() {
+ "unconfigured" => CommandDisposition::Unconfigured,
+ "unavailable" => CommandDisposition::ExternalUnavailable,
+ "error" => CommandDisposition::InternalError,
+ _ => CommandDisposition::Success,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
pub struct OrderSubmitWatchView {
pub submit: OrderSubmitView,
pub watch: OrderWatchView,
diff --git a/src/main.rs b/src/main.rs
@@ -260,6 +260,15 @@ fn execute_request(
TargetOperationRequest::OrderList(request) => {
execute_with(OrderOperationService::new(config), request)
}
+ TargetOperationRequest::OrderAccept(request) => {
+ execute_with(OrderOperationService::new(config), request)
+ }
+ TargetOperationRequest::OrderDecline(request) => {
+ execute_with(OrderOperationService::new(config), request)
+ }
+ TargetOperationRequest::OrderStatusGet(request) => {
+ execute_with(OrderOperationService::new(config), request)
+ }
TargetOperationRequest::OrderEventList(request) => {
execute_with(OrderOperationService::new(config), request)
}
@@ -379,6 +388,9 @@ fn external_network_operation(operation_id: &str) -> bool {
| "listing.publish"
| "listing.archive"
| "order.submit"
+ | "order.accept"
+ | "order.decline"
+ | "order.status.get"
| "order.event.list"
| "order.event.watch"
)
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -1035,7 +1035,7 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati
AccountCommand, AccountSelectionCommand, BasketCommand, BasketItemCommand,
BasketQuoteCommand, FarmCommand, FarmFulfillmentCommand, FarmLocationCommand,
FarmProfileCommand, ListingCommand, MarketCommand, MarketListingCommand,
- MarketProductCommand, OrderCommand, OrderEventCommand, TargetCommand,
+ MarketProductCommand, OrderCommand, OrderEventCommand, OrderStatusCommand, TargetCommand,
};
let mut input = OperationData::new();
@@ -1163,6 +1163,16 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati
insert_string(&mut input, "order_id", &args.order_id);
}
OrderCommand::Get(args) => insert_string(&mut input, "order_id", &args.order_id),
+ OrderCommand::Accept(args) => insert_string(&mut input, "order_id", &args.order_id),
+ OrderCommand::Decline(args) => {
+ insert_string(&mut input, "order_id", &args.order_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)
+ }
+ },
OrderCommand::Event(event) => match &event.command {
OrderEventCommand::List(args) | OrderEventCommand::Watch(args) => {
insert_string(&mut input, "order_id", &args.order_id)
@@ -1259,6 +1269,9 @@ target_operation_contracts! {
OrderSubmit => (OrderSubmitRequest, OrderSubmitResult, "order.submit"),
OrderGet => (OrderGetRequest, OrderGetResult, "order.get"),
OrderList => (OrderListRequest, OrderListResult, "order.list"),
+ OrderAccept => (OrderAcceptRequest, OrderAcceptResult, "order.accept"),
+ OrderDecline => (OrderDeclineRequest, OrderDeclineResult, "order.decline"),
+ OrderStatusGet => (OrderStatusGetRequest, OrderStatusGetResult, "order.status.get"),
OrderEventList => (OrderEventListRequest, OrderEventListResult, "order.event.list"),
OrderEventWatch => (OrderEventWatchRequest, OrderEventWatchResult, "order.event.watch"),
}
diff --git a/src/operation_order.rs b/src/operation_order.rs
@@ -1,16 +1,23 @@
use serde::Serialize;
use serde_json::{Value, json};
-use crate::domain::runtime::{CommandDisposition, OrderSubmitView};
+use crate::domain::runtime::{
+ CommandDisposition, OrderDecisionView, OrderStatusView, OrderSubmitView,
+};
use crate::operation_adapter::{
OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload,
- OperationResult, OperationResultData, OperationService, OrderEventListRequest,
- OrderEventListResult, OrderEventWatchRequest, OrderEventWatchResult, OrderGetRequest,
- OrderGetResult, OrderListRequest, OrderListResult, OrderSubmitRequest, OrderSubmitResult,
+ OperationResult, OperationResultData, OperationService, OrderAcceptRequest, OrderAcceptResult,
+ OrderDeclineRequest, OrderDeclineResult, OrderEventListRequest, OrderEventListResult,
+ OrderEventWatchRequest, OrderEventWatchResult, OrderGetRequest, OrderGetResult,
+ OrderListRequest, OrderListResult, OrderStatusGetRequest, OrderStatusGetResult,
+ OrderSubmitRequest, OrderSubmitResult,
};
use crate::runtime::RuntimeError;
use crate::runtime::config::RuntimeConfig;
-use crate::runtime_args::{OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs};
+use crate::runtime_args::{
+ OrderDecisionArg, OrderDecisionArgs, OrderStatusArgs, OrderSubmitArgs, OrderWatchArgs,
+ RecordLookupArgs,
+};
pub struct OrderOperationService<'a> {
config: &'a RuntimeConfig,
@@ -86,6 +93,97 @@ impl OperationService<OrderListRequest> for OrderOperationService<'_> {
}
}
+impl OperationService<OrderAcceptRequest> for OrderOperationService<'_> {
+ type Result = OrderAcceptResult;
+
+ fn execute(
+ &self,
+ request: OperationRequest<OrderAcceptRequest>,
+ ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
+ if request.context.requires_approval_token() {
+ return Err(OperationAdapterError::approval_required(
+ request.operation_id(),
+ ));
+ }
+
+ let args = OrderDecisionArgs {
+ key: required_order_key(&request)?,
+ decision: OrderDecisionArg::Accept,
+ reason: None,
+ 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::decide(&config, &args).map_err(|error| {
+ OperationAdapterError::runtime_failure(request.operation_id(), error)
+ })?;
+ decision_result::<OrderAcceptResult>(request.operation_id(), &view)
+ }
+}
+
+impl OperationService<OrderDeclineRequest> for OrderOperationService<'_> {
+ type Result = OrderDeclineResult;
+
+ fn execute(
+ &self,
+ request: OperationRequest<OrderDeclineRequest>,
+ ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
+ let reason = string_input(&request, "reason").ok_or_else(|| {
+ invalid_input(
+ request.operation_id(),
+ "missing required `reason` input".to_owned(),
+ )
+ })?;
+ if request.context.requires_approval_token() {
+ return Err(OperationAdapterError::approval_required(
+ request.operation_id(),
+ ));
+ }
+
+ let args = OrderDecisionArgs {
+ key: required_order_key(&request)?,
+ decision: OrderDecisionArg::Decline,
+ reason: Some(reason),
+ 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::decide(&config, &args).map_err(|error| {
+ OperationAdapterError::runtime_failure(request.operation_id(), error)
+ })?;
+ decision_result::<OrderDeclineResult>(request.operation_id(), &view)
+ }
+}
+
+impl OperationService<OrderStatusGetRequest> for OrderOperationService<'_> {
+ type Result = OrderStatusGetResult;
+
+ fn execute(
+ &self,
+ request: OperationRequest<OrderStatusGetRequest>,
+ ) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
+ let args = OrderStatusArgs {
+ key: required_order_key(&request)?,
+ };
+ let view = crate::runtime::order::status(self.config, &args).map_err(|error| {
+ OperationAdapterError::runtime_failure(request.operation_id(), error)
+ })?;
+ status_result::<OrderStatusGetResult>(request.operation_id(), &view)
+ }
+}
+
impl OperationService<OrderEventListRequest> for OrderOperationService<'_> {
type Result = OrderEventListResult;
@@ -119,6 +217,52 @@ impl OperationService<OrderEventWatchRequest> for OrderOperationService<'_> {
}
}
+fn decision_result<R>(
+ operation_id: &str,
+ view: &OrderDecisionView,
+) -> Result<OperationResult<R>, OperationAdapterError>
+where
+ R: OperationResultData,
+{
+ match view.disposition() {
+ CommandDisposition::Success => serialized_target_result::<R, _>(view),
+ disposition => {
+ let message = view
+ .reason
+ .clone()
+ .unwrap_or_else(|| format!("order decision finished with state `{}`", view.state));
+ Err(OperationAdapterError::from_command_disposition(
+ operation_id,
+ disposition,
+ message,
+ ))
+ }
+ }
+}
+
+fn status_result<R>(
+ operation_id: &str,
+ view: &OrderStatusView,
+) -> Result<OperationResult<R>, OperationAdapterError>
+where
+ R: OperationResultData,
+{
+ match view.disposition() {
+ CommandDisposition::Success => serialized_target_result::<R, _>(view),
+ disposition => {
+ let message = view
+ .reason
+ .clone()
+ .unwrap_or_else(|| format!("order status finished with state `{}`", view.state));
+ Err(OperationAdapterError::from_command_disposition(
+ operation_id,
+ disposition,
+ message,
+ ))
+ }
+ }
+}
+
fn serialized_target_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError>
where
R: OperationResultData,
@@ -272,8 +416,9 @@ mod tests {
use super::OrderOperationService;
use crate::operation_adapter::{
- OperationAdapter, OperationContext, OperationData, OperationRequest, OrderEventListRequest,
- OrderEventWatchRequest, OrderGetRequest, OrderListRequest, OrderSubmitRequest,
+ OperationAdapter, OperationContext, OperationData, OperationRequest, OrderAcceptRequest,
+ OrderDeclineRequest, OrderEventListRequest, OrderEventWatchRequest, OrderGetRequest,
+ OrderListRequest, OrderStatusGetRequest, OrderSubmitRequest,
};
use crate::runtime::config::{
AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig,
@@ -353,6 +498,55 @@ mod tests {
}
#[test]
+ fn order_accept_requires_approval_token() {
+ let dir = tempdir().expect("tempdir");
+ let config = sample_config(dir.path());
+ let service = OperationAdapter::new(OrderOperationService::new(&config));
+ let accept = OperationRequest::new(
+ OperationContext::default(),
+ OrderAcceptRequest::from_data(data(&[("order_id", "ord_pending")])),
+ )
+ .expect("order accept request");
+ let error = service.execute(accept).expect_err("approval required");
+
+ assert_eq!(error.to_output_error().code, "approval_required");
+ }
+
+ #[test]
+ fn order_decline_requires_reason_before_approval() {
+ let dir = tempdir().expect("tempdir");
+ let config = sample_config(dir.path());
+ let service = OperationAdapter::new(OrderOperationService::new(&config));
+ let decline = OperationRequest::new(
+ OperationContext::default(),
+ OrderDeclineRequest::from_data(data(&[("order_id", "ord_pending")])),
+ )
+ .expect("order decline request");
+ let error = service.execute(decline).expect_err("reason required");
+ let output_error = error.to_output_error();
+
+ assert_eq!(output_error.code, "invalid_input");
+ assert!(output_error.message.contains("reason"));
+ }
+
+ #[test]
+ fn order_status_get_requires_relay_configuration() {
+ let dir = tempdir().expect("tempdir");
+ let config = sample_config(dir.path());
+ let service = OperationAdapter::new(OrderOperationService::new(&config));
+ let status = OperationRequest::new(
+ OperationContext::default(),
+ OrderStatusGetRequest::from_data(data(&[("order_id", "ord_pending")])),
+ )
+ .expect("order status request");
+ let error = service.execute(status).expect_err("status unconfigured");
+ let output_error = error.to_output_error();
+
+ assert_eq!(output_error.code, "operation_unavailable");
+ assert!(output_error.message.contains("configured relay"));
+ }
+
+ #[test]
fn order_event_list_requires_relay_configuration() {
let dir = tempdir().expect("tempdir");
let config = sample_config(dir.path());
diff --git a/src/operation_registry.rs b/src/operation_registry.rs
@@ -842,6 +842,51 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[
false
),
operation!(
+ "order.accept",
+ "radroots order accept",
+ "order",
+ "order_accept",
+ "OrderAcceptRequest",
+ "OrderAcceptResult",
+ "Accept a buyer order request.",
+ Seller,
+ true,
+ Required,
+ Critical,
+ false,
+ true
+ ),
+ operation!(
+ "order.decline",
+ "radroots order decline",
+ "order",
+ "order_decline",
+ "OrderDeclineRequest",
+ "OrderDeclineResult",
+ "Decline a buyer order request.",
+ Seller,
+ true,
+ Required,
+ High,
+ false,
+ true
+ ),
+ operation!(
+ "order.status.get",
+ "radroots order status get",
+ "order",
+ "order_status_get",
+ "OrderStatusGetRequest",
+ "OrderStatusGetResult",
+ "Get reducer-derived order status.",
+ Any,
+ false,
+ None,
+ Low,
+ false,
+ false
+ ),
+ operation!(
"order.event.list",
"radroots order event list",
"order",
@@ -946,6 +991,9 @@ mod tests {
"order.submit",
"order.get",
"order.list",
+ "order.accept",
+ "order.decline",
+ "order.status.get",
"order.event.list",
"order.event.watch",
];
@@ -977,6 +1025,8 @@ mod tests {
"basket.item.remove",
"basket.quote.create",
"order.submit",
+ "order.accept",
+ "order.decline",
];
const INTENTIONALLY_UNSUPPORTED_MUTATING_DRY_RUN_OPERATION_IDS: &[&str] = &[];
@@ -989,7 +1039,7 @@ mod tests {
.copied()
.collect::<BTreeSet<_>>();
assert_eq!(actual, expected);
- assert_eq!(OPERATION_REGISTRY.len(), 53);
+ assert_eq!(OPERATION_REGISTRY.len(), 56);
}
#[test]
@@ -1036,6 +1086,8 @@ mod tests {
"listing.publish",
"listing.archive",
"order.submit",
+ "order.accept",
+ "order.decline",
]
.into_iter()
.collect::<BTreeSet<_>>();
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -27,9 +27,9 @@ use radroots_trade::order::canonicalize_active_order_request_for_signer;
use serde::{Deserialize, Serialize};
use crate::domain::runtime::{
- OrderCancelView, OrderDraftItemView, OrderGetView, OrderHistoryEntryView, OrderHistoryView,
- OrderIssueView, OrderListView, OrderNewView, OrderSubmitView, OrderSummaryView, OrderWatchView,
- RelayFailureView,
+ OrderCancelView, OrderDecisionView, OrderDraftItemView, OrderGetView, OrderHistoryEntryView,
+ OrderHistoryView, OrderIssueView, OrderListView, OrderNewView, OrderStatusView,
+ OrderSubmitView, OrderSummaryView, OrderWatchView, RelayFailureView,
};
use crate::runtime::RuntimeError;
use crate::runtime::accounts;
@@ -40,13 +40,16 @@ use crate::runtime::direct_relay::{
};
use crate::runtime::signer::ActorWriteBindingError;
use crate::runtime_args::{
- OrderDraftCreateArgs, OrderSubmitArgs, OrderWatchArgs, RecordLookupArgs,
+ OrderDecisionArgs, OrderDraftCreateArgs, OrderStatusArgs, OrderSubmitArgs, OrderWatchArgs,
+ RecordLookupArgs,
};
const ORDER_DRAFT_KIND: &str = "order_draft_v1";
const ORDER_SOURCE: &str = "local order drafts · local first";
const ORDER_SUBMIT_SOURCE: &str = "direct Nostr relay publish · local key";
+const ORDER_DECISION_SOURCE: &str = "direct Nostr relay decision publish · local key";
const ORDER_EVENT_LIST_SOURCE: &str = "direct Nostr relay fetch · selected seller identity";
+const ORDER_STATUS_SOURCE: &str = "direct Nostr relay status fetch · active order reducer";
const ORDER_EVENT_WATCH_UNAVAILABLE_REASON: &str =
"relay-backed order event watch is not implemented";
const ORDERS_DIR: &str = "orders/drafts";
@@ -635,6 +638,125 @@ pub fn history(
Ok(order_history_from_receipt(seller_pubkey, order_id, receipt))
}
+pub fn decide(
+ config: &RuntimeConfig,
+ args: &OrderDecisionArgs,
+) -> Result<OrderDecisionView, RuntimeError> {
+ let decision_reason = args
+ .reason
+ .as_deref()
+ .map(str::trim)
+ .filter(|reason| !reason.is_empty());
+ if config.output.dry_run {
+ return Ok(OrderDecisionView {
+ state: "dry_run".to_owned(),
+ source: ORDER_DECISION_SOURCE.to_owned(),
+ order_id: args.key.clone(),
+ listing_addr: None,
+ buyer_pubkey: None,
+ seller_pubkey: None,
+ decision: args.decision.as_str().to_owned(),
+ root_event_id: None,
+ prev_event_id: None,
+ event_id: None,
+ event_kind: None,
+ dry_run: true,
+ target_relays: config.relay.urls.clone(),
+ acknowledged_relays: Vec::new(),
+ failed_relays: Vec::new(),
+ idempotency_key: args.idempotency_key.clone(),
+ signer_mode: Some(config.signer.backend.as_str().to_owned()),
+ 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(),
+ }),
+ issues: Vec::new(),
+ actions: vec![format!("radroots order status get {}", args.key)],
+ });
+ }
+
+ Ok(OrderDecisionView {
+ state: "unavailable".to_owned(),
+ source: ORDER_DECISION_SOURCE.to_owned(),
+ order_id: args.key.clone(),
+ listing_addr: None,
+ buyer_pubkey: None,
+ seller_pubkey: None,
+ decision: args.decision.as_str().to_owned(),
+ root_event_id: None,
+ prev_event_id: None,
+ event_id: None,
+ event_kind: None,
+ dry_run: false,
+ target_relays: config.relay.urls.clone(),
+ acknowledged_relays: Vec::new(),
+ failed_relays: Vec::new(),
+ idempotency_key: args.idempotency_key.clone(),
+ signer_mode: Some(config.signer.backend.as_str().to_owned()),
+ reason: Some(match decision_reason {
+ Some(reason) => {
+ format!(
+ "seller order decision publication is not implemented for reason `{reason}`"
+ )
+ }
+ None => "seller order decision publication is not implemented".to_owned(),
+ }),
+ issues: Vec::new(),
+ actions: Vec::new(),
+ })
+}
+
+pub fn status(
+ config: &RuntimeConfig,
+ args: &OrderStatusArgs,
+) -> Result<OrderStatusView, RuntimeError> {
+ if config.relay.urls.is_empty() {
+ return Ok(OrderStatusView {
+ state: "unconfigured".to_owned(),
+ source: ORDER_STATUS_SOURCE.to_owned(),
+ order_id: args.key.clone(),
+ request_event_id: None,
+ decision_event_id: None,
+ listing_addr: None,
+ buyer_pubkey: None,
+ seller_pubkey: None,
+ last_event_id: 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::new(),
+ });
+ }
+
+ Ok(OrderStatusView {
+ state: "unavailable".to_owned(),
+ source: ORDER_STATUS_SOURCE.to_owned(),
+ order_id: args.key.clone(),
+ request_event_id: None,
+ decision_event_id: None,
+ listing_addr: None,
+ buyer_pubkey: None,
+ seller_pubkey: None,
+ last_event_id: None,
+ reducer_issues: Vec::new(),
+ target_relays: config.relay.urls.clone(),
+ connected_relays: Vec::new(),
+ failed_relays: Vec::new(),
+ fetched_count: 0,
+ decoded_count: 0,
+ skipped_count: 0,
+ reason: Some("order status reducer fetch is not implemented".to_owned()),
+ actions: Vec::new(),
+ })
+}
+
pub fn cancel(
config: &RuntimeConfig,
args: &RecordLookupArgs,
diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs
@@ -4,7 +4,9 @@ use crate::domain::runtime::{
};
use crate::runtime::accounts::{SHARED_ACCOUNT_STORE_SOURCE, empty_account_resolution_view};
use crate::runtime::config::{RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend};
-use radroots_events::kinds::{KIND_FARM, KIND_LISTING, KIND_PROFILE, KIND_TRADE_ORDER_REQUEST};
+use radroots_events::kinds::{
+ KIND_FARM, KIND_LISTING, KIND_PROFILE, KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REQUEST,
+};
use radroots_nostr_accounts::prelude::RadrootsNostrAccountStatus;
use radroots_nostr_signer::prelude::{
RadrootsNostrLocalSignerAvailability, RadrootsNostrLocalSignerCapability,
@@ -272,7 +274,7 @@ fn deferred_myc_binding_status() -> SignerBindingStatusView {
}
}
-fn cli_write_kinds() -> [CliWriteKind; 4] {
+fn cli_write_kinds() -> [CliWriteKind; 6] {
[
CliWriteKind {
command: "farm profile publish",
@@ -290,6 +292,14 @@ fn cli_write_kinds() -> [CliWriteKind; 4] {
command: "order submit",
event_kind: KIND_TRADE_ORDER_REQUEST,
},
+ CliWriteKind {
+ command: "order accept",
+ event_kind: KIND_TRADE_ORDER_DECISION,
+ },
+ CliWriteKind {
+ command: "order decline",
+ event_kind: KIND_TRADE_ORDER_DECISION,
+ },
]
}
@@ -333,7 +343,7 @@ fn local_availability(value: RadrootsNostrLocalSignerAvailability) -> &'static s
mod tests {
use radroots_events::kinds::KIND_TRADE_DISCOUNT_DECLINE;
- use super::{KIND_TRADE_ORDER_REQUEST, cli_write_kinds};
+ use super::{KIND_TRADE_ORDER_DECISION, KIND_TRADE_ORDER_REQUEST, cli_write_kinds};
#[test]
fn order_submit_readiness_uses_active_order_request_kind() {
@@ -345,4 +355,17 @@ mod tests {
assert_eq!(write_kind.event_kind, KIND_TRADE_ORDER_REQUEST);
assert_ne!(write_kind.event_kind, KIND_TRADE_DISCOUNT_DECLINE);
}
+
+ #[test]
+ fn order_decision_readiness_uses_active_order_decision_kind() {
+ for command in ["order accept", "order decline"] {
+ let write_kind = cli_write_kinds()
+ .into_iter()
+ .find(|kind| kind.command == command)
+ .expect("order decision readiness");
+
+ assert_eq!(write_kind.event_kind, KIND_TRADE_ORDER_DECISION);
+ assert_ne!(write_kind.event_kind, KIND_TRADE_DISCOUNT_DECLINE);
+ }
+ }
}
diff --git a/src/runtime_args.rs b/src/runtime_args.rs
@@ -176,6 +176,34 @@ pub struct OrderSubmitArgs {
pub idempotency_key: Option<String>,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum OrderDecisionArg {
+ Accept,
+ Decline,
+}
+
+impl OrderDecisionArg {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Accept => "accepted",
+ Self::Decline => "declined",
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct OrderDecisionArgs {
+ pub key: String,
+ pub decision: OrderDecisionArg,
+ pub reason: Option<String>,
+ pub idempotency_key: Option<String>,
+}
+
+#[derive(Debug, Clone)]
+pub struct OrderStatusArgs {
+ pub key: String,
+}
+
#[derive(Debug, Clone)]
pub struct OrderWatchArgs {
pub key: String,
diff --git a/src/target_cli.rs b/src/target_cli.rs
@@ -172,6 +172,11 @@ impl TargetCommand {
OrderCommand::Submit(_) => "order.submit",
OrderCommand::Get(_) => "order.get",
OrderCommand::List => "order.list",
+ OrderCommand::Accept(_) => "order.accept",
+ OrderCommand::Decline(_) => "order.decline",
+ OrderCommand::Status(status) => match &status.command {
+ OrderStatusCommand::Get(_) => "order.status.get",
+ },
OrderCommand::Event(event) => match &event.command {
OrderEventCommand::List(_) => "order.event.list",
OrderEventCommand::Watch(_) => "order.event.watch",
@@ -678,6 +683,9 @@ pub enum OrderCommand {
Submit(OrderSubmitArgs),
Get(OrderKeyArgs),
List,
+ Accept(OrderKeyArgs),
+ Decline(OrderDeclineArgs),
+ Status(OrderStatusArgs),
Event(OrderEventArgs),
}
@@ -692,6 +700,24 @@ pub struct OrderKeyArgs {
}
#[derive(Debug, Clone, Args)]
+pub struct OrderDeclineArgs {
+ pub order_id: Option<String>,
+ #[arg(long)]
+ pub reason: Option<String>,
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct OrderStatusArgs {
+ #[command(subcommand)]
+ pub command: OrderStatusCommand,
+}
+
+#[derive(Debug, Clone, Subcommand)]
+pub enum OrderStatusCommand {
+ Get(OrderKeyArgs),
+}
+
+#[derive(Debug, Clone, Args)]
pub struct OrderEventArgs {
#[command(subcommand)]
pub command: OrderEventCommand,
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -111,21 +111,60 @@ fn removed_command_families_are_rejected_publicly() {
}
#[test]
-fn seller_order_decision_commands_are_deferred() {
- for args in [
- ["order", "accept", "ord_deferred"].as_slice(),
- ["order", "decline", "ord_deferred"].as_slice(),
- ["order", "decision", "accept", "ord_deferred"].as_slice(),
+fn seller_order_decision_and_status_commands_are_public() {
+ for (operation_id, args) in [
+ (
+ "order.accept",
+ [
+ "--format",
+ "json",
+ "--dry-run",
+ "order",
+ "accept",
+ "ord_public",
+ ]
+ .as_slice(),
+ ),
+ (
+ "order.decline",
+ [
+ "--format",
+ "json",
+ "--dry-run",
+ "order",
+ "decline",
+ "ord_public",
+ "--reason",
+ "out_of_stock",
+ ]
+ .as_slice(),
+ ),
+ (
+ "order.status.get",
+ ["--format", "json", "order", "status", "get", "ord_public"].as_slice(),
+ ),
] {
let output = radroots()
.args(args)
.output()
- .expect("run deferred seller order decision command");
+ .expect("run seller order command");
+ let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope");
- assert!(!output.status.success(), "`{args:?}` should be rejected");
- let stderr = String::from_utf8(output.stderr).expect("utf8 stderr");
- assert!(stderr.contains("unrecognized subcommand"));
+ assert_eq!(value["operation_id"], operation_id);
+ assert_ne!(
+ String::from_utf8(output.stderr).expect("utf8 stderr"),
+ "unrecognized subcommand"
+ );
}
+
+ let output = radroots()
+ .args(["order", "decision", "accept", "ord_deferred"])
+ .output()
+ .expect("run removed nested decision command");
+
+ assert!(!output.status.success());
+ let stderr = String::from_utf8(output.stderr).expect("utf8 stderr");
+ assert!(stderr.contains("unrecognized subcommand"));
}
#[test]
@@ -517,6 +556,19 @@ fn online_requires_relay_for_external_network_operations() {
["--format", "json", "--online", "order", "event", "list"].as_slice(),
),
(
+ "order.status.get",
+ [
+ "--format",
+ "json",
+ "--online",
+ "order",
+ "status",
+ "get",
+ "ord_missing",
+ ]
+ .as_slice(),
+ ),
+ (
"order.event.watch",
[
"--format",
@@ -979,6 +1031,12 @@ fn required_approval_token_rejects_absent_empty_and_whitespace_values() {
&["listing", "archive", "missing-listing.toml"],
);
assert_required_approval_token_rejected(&sandbox, "order.submit", &["order", "submit"]);
+ assert_required_approval_token_rejected(&sandbox, "order.accept", &["order", "accept"]);
+ assert_required_approval_token_rejected(
+ &sandbox,
+ "order.decline",
+ &["order", "decline", "--reason", "out_of_stock"],
+ );
}
fn assert_required_approval_token_rejected(